diff --git a/.github/scripts/verifyPodfile.sh b/.github/scripts/verifyPodfile.sh index dc4c54fd229a..3a57a736cd70 100755 --- a/.github/scripts/verifyPodfile.sh +++ b/.github/scripts/verifyPodfile.sh @@ -10,6 +10,15 @@ title "Verifying that Podfile.lock is synced with the project" declare EXIT_CODE=0 +# Check Provisioning Style. If automatic signing is enabled, iOS builds will fail, so ensure we always have the proper profile specified +info "Verifying that automatic signing is not enabled" +if grep -q 'PROVISIONING_PROFILE_SPECIFIER = chat_expensify_appstore' ios/NewExpensify.xcodeproj/project.pbxproj; then + success "Automatic signing not enabled" +else + error "Error: Automatic provisioning style is not allowed!" + EXIT_CODE=1 +fi + PODFILE_SHA=$(openssl sha1 ios/Podfile | awk '{print $2}') PODFILE_LOCK_SHA=$(awk '/PODFILE CHECKSUM: /{print $3}' ios/Podfile.lock) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 72dde5fe38cf..09920114b19e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,69 +6,44 @@ on: types: [opened, synchronize] branches-ignore: [staging, production] -env: - # Number of parallel jobs for jest tests - CHUNKS: 3 jobs: - config: - runs-on: ubuntu-latest - name: Define matrix parameters - outputs: - MATRIX: ${{ steps.set-matrix.outputs.MATRIX }} - JEST_CHUNKS: ${{ steps.set-matrix.outputs.JEST_CHUNKS }} - steps: - - name: Set Matrix - id: set-matrix - uses: actions/github-script@v6 - with: - # Generate matrix array i.e. [0, 1, 2, ...., CHUNKS - 1] for test job - script: | - core.setOutput('MATRIX', Array.from({ length: Number(process.env.CHUNKS) }, (v, i) => i + 1)); - core.setOutput('JEST_CHUNKS', Number(process.env.CHUNKS) - 1); - - test: - needs: config + jest: if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} runs-on: ubuntu-latest - name: test (job ${{ fromJSON(matrix.chunk) }}) env: CI: true strategy: fail-fast: false matrix: - chunk: ${{fromJson(needs.config.outputs.MATRIX)}} - + chunk: [ 1, 2, 3 ] + name: test (job ${{ fromJSON(matrix.chunk) }}) steps: - # This action checks-out the repository, so the workflow can access it. - - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 - with: - fetch-depth: 0 + - uses: actions/checkout@885641592076c27bfb56c028cd5612cdad63e16d - uses: Expensify/App/.github/actions/composite/setupNode@main - # If automatic signing is enabled, iOS builds will fail, so ensure we always have the proper profile specified - - name: Check Provisioning Style - run: | - if grep -q 'PROVISIONING_PROFILE_SPECIFIER = chat_expensify_appstore' ios/NewExpensify.xcodeproj/project.pbxproj; then - exit 0 - else - echo "Error: Automatic provisioning style is not allowed!" - exit 1 - fi + - name: Get number of CPU cores + id: cpu-cores + uses: SimenB/github-actions-cpu-cores@31e91de0f8654375a21e8e83078be625380e2b18 - name: Cache Jest cache id: cache-jest-cache - uses: actions/cache@v1 + uses: actions/cache@ac25611caef967612169ab7e95533cf932c32270 with: path: .jest-cache key: ${{ runner.os }}-jest - - name: All Unit Tests - if: ${{ fromJSON(matrix.chunk) < fromJSON(env.CHUNKS) }} - # Split the jest based test files in multiple chunks/groups and then execute them in parallel in different jobs/runners. - run: npx jest --listTests --json | jq -cM '[_nwise(length / ${{ fromJSON(needs.config.outputs.JEST_CHUNKS) }} | ceil)]' | jq '[[]] + .' | jq '.[${{ fromJSON(matrix.chunk) }}] | .[] | @text' | xargs npm test + - name: Jest tests + run: npx jest --shard=${{ fromJSON(matrix.chunk) }}/${{ strategy.job-total }} --max-workers ${{ steps.cpu-cores.outputs.count }} + + shellTests: + if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} + runs-on: ubuntu-latest + name: Shell tests + steps: + - uses: actions/checkout@885641592076c27bfb56c028cd5612cdad63e16d + + - uses: Expensify/App/.github/actions/composite/setupNode@main - - name: Pull Request Tests - # Pull request related tests will be run in separate runner in parallel. - if: ${{ fromJSON(matrix.chunk) == fromJSON(env.CHUNKS) }} + - name: getPullRequestsMergedBetween run: tests/unit/getPullRequestsMergedBetweenTest.sh diff --git a/.storybook/fonts.css b/.storybook/fonts.css new file mode 100644 index 000000000000..bbbcf3839000 --- /dev/null +++ b/.storybook/fonts.css @@ -0,0 +1,50 @@ +@font-face { + font-family: ExpensifyNeue-Regular; + font-weight: 400; + font-style: normal; + src: url('../assets/fonts/web/ExpensifyNeue-Regular.woff2') format('woff2'), url('../assets/fonts/web/ExpensifyNeue-Regular.woff') format('woff'); +} + +@font-face { + font-family: ExpensifyNeue-Regular; + font-weight: 700; + font-style: normal; + src: url('../assets/fonts/web/ExpensifyNeue-Bold.woff2') format('woff2'), url('../assets/fonts/web/ExpensifyNeue-Bold.woff') format('woff'); +} + +@font-face { + font-family: ExpensifyNeue-Regular; + font-weight: 400; + font-style: italic; + src: url('../assets/fonts/web/ExpensifyNeue-Italic.woff2') format('woff2'), url('../assets/fonts/web/ExpensifyNeue-Italic.woff') format('woff'); +} + +@font-face { + font-family: ExpensifyNeue-Regular; + font-weight: 700; + font-style: italic; + src: url('../assets/fonts/web/ExpensifyNeue-BoldItalic.woff2') format('woff2'), url('../assets/fonts/web/ExpensifyNeue-BoldItalic.woff') format('woff'); +} + +@font-face { + font-family: ExpensifyMono-Regular; + font-weight: 400; + font-style: normal; + src: url('../assets/fonts/web/ExpensifyMono-Regular.woff2') format('woff2'), url('../assets/fonts/web/ExpensifyMono-Regular.woff') format('woff'); +} + +@font-face { + font-family: ExpensifyMono-Bold; + font-weight: 700; + font-style: normal; + src: url('../assets/fonts/web/ExpensifyMono-Bold.woff2') format('woff2'), url('../assets/fonts/web/ExpensifyMono-Bold.woff') format('woff'); +} + +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +strong { + font-weight: 600; +} diff --git a/.storybook/main.js b/.storybook/main.js index d34252604b41..479ed26cc907 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -10,7 +10,8 @@ module.exports = { ], staticDirs: [ './public', - '../assets/css', + {from: '../assets/css', to: 'css'}, + {from: '../assets/fonts/web', to: 'fonts'}, ], core: { builder: 'webpack5', diff --git a/.storybook/manager-head.html b/.storybook/manager-head.html index b9a271bb6097..d9e41443cbb2 100644 --- a/.storybook/manager-head.html +++ b/.storybook/manager-head.html @@ -1,3 +1,3 @@ - + diff --git a/.storybook/preview.js b/.storybook/preview.js index 65508e6bed71..d69b253a18b6 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,6 +1,6 @@ import React from 'react'; import Onyx from 'react-native-onyx'; -import '../assets/css/fonts.css'; +import './fonts.css'; import ComposeProviders from '../src/components/ComposeProviders'; import HTMLEngineProvider from '../src/components/HTMLEngineProvider'; import OnyxProvider from '../src/components/OnyxProvider'; diff --git a/android/app/build.gradle b/android/app/build.gradle index 74f23aac9bd4..dcb7a2e2f4c5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -156,8 +156,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001026500 - versionName "1.2.65-0" + versionCode 1001027200 + versionName "1.2.72-0" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() if (isNewArchitectureEnabled()) { diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java index 10a25f05d7d4..170d082cb479 100644 --- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java @@ -171,10 +171,7 @@ private void applyMessageStyle(@NonNull Context context, NotificationCompat.Buil String message = reportAction.get("message").getList().get(0).getMap().get("text").getString(); long time = Timestamp.valueOf(reportAction.get("created").getString(Instant.now().toString())).getTime(); String roomName = payload.get("roomName") == null ? "" : payload.get("roomName").getString(""); - String conversationTitle = "Chat with " + name; - if (!roomName.isEmpty()) { - conversationTitle = "#" + roomName; - } + String conversationTitle = roomName.isEmpty() ? "Chat with " + name : roomName; // Retrieve or create the Person object who sent the latest report comment Person person = notificationCache.people.get(accountID); diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 1b925f61ae28..55a3b29e3695 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,7 +1,7 @@ #061B09 #FFFFFF - #0185ff + #03D47C #0b1b34 #7D8B8F diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index a804c14abb53..dbaeb878951e 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -5,7 +5,6 @@ + + + + + + + + + + + + + diff --git a/assets/images/emojiCategoryIcons/calendar.svg b/assets/images/emojiCategoryIcons/calendar.svg new file mode 100644 index 000000000000..18885029a7c8 --- /dev/null +++ b/assets/images/emojiCategoryIcons/calendar.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/assets/images/emojiCategoryIcons/car.svg b/assets/images/emojiCategoryIcons/car.svg new file mode 100644 index 000000000000..e5cde58b2615 --- /dev/null +++ b/assets/images/emojiCategoryIcons/car.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/assets/images/emojiCategoryIcons/flag.svg b/assets/images/emojiCategoryIcons/flag.svg new file mode 100644 index 000000000000..e72787c3665b --- /dev/null +++ b/assets/images/emojiCategoryIcons/flag.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/assets/images/emojiCategoryIcons/hamburger.svg b/assets/images/emojiCategoryIcons/hamburger.svg new file mode 100644 index 000000000000..52945988effc --- /dev/null +++ b/assets/images/emojiCategoryIcons/hamburger.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/assets/images/emojiCategoryIcons/heart.svg b/assets/images/emojiCategoryIcons/heart.svg new file mode 100644 index 000000000000..95e73f329cfa --- /dev/null +++ b/assets/images/emojiCategoryIcons/heart.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/assets/images/emojiCategoryIcons/light-bulb.svg b/assets/images/emojiCategoryIcons/light-bulb.svg new file mode 100644 index 000000000000..0e6a33c041df --- /dev/null +++ b/assets/images/emojiCategoryIcons/light-bulb.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/assets/images/emojiCategoryIcons/peace-sign.svg b/assets/images/emojiCategoryIcons/peace-sign.svg new file mode 100644 index 000000000000..ab76642fc48d --- /dev/null +++ b/assets/images/emojiCategoryIcons/peace-sign.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/assets/images/emojiCategoryIcons/plane.svg b/assets/images/emojiCategoryIcons/plane.svg new file mode 100644 index 000000000000..17aca931f8a3 --- /dev/null +++ b/assets/images/emojiCategoryIcons/plane.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/assets/images/emojiCategoryIcons/plant.svg b/assets/images/emojiCategoryIcons/plant.svg new file mode 100644 index 000000000000..a17ed231e1df --- /dev/null +++ b/assets/images/emojiCategoryIcons/plant.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/assets/images/emojiCategoryIcons/soccer-ball.svg b/assets/images/emojiCategoryIcons/soccer-ball.svg new file mode 100644 index 000000000000..40fa05516a11 --- /dev/null +++ b/assets/images/emojiCategoryIcons/soccer-ball.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/assets/images/product-illustrations/abracadabra.svg b/assets/images/product-illustrations/abracadabra.svg new file mode 100644 index 000000000000..dba7336cd11d --- /dev/null +++ b/assets/images/product-illustrations/abracadabra.svg @@ -0,0 +1,710 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/product-illustrations/magic-code.svg b/assets/images/product-illustrations/magic-code.svg new file mode 100644 index 000000000000..7f26cf51874c --- /dev/null +++ b/assets/images/product-illustrations/magic-code.svg @@ -0,0 +1,931 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/babel.config.js b/babel.config.js index dad806c0e416..0b3ea157e234 100644 --- a/babel.config.js +++ b/babel.config.js @@ -33,6 +33,14 @@ const metro = { presets: [require('metro-react-native-babel-preset')], plugins: [ 'react-native-reanimated/plugin', + + // This is needed due to a react-native bug: https://github.com/facebook/react-native/issues/29084#issuecomment-1030732709 + // It is included in metro-react-native-babel-preset but needs to be before plugin-proposal-class-properties or FlatList will break + '@babel/plugin-transform-flow-strip-types', + + ['@babel/plugin-proposal-class-properties', {loose: true}], + ['@babel/plugin-proposal-private-methods', {loose: true}], + ['@babel/plugin-proposal-private-property-in-object', {loose: true}], ], }; @@ -60,6 +68,7 @@ if (process.env.CAPTURE_METRICS === 'true') { module.exports = ({caller}) => { // For `react-native` (iOS/Android) caller will be "metro" // For `webpack` (Web) caller will be "@babel-loader" + // For jest, it will be babel-jest // For `storybook` there won't be any config at all so we must give default argument of an empty object const runningIn = caller((args = {}) => args.name); return ['metro', 'babel-jest'].includes(runningIn) ? metro : webpack; diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 8334cfd33a46..84f0989328ad 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -8,6 +8,7 @@ const CopyPlugin = require('copy-webpack-plugin'); const dotenv = require('dotenv'); const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer'); const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin'); +const FontPreloadPlugin = require('webpack-font-preload-plugin'); const CustomVersionFilePlugin = require('./CustomVersionFilePlugin'); const includeModules = [ @@ -79,6 +80,9 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ new HtmlInlineScriptPlugin({ scriptMatchPattern: [/splash.+[.]js$/], }), + new FontPreloadPlugin({ + extensions: ['woff2'], + }), new ProvidePlugin({ process: 'process/browser', }), @@ -152,22 +156,16 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ // Rule for react-native-web-webview { test: /postMock.html$/, - use: { - loader: 'file-loader', - options: { - name: '[name].[ext]', - }, + type: 'asset', + generator: { + filename: '[name].[ext]', }, }, // Gives the ability to load local images { test: /\.(png|jpe?g|gif)$/i, - use: [ - { - loader: 'file-loader', - }, - ], + type: 'asset', }, // Load svg images diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index dc531040812e..590eab24efe5 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -111,11 +111,18 @@ Additionally if you want to discuss an idea with the open source community witho 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 written technical explanation of the changes you will make. Include "Proposal" as the first word in your comment. - - 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). -6. If your solution proposal is accepted by the Expensify engineer assigned to the issue, Expensify will hire you on Upwork and assign the GitHub issue to you. -7. Once hired, post a comment in the Github issue stating when you expect to have your PR ready for review +4. After you reproduce the issue, complete the [proposal template here](./PROPOSAL_TEMPLATE.md) and post it as a comment in the corresponding GitHub issue (linked in the Upwork job). + - Note: Before submitting a proposal on an issue, be sure to read any other existing proposals. ALL NEW PROPOSALS MUST BE DIFFERENT FROM EXISTING PROPOSALS. The *difference* should be important, meaningful or considerable. +5. Refrain from leaving additional comments until someone from the Contributor-Plus team and / or someone from Expensify provides feedback on your proposal (do not create a pull request yet). + - Do not leave more than one proposal. + - Do not make extensive changes to your current proposal until after it has been reviewed. + - If you want to make an entirely new proposal or update an existing proposal, please go back and edit your original proposal, then post a new comment to the issue in this format to alert everyone that it has been updated: + ``` + ## Proposal + [Updated](link to proposal) + ``` +6. If your proposal is accepted by the Expensify engineer assigned to the issue, Expensify will hire you on Upwork and assign the GitHub issue to you. +7. Once hired, post a comment in the Github issue stating when you expect to have your PR ready for review. #### Begin coding your solution in a pull request 7. When you are ready to start, fork the repository and create a new branch. diff --git a/contributingGuides/PROPOSAL_TEMPLATE.md b/contributingGuides/PROPOSAL_TEMPLATE.md new file mode 100644 index 000000000000..53330dfe96c9 --- /dev/null +++ b/contributingGuides/PROPOSAL_TEMPLATE.md @@ -0,0 +1,33 @@ +## Proposal + +### Please re-state the problem that we are trying to solve in this issue. + +### What is the root cause of that problem? + +### What changes do you think we should make in order to solve the problem? + + +### What alternative solutions did you explore? (Optional) + +**Reminder:** Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job. + + diff --git a/docs/_data/routes.yml b/docs/_data/routes.yml index 9a50200a9576..4d1c135d577c 100644 --- a/docs/_data/routes.yml +++ b/docs/_data/routes.yml @@ -29,6 +29,14 @@ hubs: - href: Request-and-Send-Money title: Request and Send Money + - href: playbooks + title: Playbooks + icon: /assets/images/playbook.svg + description: Best practices for how to best deploy Expensify for your business + articles: + - href: Expensify-Playbook-for-US-based-VC-Backed-Startups + title: Expensify Playbook for US-Based VC-Backed Startups + - href: other title: Other description: Everything else you're looking for is right here. diff --git a/docs/articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups.md b/docs/articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups.md new file mode 100644 index 000000000000..0fc0525c2030 --- /dev/null +++ b/docs/articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups.md @@ -0,0 +1,208 @@ +--- +title: Expensify Playbook for US-Based VC-Backed Startups +description: Best practices for how to deploy Expensify for your business +--- +# Overview +This playbook details best practices on how Seed to Series A startups with under 100 employees can use Expensify to prioritize top-line revenue growth while managing spend responsibly. + +- See our Playbook for Small Businesses if you are more concerned with maintaining profitability than growing top-line revenue. [Coming soon…] +- See our Playbook for Midsize Businesses if you are series B or beyond, or have more than 100 employees. [Coming soon…] + +# Who you are +As a VC-backed business focused on growth and efficiency, you are looking for a product that puts smart automation in your hands. You prioritize top-line revenue growth over cash conservation, understanding that you’ll need to spend in order to grow. As a result, you want to enable your employees by putting spending power in their hands responsibly so there are appropriate compliance controls in place that scale with the business growth. Not only that, you want to decrease the amount of time you spend at the end of each month reimbursing employees, reconciling spend, and closing your books. + +# Step-by-step instructions for setting up Expensify +This playbook is built based on best practices we’ve developed after processing expenses for tens of thousands of companies around the world. As such, use this playbook as your starting point, knowing that you can customize Expensify to suit your business needs. Every company is different, and we’re always one chat away with any questions you may have. + +## Step 1: Create your Expensify account +If you don't already have one, go to [Expensify.com](https://expensify.com) and sign up for an account with your work email address. The account is free so don’t worry about the cost at this stage. + +## Step 2: Create a Control Policy +There are three policy types, but for your needs we recommend the Control Policy for the following reasons: + +- You can cap spend on certain expense types, and set compliance controls so Expensify’s built-in Concierge Audit Tracking can detect violations on your behalf +- As a growing business with VC-funding, the Control plan will scale with you as your team grows and you start to introduce more sophisticated approval workflows + +To create your Control Policy: + +1. Go to *Settings > Policies* +2. Select *Group* and click the button that says *New Policy* +3. Click *Select* under Control + +The Control plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your policy's #admins room in new.expensify.com, and chat with them there. The Control plan bundled with the Expensify Card is $9 per user per month when you commit annually, which is a 75% discount off our standard unbundled price point. The Control plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your policy's *#admins* room in [new.expensify.com](https://new.expensify.com), and chat with them there. + +## Step 3: Connect your accounting system +As a VC-backed company, your investors will want to see that your books are managed properly. That means making sure that: + +- Every purchase is categorized into the correct account in your chart of accounts +- Every expense is accounted for and added to your accounting system + +You do this by synchronizing Expensify and your accounting package as follows: + +1. Click *Settings > Policies* +2. Navigate to the *Connections* tab +3. Select your accounting system + - If you don’t see your accounting solution in the list of integrations we support, you can review an alternative solution in the Feature Deep Dives section below. +4. Follow the prompts to connect your accounting package + - Detailed instructions on connecting your accounting package are linked on the Connections page +5. Once connected, your categories will sync, and you’re ready to set Category Rules + +"“Expensify syncs seamlessly with QuickBooks, supports our web-based, paperless workflow, and offers internal controls, so it was the natural choice.” +Laura Redmond, CEO of Redmond Accounting + +## Step 4: Set up category rules +[Category rules](https://community.expensify.com/discussion/4638/how-to-enable-category-specific-rules-and-descriptions) are how you provide employees hints and requirements to make sure purchases stay within reasonable ranges and are documented appropriately for approval. For your company size and stage, we recommend the following: + +1. Click *Settings > Policies* +2. Navigate to the *Categories* tab where you’ll see all the categories you just imported from your accounting package +3. To set a rule for a specific category, click *“Edit Rules”* +4. The Edit Rules section will provide several expense category rules that tie to specific general ledger categories. While the individual rules might change slightly from business to business, and the exact category name will depend on your specific chart of accounts, we recommend these settings for VC backed startups: + - Set a $75 daily limit on meals and entertainment purchases + - Though we recommend [Expensify Guaranteed eReceipts](https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts) for most purchases, for large purchases or those in categories most often associated with fraud, we recommend scanned receipts for extra protection: + - For any purchase over $1000 + - For all lodging purchases, regardless of size + - For any meal over $50/person + - For all office supplies + - For all software purchases + - For all airfare purchases + - Require manual explanations for certain high risk categories: + - For airfare expenses a description of the expense mandatory for the employee to include the purpose of the travel + - Require a description for all rideshare and taxi expenses, ensuring employees are listing a purpose for the expense + +Setting up these category rules allows you to concentrate on growth versus needing to micromanage your employees' spending. +## Step 5: Set up scheduled submit +For an efficiency-focused company, we recommend setting up [Scheduled Submit](https://community.expensify.com/discussion/4476/how-to-enable-scheduled-submit-for-a-group-policy) on a Daily frequency: + +1. Click *Settings > Policies* +2. From here, select your group Control policy +3. Within your policy settings, select the *Reports* tab +4. You’ll notice *Scheduled Submit* is located directly under *Report Basics* +5. Choose *Daily* + +Between Expensify's SmartScan technology, direct corporate card feed import, automatic categorization, and [DoubleCheck](https://community.expensify.com/discussion/5738/deep-dive-how-does-concierge-receipt-audit-work) features, your employees shouldn't need to do anything more than swipe their Expensify Card or scan their receipt. + +Scheduled Submit will ensure all expenses are submitted automatically. Any expenses that do not fall within the rules you’ve set up for your policy will be escalated to you for manual review. + +"“Our employees just SmartScan a receipt as soon as they receive it, and regardless of what currency it's in, we process the expense and issue reimbursement automatically.”" +Amina Mobasher, Accountant at Ideo.org + +## Step 6: Connect your business bank account +If you’re located in the US, you can utilize Expensify’s payment processing and reimbursement features. + +*Note:* Before you begin, you’ll need the following to validate your business bank account: + +- Your bank account credentials +- A form of ID (a driver’s license or passport) +- Your business tax ID number, your business’ address and your website URL + +Let’s walk through the process of linking your business bank account: + +1. Go to *Settings > Account*, and select the *Payments* tab +2. Select *Add Verified Bank Account* +3. From here, we’ll ask you to use your online banking credentials to connect to your bank + - Alternatively, you can go the more manual route by selecting “Connect Manually” +4. Once that’s done, we’ll collect all of the necessary information on your business, such as your legal business name and address +5. We’ll then collect your personal information, and a photo ID to confirm your identity + +You only need to do this once: you are fully set up for not only reimbursing expense reports, but issuing Expensify Cards, collecting invoice payments online, as well as paying bills online. + +## Step 7: Invite employees +Next, you’ll want to invite your employees to the company policy you created. You can invite employees under *Settings > Policies > Policy Name > People*. From there, you can add employees one of three ways: + +- [Unique Policy Link](https://community.expensify.com/discussion/4643/how-to-invite-people-to-your-policy-using-a-join-link) - Each policy has a unique policy invite link, which is located at the top of the People tab in your policy settings. Simply share that link with anyone you’d like to add to your policy. +- [Manually](https://community.expensify.com/discussion/4975/how-to-invite-users-to-your-policy-manually-or-in-bulk/p1?new=1) - Enter employee email addresses manually by clicking the green Invite button in the People tab of your policy +- [Google SSO](https://community.expensify.com/discussion/4774/how-to-enable-google-apps-sso-with-your-expensify-group-policy) - Or, if you have a Google Workspace configured, you can synchronize your policy's people list to match your Google Workspace employee list. + +In the next section, we’ll go through how to configure approval routing but it’s important to remember that you’ll always have these 3 options to utilize, specifically the unique policy link and manual invites as your team continues to grow. + +## Step 8: Set up an approval workflow +Now, let’s set up some approval rules for your business as well as the ideal approval workflow that employee reports will follow after report submission: + +1. Go to *Settings > Policies*, and select the *People* tab. +2. From there, select [Submit & Approve](https://community.expensify.com/discussion/5643/deep-dive-submit-and-approve) - this will automatically add you as the approver, which ensures that any expenses that fall outside of the rules you set for your policy are brought to your attention. + - *Note*: If you are over 50 employees, please ask your Guide about the benefits of setting up an Advanced Approval workflow. +3. Next, enable manual approval for *expenses over $1000*. + - *Note*: We do not recommend configuring random report auditing for companies of your stage and scale. +4. Next, enable *Workflow Enforcement*. + - This ensures that employees are required to submit to you and not to someone else. +5. Disable *Prevent Self-Approval*. This is a more powerful feature recommended for companies with advanced compliance requirements, but generally isn't recommended for a company of your scale. + +Thanks to our [Concierge Receipt audit technology](https://community.expensify.com/discussion/5738/deep-dive-how-does-concierge-receipt-audit-work), once you set up an approval workflow, most expenses will be audited automatically and won’t require manual review. Your time is valuable, so you should focus it on reviewing only the expenses that fall outside of your policy’s rules. + +## Step 9: Set up your corporate card and assign cards to employees +Expensify is optimized to work with corporate cards from all banks – or even better, use our own perfectly integrated Expensify Card. + +### If you have an existing corporate card +Expensify supports direct card feeds from most financial institutions. Setting up a corporate card feed will pull in the transactions from the connected cards on a daily basis. To set this up, do the following: + +1. Go to *Settings > Domains > Company Cards >* Select your bank + - If you don’t see your financial institution in the list of banks we support, you can review an alternative solution in the Feature Deep Dives section below +2. Next, enter your bank account login credentials. + - To successfully connect to your bank, we’ll need the *master admin (primary) account* login credentials. +3. Next, assign the corporate cards to your employees by selecting the employee’s email address and the corresponding card number from the two drop-down menus under the *Assign a Card* section +4. Set a transaction start date + - If you don’t have a backlog of transactions you’d like to account for, feel free to skip this step. + +As mentioned above, we’ll be able to pull in transactions as they post (daily) and handle receipt-matching for you and your employees. However, with the Expensify Card, we’re able to bring in transactions at the point of sale which provides you with real time compliance. Next, let’s dive into how to set up the Expensify Card and the benefits of using the Expensify Card. + +### If you don't have a corporate card, use the Expensify Card +Expensify provides a corporate card with the following features: + +- 2% cashback on all card spend +- [SmartLimits](https://community.expensify.com/discussion/4851/deep-dive-what-are-unapproved-expense-limits#latest) +- A stable, unbreakable connection (third-party bank feeds can run into connectivity issues) + +The Expensify Card is recommended as the most efficient way to manage your company's spending. + +Here’s how to enable it: + +1. There are *two ways* you can [apply for the Expensify Card](https://community.expensify.com/discussion/4874/how-to-apply-for-the-expensify-card) + - *Via your Inbox* + - *Via Domain Settings* - Go to Settings > Domain > Company Cards > Enable Expensify Card +2. Assign the cards to your employees +3. Set *SmartLimits*: + - *Employees* - We recommend a low limit for most employees, roughly double the size of the maximum daily spend – such as $1000. + - *Execs* - We recommend a higher limit for executives, roughly 10x the limit of a non-executive employee (eg, $10,000). + +Once the Expensify Cards have been assigned, each employee will be prompted to enter their mailing address so they can receive their physical card. In the meantime, a virtual card will be ready to use immediately. + +If you have an accounting system we directly integrate with, check out how we take automation a step further with [Continuous Reconciliation](https://community.expensify.com/discussion/7335/faq-what-is-the-expensify-card-auto-reconciliation-process). We’ll create an Expensify Card clearing and liability account for you. Each time settlement occurs, we’ll take the total amount of your purchases and create a journal entry that credits the settlement account and debits the liability account - saving you hours of manual reconciliation work at the end of your statement period. + +“Moving from our other bank and getting Expensify cards into the hands of employees was super easy. I also love how simple it is to adjust credit limits and the auto reconciliation with the daily settlement.’” +Robin Gresham, Senior Accounting Systems Manager at SunCommon + +## Step 10: Set up Bill Pay and Invoicing +As a VC-backed startup, you might have vendors you work with that send you bills. And in most cases, you probably use some third party to pay those bills if you aren’t cutting checks the old fashioned way. Similarly, you probably have clients you invoice from time to time. As an all-in-one solution, we’re here to make bill payments and invoicing easy, and every policy and workspace comes with bill pay and invoicing - at no additional cost. Since you have your business bank account verified, you can either pay your bills via ACH. Alternatively, you can pay via credit card or by check. + +Let’s first chat through how Bill Pay works + +1. Have your vendors submit bills to domain.com@expensify.cash. + - This email address comes with every account, so no need to activate it anywhere. +2. Once the bill has been received, we’ll create the bill for your review directly in Expensify +3. At the top of the bill/invoice, you’ll notice a Pay button. Once you click that, you’ll see options including ACH, credit/debit card, along with mailing a physical check. + +Similarly, you can send bills directly from Expensify as well. + +1. From the *Reports* tab, select the down arrow next to *New Report* and select *Bill* +2. Next, enter in the Supplier’s email address, the Merchant name, total amount and date +3. At this point, you can also enter in an attachment to further validate the bill if necessary +4. Click *Submit*, we’ll forward the newly created bill directly to your Supplier. + +Reports, invoices and bills - they are largely the same in theory, just with different rules. As such, creating an invoice is just like creating an expense report and even a Bill. + +1. From the *Reports* tab, select the down arrow next to *New Report* and select *Invoice*. +2. Add all of the expenses/transactions tied to the Invoice +3. Enter the recipient’s email address, a memo if needed, and a due date for when it needs to get paid, and click *Send* + +You’ll notice it’s a slightly different flow from creating a Bill. Here, you are adding the transactions tied to the Invoice, and establishing a due date for when it needs to get paid. If you need to apply any markups, you can do so from your policy settings under the Invoices tab. + +## Step 11: Add a billing card +Now that we’ve gone through all of the steps for setting up your account, let’s make it official so there are no interruptions in service as your employees begin using Expensify. We handle billing via a billing card, and to add one: + +1. Go to *Account > Settings > Payments* +2. Select *Add Payment Card* +3. Enter your name, card number, postal code, expiration and CVV +4. Click *Accept Terms* + +# You’re all set! +Congrats, you are all set up! If you need any assistance with anything mentioned above, reach out to either your Setup Specialist or your Account Manager directly in new.expensify.com. Don’t have one yet? Create a Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you. diff --git a/docs/assets/images/lightbulb.svg b/docs/assets/images/lightbulb.svg index a704c9731b9c..45a889fb9e0c 100644 --- a/docs/assets/images/lightbulb.svg +++ b/docs/assets/images/lightbulb.svg @@ -1,16 +1,71 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/money-case.svg b/docs/assets/images/money-case.svg index bc2202d7fa3e..c1bb49b3ab5a 100644 --- a/docs/assets/images/money-case.svg +++ b/docs/assets/images/money-case.svg @@ -1,32 +1,135 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/paper-airplane.svg b/docs/assets/images/paper-airplane.svg index 3eb5ef96d5b9..8daa13bfa38b 100644 --- a/docs/assets/images/paper-airplane.svg +++ b/docs/assets/images/paper-airplane.svg @@ -1,25 +1,113 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/images/playbook.svg b/docs/assets/images/playbook.svg new file mode 100644 index 000000000000..0088d8f915f1 --- /dev/null +++ b/docs/assets/images/playbook.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/hubs/playbooks.html b/docs/hubs/playbooks.html new file mode 100644 index 000000000000..0f15922fd061 --- /dev/null +++ b/docs/hubs/playbooks.html @@ -0,0 +1,6 @@ +--- +layout: default +title: Playbooks +--- + +{% include hub.html %} diff --git a/docs/index.html b/docs/index.html index f3f27b7c9b76..d79c87e281a8 100644 --- a/docs/index.html +++ b/docs/index.html @@ -13,6 +13,7 @@

{% include hub-card.html href="send-money" %} {% include hub-card.html href="request-money" %} + {% include hub-card.html href="playbooks" %} {% include hub-card.html href="other" %}
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 998254346260..551df13bf1da 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.65 + 1.2.72 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.2.65.0 + 1.2.72.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index aaba4d654499..f2b2a55a0f51 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.2.65 + 1.2.72 CFBundleSignature ???? CFBundleVersion - 1.2.65.0 + 1.2.72.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index fbccc4067560..9e9ed15c25c4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -472,7 +472,7 @@ PODS: - React-Core - react-native-image-manipulator (1.0.5): - React - - react-native-image-picker (5.0.1): + - react-native-image-picker (5.0.2): - React-Core - react-native-netinfo (8.3.1): - React-Core @@ -594,13 +594,13 @@ PODS: - Firebase/Performance (= 8.8.0) - React-Core - RNFBApp - - RNGestureHandler (2.6.0): + - RNGestureHandler (2.9.0): - React-Core - RNPermissions (3.6.1): - React-Core - RNReactNativeHapticFeedback (1.14.0): - React-Core - - RNReanimated (3.0.0-rc.6): + - RNReanimated (3.0.0-rc.10): - DoubleConversion - FBLazyVector - FBReactNativeSpec @@ -992,7 +992,7 @@ SPEC CHECKSUMS: react-native-document-picker: f68191637788994baed5f57d12994aa32cf8bf88 react-native-flipper: dc5290261fbeeb2faec1bdc57ae6dd8d562e1de4 react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 - react-native-image-picker: 8cb4280e2c1efc3daeb2d9d597f9429a60472e40 + react-native-image-picker: a5dddebb4d2955ac4712a4ed66b00a85f62a63ac react-native-netinfo: 1a6035d3b9780221d407c277ebfb5722ace00658 react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 @@ -1024,10 +1024,10 @@ SPEC CHECKSUMS: RNFBApp: 729c0666395b1953198dc4a1ec6deb8fbe1c302e RNFBCrashlytics: 2061ca863e8e2fa1aae9b12477d7dfa8e88ca0f9 RNFBPerf: 389914cda4000fe0d996a752532a591132cbf3f9 - RNGestureHandler: 920eb17f5b1e15dae6e5ed1904045f8f90e0b11e + RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39 RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c - RNReanimated: 069f3aff5df4cbefaf81589c0622370073a89f1d + RNReanimated: 3eb05867410b44acaa81dd423945af3093305bd4 RNScreens: 0df01424e9e0ed7827200d6ed1087ddd06c493f9 RNSVG: 38ca962c970dbce1ca38991a5aebf26d163f9efb SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000000..32c07a669aa7 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,36 @@ +const testFileExtension = '[jt]s?(x)'; +module.exports = { + // TODO: change this back to preset: 'react-native' once we upgrade to React Native >= 0.71.2 + preset: '@testing-library/react-native', + testMatch: [ + `/tests/ui/**/*.${testFileExtension}`, + `/tests/unit/**/*.${testFileExtension}`, + `/tests/actions/**/*.${testFileExtension}`, + `/?(*.)+(spec|test).${testFileExtension}`, + ], + transform: { + '^.+\\.jsx?$': 'babel-jest', + }, + transformIgnorePatterns: [ + '/node_modules/(?!react-native)/', + ], + testPathIgnorePatterns: [ + '/node_modules', + ], + globals: { + __DEV__: true, + WebSocket: {}, + }, + fakeTimers: { + enableGlobally: true, + doNotFake: ['nextTick'], + }, + testEnvironment: 'jsdom', + setupFiles: [ + '/jest/setup.js', + ], + setupFilesAfterEnv: [ + '@testing-library/jest-native/extend-expect', + ], + cacheDirectory: '/.jest-cache', +}; diff --git a/patches/react-native-fast-image+8.6.3.patch b/patches/react-native-fast-image+8.6.3.patch index fc7e59c17c2e..f01b87b7fd91 100644 --- a/patches/react-native-fast-image+8.6.3.patch +++ b/patches/react-native-fast-image+8.6.3.patch @@ -1,15 +1,16 @@ diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java new file mode 100644 -index 0000000..03ad017 +index 0000000..2bd58b8 --- /dev/null +++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java -@@ -0,0 +1,31 @@ +@@ -0,0 +1,44 @@ +package com.dylanvann.fastimage; + +import android.graphics.BitmapFactory; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; ++import androidx.exifinterface.media.ExifInterface; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; @@ -32,6 +33,18 @@ index 0000000..03ad017 + BitmapFactory.Options bitmapOptions = new BitmapFactory.Options(); + bitmapOptions.inJustDecodeBounds = true; + BitmapFactory.decodeStream(source, null, bitmapOptions); ++ ++ // BitmapFactory#decodeStream leaves stream's position where ever it was after reading the encoded data ++ // https://developer.android.com/reference/android/graphics/BitmapFactory#decodeStream(java.io.InputStream) ++ // so we need to rewind the stream to be able to read image header with exif values ++ source.reset(); ++ ++ int orientation = new ExifInterface(source).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED); ++ if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270) { ++ int tmpWidth = bitmapOptions.outWidth; ++ bitmapOptions.outWidth = bitmapOptions.outHeight; ++ bitmapOptions.outHeight = tmpWidth; ++ } + return new SimpleResource(bitmapOptions); + } +} diff --git a/src/CONST.js b/src/CONST.js index ae3ffca7f025..10f2a7e63dec 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -7,6 +7,7 @@ const ACTIVE_EXPENSIFY_URL = Url.addTrailingForwardSlash(lodashGet(Config, 'NEW_ const USE_EXPENSIFY_URL = 'https://use.expensify.com'; const PLATFORM_OS_MACOS = 'Mac OS'; const ANDROID_PACKAGE_NAME = 'com.expensify.chat'; +const USA_COUNTRY_NAME = 'United States'; const CONST = { ANDROID_PACKAGE_NAME, @@ -27,7 +28,7 @@ const CONST = { AVATAR_MAX_ATTACHMENT_SIZE: 6291456, - AVATAR_ALLOWED_EXTENSIONS: ['jpg', 'jpeg', 'png', 'gif', 'bmp'], + AVATAR_ALLOWED_EXTENSIONS: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg'], // Minimum width and height size in px for a selected image AVATAR_MIN_WIDTH_PX: 80, @@ -40,6 +41,11 @@ const CONST = { DEFAULT_AVATAR_COUNT: 24, OLD_DEFAULT_AVATAR_COUNT: 8, + DISPLAY_NAME: { + MAX_LENGTH: 50, + RESERVED_FIRST_NAMES: ['Expensify', 'Concierge'], + }, + // Sizes needed for report empty state background image handling EMPTY_STATE_BACKGROUND: { SMALL_SCREEN: { @@ -484,6 +490,11 @@ const CONST = { EMOJI_SPACER: 'SPACER', + // This is the number of columns in each row of the picker. + // Because of how flatList implements these rows, each row is an index rather than each element + // For this reason to make headers work, we need to have the header be the only rendered element in its row + // If this number is changed, emojis.js will need to be updated to have the proper number of spacer elements + // around each header. EMOJI_NUM_PER_ROW: 8, EMOJI_FREQUENT_ROW_COUNT: 3, @@ -504,6 +515,7 @@ const CONST = { VISIBLE_PASSWORD: 'visible-password', EMAIL_ADDRESS: 'email-address', ASCII_CAPABLE: 'ascii-capable', + URL: 'url', }, ATTACHMENT_SOURCE_ATTRIBUTE: 'data-expensify-source', @@ -530,9 +542,9 @@ const CONST = { ADD_PAYMENT_MENU_POSITION_X: 356, EMOJI_PICKER_SIZE: { WIDTH: 320, - HEIGHT: 390, + HEIGHT: 392, }, - NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 288, + NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 256, EMOJI_PICKER_ITEM_HEIGHT: 32, EMOJI_PICKER_HEADER_HEIGHT: 32, COMPOSER_MAX_HEIGHT: 125, @@ -717,7 +729,6 @@ const CONST = { }, DEFAULT_LOCALE: 'en', - DEFAULT_SKIN_TONE: 'default', POLICY: { TYPE: { @@ -798,6 +809,7 @@ const CONST = { AFTER_FIRST_LINE_BREAK: /\n.*/g, CODE_2FA: /^\d{6}$/, ATTACHMENT_ID: /chat-attachments\/(\d+)/, + MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/, }, PRONOUNS: { @@ -840,6 +852,8 @@ const CONST = { MAX_COMMENT_LENGTH: 15000, FORM_CHARACTER_LIMIT: 50, + LEGAL_NAMES_CHARACTER_LIMIT: 150, + WORKSPACE_NAME_CHARACTER_LIMIT: 80, AVATAR_CROP_MODAL: { // The next two constants control what is min and max value of the image crop scale. // Values define in how many times the image can be bigger than its container. @@ -907,6 +921,259 @@ const CONST = { TFA_CODE_LENGTH: 6, CHAT_ATTACHMENT_TOKEN_KEY: 'X-Chat-Attachment-Token', + + USA_COUNTRY_NAME, + ALL_COUNTRIES: [ + 'Afghanistan', + 'Aland Islands', + 'Albania', + 'Algeria', + 'American Samoa', + 'Andorra', + 'Angola', + 'Anguilla', + 'Antarctica', + 'Antigua and Barbuda', + 'Argentina', + 'Armenia', + 'Aruba', + 'Australia', + 'Austria', + 'Azerbaijan', + 'Bahamas', + 'Bahrain', + 'Bangladesh', + 'Barbados', + 'Belarus', + 'Belgium', + 'Belize', + 'Benin', + 'Bermuda', + 'Bhutan', + 'Bolivia', + 'Bonaire, Saint Eustatius and Saba ', + 'Bosnia and Herzegovina', + 'Botswana', + 'Bouvet Island', + 'Brazil', + 'British Indian Ocean Territory', + 'British Virgin Islands', + 'Brunei', + 'Bulgaria', + 'Burkina Faso', + 'Burundi', + 'Cambodia', + 'Cameroon', + 'Canada', + 'Cape Verde', + 'Cayman Islands', + 'Central African Republic', + 'Chad', + 'Chile', + 'China', + 'Christmas Island', + 'Cocos Islands', + 'Colombia', + 'Comoros', + 'Cook Islands', + 'Costa Rica', + 'Croatia', + 'Cuba', + 'Curacao', + 'Cyprus', + 'Czech Republic', + 'Democratic Republic of the Congo', + 'Denmark', + 'Djibouti', + 'Dominica', + 'Dominican Republic', + 'East Timor', + 'Ecuador', + 'Egypt', + 'El Salvador', + 'Equatorial Guinea', + 'Eritrea', + 'Estonia', + 'Ethiopia', + 'Falkland Islands', + 'Faroe Islands', + 'Fiji', + 'Finland', + 'France', + 'French Guiana', + 'French Polynesia', + 'French Southern Territories', + 'Gabon', + 'Gambia', + 'Georgia', + 'Germany', + 'Ghana', + 'Gibraltar', + 'Greece', + 'Greenland', + 'Grenada', + 'Guadeloupe', + 'Guam', + 'Guatemala', + 'Guernsey', + 'Guinea', + 'Guinea-Bissau', + 'Guyana', + 'Haiti', + 'Heard Island and McDonald Islands', + 'Honduras', + 'Hong Kong', + 'Hungary', + 'Iceland', + 'India', + 'Indonesia', + 'Iran', + 'Iraq', + 'Ireland', + 'Isle of Man', + 'Israel', + 'Italy', + 'Ivory Coast', + 'Jamaica', + 'Japan', + 'Jersey', + 'Jordan', + 'Kazakhstan', + 'Kenya', + 'Kiribati', + 'Kosovo', + 'Kuwait', + 'Kyrgyzstan', + 'Laos', + 'Latvia', + 'Lebanon', + 'Lesotho', + 'Liberia', + 'Libya', + 'Liechtenstein', + 'Lithuania', + 'Luxembourg', + 'Macao', + 'Macedonia', + 'Madagascar', + 'Malawi', + 'Malaysia', + 'Maldives', + 'Mali', + 'Malta', + 'Marshall Islands', + 'Martinique', + 'Mauritania', + 'Mauritius', + 'Mayotte', + 'Mexico', + 'Micronesia', + 'Moldova', + 'Monaco', + 'Mongolia', + 'Montenegro', + 'Montserrat', + 'Morocco', + 'Mozambique', + 'Myanmar', + 'Namibia', + 'Nauru', + 'Nepal', + 'Netherlands', + 'New Caledonia', + 'New Zealand', + 'Nicaragua', + 'Niger', + 'Nigeria', + 'Niue', + 'Norfolk Island', + 'North Korea', + 'Northern Mariana Islands', + 'Norway', + 'Oman', + 'Pakistan', + 'Palau', + 'Palestinian Territory', + 'Panama', + 'Papua New Guinea', + 'Paraguay', + 'Peru', + 'Philippines', + 'Pitcairn', + 'Poland', + 'Portugal', + 'Puerto Rico', + 'Qatar', + 'Republic of the Congo', + 'Reunion', + 'Romania', + 'Russia', + 'Rwanda', + 'Saint Barthelemy', + 'Saint Helena', + 'Saint Kitts and Nevis', + 'Saint Lucia', + 'Saint Martin', + 'Saint Pierre and Miquelon', + 'Saint Vincent and the Grenadines', + 'Samoa', + 'San Marino', + 'Sao Tome and Principe', + 'Saudi Arabia', + 'Senegal', + 'Serbia', + 'Seychelles', + 'Sierra Leone', + 'Singapore', + 'Sint Maarten', + 'Slovakia', + 'Slovenia', + 'Solomon Islands', + 'Somalia', + 'South Africa', + 'South Georgia and the South Sandwich Islands', + 'South Korea', + 'South Sudan', + 'Spain', + 'Sri Lanka', + 'Sudan', + 'Suriname', + 'Svalbard and Jan Mayen', + 'Swaziland', + 'Sweden', + 'Switzerland', + 'Syria', + 'Taiwan', + 'Tajikistan', + 'Tanzania', + 'Thailand', + 'Togo', + 'Tokelau', + 'Tonga', + 'Trinidad and Tobago', + 'Tunisia', + 'Turkey', + 'Turkmenistan', + 'Turks and Caicos Islands', + 'Tuvalu', + 'U.S. Virgin Islands', + 'Uganda', + 'Ukraine', + 'United Arab Emirates', + 'United Kingdom', + USA_COUNTRY_NAME, + 'Uruguay', + 'Uzbekistan', + 'Vanuatu', + 'Vatican', + 'Venezuela', + 'Vietnam', + 'Wallis and Futuna', + 'Western Sahara', + 'Yemen', + 'Zambia', + 'Zimbabwe', + ], }; export default CONST; diff --git a/src/Expensify.js b/src/Expensify.js index 6750c365c164..b4a4f341194e 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -2,9 +2,10 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; -import {AppState} from 'react-native'; +import {AppState, Linking} from 'react-native'; import Onyx, {withOnyx} from 'react-native-onyx'; +import * as ReportUtils from './libs/ReportUtils'; import BootSplash from './libs/BootSplash'; import * as ActiveClientManager from './libs/ActiveClientManager'; import ONYXKEYS from './ONYXKEYS'; @@ -26,6 +27,7 @@ import Navigation from './libs/Navigation/Navigation'; import DeeplinkWrapper from './components/DeeplinkWrapper'; import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu'; import * as ReportActionContextMenu from './pages/home/report/ContextMenu/ReportActionContextMenu'; +import KeyboardShortcutsModal from './components/KeyboardShortcutsModal'; // 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 @@ -120,6 +122,9 @@ class Expensify extends PureComponent { }); this.appStateChangeListener = AppState.addEventListener('change', this.initializeClient); + + // Open chat report from a deep link (only mobile native) + Linking.addEventListener('url', state => ReportUtils.openReportFromDeepLink(state.url)); } componentDidUpdate() { @@ -134,6 +139,9 @@ class Expensify extends PureComponent { // eslint-disable-next-line react/no-did-update-set-state this.setState({isSplashShown: false}); + + // 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 => ReportUtils.openReportFromDeepLink(url)); } } @@ -190,6 +198,7 @@ class Expensify extends PureComponent { {!this.state.isSplashShown && ( <> + `settings/addlogin/${type}`, SETTINGS_PAYMENTS_TRANSFER_BALANCE: 'settings/payments/transfer-balance', SETTINGS_PAYMENTS_CHOOSE_TRANSFER_ACCOUNT: 'settings/payments/choose-transfer-account', + SETTINGS_PERSONAL_DETAILS, + SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: `${SETTINGS_PERSONAL_DETAILS}/legal-name`, + SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: `${SETTINGS_PERSONAL_DETAILS}/date-of-birth`, + SETTINGS_PERSONAL_DETAILS_ADDRESS: `${SETTINGS_PERSONAL_DETAILS}/address`, NEW_GROUP: 'new/group', NEW_CHAT: 'new/chat', REPORT, diff --git a/src/components/AddressSearch.js b/src/components/AddressSearch.js index 77aeb76c7624..3ba622591546 100644 --- a/src/components/AddressSearch.js +++ b/src/components/AddressSearch.js @@ -7,9 +7,10 @@ import lodashGet from 'lodash/get'; import CONFIG from '../CONFIG'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import styles from '../styles/styles'; +import themeColors from '../styles/themes/default'; import TextInput from './TextInput'; -import Log from '../libs/Log'; import * as GooglePlacesUtils from '../libs/GooglePlacesUtils'; +import CONST from '../CONST'; // The error that's being thrown below will be ignored until we fork the // react-native-google-places-autocomplete repo and replace the @@ -48,6 +49,9 @@ const propTypes = { // eslint-disable-next-line react/forbid-prop-types containerStyles: PropTypes.arrayOf(PropTypes.object), + /** Should address search be limited to results in the USA */ + isLimitedToUSA: PropTypes.bool, + /** A map of inputID key names */ renamedInputKeys: PropTypes.shape({ street: PropTypes.string, @@ -56,6 +60,9 @@ const propTypes = { zipCode: PropTypes.string, }), + /** Maximum number of characters allowed in search input */ + maxInputLength: PropTypes.number, + ...withLocalizePropTypes, }; @@ -68,12 +75,14 @@ const defaultProps = { value: undefined, defaultValue: undefined, containerStyles: [], + isLimitedToUSA: true, renamedInputKeys: { street: 'addressStreet', city: 'addressCity', state: 'addressState', zipCode: 'addressZipCode', }, + maxInputLength: undefined, }; // Do not convert to class component! It's been tried before and presents more challenges than it's worth. @@ -81,6 +90,10 @@ const defaultProps = { // Reference: https://github.com/FaridSafi/react-native-google-places-autocomplete/issues/609#issuecomment-886133839 const AddressSearch = (props) => { const [displayListViewBorder, setDisplayListViewBorder] = useState(false); + const query = {language: props.preferredLocale, types: 'address'}; + if (props.isLimitedToUSA) { + query.components = 'country:us'; + } const saveLocationDetails = (details) => { const addressComponents = details.address_components; @@ -89,20 +102,33 @@ const AddressSearch = (props) => { } // Gather the values from the Google details - const streetNumber = GooglePlacesUtils.getAddressComponent(addressComponents, 'street_number', 'long_name') || ''; - const streetName = GooglePlacesUtils.getAddressComponent(addressComponents, 'route', 'long_name') || ''; - const street = `${streetNumber} ${streetName}`.trim(); - let city = GooglePlacesUtils.getAddressComponent(addressComponents, 'locality', 'long_name'); - if (!city) { - city = GooglePlacesUtils.getAddressComponent(addressComponents, 'sublocality', 'long_name'); - Log.hmmm('[AddressSearch] Replacing missing locality with sublocality: ', {address: details.formatted_address, sublocality: city}); - } - const zipCode = GooglePlacesUtils.getAddressComponent(addressComponents, 'postal_code', 'long_name'); - const state = GooglePlacesUtils.getAddressComponent(addressComponents, 'administrative_area_level_1', 'short_name'); + const { + street_number: streetNumber, + route: streetName, + locality: city, + sublocality: cityFallback, // Some locations only return sublocality instead of locality + postal_code: zipCode, + administrative_area_level_1: state, + country, + } = GooglePlacesUtils.getAddressComponents(addressComponents, { + street_number: 'long_name', + route: 'long_name', + locality: 'long_name', + sublocality: 'long_name', + postal_code: 'long_name', + administrative_area_level_1: 'short_name', + country: 'long_name', + }); const values = { street: props.value ? props.value.trim() : '', + city: city || cityFallback, + zipCode, + state, + country: '', }; + + const street = `${streetNumber} ${streetName}`.trim(); if (street && street.length >= values.street.length) { // We are only passing the street number and name if the combined length is longer than the value // that was initially passed to the autocomplete component. Google Places can truncate details @@ -110,18 +136,11 @@ const AddressSearch = (props) => { // specific than the one the user entered manually. values.street = street; } - if (city) { - values.city = city; - } - if (zipCode) { - values.zipCode = zipCode; - } - if (state) { - values.state = state; - } - if (_.size(values) === 0) { - return; + + if (_.includes(CONST.ALL_COUNTRIES, country)) { + values.country = country; } + if (props.inputID) { _.each(values, (value, key) => { const inputKey = lodashGet(props.renamedInputKeys, key, key); @@ -162,11 +181,7 @@ const AddressSearch = (props) => { // After we select an option, we set displayListViewBorder to false to prevent UI flickering setDisplayListViewBorder(false); }} - query={{ - language: props.preferredLocale, - types: 'address', - components: 'country:us', - }} + query={query} requestUrl={{ useOnPlatform: 'all', url: `${CONFIG.EXPENSIFY.URL_API_ROOT}api?command=Proxy_GooglePlaces&proxyUrl=`, @@ -208,6 +223,7 @@ const AddressSearch = (props) => { setDisplayListViewBorder(false); } }, + maxLength: props.maxInputLength, }} styles={{ textInputContainer: [styles.flexColumn], @@ -228,6 +244,8 @@ const AddressSearch = (props) => { description: [styles.googleSearchText], separator: [styles.googleSearchSeparator], }} + listHoverColor={themeColors.border} + listUnderlayColor={themeColors.buttonPressedBG} onLayout={(event) => { // We use the height of the element to determine if we should hide the border of the listView dropdown // to prevent a lingering border when there are no address suggestions. diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 7e3e0713aca1..d76106411eca 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -20,7 +20,6 @@ import HeaderWithCloseButton from './HeaderWithCloseButton'; import fileDownload from '../libs/fileDownload'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import ConfirmModal from './ConfirmModal'; -import TextWithEllipsis from './TextWithEllipsis'; import HeaderGap from './HeaderGap'; import SafeAreaConsumer from './SafeAreaConsumer'; @@ -233,8 +232,6 @@ class AttachmentModal extends PureComponent { // If source is a URL, add auth token to get access const source = this.state.source; - const {fileName, fileExtension} = FileUtils.splitExtensionFromFileName(this.props.originalFileName || lodashGet(this.state, 'file.name', '')); - return ( <> this.downloadAttachment(source)} onCloseButtonPress={() => this.setState({isModalOpen: false})} - subtitle={fileName ? ( - - ) : ''} /> {this.state.source && ( diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js index 1d9922ead4cc..b0b568d986c8 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.js +++ b/src/components/AvatarCropModal/AvatarCropModal.js @@ -270,10 +270,16 @@ const AvatarCropModal = (props) => { height: size, width: size, originX, originY, }; + // Svg images are converted to a png blob to preserve transparency, so we need to update the + // image name and type accordingly. + const isSvg = props.imageType.includes('image/svg'); + const imageName = isSvg ? 'fileName.png' : props.imageName; + const imageType = isSvg ? 'image/png' : props.imageType; + cropOrRotateImage( props.imageUri, [{rotate: rotation.value % 360}, {crop}], - {compress: 1, name: props.imageName, type: props.imageType}, + {compress: 1, name: imageName, type: imageType}, ) .then((newImage) => { props.onClose(); diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index 5e00825c5a50..6a0167fe937a 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -12,6 +12,8 @@ import updateIsFullComposerAvailable from '../../libs/ComposerUtils/updateIsFull import getNumberOfLines from '../../libs/ComposerUtils/index'; import * as Browser from '../../libs/Browser'; import Clipboard from '../../libs/Clipboard'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; +import compose from '../../libs/compose'; const propTypes = { /** Maximum number of lines in the text input */ @@ -65,6 +67,8 @@ const propTypes = { isComposerFullSize: PropTypes.bool, ...withLocalizePropTypes, + + ...windowDimensionsPropTypes, }; const defaultProps = { @@ -156,7 +160,8 @@ class Composer extends React.Component { if (prevProps.value !== this.props.value || prevProps.defaultValue !== this.props.defaultValue - || prevProps.isComposerFullSize !== this.props.isComposerFullSize) { + || prevProps.isComposerFullSize !== this.props.isComposerFullSize + || prevProps.windowWidth !== this.props.windowWidth) { this.updateNumberOfLines(); } @@ -268,7 +273,7 @@ class Composer extends React.Component { return; } - const plainText = event.clipboardData.getData('text/plain').replace(/\n\n/g, '\n'); + const plainText = event.clipboardData.getData('text/plain'); this.paste(Str.htmlDecode(plainText)); } @@ -297,9 +302,7 @@ class Composer extends React.Component { // the only stuff put into the clipboard is what the user selected. const selectedText = event.target.value.substring(this.state.selection.start, this.state.selection.end); - // The plaintext portion that is put into the clipboard needs to have the newlines duplicated. This is because - // the paste functionality is stripping all duplicate newlines to try and provide consistent behavior. - Clipboard.setHtml(selectedText, selectedText.replace(/\n/g, '\n\n')); + Clipboard.setHtml(selectedText, selectedText); } /** @@ -366,7 +369,10 @@ class Composer extends React.Component { Composer.propTypes = propTypes; Composer.defaultProps = defaultProps; -export default withLocalize(React.forwardRef((props, ref) => ( +export default compose( + withLocalize, + withWindowDimensions, +)(React.forwardRef((props, ref) => ( /* eslint-disable-next-line react/jsx-props-no-spreading */ ))); diff --git a/src/components/CopySelectionHelper.js b/src/components/CopySelectionHelper.js index d7d5d206d2c1..f300adabcbff 100644 --- a/src/components/CopySelectionHelper.js +++ b/src/components/CopySelectionHelper.js @@ -36,7 +36,9 @@ class CopySelectionHelper extends React.Component { Clipboard.setString(parser.htmlToMarkdown(selection)); return; } - Clipboard.setHtml(selection, Str.htmlDecode(parser.htmlToText(selection))); + + // Replace doubled newlines with the single ones because selection from SelectionScraper html contains doubled
marks + Clipboard.setHtml(selection, Str.htmlDecode(parser.htmlToText(selection).replace(/\n\n/g, '\n'))); } render() { diff --git a/src/components/CountryPicker.js b/src/components/CountryPicker.js new file mode 100644 index 000000000000..d673e3719079 --- /dev/null +++ b/src/components/CountryPicker.js @@ -0,0 +1,66 @@ +import _ from 'underscore'; +import React, {forwardRef} from 'react'; +import PropTypes from 'prop-types'; +import CONST from '../CONST'; +import Picker from './Picker'; +import withLocalize, {withLocalizePropTypes} from './withLocalize'; + +const COUNTRIES = _.map(CONST.ALL_COUNTRIES, countryName => ({ + value: countryName, + label: countryName, +})); + +const propTypes = { + /** The label for the field */ + label: PropTypes.string, + + /** 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, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + label: '', + value: undefined, + errorText: '', + shouldSaveDraft: false, + inputID: undefined, + onBlur: () => {}, +}; + +const CountryPicker = forwardRef((props, ref) => ( + +)); + +CountryPicker.propTypes = propTypes; +CountryPicker.defaultProps = defaultProps; +CountryPicker.displayName = 'CountryPicker'; + +export default withLocalize(CountryPicker); diff --git a/src/components/DeeplinkWrapper/deeplinkRoutes.js b/src/components/DeeplinkWrapper/deeplinkRoutes.js index 2e41db1a48cf..06b6aba8e828 100644 --- a/src/components/DeeplinkWrapper/deeplinkRoutes.js +++ b/src/components/DeeplinkWrapper/deeplinkRoutes.js @@ -1,4 +1,5 @@ import ROUTES from '../../ROUTES'; +import Permissions from '../../libs/Permissions'; /** @type {Array} Routes regex used for desktop deeplinking */ export default [ @@ -21,6 +22,10 @@ export default [ { // /v/* pattern: '/v($|(//*))', + + // Disable deep linking in desktop App when passwordless is enabled because + // we want to open the magic link in its own tab + isDisabled: betas => Permissions.canUsePasswordlessLogins(betas), }, { // /bank-account/* diff --git a/src/components/DeeplinkWrapper/index.website.js b/src/components/DeeplinkWrapper/index.website.js index 5498d81627ab..cfcda9660ca8 100644 --- a/src/components/DeeplinkWrapper/index.website.js +++ b/src/components/DeeplinkWrapper/index.website.js @@ -2,6 +2,7 @@ import _ from 'underscore'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; +import {withOnyx} from 'react-native-onyx'; import deeplinkRoutes from './deeplinkRoutes'; import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator'; import TextLink from '../TextLink'; @@ -9,12 +10,14 @@ import * as Illustrations from '../Icon/Illustrations'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import Text from '../Text'; import styles from '../../styles/styles'; +import compose from '../../libs/compose'; import CONST from '../../CONST'; import CONFIG from '../../CONFIG'; import Icon from '../Icon'; import * as Expensicons from '../Icon/Expensicons'; import colors from '../../styles/colors'; import * as Browser from '../../libs/Browser'; +import ONYXKEYS from '../../ONYXKEYS'; const propTypes = { /** Children to render. */ @@ -54,6 +57,9 @@ class DeeplinkWrapper extends PureComponent { // check if pathname matches with deeplink routes const matchedRoute = _.find(deeplinkRoutes, (route) => { + if (route.isDisabled && route.isDisabled(this.props.betas)) { + return false; + } const routeRegex = new RegExp(route.pattern); return routeRegex.test(window.location.pathname); }); @@ -156,4 +162,9 @@ class DeeplinkWrapper extends PureComponent { } DeeplinkWrapper.propTypes = propTypes; -export default withLocalize(DeeplinkWrapper); +export default compose( + withLocalize, + withOnyx({ + betas: {key: ONYXKEYS.BETAS}, + }), +)(DeeplinkWrapper); diff --git a/src/components/EmojiPicker/CategoryShortcutBar.js b/src/components/EmojiPicker/CategoryShortcutBar.js new file mode 100644 index 000000000000..0114df692850 --- /dev/null +++ b/src/components/EmojiPicker/CategoryShortcutBar.js @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import _ from 'underscore'; +import styles from '../../styles/styles'; +import FrequentlyUsed from '../../../assets/images/history.svg'; +import Smiley from '../../../assets/images/emoji.svg'; +import AnimalsAndNature from '../../../assets/images/emojiCategoryIcons/plant.svg'; +import FoodAndDrink from '../../../assets/images/emojiCategoryIcons/hamburger.svg'; +import TravelAndPlaces from '../../../assets/images/emojiCategoryIcons/plane.svg'; +import Activities from '../../../assets/images/emojiCategoryIcons/soccer-ball.svg'; +import Objects from '../../../assets/images/emojiCategoryIcons/light-bulb.svg'; +import Symbols from '../../../assets/images/emojiCategoryIcons/peace-sign.svg'; +import Flags from '../../../assets/images/emojiCategoryIcons/flag.svg'; +import CategoryShortcutButton from './CategoryShortcutButton'; + +const propTypes = { + /** The function to call when an emoji is selected */ + onPress: PropTypes.func.isRequired, + + /** The indices that the icons should link to */ + headerIndices: PropTypes.arrayOf(PropTypes.number).isRequired, +}; + +const CategoryShortcutBar = (props) => { + const icons = [Smiley, AnimalsAndNature, FoodAndDrink, TravelAndPlaces, Activities, Objects, Symbols, Flags]; + + // If the user has frequently used emojis, there will be 9 headers, otherwise there will be 8 + if (props.headerIndices.length === 9) { + icons.unshift(FrequentlyUsed); + } + + return ( + + {_.map(props.headerIndices, (headerIndex, i) => ( + props.onPress(headerIndex)} + key={`categoryShortcut${i}`} + /> + ))} + + ); +}; +CategoryShortcutBar.propTypes = propTypes; +CategoryShortcutBar.displayName = 'CategoryShortcutBar'; + +export default CategoryShortcutBar; diff --git a/src/components/EmojiPicker/CategoryShortcutButton.js b/src/components/EmojiPicker/CategoryShortcutButton.js new file mode 100644 index 000000000000..3b5d43f9b10d --- /dev/null +++ b/src/components/EmojiPicker/CategoryShortcutButton.js @@ -0,0 +1,53 @@ +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {Pressable, View} from 'react-native'; +import Icon from '../Icon'; +import variables from '../../styles/variables'; +import styles from '../../styles/styles'; +import * as StyleUtils from '../../styles/StyleUtils'; +import getButtonState from '../../libs/getButtonState'; +import themeColors from '../../styles/themes/default'; + +const propTypes = { + /** The icon representation of the category that this button links to */ + icon: PropTypes.func.isRequired, + + /** The function to call when an emoji is selected */ + onPress: PropTypes.func.isRequired, +}; + +class CategoryShortcutButton extends PureComponent { + constructor(props) { + super(props); + this.state = { + isHighlighted: false, + }; + } + + render() { + return ( + this.setState({isHighlighted: true})} + onHoverOut={() => this.setState({isHighlighted: false})} + style={({pressed}) => ([ + StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)), + styles.categoryShortcutButton, + this.state.isHighlighted && styles.emojiItemHighlighted, + ])} + > + + + + + ); + } +} +CategoryShortcutButton.propTypes = propTypes; + +export default CategoryShortcutButton; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index e2ad3bed1b78..810fc49040d7 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -8,11 +8,9 @@ import CONST from '../../../CONST'; import ONYXKEYS from '../../../ONYXKEYS'; import styles from '../../../styles/styles'; import * as StyleUtils from '../../../styles/StyleUtils'; -import themeColors from '../../../styles/themes/default'; import emojis from '../../../../assets/emojis'; import EmojiPickerMenuItem from '../EmojiPickerMenuItem'; import Text from '../../Text'; -import Composer from '../../Composer'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../../withLocalize'; import compose from '../../../libs/compose'; @@ -20,6 +18,8 @@ import getOperatingSystem from '../../../libs/getOperatingSystem'; import * as User from '../../../libs/actions/User'; import EmojiSkinToneList from '../EmojiSkinToneList'; import * as EmojiUtils from '../../../libs/EmojiUtils'; +import CategoryShortcutBar from '../CategoryShortcutBar'; +import TextInput from '../../TextInput'; const propTypes = { /** Function to add the selected emoji to the main compose text input */ @@ -57,24 +57,20 @@ class EmojiPickerMenu extends Component { // Ref for emoji FlatList this.emojiList = undefined; - // This is the number of columns in each row of the picker. - // Because of how flatList implements these rows, each row is an index rather than each element - // For this reason to make headers work, we need to have the header be the only rendered element in its row - // If this number is changed, emojis.js will need to be updated to have the proper number of spacer elements - // around each header. - this.numColumns = CONST.EMOJI_NUM_PER_ROW; - const allEmojis = EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis, this.props.frequentlyUsedEmojis); - // This is the indices of each category of emojis + // This is the actual header index starting at the first emoji and counting each one + this.headerIndices = EmojiUtils.getHeaderIndices(allEmojis); + + // This is the indices of each header's Row // The positions are static, and are calculated as index/numColumns (8 in our case) - // This is because each row of 8 emojis counts as one index - this.unfilteredHeaderIndices = EmojiUtils.getDynamicHeaderIndices(allEmojis); + // This is because each row of 8 emojis counts as one index to the flatlist + this.headerRowIndices = _.map(this.headerIndices, headerIndex => Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW)); // If we're on Windows, don't display the flag emojis (the last category), // since Windows doesn't support them (and only displays country codes instead) this.emojis = getOperatingSystem() === CONST.OS.WINDOWS - ? allEmojis.slice(0, this.unfilteredHeaderIndices.pop() * this.numColumns) + ? allEmojis.slice(0, this.headerRowIndices.pop() * CONST.EMOJI_NUM_PER_ROW) : allEmojis; this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300); @@ -88,13 +84,14 @@ class EmojiPickerMenu extends Component { this.updatePreferredSkinTone = this.updatePreferredSkinTone.bind(this); this.setFirstNonHeaderIndex = this.setFirstNonHeaderIndex.bind(this); this.getItemLayout = this.getItemLayout.bind(this); + this.scrollToHeader = this.scrollToHeader.bind(this); this.currentScrollOffset = 0; this.firstNonHeaderIndex = 0; this.state = { filteredEmojis: this.emojis, - headerIndices: this.unfilteredHeaderIndices, + headerIndices: this.headerRowIndices, highlightedIndex: -1, arePointerEventsDisabled: false, selection: { @@ -301,8 +298,8 @@ class EmojiPickerMenu extends Component { switch (arrowKey) { case 'ArrowDown': move( - this.numColumns, - () => this.state.highlightedIndex + this.numColumns > this.state.filteredEmojis.length - 1, + CONST.EMOJI_NUM_PER_ROW, + () => this.state.highlightedIndex + CONST.EMOJI_NUM_PER_ROW > this.state.filteredEmojis.length - 1, ); break; case 'ArrowLeft': @@ -319,8 +316,8 @@ class EmojiPickerMenu extends Component { break; case 'ArrowUp': move( - -this.numColumns, - () => this.state.highlightedIndex - this.numColumns < this.firstNonHeaderIndex, + -CONST.EMOJI_NUM_PER_ROW, + () => this.state.highlightedIndex - CONST.EMOJI_NUM_PER_ROW < this.firstNonHeaderIndex, () => { // Reaching start of the list, arrow up set the focus to searchInput. this.focusInputWithTextSelect(); @@ -339,25 +336,23 @@ class EmojiPickerMenu extends Component { } } + scrollToHeader(headerIndex) { + const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT; + this.emojiList.flashScrollIndicators(); + this.emojiList.scrollToOffset({offset: calculatedOffset, animated: true}); + } + /** * Calculates the required scroll offset (aka distance from top) and scrolls the FlatList to the highlighted emoji * if any portion of it falls outside of the window. * Doing this because scrollToIndex doesn't work as expected. */ scrollToHighlightedIndex() { - // If there are headers in the emoji array, so we need to offset by their heights as well - let numHeaders = 0; - if (this.state.filteredEmojis.length === this.emojis.length) { - numHeaders = _.filter(this.unfilteredHeaderIndices, i => this.state.highlightedIndex > i * this.numColumns).length; - } - - // Calculate the scroll offset at the bottom of the currently highlighted emoji - // (subtract numHeaders because the highlightedIndex includes them, and add 1 to include the current row) - const numEmojiRows = (Math.floor(this.state.highlightedIndex / this.numColumns) - numHeaders) + 1; + // Calculate the number of rows above the current row, then add 1 to include the current row + const numRows = Math.floor(this.state.highlightedIndex / CONST.EMOJI_NUM_PER_ROW) + 1; // The scroll offsets at the top and bottom of the highlighted emoji - const offsetAtEmojiBottom = ((numHeaders) * CONST.EMOJI_PICKER_HEADER_HEIGHT) - + (numEmojiRows * CONST.EMOJI_PICKER_ITEM_HEIGHT); + const offsetAtEmojiBottom = numRows * CONST.EMOJI_PICKER_HEADER_HEIGHT; const offsetAtEmojiTop = offsetAtEmojiBottom - CONST.EMOJI_PICKER_ITEM_HEIGHT; // Scroll to fit the entire highlighted emoji into the window if we need to @@ -388,7 +383,7 @@ class EmojiPickerMenu extends Component { // There are no headers when searching, so we need to re-make them sticky when there is no search term this.setState({ filteredEmojis: this.emojis, - headerIndices: this.unfilteredHeaderIndices, + headerIndices: this.headerRowIndices, highlightedIndex: -1, }); this.setFirstNonHeaderIndex(this.emojis); @@ -439,7 +434,7 @@ class EmojiPickerMenu extends Component { if (header) { return ( - + {this.props.translate(`emojiPicker.headers.${code}`)} @@ -473,14 +468,15 @@ class EmojiPickerMenu extends Component { style={[styles.emojiPickerContainer, StyleUtils.getEmojiPickerStyle(this.props.isSmallScreenWidth)]} pointerEvents={this.state.arePointerEventsDisabled ? 'none' : 'auto'} > + {!this.props.isSmallScreenWidth && ( - - + this.searchInput = el} autoFocus @@ -512,7 +508,7 @@ class EmojiPickerMenu extends Component { data={this.state.filteredEmojis} renderItem={this.renderItem} keyExtractor={item => `emoji_picker_${item.code}`} - numColumns={this.numColumns} + numColumns={CONST.EMOJI_NUM_PER_ROW} style={[ styles.emojiPickerList, this.isMobileLandscape() && styles.emojiPickerListLandscape, diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js index d70eed98800b..edf383eda1d8 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js @@ -1,7 +1,9 @@ import React, {Component} from 'react'; -import {View, FlatList} from 'react-native'; +import {View, findNodeHandle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; +import _ from 'underscore'; +import Animated, {runOnUI, _scrollTo} from 'react-native-reanimated'; import compose from '../../../libs/compose'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../withWindowDimensions'; import CONST from '../../../CONST'; @@ -14,6 +16,7 @@ import withLocalize, {withLocalizePropTypes} from '../../withLocalize'; import EmojiSkinToneList from '../EmojiSkinToneList'; import * as EmojiUtils from '../../../libs/EmojiUtils'; import * as User from '../../../libs/actions/User'; +import CategoryShortcutBar from '../CategoryShortcutBar'; const propTypes = { /** Function to add the selected emoji to the main compose text input */ @@ -43,23 +46,28 @@ class EmojiPickerMenu extends Component { constructor(props) { super(props); - // This is the number of columns in each row of the picker. - // Because of how flatList implements these rows, each row is an index rather than each element - // For this reason to make headers work, we need to have the header be the only rendered element in its row - // If this number is changed, emojis.js will need to be updated to have the proper number of spacer elements - // around each header. - this.numColumns = CONST.EMOJI_NUM_PER_ROW; + // Ref for emoji FlatList + this.emojiList = undefined; this.emojis = EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis, this.props.frequentlyUsedEmojis); - // This is the indices of each category of emojis + // This is the actual header index starting at the first emoji and counting each one + this.headerIndices = EmojiUtils.getHeaderIndices(this.emojis); + + // This is the indices of each header's Row // The positions are static, and are calculated as index/numColumns (8 in our case) - // This is because each row of 8 emojis counts as one index - this.unfilteredHeaderIndices = EmojiUtils.getDynamicHeaderIndices(this.emojis); + // This is because each row of 8 emojis counts as one index to the flatlist + this.headerRowIndices = _.map(this.headerIndices, headerIndex => Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW)); this.renderItem = this.renderItem.bind(this); this.isMobileLandscape = this.isMobileLandscape.bind(this); this.updatePreferredSkinTone = this.updatePreferredSkinTone.bind(this); + this.scrollToHeader = this.scrollToHeader.bind(this); + this.getItemLayout = this.getItemLayout.bind(this); + } + + getItemLayout(data, index) { + return {length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index}; } /** @@ -91,6 +99,17 @@ class EmojiPickerMenu extends Component { User.updatePreferredSkinTone(skinTone); } + scrollToHeader(headerIndex) { + const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT; + this.emojiList.flashScrollIndicators(); + const node = findNodeHandle(this.emojiList); + runOnUI(() => { + 'worklet'; + + _scrollTo(node, 0, calculatedOffset, true); + })(); + } + /** * Given an emoji item object, render a component based on its type. * Items with the code "SPACER" return nothing and are used to fill rows up to 8 @@ -108,7 +127,7 @@ class EmojiPickerMenu extends Component { if (item.header) { return ( - + {this.props.translate(`emojiPicker.headers.${item.code}`)} @@ -130,16 +149,25 @@ class EmojiPickerMenu extends Component { render() { return ( - + + + this.emojiList = el} data={this.emojis} renderItem={this.renderItem} keyExtractor={item => (`emoji_picker_${item.code}`)} - numColumns={this.numColumns} + numColumns={CONST.EMOJI_NUM_PER_ROW} style={[ styles.emojiPickerList, this.isMobileLandscape() && styles.emojiPickerListLandscape, ]} - stickyHeaderIndices={this.unfilteredHeaderIndices} + stickyHeaderIndices={this.headerRowIndices} + getItemLayout={this.getItemLayout} + showsVerticalScrollIndicator /> diff --git a/src/components/LocalePicker.js b/src/components/LocalePicker.js index dece444a303f..5be074168250 100644 --- a/src/components/LocalePicker.js +++ b/src/components/LocalePicker.js @@ -7,7 +7,6 @@ import * as App from '../libs/actions/App'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; -import Permissions from '../libs/Permissions'; import * as Localize from '../libs/Localize'; import Picker from './Picker'; import styles from '../styles/styles'; @@ -19,51 +18,41 @@ const propTypes = { /** Indicates size of a picker component and whether to render the label or not */ size: PropTypes.oneOf(['normal', 'small']), - /** Beta features list */ - betas: PropTypes.arrayOf(PropTypes.string), - ...withLocalizePropTypes, }; const defaultProps = { preferredLocale: CONST.DEFAULT_LOCALE, size: 'normal', - betas: [], }; const localesToLanguages = { default: { value: 'en', - label: Localize.translate('en', 'preferencesPage.languages.english'), + label: Localize.translate('en', 'languagePage.languages.en.label'), }, es: { value: 'es', - label: Localize.translate('es', 'preferencesPage.languages.spanish'), + label: Localize.translate('es', 'languagePage.languages.es.label'), }, }; -const LocalePicker = (props) => { - if (!Permissions.canUseInternationalization(props.betas)) { - return null; - } - - return ( - { - if (locale === props.preferredLocale) { - return; - } +const LocalePicker = props => ( + { + if (locale === props.preferredLocale) { + return; + } - App.setLocale(locale); - }} - items={_.values(localesToLanguages)} - size={props.size} - value={props.preferredLocale} - containerStyles={props.size === 'small' ? [styles.pickerContainerSmall] : []} - /> - ); -}; + App.setLocale(locale); + }} + items={_.values(localesToLanguages)} + size={props.size} + value={props.preferredLocale} + containerStyles={props.size === 'small' ? [styles.pickerContainerSmall] : []} + /> +); LocalePicker.defaultProps = defaultProps; LocalePicker.propTypes = propTypes; @@ -75,8 +64,5 @@ export default compose( preferredLocale: { key: ONYXKEYS.NVP_PREFERRED_LOCALE, }, - betas: { - key: ONYXKEYS.BETAS, - }, }), )(LocalePicker); diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js index 1b58579f93cf..dc29bc489618 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.js @@ -76,7 +76,8 @@ class BaseModal extends PureComponent { isSmallScreenWidth: this.props.isSmallScreenWidth, }, this.props.popoverAnchorPosition, - this.props.containerStyle, + this.props.innerContainerStyle, + this.props.outerStyle, ); return ( 0 && this.props.shouldHaveOptionSeparator} + shouldDisableRowInnerPadding={this.props.shouldDisableRowInnerPadding} /> ); } diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js index 0ca8b7a70901..91aaa5988d57 100644 --- a/src/components/OptionsList/optionsListPropTypes.js +++ b/src/components/OptionsList/optionsListPropTypes.js @@ -70,6 +70,9 @@ const propTypes = { /** Whether to show a line separating options in list */ shouldHaveOptionSeparator: PropTypes.bool, + + /** Whether to disable the inner padding in rows */ + shouldDisableRowInnerPadding: PropTypes.bool, }; const defaultProps = { @@ -90,6 +93,7 @@ const defaultProps = { isDisabled: false, onLayout: undefined, shouldHaveOptionSeparator: false, + shouldDisableRowInnerPadding: false, }; export {propTypes, defaultProps}; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 78afe0f379b2..d430e4ab13c2 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -20,12 +20,19 @@ const propTypes = { /** Whether we should wait before focusing the TextInput, useful when using transitions on Android */ shouldDelayFocus: PropTypes.bool, + /** padding bottom style of safe area */ + safeAreaPaddingBottomStyle: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.object), + PropTypes.object, + ]), + ...optionsSelectorPropTypes, ...withLocalizePropTypes, }; const defaultProps = { shouldDelayFocus: false, + safeAreaPaddingBottomStyle: {}, ...optionsSelectorDefaultProps, }; @@ -271,6 +278,7 @@ class BaseOptionsSelector extends Component { isDisabled={this.props.isDisabled} shouldHaveOptionSeparator={this.props.shouldHaveOptionSeparator} onLayout={this.props.onLayout} + contentContainerStyles={shouldShowFooter ? undefined : [this.props.safeAreaPaddingBottomStyle]} /> ) : ; return ( diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js index f8a6d961ca7e..f3ef801f668d 100644 --- a/src/components/PDFView/index.js +++ b/src/components/PDFView/index.js @@ -148,6 +148,9 @@ class PDFView extends Component { width={pageWidth} key={`page_${index + 1}`} pageNumber={index + 1} + + // This needs to be empty to avoid multiple loading texts which show per page and look ugly + // See https://github.com/Expensify/App/issues/14358 for more details loading="" /> ))} diff --git a/src/components/PasswordPopover/BasePasswordPopover.js b/src/components/PasswordPopover/BasePasswordPopover.js index d4d9fb5a9a82..0029fe97040f 100644 --- a/src/components/PasswordPopover/BasePasswordPopover.js +++ b/src/components/PasswordPopover/BasePasswordPopover.js @@ -11,6 +11,7 @@ import TextInput from '../TextInput'; import KeyboardSpacer from '../KeyboardSpacer'; import {propTypes as passwordPopoverPropTypes, defaultProps as passwordPopoverDefaultProps} from './passwordPopoverPropTypes'; import Button from '../Button'; +import withViewportOffsetTop from '../withViewportOffsetTop'; const propTypes = { /** Whether we should wait before focusing the TextInput, useful when using transitions on Android */ @@ -55,6 +56,7 @@ class BasePasswordPopover extends Component { onClose={this.props.onClose} anchorPosition={this.props.anchorPosition} onModalShow={this.focusInput} + outerStyle={{maxHeight: this.props.windowHeight, marginTop: this.props.viewportOffsetTop}} > ), onBlur: () => {}, @@ -162,14 +166,18 @@ class Picker extends PureComponent { ]} > {this.props.label && ( - {this.props.label} + + {this.props.label} + )} ({...item, color: themeColors.pickerOptionsTextColor}))} - style={this.props.size === 'normal' ? styles.picker(this.props.isDisabled) : styles.pickerSmall} + style={this.props.size === 'normal' + ? styles.picker(this.props.isDisabled, this.props.backgroundColor) + : styles.pickerSmall(this.props.backgroundColor)} useNativeAndroidPickerStyle={false} placeholder={this.placeholder} value={this.props.value} diff --git a/src/components/Popover/index.js b/src/components/Popover/index.js index 9a2e20ed3da7..54ca87239593 100644 --- a/src/components/Popover/index.js +++ b/src/components/Popover/index.js @@ -1,22 +1,44 @@ import React from 'react'; +import PropTypes from 'prop-types'; import {createPortal} from 'react-dom'; -import {propTypes, defaultProps} from './popoverPropTypes'; +import {withOnyx} from 'react-native-onyx'; +import {propTypes as popoverPropTypes, defaultProps as popoverDefaultProps} from './popoverPropTypes'; import CONST from '../../CONST'; import Modal from '../Modal'; import withWindowDimensions from '../withWindowDimensions'; +import compose from '../../libs/compose'; +import ONYXKEYS from '../../ONYXKEYS'; + +const propTypes = { + isShortcutsModalOpen: PropTypes.bool, + ...popoverPropTypes, +}; + +const defaultProps = { + isShortcutsModalOpen: false, + ...popoverDefaultProps, +}; /* * This is a convenience wrapper around the Modal component for a responsive Popover. * On small screen widths, it uses BottomDocked modal type, and a Popover type on wide screen widths. */ const Popover = (props) => { + if (props.isShortcutsModalOpen && props.isVisible) { + // There are modals that can show up on top of these pop-overs, for example, the keyboard shortcut menu, + // if that happens, close the pop-over as if we were clicking outside. + props.onClose(); + return null; + } + if (!props.fullscreen && !props.isSmallScreenWidth) { return createPortal( { } return ( {Boolean(this.props.prefixCharacter) && ( - - {this.props.prefixCharacter} - + + + {this.props.prefixCharacter} + + )} { diff --git a/src/components/ValidateCodeModal.js b/src/components/ValidateCodeModal.js new file mode 100644 index 000000000000..9bf52d2c4795 --- /dev/null +++ b/src/components/ValidateCodeModal.js @@ -0,0 +1,91 @@ +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import colors from '../styles/colors'; +import styles from '../styles/styles'; +import Icon from './Icon'; +import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import Text from './Text'; +import * as Expensicons from './Icon/Expensicons'; +import * as Illustrations from './Icon/Illustrations'; +import variables from '../styles/variables'; +import TextLink from './TextLink'; + +const propTypes = { + + /** Whether the user has been signed in with the link. */ + isSuccessfullySignedIn: PropTypes.bool, + + /** Code to display. */ + code: PropTypes.string.isRequired, + + /** Whether the user can get signed straight in the App from the current page */ + shouldShowSignInHere: PropTypes.bool, + + /** Callback to be called when user clicks the Sign in here link */ + onSignInHereClick: PropTypes.func, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + isSuccessfullySignedIn: false, + shouldShowSignInHere: false, + onSignInHereClick: () => {}, +}; + +class ValidateCodeModal extends PureComponent { + render() { + return ( + + + + + + + {this.props.translate(this.props.isSuccessfullySignedIn ? 'validateCodeModal.successfulSignInTitle' : 'validateCodeModal.title')} + + + + {this.props.translate(this.props.isSuccessfullySignedIn ? 'validateCodeModal.successfulSignInDescription' : 'validateCodeModal.description')} + {this.props.shouldShowSignInHere + && ( + <> + {this.props.translate('validateCodeModal.or')} + {' '} + + {this.props.translate('validateCodeModal.signInHere')} + + + )} + {this.props.shouldShowSignInHere ? '!' : '.'} + + + {!this.props.isSuccessfullySignedIn && ( + + + {this.props.code} + + + )} + + + + + + ); + } +} + +ValidateCodeModal.propTypes = propTypes; +ValidateCodeModal.defaultProps = defaultProps; +export default withLocalize(ValidateCodeModal); diff --git a/src/components/withViewportOffsetTop.js b/src/components/withViewportOffsetTop.js new file mode 100644 index 000000000000..2fcad4f48651 --- /dev/null +++ b/src/components/withViewportOffsetTop.js @@ -0,0 +1,72 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import lodashGet from 'lodash/get'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; +import addViewportResizeListener from '../libs/VisualViewport'; + +const viewportOffsetTopPropTypes = { + // viewportOffsetTop returns the offset of the top edge of the visual viewport from the + // top edge of the layout viewport in CSS pixels, when the visual viewport is resized. + + viewportOffsetTop: PropTypes.number.isRequired, +}; + +export default function (WrappedComponent) { + class WithViewportOffsetTop extends Component { + constructor(props) { + super(props); + + this.updateDimensions = this.updateDimensions.bind(this); + + this.state = { + viewportOffsetTop: 0, + }; + } + + componentDidMount() { + this.removeViewportResizeListener = addViewportResizeListener(this.updateDimensions); + } + + componentWillUnmount() { + this.removeViewportResizeListener(); + } + + /** + * @param {SyntheticEvent} e + */ + updateDimensions(e) { + const viewportOffsetTop = lodashGet(e, 'target.offsetTop', 0); + this.setState({viewportOffsetTop}); + } + + render() { + return ( + + ); + } + } + + WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`; + WithViewportOffsetTop.propTypes = { + forwardedRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({current: PropTypes.instanceOf(React.Component)}), + ]), + }; + WithViewportOffsetTop.defaultProps = { + forwardedRef: undefined, + }; + return React.forwardRef((props, ref) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + + )); +} + +export { + viewportOffsetTopPropTypes, +}; diff --git a/src/languages/en.js b/src/languages/en.js index f57d68bfe2d7..ad9310d9dcd1 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -53,16 +53,20 @@ export default { members: 'Members', invite: 'Invite', here: 'here', + date: 'Date', dob: 'Date of birth', ssnLast4: 'Last 4 digits of SSN', ssnFull9: 'Full 9 digits of SSN', + addressLine: ({lineNumber}) => `Address line ${lineNumber}`, personalAddress: 'Personal address', companyAddress: 'Company address', noPO: 'PO boxes and mail drop addresses are not allowed', city: 'City', state: 'State', + stateOrProvince: 'State / Province', + country: 'Country', zip: 'Zip code', - isRequiredField: 'is a required field', + zipPostCode: 'Zip / Postcode', whatThis: 'What\'s this?', iAcceptThe: 'I accept the ', remove: 'Remove', @@ -83,7 +87,12 @@ export default { invalidAmount: 'Invalid amount', acceptedTerms: 'You must accept the Terms of Service to continue', phoneNumber: `Please enter a valid phone number, with the country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER})`, + fieldRequired: 'This field is required.', + characterLimit: ({limit}) => `Exceeds the maximum length of ${limit} characters`, + dateInvalid: 'Please enter a valid date', }, + comma: 'comma', + semicolon: 'semicolon', please: 'Please', contactUs: 'contact us', pleaseEnterEmailOrPhoneNumber: 'Please enter an email or phone number', @@ -113,8 +122,8 @@ export default { websiteExample: 'e.g. https://www.expensify.com', }, attachmentPicker: { - cameraPermissionRequired: 'Camera permission required', - expensifyDoesntHaveAccessToCamera: 'This app does not have access to your camera, please enable the permission and try again.', + cameraPermissionRequired: 'Camera access', + expensifyDoesntHaveAccessToCamera: 'Expensify can\'t take photos without access to your camera. Tap Settings to update permissions.', attachmentError: 'Attachment error', errorWhileSelectingAttachment: 'An error occurred while selecting an attachment, please try again', errorWhileSelectingCorruptedImage: 'An error occurred while selecting a corrupted attachment, please try another file', @@ -146,6 +155,14 @@ export default { youCanAlso: 'You can also', openLinkInBrowser: 'open this link in your browser', }, + validateCodeModal: { + successfulSignInTitle: 'Abracadabra,\nyou are signed in!', + successfulSignInDescription: 'Head back to your original tab to continue.', + title: 'Here is your magic code', + description: 'Please enter the code using the device\nwhere it was originally requested', + or: ', or', + signInHere: 'just sign in here', + }, iOUConfirmationList: { whoPaid: 'Who paid?', whoWasThere: 'Who was there?', @@ -327,7 +344,9 @@ export default { eEyEmEir: 'E / Ey / Em / Eir', faeFaer: 'Fae / Faer', heHimHis: 'He / Him / His', + heHimHisTheyThemTheirs: 'He / Him / His / They / Them / Theirs', sheHerHers: 'She / Her / Hers', + sheHerHersTheyThemTheirs: 'She / Her / Hers / They / Them / Theirs', merMers: 'Mer / Mers', neNirNirs: 'Ne / Nir / Nirs', neeNerNers: 'Nee / Ner / Ners', @@ -493,16 +512,31 @@ export default { defaultPaymentMethod: 'Default', }, preferencesPage: { - mostRecent: 'Most recent', - mostRecentModeDescription: 'This will display all chats by default, sorted by most recent, with pinned items at the top.', - focus: '#focus', - focusModeDescription: '#focus – This will only display unread and pinned chats, all sorted alphabetically.', receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receive relevant feature updates and Expensify news', + }, + priorityModePage: { priorityMode: 'Priority mode', + explainerText: 'Choose whether to show all chats by default sorted with most recent with pinned items at the top, or #focus on unread pinned items, sorted alphabetically.', + priorityModes: { + default: { + label: 'Most recent', + description: 'Show all chats sorted by most recent', + }, + gsd: { + label: '#focus', + description: 'Only show unread sorted alphabetically', + }, + }, + }, + languagePage: { language: 'Language', languages: { - english: 'English', - spanish: 'Spanish', + en: { + label: 'English', + }, + es: { + label: 'Spanish', + }, }, }, signInPage: { @@ -566,12 +600,22 @@ export default { }, personalDetails: { error: { - firstNameLength: 'First name shouldn\'t be longer than 50 characters', - lastNameLength: 'Last name shouldn\'t be longer than 50 characters', - characterLimit: ({limit}) => `Exceeds the max length of ${limit} characters`, - hasInvalidCharacter: ({invalidCharacter}) => `Please remove the ${invalidCharacter} from the name field.`, - comma: 'comma', - semicolon: 'semicolon', + firstNameLength: `First name cannot be longer than ${CONST.DISPLAY_NAME.MAX_LENGTH} characters`, + lastNameLength: `Last name cannot be longer than ${CONST.DISPLAY_NAME.MAX_LENGTH} characters`, + containsReservedWord: 'First name cannot contain the words Expensify or Concierge', + hasInvalidCharacter: 'Name cannot contain a comma or semicolon', + }, + }, + privatePersonalDetails: { + personalDetails: 'Personal details', + privateDataMessage: 'These details are used for travel and payments. They are never shown on your public profile.', + legalName: 'Legal name', + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + homeAddress: 'Home address', + error: { + dateShouldBeBefore: ({dateString}) => `Date should be before ${dateString}.`, + dateShouldBeAfter: ({dateString}) => `Date should be after ${dateString}.`, }, }, resendValidationForm: { @@ -631,7 +675,6 @@ export default { addressCity: 'Please enter a valid city', addressStreet: 'Please enter a valid street address that is not a PO Box', addressState: 'Please select a valid state', - incorporationDate: 'Please enter a valid date', incorporationDateFuture: 'Incorporation date cannot be in the future', incorporationState: 'Please enter a valid state', industryCode: 'Please enter a valid industry classification code. Must be 6 digits.', @@ -1024,8 +1067,6 @@ export default { phoneNumberExtension: 'Please enter a valid phone extension number', firstName: 'Please provide your first name', lastName: 'Please provide your last name', - firstNameLength: 'First name shouldn\'t be longer than 50 characters', - lastNameLength: 'Last name shouldn\'t be longer than 50 characters', }, }, requestCallConfirmationScreen: { @@ -1106,8 +1147,8 @@ export default { message: 'Attachment cannot be downloaded', }, permissionError: { - title: 'Access needed', - message: 'Expensify does not have access to save attachments. To enable access, go to Settings and allow access', + title: 'Storage access', + message: 'Expensify can\'t save attachments without storage access. Tap Settings to update permissions.', }, }, desktopApplicationMenu: { diff --git a/src/languages/es.js b/src/languages/es.js index c46381c8d169..e299f3a7db0f 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -28,8 +28,8 @@ export default { not: 'No', signIn: 'Conectarse', continue: 'Continuar', - firstName: 'Primer nombre', - lastName: 'Apellido', + firstName: 'Nombre', + lastName: 'Apellidos', phone: 'teléfono', phoneNumber: 'Número de teléfono', phoneNumberPlaceholder: '(prefijo) + (número)', @@ -53,16 +53,20 @@ export default { members: 'Miembros', invite: 'Invitar', here: 'aquí', + date: 'Fecha', dob: 'Fecha de Nacimiento', ssnLast4: 'Últimos 4 dígitos de su SSN', ssnFull9: 'Los 9 dígitos del SSN', + addressLine: ({lineNumber}) => `Dirección línea ${lineNumber}`, personalAddress: 'Dirección física personal', companyAddress: 'Dirección física de la empresa', noPO: 'No se aceptan apartados ni direcciones postales', city: 'Ciudad', state: 'Estado', + stateOrProvince: 'Estado / Provincia', + country: 'País', zip: 'Código postal', - isRequiredField: 'es un campo obligatorio', + zipPostCode: 'Código Postal', whatThis: '¿Qué es esto?', iAcceptThe: 'Acepto los ', remove: 'Eliminar', @@ -83,7 +87,12 @@ export default { invalidAmount: 'Monto no válido', acceptedTerms: 'Debes aceptar los Términos de servicio para continuar', phoneNumber: `Ingresa un teléfono válido, incluyendo el código de país (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER})`, + fieldRequired: 'Este campo es obligatorio.', + characterLimit: ({limit}) => `Supera el límite de ${limit} caracteres`, + dateInvalid: 'Ingresa una fecha válida', }, + comma: 'la coma', + semicolon: 'el punto y coma', please: 'Por favor', contactUs: 'contáctenos', pleaseEnterEmailOrPhoneNumber: 'Por favor escribe un email o número de teléfono', @@ -113,8 +122,8 @@ export default { websiteExample: 'p. ej. https://www.expensify.com', }, attachmentPicker: { - cameraPermissionRequired: 'Se necesita permiso para usar la cámara', - expensifyDoesntHaveAccessToCamera: 'Esta aplicación no tiene acceso a tu cámara, por favor activa el permiso y vuelve a intentarlo.', + cameraPermissionRequired: 'Permiso para acceder a la cámara', + expensifyDoesntHaveAccessToCamera: 'Expensify no puede tomar fotos sin acceso a tu cámara. Haz click en Configuración para actualizar los permisos.', attachmentError: 'Error al adjuntar archivo', errorWhileSelectingAttachment: 'Ha ocurrido un error al seleccionar un adjunto, por favor inténtalo de nuevo', errorWhileSelectingCorruptedImage: 'Ha ocurrido un error al seleccionar un adjunto corrupto, por favor inténtalo con otro archivo', @@ -146,6 +155,14 @@ export default { youCanAlso: 'También puedes', openLinkInBrowser: 'abrir este enlace en tu navegador', }, + validateCodeModal: { + successfulSignInTitle: 'Abracadabra,\n¡sesión iniciada!', + successfulSignInDescription: 'Vuelve a la pestaña original para continuar.', + title: 'Aquí está tu código mágico', + or: ', ¡o', + description: 'Por favor, introduzca el código utilizando el dispositivo\nen el que se solicitó originalmente', + signInHere: 'simplemente inicia sesión aquí', + }, iOUConfirmationList: { whoPaid: '¿Quién pago?', whoWasThere: '¿Quién asistió?', @@ -327,7 +344,9 @@ export default { eEyEmEir: 'E / Ey / Em / Eir', faeFaer: 'Fae / Faer', heHimHis: 'Él', + heHimHisTheyThemTheirs: 'Él / Ellos', sheHerHers: 'Ella', + sheHerHersTheyThemTheirs: 'Ella / Ellos', merMers: 'Mer / Mers', neNirNirs: 'Ne / Nir / Nirs', neeNerNers: 'Nee / Ner / Ners', @@ -493,16 +512,31 @@ export default { defaultPaymentMethod: 'Predeterminado', }, preferencesPage: { - mostRecent: 'Más recientes', - mostRecentModeDescription: 'Esta opción muestra por defecto todos los chats, ordenados a partir del más reciente, con los chats destacados arriba de todo.', - focus: '#concentración', - focusModeDescription: '#concentración – Muestra sólo los chats no leídos y destacados ordenados alfabéticamente.', receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto', + }, + priorityModePage: { priorityMode: 'Modo prioridad', + explainerText: 'Elija si desea mostrar por defecto todos los chats ordenados desde el más reciente y con los elementos anclados en la parte superior, o elija el modo #concentración, con los elementos no leídos anclados en la parte superior y ordenados alfabéticamente.', + priorityModes: { + default: { + label: 'Más recientes', + description: 'Mostrar todos los chats ordenados desde el más reciente', + }, + gsd: { + label: '#concentración', + description: 'Mostrar sólo los no leídos ordenados alfabéticamente', + }, + }, + }, + languagePage: { language: 'Idioma', languages: { - english: 'Inglés', - spanish: 'Español', + en: { + label: 'Inglés', + }, + es: { + label: 'Español', + }, }, }, signInPage: { @@ -566,12 +600,22 @@ export default { }, personalDetails: { error: { - firstNameLength: 'El nombre no debe tener más de 50 caracteres', - lastNameLength: 'El apellido no debe tener más de 50 caracteres', - characterLimit: ({limit}) => `Supera el límite de ${limit} caracteres`, - hasInvalidCharacter: ({invalidCharacter}) => `Por favor elimina ${invalidCharacter} del campo nombre.`, - comma: 'la coma', - semicolon: 'el punto y coma', + firstNameLength: `El nombre no puede tener más de ${CONST.DISPLAY_NAME.MAX_LENGTH} caracteres`, + lastNameLength: `El apellido no puede tener más de ${CONST.DISPLAY_NAME.MAX_LENGTH} caracteres`, + containsReservedWord: 'El nombre no puede contener las palabras Expensify o Concierge', + hasInvalidCharacter: 'El nombre no puede contener una coma o un punto y coma', + }, + }, + privatePersonalDetails: { + personalDetails: 'Datos personales', + privateDataMessage: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en su perfil público.', + legalName: 'Nombre completo', + legalFirstName: 'Nombre legal', + legalLastName: 'Apellidos legales', + homeAddress: 'Domicilio', + error: { + dateShouldBeBefore: ({dateString}) => `La fecha debe ser anterior a ${dateString}.`, + dateShouldBeAfter: ({dateString}) => `La fecha debe ser posterior a ${dateString}.`, }, }, resendValidationForm: { @@ -631,7 +675,6 @@ export default { addressCity: 'Ingresa una ciudad válida', addressStreet: 'Ingresa una calle de dirección válida que no sea un apartado postal', addressState: 'Por favor, selecciona un estado', - incorporationDate: 'Ingresa una fecha válida', incorporationDateFuture: 'La fecha de incorporación no puede ser futura', incorporationState: 'Ingresa un estado válido', industryCode: 'Ingresa un código de clasificación de industria válido', @@ -644,8 +687,8 @@ export default { dob: 'Ingresa una fecha de nacimiento válida', age: 'Debe ser mayor de 18 años', ssnLast4: 'Ingresa los últimos 4 dígitos del número de seguro social', - firstName: 'Ingresa un nombre válido', - lastName: 'Ingresa un apellido válido', + firstName: 'Ingresa el nombre', + lastName: 'Ingresa los apellidos', noDefaultDepositAccountOrDebitCardAvailable: 'Por favor agrega una cuenta bancaria para depósitos o una tarjeta de débito', validationAmounts: 'Los montos de validación que ingresaste son incorrectos. Verifica tu cuenta de banco e intenta de nuevo.', }, @@ -696,7 +739,7 @@ export default { helpLink: 'Obtenga más información sobre por qué necesitamos esto.', legalFirstNameLabel: 'Primer nombre legal', legalMiddleNameLabel: 'Segundo nombre legal', - legalLastNameLabel: 'Apellido legal', + legalLastNameLabel: 'Apellidos legales', selectAnswer: 'Selecciona una respuesta.', ssnFull9Error: 'Por favor escribe los 9 dígitos de un SSN válido', needSSNFull9: 'Estamos teniendo problemas para verificar su SSN. Ingresa los 9 dígitos del SSN.', @@ -1025,9 +1068,7 @@ export default { error: { phoneNumberExtension: 'Por favor, introduce una extensión telefónica válida', firstName: 'Por favor, ingresa tu nombre', - lastName: 'Por favor, ingresa tu apellido', - firstNameLength: 'El nombre no debe tener más de 50 caracteres', - lastNameLength: 'El apellido no debe tener más de 50 caracteres', + lastName: 'Por favor, ingresa tus apellidos', }, }, requestCallConfirmationScreen: { @@ -1108,8 +1149,8 @@ export default { message: 'No se puede descargar el archivo adjunto', }, permissionError: { - title: 'Se necesita acceso', - message: 'Expensify no tiene acceso para guardar archivos. Para habilitar la descarga de archivos, entra en Preferencias y habilita el acceso', + title: 'Permiso para acceder al almacenamiento', + message: 'Expensify no puede guardar los archivos adjuntos sin permiso para acceder al almacenamiento. Haz click en Configuración para actualizar los permisos.', }, }, desktopApplicationMenu: { diff --git a/src/libs/E2E/apiMocks/openApp.js b/src/libs/E2E/apiMocks/openApp.js index 8ebea45e79d0..da612f4dab87 100644 --- a/src/libs/E2E/apiMocks/openApp.js +++ b/src/libs/E2E/apiMocks/openApp.js @@ -888,7 +888,7 @@ export default () => ({ { onyxMethod: 'merge', key: 'preferredEmojiSkinTone', - value: 'default', + value: -1, }, { onyxMethod: 'set', diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js index d6ed8875e998..6f3583b70c0a 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -86,13 +86,13 @@ function containsOnlyEmojis(message) { * @param {Object[]} emojis * @returns {Number[]} */ -function getDynamicHeaderIndices(emojis) { +function getHeaderIndices(emojis) { const headerIndices = []; _.each(emojis, (emoji, index) => { if (!emoji.header) { return; } - headerIndices.push(Math.floor(index / CONST.EMOJI_NUM_PER_ROW)); + headerIndices.push(index); }); return headerIndices; } @@ -246,7 +246,7 @@ function suggestEmojis(text, limit = 5) { } export { - getDynamicHeaderIndices, + getHeaderIndices, mergeEmojisWithFrequentlyUsedEmojis, addToFrequentlyUsedEmojis, containsOnlyEmojis, diff --git a/src/libs/Environment/betaChecker/index.android.js b/src/libs/Environment/betaChecker/index.android.js index 3b38f37bcb52..8fbe93f84c0c 100644 --- a/src/libs/Environment/betaChecker/index.android.js +++ b/src/libs/Environment/betaChecker/index.android.js @@ -1,6 +1,15 @@ import semver from 'semver'; +import Onyx from 'react-native-onyx'; import CONST from '../../../CONST'; import pkg from '../../../../package.json'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as AppUpdate from '../../actions/AppUpdate'; + +let isLastSavedBeta = false; +Onyx.connect({ + key: ONYXKEYS.IS_BETA, + callback: value => isLastSavedBeta = value, +}); /** * Check the GitHub releases to see if the current build is a beta build or production build @@ -14,15 +23,18 @@ function isBetaBuild() { .then((json) => { const productionVersion = json.tag_name; if (!productionVersion) { + AppUpdate.setIsAppInBeta(false); resolve(false); } // If the current version we are running is greater than the production version, we are on a beta version of Android const isBeta = semver.gt(pkg.version, productionVersion); + AppUpdate.setIsAppInBeta(isBeta); resolve(isBeta); }) .catch(() => { - resolve(false); + // Use isLastSavedBeta in case we fail to fetch the new one, e.g. when we are offline + resolve(isLastSavedBeta); }); }); } diff --git a/src/libs/GooglePlacesUtils.js b/src/libs/GooglePlacesUtils.js index 5a875e7f102a..36b776e05a52 100644 --- a/src/libs/GooglePlacesUtils.js +++ b/src/libs/GooglePlacesUtils.js @@ -3,25 +3,31 @@ import _ from 'underscore'; /** * Finds an address component by type, and returns the value associated to key. Each address component object * inside the addressComponents array has the following structure: - * { + * [{ * long_name: "New York", * short_name: "New York", * types: [ "locality", "political" ] - * } + * }] * * @param {Array} addressComponents - * @param {String} type - * @param {String} key - * @returns {String|undefined} + * @param {Object} fieldsToExtract – has shape: {addressType: 'keyToUse'} + * @returns {Object} */ -function getAddressComponent(addressComponents, type, key) { - return _.chain(addressComponents) - .find(component => _.contains(component.types, type)) - .get(key) - .value(); +function getAddressComponents(addressComponents, fieldsToExtract) { + const result = _.mapObject(fieldsToExtract, () => ''); + _.each(addressComponents, (addressComponent) => { + _.each(addressComponent.types, (addressType) => { + if (!_.has(fieldsToExtract, addressType) || !_.isEmpty(result[addressType])) { + return; + } + const value = addressComponent[fieldsToExtract[addressType]] ? addressComponent[fieldsToExtract[addressType]] : ''; + result[addressType] = value; + }); + }); + return result; } export { // eslint-disable-next-line import/prefer-default-export - getAddressComponent, + getAddressComponents, }; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 58fae5f8b4c5..c3230a6d353e 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -22,6 +22,7 @@ import * as Modal from '../../actions/Modal'; import modalCardStyleInterpolator from './modalCardStyleInterpolator'; import createCustomModalStackNavigator from './createCustomModalStackNavigator'; import NotFoundPage from '../../../pages/ErrorPage/NotFoundPage'; +import getCurrentUrl from '../currentUrl'; // Modal Stack Navigators import * as ModalStackNavigators from './ModalStackNavigators'; @@ -156,6 +157,8 @@ class AuthScreens extends React.Component { // when displaying a modal. This allows us to dismiss by clicking outside on web / large screens. isModal: true, }; + const url = getCurrentUrl(); + const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : ''; return ( { - const last = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies); +const getInitialReportScreenParams = (reports, ignoreDefaultRooms, policies, openOnAdminRoom) => { + const last = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, openOnAdminRoom); // Fallback to empty if for some reason reportID cannot be derived - prevents the app from crashing const reportID = lodashGet(last, 'reportID', ''); @@ -61,7 +68,12 @@ class MainDrawerNavigator extends Component { constructor(props) { super(props); this.trackAppStartTiming = this.trackAppStartTiming.bind(this); - this.initialParams = getInitialReportScreenParams(props.reports, !Permissions.canUseDefaultRooms(props.betas), props.policies); + this.initialParams = getInitialReportScreenParams( + props.reports, + !Permissions.canUseDefaultRooms(props.betas), + props.policies, + lodashGet(props, 'route.params.openOnAdminRoom', false), + ); // When we have chat reports the moment this component got created // we know that the data was served from storage/cache @@ -69,7 +81,12 @@ class MainDrawerNavigator extends Component { } shouldComponentUpdate(nextProps) { - const initialNextParams = getInitialReportScreenParams(nextProps.reports, !Permissions.canUseDefaultRooms(nextProps.betas), nextProps.policies); + const initialNextParams = getInitialReportScreenParams( + nextProps.reports, + !Permissions.canUseDefaultRooms(nextProps.betas), + nextProps.policies, + lodashGet(nextProps, 'route.params.openOnAdminRoom', false), + ); if (this.initialParams.reportID === initialNextParams.reportID) { return false; } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index e95581674fab..607ee566c6cd 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -245,6 +245,34 @@ const SettingsModalStackNavigator = createModalStackNavigator([ }, name: 'Settings_Timezone_Select', }, + { + getComponent: () => { + const SettingsPersonalDetailsInitialPage = require('../../../pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage').default; + return SettingsPersonalDetailsInitialPage; + }, + name: 'Settings_PersonalDetails_Initial', + }, + { + getComponent: () => { + const SettingsLegalNamePage = require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default; + return SettingsLegalNamePage; + }, + name: 'Settings_PersonalDetails_LegalName', + }, + { + getComponent: () => { + const SettingsDateOfBirthPage = require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default; + return SettingsDateOfBirthPage; + }, + name: 'Settings_PersonalDetails_DateOfBirth', + }, + { + getComponent: () => { + const SettingsAddressPage = require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default; + return SettingsAddressPage; + }, + name: 'Settings_PersonalDetails_Address', + }, { getComponent: () => { const SettingsAddSecondaryLoginPage = require('../../../pages/settings/AddSecondaryLoginPage').default; @@ -254,11 +282,25 @@ const SettingsModalStackNavigator = createModalStackNavigator([ }, { getComponent: () => { - const SettingsPreferencesPage = require('../../../pages/settings/PreferencesPage').default; + const SettingsPreferencesPage = require('../../../pages/settings/Preferences/PreferencesPage').default; return SettingsPreferencesPage; }, name: 'Settings_Preferences', }, + { + getComponent: () => { + const SettingsPreferencesPriorityModePage = require('../../../pages/settings/Preferences/PriorityModePage').default; + return SettingsPreferencesPriorityModePage; + }, + name: 'Settings_Preferences_PriorityMode', + }, + { + getComponent: () => { + const SettingsPreferencesLanguagePage = require('../../../pages/settings/Preferences/LanguagePage').default; + return SettingsPreferencesLanguagePage; + }, + name: 'Settings_Preferences_Language', + }, { getComponent: () => { const SettingsPasswordPage = require('../../../pages/settings/PasswordPage').default; diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index ea730bb45757..447fd481efab 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -21,6 +21,11 @@ const drawerIsReadyPromise = new Promise((resolve) => { resolveDrawerIsReadyPromise = resolve; }); +let resolveReportScreenIsReadyPromise; +const reportScreenIsReadyPromise = new Promise((resolve) => { + resolveReportScreenIsReadyPromise = resolve; +}); + let isLoggedIn = false; let pendingRoute = null; let isNavigating = false; @@ -267,6 +272,14 @@ function setIsDrawerReady() { resolveDrawerIsReadyPromise(); } +function isReportScreenReady() { + return reportScreenIsReadyPromise; +} + +function setIsReportScreenIsReady() { + resolveReportScreenIsReadyPromise(); +} + export default { canNavigate, navigate, @@ -284,6 +297,8 @@ export default { setIsDrawerReady, isDrawerRoute, setIsNavigating, + isReportScreenReady, + setIsReportScreenIsReady, }; export { diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index e9bbeb987d25..12b31245caf0 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -44,6 +44,14 @@ export default { path: ROUTES.SETTINGS_PREFERENCES, exact: true, }, + Settings_Preferences_PriorityMode: { + path: ROUTES.SETTINGS_PRIORITY_MODE, + exact: true, + }, + Settings_Preferences_Language: { + path: ROUTES.SETTINGS_LANGUAGE, + exact: true, + }, Settings_Close: { path: ROUTES.SETTINGS_CLOSE, exact: true, @@ -115,6 +123,22 @@ export default { Settings_Add_Secondary_Login: { path: ROUTES.SETTINGS_ADD_LOGIN, }, + Settings_PersonalDetails_Initial: { + path: ROUTES.SETTINGS_PERSONAL_DETAILS, + exact: true, + }, + Settings_PersonalDetails_LegalName: { + path: ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME, + exact: true, + }, + Settings_PersonalDetails_DateOfBirth: { + path: ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH, + exact: true, + }, + Settings_PersonalDetails_Address: { + path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS, + exact: true, + }, Workspace_Initial: { path: ROUTES.WORKSPACE_INITIAL, }, diff --git a/src/libs/Notification/PushNotification/index.js b/src/libs/Notification/PushNotification/index.js index c4100583442d..88136ff5dc72 100644 --- a/src/libs/Notification/PushNotification/index.js +++ b/src/libs/Notification/PushNotification/index.js @@ -2,6 +2,7 @@ import NotificationType from './NotificationType'; // Push notifications are only supported on mobile, so we'll just noop here export default { + init: () => {}, register: () => {}, deregister: () => {}, onReceived: () => {}, diff --git a/src/libs/Notification/PushNotification/index.native.js b/src/libs/Notification/PushNotification/index.native.js index 4dc3ad1c3a8e..b6787858191d 100644 --- a/src/libs/Notification/PushNotification/index.native.js +++ b/src/libs/Notification/PushNotification/index.native.js @@ -4,8 +4,6 @@ import {UrbanAirship, EventType, iOS} from 'urbanairship-react-native'; import lodashGet from 'lodash/get'; import Log from '../../Log'; import NotificationType from './NotificationType'; -import PushNotification from '.'; -import * as Report from '../../actions/Report'; const notificationEventActionMap = {}; @@ -109,12 +107,6 @@ function register(accountID) { // Regardless of the user's opt-in status, we still want to receive silent push notifications. Log.info(`[PUSH_NOTIFICATIONS] Subscribing to notifications for account ID ${accountID}`); UrbanAirship.setNamedUser(accountID.toString()); - - // When the user logged out and then logged in with a different account - // while the app is still in background, we must resubscribe to the report - // push notification in order to render the report click behaviour correctly - PushNotification.init(); - Report.subscribeToReportCommentPushNotifications(); } /** diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js index 905b29c99cf4..68f1a493f158 100644 --- a/src/libs/Permissions.js +++ b/src/libs/Permissions.js @@ -43,14 +43,6 @@ function canUseDefaultRooms(betas) { return _.contains(betas, CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas); } -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseInternationalization(betas) { - return _.contains(betas, CONST.BETAS.INTERNATIONALIZATION) || canUseAllBetas(betas); -} - /** * @param {Array} betas * @returns {Boolean} @@ -107,7 +99,6 @@ export default { canUseIOU, canUsePayWithExpensify, canUseDefaultRooms, - canUseInternationalization, canUseIOUSend, canUseWallet, canUseCommentLinking, diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index bb67b2fb39b6..f260bbc4c58d 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -7,7 +7,6 @@ import moment from 'moment'; import * as CollectionUtils from './CollectionUtils'; import CONST from '../CONST'; import ONYXKEYS from '../ONYXKEYS'; -import * as ReportUtils from './ReportUtils'; const allReportActions = {}; Onyx.connect({ @@ -170,7 +169,9 @@ function getLastVisibleMessageText(reportID, actionsToMerge = {}) { const parser = new ExpensiMark(); const messageText = parser.htmlToText(htmlText); - return ReportUtils.formatReportLastMessageText(messageText); + return String(messageText) + .replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '') + .substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH); } export { diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 51a28759d45c..0a4fdcdebf87 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -3,6 +3,7 @@ import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import Onyx from 'react-native-onyx'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import {InteractionManager} from 'react-native'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; import * as Localize from './Localize'; @@ -13,8 +14,10 @@ import Navigation from './Navigation/Navigation'; import ROUTES from '../ROUTES'; import * as NumberUtils from './NumberUtils'; import * as NumberFormatUtils from './NumberFormatUtils'; +import * as ReportActionsUtils from './ReportActionsUtils'; import Permissions from './Permissions'; import DateUtils from './DateUtils'; +import linkingConfig from './Navigation/linkingConfig'; import * as defaultAvatars from '../components/Icon/DefaultAvatars'; let sessionEmail; @@ -70,7 +73,7 @@ let doesDomainHaveApprovedAccountant; Onyx.connect({ key: ONYXKEYS.ACCOUNT, waitForCollectionCallback: true, - callback: val => doesDomainHaveApprovedAccountant = val.doesDomainHaveApprovedAccountant, + callback: val => doesDomainHaveApprovedAccountant = lodashGet(val, 'doesDomainHaveApprovedAccountant', false), }); function getChatType(report) { @@ -224,6 +227,15 @@ function isChatRoom(report) { return isUserCreatedPolicyRoom(report) || isDefaultRoom(report); } +/** + * Whether the provided report is a direct message + * @param {Object} report + * @returns {Boolean} + */ +function isDirectMessage(report) { + return _.isEmpty(getChatType(report)); +} + /** * Get the policy type from a given report * @param {Object} report @@ -248,9 +260,10 @@ function hasExpensifyGuidesEmails(emails) { * @param {Record|Array<{lastReadTime, reportID}>} reports * @param {Boolean} [ignoreDefaultRooms] * @param {Object} policies + * @param {Boolean} openOnAdminRoom * @returns {Object} */ -function findLastAccessedReport(reports, ignoreDefaultRooms, policies) { +function findLastAccessedReport(reports, ignoreDefaultRooms, policies, openOnAdminRoom = false) { let sortedReports = sortReportsByLastRead(reports); if (ignoreDefaultRooms) { @@ -259,7 +272,15 @@ function findLastAccessedReport(reports, ignoreDefaultRooms, policies) { || hasExpensifyGuidesEmails(lodashGet(report, ['participants'], []))); } - return _.last(sortedReports); + let adminReport; + if (!ignoreDefaultRooms && openOnAdminRoom) { + adminReport = _.find(sortedReports, (report) => { + const chatType = getChatType(report); + return chatType === CONST.REPORT.CHAT_TYPE.POLICY_ADMINS; + }); + } + + return adminReport || _.last(sortedReports); } /** @@ -643,6 +664,39 @@ function getDisplayNamesWithTooltips(participants, isMultipleParticipantReport) }); } +/** + * Get the title for a policy expense chat which depends on the role of the policy member seeing this report + * + * @param {Object} report + * @param {Object} [policies] + * @returns {String} + */ +function getPolicyExpenseChatName(report, policies = {}) { + const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerEmail) || report.ownerEmail || report.reportName; + + // If the policy expense chat is owned by this user, use the name of the policy as the report name. + if (report.isOwnPolicyExpenseChat) { + return getPolicyName(report, policies); + } + + const policyExpenseChatRole = lodashGet(policies, [ + `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role', + ]) || 'user'; + + // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat + // of the account which was merged into the current user's account. Use the name of the policy as the name of the report. + if (isArchivedRoom(report)) { + const lastAction = ReportActionsUtils.getLastVisibleAction(report.reportID); + const archiveReason = (lastAction && lastAction.originalMessage && lastAction.originalMessage.reason) || CONST.REPORT.ARCHIVE_REASON.DEFAULT; + if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED && policyExpenseChatRole !== CONST.POLICY.ROLE.ADMIN) { + return getPolicyName(report, policies); + } + } + + // If user can see this report and they are not its owner, they must be an admin and the report name should be the name of the policy member + return reportOwnerDisplayName; +} + /** * Get the title for a report. * @@ -657,8 +711,7 @@ function getReportName(report, policies = {}) { } if (isPolicyExpenseChat(report)) { - const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerEmail) || report.ownerEmail || report.reportName; - formattedName = report.isOwnPolicyExpenseChat ? getPolicyName(report, policies) : reportOwnerDisplayName; + formattedName = getPolicyExpenseChatName(report, policies); } if (isArchivedRoom(report)) { @@ -974,7 +1027,7 @@ function buildOptimisticChatReport( lastActionCreated: currentTime, notificationPreference, oldPolicyName, - ownerEmail, + ownerEmail: ownerEmail || CONST.REPORT.OWNER_EMAIL_FAKE, participants: participantList, policyID, reportID: generateReportID(), @@ -1313,6 +1366,11 @@ function shouldReportBeInOptionList(report, reportIDFromRoute, isInGSDMode, curr return false; } + // Exclude direct message chats that don't have any chat history + if (isDirectMessage(report) && report.maxSequenceNumber === 1) { + return false; + } + return true; } @@ -1371,6 +1429,66 @@ function getNewMarkerReportActionID(report, sortedAndFilteredReportActions) { : ''; } +/** + * Replace code points > 127 with C escape sequences, and return the resulting string's overall length + * Used for compatibility with the backend auth validator for AddComment + * @param {String} textComment + * @returns {Number} + */ +function getCommentLength(textComment) { + return textComment.replace(/[^ -~]/g, '\\u????').length; +} + +/** + * @param {String|null} url + * @returns {String} + */ +function getReportIDFromDeepLink(url) { + if (!url) { + return ''; + } + + // Get the reportID from URL + let route = url; + _.each(linkingConfig.prefixes, (prefix) => { + const localWebAndroidRegEx = /^(http:\/\/([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3}))/; + if (route.startsWith(prefix)) { + route = route.replace(prefix, ''); + } else if (localWebAndroidRegEx.test(route)) { + route = route.replace(localWebAndroidRegEx, ''); + } else { + return; + } + + // Remove the port if it's a localhost URL + if (/^:\d+/.test(route)) { + route = route.replace(/:\d+/, ''); + } + + // Remove the leading slash if exists + if (route.startsWith('/')) { + route = route.replace('/', ''); + } + }); + const {reportID} = ROUTES.parseReportRouteParams(route); + return reportID; +} + +/** + * @param {String|null} url + */ +function openReportFromDeepLink(url) { + const reportID = getReportIDFromDeepLink(url); + if (!reportID) { + return; + } + InteractionManager.runAfterInteractions(() => { + Navigation.isReportScreenReady().then(() => { + Navigation.navigate(ROUTES.getReportRoute(reportID)); + }); + }); +} + export { getReportParticipantsTitle, isReportMessageAttachment, @@ -1402,6 +1520,7 @@ export { getRoomWelcomeMessage, getDisplayNamesWithTooltips, getReportName, + getReportIDFromDeepLink, navigateToDetailsPage, generateReportID, hasReportNameError, @@ -1425,4 +1544,6 @@ export { getOldDotDefaultAvatar, getNewMarkerReportActionID, canSeeDefaultRoom, + getCommentLength, + openReportFromDeepLink, }; diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index f287c821f73b..ee8cd145f055 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -206,6 +206,30 @@ function meetsAgeRequirements(date) { return testDate.isValid() && testDate.isBetween(oneHundredFiftyYearsAgo, eighteenYearsAgo); } +/** + * Validate that given date is in a specified range of years before now. + * + * @param {String} date + * @param {Number} minimumAge + * @param {Number} maximumAge + * @returns {String} + */ +function getAgeRequirementError(date, minimumAge, maximumAge) { + const recentDate = moment().subtract(minimumAge, 'years'); + const longAgoDate = moment().subtract(maximumAge, 'years'); + const testDate = moment(date); + if (!testDate.isValid()) { + return Localize.translateLocal('common.error.dateInvalid'); + } + if (testDate.isBetween(longAgoDate, recentDate)) { + return ''; + } + if (testDate.isAfter(recentDate)) { + return Localize.translateLocal('privatePersonalDetails.error.dateShouldBeBefore', {dateString: recentDate.format(CONST.DATE.MOMENT_FORMAT_STRING)}); + } + return Localize.translateLocal('privatePersonalDetails.error.dateShouldBeAfter', {dateString: longAgoDate.format(CONST.DATE.MOMENT_FORMAT_STRING)}); +} + /** * Similar to backend, checks whether a website has a valid URL or not. * http/https/ftp URL scheme required. @@ -333,46 +357,25 @@ function isValidRoutingNumber(number) { } /** - * Checks if each string in array is of valid length and then returns true - * for each string which exceeds the limit. + * Checks that the provided name doesn't contain any commas or semicolons * - * @param {Number} maxLength - * @param {String[]} valuesToBeValidated - * @returns {Boolean[]} - */ -function doesFailCharacterLimit(maxLength, valuesToBeValidated) { - return _.map(valuesToBeValidated, value => value && value.length > maxLength); -} - -/** - * Checks if each string in array is of valid length and then returns true - * for each string which exceeds the limit. The function trims the passed values. - * - * @param {Number} maxLength - * @param {String[]} valuesToBeValidated - * @returns {Boolean[]} + * @param {String} name + * @returns {Boolean} */ -function doesFailCharacterLimitAfterTrim(maxLength, valuesToBeValidated) { - return _.map(valuesToBeValidated, value => value && value.trim().length > maxLength); +function isValidDisplayName(name) { + return !name.includes(',') && !name.includes(';'); } /** - * Checks if input value includes comma or semicolon which are not accepted + * Checks if the provided string includes any of the provided reserved words * - * @param {String[]} valuesToBeValidated - * @returns {String[]} + * @param {String} value + * @param {String[]} reservedWords + * @returns {Boolean} */ -function findInvalidSymbols(valuesToBeValidated) { - return _.map(valuesToBeValidated, (value) => { - if (!value) { - return ''; - } - let inValidSymbol = value.replace(/[,]+/g, '') !== value ? Localize.translateLocal('personalDetails.error.comma') : ''; - if (_.isEmpty(inValidSymbol)) { - inValidSymbol = value.replace(/[;]+/g, '') !== value ? Localize.translateLocal('personalDetails.error.semicolon') : ''; - } - return inValidSymbol; - }); +function doesContainReservedWord(value, reservedWords) { + const valueToCheck = value.trim().toLowerCase(); + return _.some(reservedWords, reservedWord => valueToCheck.includes(reservedWord.toLowerCase())); } /** @@ -427,6 +430,7 @@ function isValidTaxID(taxID) { export { meetsAgeRequirements, + getAgeRequirementError, isValidAddress, isValidDate, isValidCardName, @@ -448,12 +452,11 @@ export { isValidRoutingNumber, isValidSSNLastFour, isValidSSNFullNine, - doesFailCharacterLimit, - doesFailCharacterLimitAfterTrim, isReservedRoomName, isExistingRoomName, isValidRoomName, isValidTaxID, isValidValidateCode, - findInvalidSymbols, + isValidDisplayName, + doesContainReservedWord, }; diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js index b405ae82aa49..b3d05bed7f6a 100644 --- a/src/libs/actions/App.js +++ b/src/libs/actions/App.js @@ -52,6 +52,12 @@ Onyx.connect({ callback: policies => allPolicies = policies, }); +let preferredLocale; +Onyx.connect({ + key: ONYXKEYS.NVP_PREFERRED_LOCALE, + callback: val => preferredLocale = val, +}); + /** * @param {Array} policies * @return {Array} array of policy ids @@ -68,6 +74,10 @@ function getNonOptimisticPolicyIDs(policies) { * @param {String} locale */ function setLocale(locale) { + if (locale === preferredLocale) { + return; + } + // If user is not signed in, change just locally. if (!currentUserAccountID) { Onyx.merge(ONYXKEYS.NVP_PREFERRED_LOCALE, locale); @@ -88,6 +98,14 @@ function setLocale(locale) { }, {optimisticData}); } +/** +* @param {String} locale +*/ +function setLocaleAndNavigate(locale) { + setLocale(locale); + Navigation.navigate(ROUTES.SETTINGS_PREFERENCES); +} + function setSidebarLoaded() { if (isSidebarLoaded) { return; @@ -270,6 +288,7 @@ function openProfile() { export { setLocale, + setLocaleAndNavigate, setSidebarLoaded, setUpPoliciesAndNavigate, openProfile, diff --git a/src/libs/actions/AppUpdate.js b/src/libs/actions/AppUpdate.js index 1ba2f5fb8384..c198c0e1adbb 100644 --- a/src/libs/actions/AppUpdate.js +++ b/src/libs/actions/AppUpdate.js @@ -5,7 +5,14 @@ function triggerUpdateAvailable() { Onyx.set(ONYXKEYS.UPDATE_AVAILABLE, true); } +/** + * @param {Boolean} isBeta + */ +function setIsAppInBeta(isBeta) { + Onyx.set(ONYXKEYS.IS_BETA, isBeta); +} + export { - // eslint-disable-next-line import/prefer-default-export triggerUpdateAvailable, + setIsAppInBeta, }; diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index 57443eb54e9d..9220b851cc42 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -173,6 +173,74 @@ function updateDisplayName(firstName, lastName) { Navigation.navigate(ROUTES.SETTINGS_PROFILE); } +/** + * @param {String} legalFirstName + * @param {String} legalLastName + */ +function updateLegalName(legalFirstName, legalLastName) { + API.write('UpdateLegalName', {legalFirstName, legalLastName}, { + optimisticData: [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + legalFirstName, + legalLastName, + }, + }], + }); + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS); +} + +/** + * @param {String} dob - date of birth + */ +function updateDateOfBirth(dob) { + API.write('UpdateDateOfBirth', {dob}, { + optimisticData: [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + dob, + }, + }], + }); + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS); +} + +/** + * @param {String} street + * @param {String} street2 + * @param {String} city + * @param {String} state + * @param {String} zip + * @param {String} country + */ +function updateAddress(street, street2, city, state, zip, country) { + API.write('UpdateHomeAddress', { + addressStreet: street, + addressStreet2: street2, + addressCity: city, + addressState: state, + addressZipCode: zip, + addressCountry: country, + }, { + optimisticData: [{ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + address: { + street: `${street}\n${street2}`, + city, + state, + zip, + country, + }, + }, + }], + }); + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS); +} + /** * Updates timezone's 'automatic' setting, and updates * selected timezone if set to automatically update. @@ -230,6 +298,13 @@ function openIOUModalPage() { API.read('OpenIOUModalPage'); } +/** + * Fetches additional personal data like legal name, date of birth, address + */ +function openPersonalDetailsPage() { + API.read('OpenPersonalDetailsPage'); +} + /** * Updates the user's avatar image * @@ -331,8 +406,12 @@ export { updateAvatar, deleteAvatar, openIOUModalPage, + openPersonalDetailsPage, extractFirstAndLastNameFromAvailableDetails, updateDisplayName, + updateLegalName, + updateDateOfBirth, + updateAddress, updatePronouns, clearAvatarErrors, updateAutomaticTimezone, diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js index 4255b79d5d22..94799fa6b3c6 100644 --- a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js @@ -25,7 +25,7 @@ function resetFreePlanBankAccount(bankAccountID) { { optimisticData: [ { - onyxMethod: 'set', + onyxMethod: CONST.ONYX.METHOD.SET, key: ONYXKEYS.ONFIDO_TOKEN, value: '', }, @@ -44,12 +44,31 @@ function resetFreePlanBankAccount(bankAccountID) { key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, value: ReimbursementAccountProps.reimbursementAccountDefaultProps, }, + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: {isLoading: true}, + }, { onyxMethod: CONST.ONYX.METHOD.SET, key: ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, value: {}, }, ], + successData: [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: {isLoading: false}, + }, + ], + failureData: [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: {isLoading: false}, + }, + ], }); } diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index bcf3f0919de2..00539f2591ac 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -15,7 +15,10 @@ import * as Authentication from '../../Authentication'; import * as Welcome from '../Welcome'; import * as API from '../../API'; import * as NetworkStore from '../../Network/NetworkStore'; +import * as Report from '../Report'; import DateUtils from '../../DateUtils'; +import Navigation from '../../Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; let credentials = {}; Onyx.connect({ @@ -40,6 +43,10 @@ Onyx.connect({ if (accountID) { PushNotification.register(accountID); + + // Prevent issue where report linking fails after users switch accounts without closing the app + PushNotification.init(); + Report.subscribeToReportCommentPushNotifications(); } else { PushNotification.deregister(); PushNotification.clearNotifications(); @@ -133,6 +140,13 @@ function beginSignIn(login) { isLoading: false, }, }, + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.CREDENTIALS, + value: { + validateCode: null, + }, + }, ]; const failureData = [ @@ -237,8 +251,16 @@ function signIn(password, validateCode, twoFactorAuthCode) { }, ]; + const params = {twoFactorAuthCode}; + if (credentials.login) { + // The user initiated the sign in operation on the current device, sign in with the email + params.email = credentials.login; + } else { + // The user is signing in with the accountID and validateCode from the magic link + params.accountID = credentials.accountID; + } + // Conditionally pass a password or validateCode to command since we temporarily allow both flows - const params = {email: credentials.login, twoFactorAuthCode}; if (validateCode) { params.validateCode = validateCode; } else { @@ -248,6 +270,58 @@ function signIn(password, validateCode, twoFactorAuthCode) { API.write('SigninUser', params, {optimisticData, successData, failureData}); } +function signInWithValidateCode(accountID, validateCode) { + const optimisticData = [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + ...CONST.DEFAULT_ACCOUNT_DATA, + isLoading: true, + }, + }, + ]; + + const successData = [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + isLoading: false, + }, + }, + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.CREDENTIALS, + value: { + accountID, + validateCode, + }, + }, + ]; + + const failureData = [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + isLoading: false, + }, + }, + ]; + + // This is temporary for now. Server should login with the accountID and validateCode + API.write('SigninUser', { + validateCode, + accountID, + }, {optimisticData, successData, failureData}); +} + +function signInWithValidateCodeAndNavigate(accountID, validateCode) { + signInWithValidateCode(accountID, validateCode); + Navigation.navigate(ROUTES.HOME); +} + /** * User forgot the password so let's send them the link to reset their password */ @@ -466,6 +540,8 @@ export { beginSignIn, updatePasswordAndSignin, signIn, + signInWithValidateCode, + signInWithValidateCodeAndNavigate, signInWithShortLivedAuthToken, cleanupSession, signOut, diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index dd23484176ec..a74a7350e06a 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -402,6 +402,7 @@ function updateChatPriorityMode(mode) { API.write('UpdateChatPriorityMode', { value: mode, }, {optimisticData}); + Navigation.navigate(ROUTES.SETTINGS_PREFERENCES); } /** diff --git a/src/libs/actions/Welcome.js b/src/libs/actions/Welcome.js index 58a760a50f63..d3795ad825a3 100644 --- a/src/libs/actions/Welcome.js +++ b/src/libs/actions/Welcome.js @@ -111,10 +111,11 @@ function show({routes, showCreateMenu}) { // If we are rendering the SidebarScreen at the same time as a workspace route that means we've already created a workspace via workspace/new and should not open the global // create menu right now. We should also stay on the workspace page if that is our destination. - const topRouteName = lodashGet(_.last(routes), 'name', ''); + const topRoute = _.last(routes); + const isWorkspaceRoute = topRoute.name === 'Settings' && topRoute.params.path.includes('workspace'); const transitionRoute = _.find(routes, route => route.name === SCREENS.TRANSITION_FROM_OLD_DOT); const exitingToWorkspaceRoute = lodashGet(transitionRoute, 'params.exitTo', '') === 'workspace/new'; - const isDisplayingWorkspaceRoute = topRouteName.toLowerCase().includes('workspace') || exitingToWorkspaceRoute; + const isDisplayingWorkspaceRoute = isWorkspaceRoute || exitingToWorkspaceRoute; // We want to display the Workspace chat first since that means a user is already in a Workspace and doesn't need to create another one const workspaceChatReport = _.find(allReports, report => ReportUtils.isPolicyExpenseChat(report) && report.ownerEmail === email); diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 1c0294881360..1ede56c65ca7 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -260,6 +260,7 @@ class NewChatPage extends Component { confirmButtonText={this.props.translate('newChatPage.createGroup')} onConfirmSelection={this.createGroup} placeholderText={this.props.translate('optionsSelector.nameEmailOrPhoneNumber')} + safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} /> ) : ( diff --git a/src/pages/ReimbursementAccount/CompanyStep.js b/src/pages/ReimbursementAccount/CompanyStep.js index a666631d3738..16a550c0080f 100644 --- a/src/pages/ReimbursementAccount/CompanyStep.js +++ b/src/pages/ReimbursementAccount/CompanyStep.js @@ -103,7 +103,7 @@ class CompanyStep extends React.Component { } if (!values.incorporationDate || !ValidationUtils.isValidDate(values.incorporationDate)) { - errors.incorporationDate = this.props.translate('bankAccount.error.incorporationDate'); + errors.incorporationDate = this.props.translate('common.error.dateInvalid'); } else if (!values.incorporationDate || !ValidationUtils.isValidPastDate(values.incorporationDate)) { errors.incorporationDate = this.props.translate('bankAccount.error.incorporationDateFuture'); } @@ -200,6 +200,7 @@ class CompanyStep extends React.Component { defaultValue={this.props.getDefaultStateForField('website', this.defaultWebsite)} shouldSaveDraft hint={this.props.translate('common.websiteExample')} + keyboardType={CONST.KEYBOARD_TYPE.URL} /> ( BankAccounts.requestResetFreePlanBankAccount()} shouldShowRightIcon wrapperStyle={[styles.cardMenuItem]} /> + + {props.reimbursementAccount.shouldShowResetModal && ( + + )} ); diff --git a/src/pages/ReimbursementAccount/EnableStep.js b/src/pages/ReimbursementAccount/EnableStep.js index 329f8c61cc9d..b8b7dd1a029f 100644 --- a/src/pages/ReimbursementAccount/EnableStep.js +++ b/src/pages/ReimbursementAccount/EnableStep.js @@ -22,6 +22,7 @@ import * as Link from '../../libs/actions/Link'; import * as User from '../../libs/actions/User'; import ScreenWrapper from '../../components/ScreenWrapper'; import * as BankAccounts from '../../libs/actions/ReimbursementAccount'; +import WorkspaceResetBankAccountModal from '../workspace/WorkspaceResetBankAccountModal'; const propTypes = { /** Bank account currently in setup */ @@ -102,6 +103,11 @@ const EnableStep = (props) => { )} + {props.reimbursementAccount.shouldShowResetModal && ( + + )} ); }; diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js index 193bf2c9a46b..4337292048bb 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js @@ -29,7 +29,6 @@ import EnableStep from './EnableStep'; import ROUTES from '../../ROUTES'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import * as ReimbursementAccountProps from './reimbursementAccountPropTypes'; -import WorkspaceResetBankAccountModal from '../workspace/WorkspaceResetBankAccountModal'; import reimbursementAccountDraftPropTypes from './ReimbursementAccountDraftPropTypes'; import * as ReimbursementAccountUtils from '../../libs/ReimbursementAccountUtils'; @@ -263,13 +262,8 @@ class ReimbursementAccountPage extends React.Component { ); } - if (this.props.reimbursementAccount.shouldShowResetModal && Boolean(achData.bankAccountID)) { - return ( - - ); - } - // Show the "Continue with setup" button if a bank account setup is already in progress and no specific further step was passed in the url + // We'll show the workspace bank account reset modal if the user wishes to start over if (!this.state.shouldHideContinueSetupButton && Boolean(achData.bankAccountID) && achData.state !== BankAccount.STATE.OPEN @@ -280,11 +274,9 @@ class ReimbursementAccountPage extends React.Component { )) { return ( { - this.setState({shouldHideContinueSetupButton: true}); - BankAccounts.requestResetFreePlanBankAccount(); - }} + startOver={() => this.setState({shouldHideContinueSetupButton: true})} /> ); } diff --git a/src/pages/ReimbursementAccount/ValidationStep.js b/src/pages/ReimbursementAccount/ValidationStep.js index ea3d5a93c115..077ee7544029 100644 --- a/src/pages/ReimbursementAccount/ValidationStep.js +++ b/src/pages/ReimbursementAccount/ValidationStep.js @@ -27,7 +27,9 @@ import Section from '../../components/Section'; import CONST from '../../CONST'; import Button from '../../components/Button'; import MenuItem from '../../components/MenuItem'; +import WorkspaceResetBankAccountModal from '../workspace/WorkspaceResetBankAccountModal'; import Enable2FAPrompt from './Enable2FAPrompt'; +import ScreenWrapper from '../../components/ScreenWrapper'; const propTypes = { ...withLocalizePropTypes, @@ -102,7 +104,7 @@ class ValidationStep extends React.Component { * @returns {String} */ filterInput(amount) { - let value = amount ? amount.trim() : ''; + let value = amount ? amount.toString().trim() : ''; if (value === '' || !Math.abs(Str.fromUSDToNumber(value)) || _.isNaN(Number(value))) { return ''; } @@ -128,7 +130,7 @@ class ValidationStep extends React.Component { const requiresTwoFactorAuth = lodashGet(this.props, 'account.requiresTwoFactorAuth'); return ( - + + {this.props.reimbursementAccount.shouldShowResetModal && ( + + )} {!requiresTwoFactorAuth && ( )} )} - + ); } } diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index b97b31e7e5de..688b691fb62c 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -23,6 +23,7 @@ import Text from '../components/Text'; import CONST from '../CONST'; import reportPropTypes from './reportPropTypes'; import withReportOrNavigateHome from './home/report/withReportOrNavigateHome'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; const propTypes = { ...withLocalizePropTypes, @@ -109,66 +110,68 @@ class ReportDetailsPage extends Component { const menuItems = this.getMenuItems(); return ( - Navigation.goBack()} - onCloseButtonPress={() => Navigation.dismissModal()} - /> - - - - - - - - - + Navigation.goBack()} + onCloseButtonPress={() => Navigation.dismissModal()} + /> + + + + + - - {chatRoomSubtitle} - + + + + + + {chatRoomSubtitle} + + - - {_.map(menuItems, (item) => { - const brickRoadIndicator = ( - ReportUtils.hasReportNameError(this.props.report) - && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS - ) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : ''; - return ( - - ); - })} - + {_.map(menuItems, (item) => { + const brickRoadIndicator = ( + ReportUtils.hasReportNameError(this.props.report) + && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS + ) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : ''; + return ( + + ); + })} + + ); } diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index e91667d4e908..07c4d62b448a 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -20,6 +20,7 @@ import compose from '../libs/compose'; import * as ReportUtils from '../libs/ReportUtils'; import reportPropTypes from './reportPropTypes'; import withReportOrNavigateHome from './home/report/withReportOrNavigateHome'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; const propTypes = { /* Onyx Props */ @@ -72,38 +73,42 @@ const ReportParticipantsPage = (props) => { const participants = getAllParticipants(props.report, props.personalDetails); return ( - - - - {Boolean(participants.length) - && ( - { - Navigation.navigate(ROUTES.getReportParticipantRoute( - props.route.params.reportID, option.login, - )); - }} - hideSectionHeaders - showTitleTooltip - disableFocusOptions - boldStyle - optionHoveredStyle={styles.hoveredComponentBG} + + {({safeAreaPaddingBottomStyle}) => ( + + - )} - + + {Boolean(participants.length) && ( + { + Navigation.navigate(ROUTES.getReportParticipantRoute( + props.route.params.reportID, option.login, + )); + }} + hideSectionHeaders + showTitleTooltip + disableFocusOptions + boldStyle + optionHoveredStyle={styles.hoveredComponentBG} + contentContainerStyles={[safeAreaPaddingBottomStyle]} + /> + )} + + + )} ); }; diff --git a/src/pages/ReportSettingsPage.js b/src/pages/ReportSettingsPage.js index 1ff79c3b98a2..86fa8b6cec16 100644 --- a/src/pages/ReportSettingsPage.js +++ b/src/pages/ReportSettingsPage.js @@ -22,6 +22,7 @@ import OfflineWithFeedback from '../components/OfflineWithFeedback'; import reportPropTypes from './reportPropTypes'; import withReportOrNavigateHome from './home/report/withReportOrNavigateHome'; import Form from '../components/Form'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; const propTypes = { /** Route params */ @@ -54,7 +55,6 @@ class ReportSettingsPage extends Component { super(props); this.validate = this.validate.bind(this); - this.updatePolicyRoomName = this.updatePolicyRoomName.bind(this); } getNotificationPreferenceOptions() { @@ -110,106 +110,107 @@ class ReportSettingsPage extends Component { render() { const shouldShowRoomName = !ReportUtils.isPolicyExpenseChat(this.props.report); - const shouldDisableRename = ReportUtils.isDefaultRoom(this.props.report) - || ReportUtils.isArchivedRoom(this.props.report); + const shouldDisableRename = ReportUtils.isDefaultRoom(this.props.report) || ReportUtils.isArchivedRoom(this.props.report); const linkedWorkspace = _.find(this.props.policies, policy => policy && policy.id === this.props.report.policyID); return ( - -
- - - { - if (this.props.report.notificationPreference === notificationPreference) { - return; - } - - Report.updateNotificationPreference( - this.props.report.reportID, - this.props.report.notificationPreference, - notificationPreference, - ); - }} - items={this.getNotificationPreferenceOptions()} - value={this.props.report.notificationPreference} - /> + + + !shouldDisableRename && this.updatePolicyRoomName(values)} + scrollContextEnabled + isSubmitButtonVisible={shouldShowRoomName && !shouldDisableRename} + enabledWhenOffline + > + + + { + if (this.props.report.notificationPreference === notificationPreference) { + return; + } + + Report.updateNotificationPreference( + this.props.report.reportID, + this.props.report.notificationPreference, + notificationPreference, + ); + }} + items={this.getNotificationPreferenceOptions()} + value={this.props.report.notificationPreference} + /> + - - {shouldShowRoomName && ( - - Report.clearPolicyRoomNameErrors(this.props.report.reportID)} - > - - - {shouldDisableRename ? ( - - - {this.props.translate('newRoomPage.roomName')} - - - {this.props.report.reportName} - - - ) - : ( - - )} + {shouldShowRoomName && ( + + Report.clearPolicyRoomNameErrors(this.props.report.reportID)} + > + + + {shouldDisableRename ? ( + + + {this.props.translate('newRoomPage.roomName')} + + + {this.props.report.reportName} + + + ) + : ( + + )} + - - - - )} - {linkedWorkspace && ( - - - {this.props.translate('workspace.common.workspace')} - - - {linkedWorkspace.name} - - - )} - {this.props.report.visibility && ( - - - {this.props.translate('newRoomPage.visibility')} - - - {this.props.translate(`newRoomPage.visibilityOptions.${this.props.report.visibility}`)} - - - { - this.props.report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED - ? this.props.translate('newRoomPage.restrictedDescription') - : this.props.translate('newRoomPage.privateDescription') - } - - - )} - + +
+ )} + {linkedWorkspace && ( + + + {this.props.translate('workspace.common.workspace')} + + + {linkedWorkspace.name} + + + )} + {this.props.report.visibility && ( + + + {this.props.translate('newRoomPage.visibility')} + + + {this.props.translate(`newRoomPage.visibilityOptions.${this.props.report.visibility}`)} + + + { + this.props.report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED + ? this.props.translate('newRoomPage.restrictedDescription') + : this.props.translate('newRoomPage.privateDescription') + } + + + )} + +
); } diff --git a/src/pages/RequestCallPage.js b/src/pages/RequestCallPage.js index 4bcd38d97410..91b6d1e5e0e8 100644 --- a/src/pages/RequestCallPage.js +++ b/src/pages/RequestCallPage.js @@ -219,16 +219,6 @@ class RequestCallPage extends Component { errors.lastName = this.props.translate('requestCallPage.error.lastName'); } - const [firstNameLengthError, lastNameLengthError] = ValidationUtils.doesFailCharacterLimit(50, [values.firstName, values.lastName]); - - if (firstNameLengthError) { - errors.firstName = this.props.translate('requestCallPage.error.firstNameLength'); - } - - if (lastNameLengthError) { - errors.lastName = this.props.translate('requestCallPage.error.lastNameLength'); - } - const phoneNumber = LoginUtils.getPhoneNumberWithoutSpecialChars(values.phoneNumber); if (_.isEmpty(values.phoneNumber.trim()) || !Str.isValidPhone(phoneNumber)) { errors.phoneNumber = this.props.translate('common.error.phoneNumber'); @@ -285,6 +275,7 @@ class RequestCallPage extends Component { name="fname" placeholder={this.props.translate('profilePage.john')} containerStyles={[styles.mt4]} + maxLength={CONST.DISPLAY_NAME.MAX_LENGTH} /> - {({didScreenTransitionEnd}) => ( + {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( <>
diff --git a/src/pages/SetPasswordPage.js b/src/pages/SetPasswordPage.js index 7a75677c7486..32d25f38fa56 100755 --- a/src/pages/SetPasswordPage.js +++ b/src/pages/SetPasswordPage.js @@ -10,7 +10,7 @@ import lodashGet from 'lodash/get'; import { propTypes as validateLinkPropTypes, defaultProps as validateLinkDefaultProps, -} from './validateLinkPropTypes'; +} from './ValidateLoginPage/validateLinkPropTypes'; import styles from '../styles/styles'; import * as Session from '../libs/actions/Session'; import ONYXKEYS from '../ONYXKEYS'; diff --git a/src/pages/ValidateLoginPage.js b/src/pages/ValidateLoginPage.js deleted file mode 100644 index ffa1d295225b..000000000000 --- a/src/pages/ValidateLoginPage.js +++ /dev/null @@ -1,34 +0,0 @@ -import React, {Component} from 'react'; -import lodashGet from 'lodash/get'; -import { - propTypes as validateLinkPropTypes, - defaultProps as validateLinkDefaultProps, -} from './validateLinkPropTypes'; -import * as User from '../libs/actions/User'; -import FullScreenLoadingIndicator from '../components/FullscreenLoadingIndicator'; - -const propTypes = { - /** The accountID and validateCode are passed via the URL */ - route: validateLinkPropTypes, -}; - -const defaultProps = { - route: validateLinkDefaultProps, -}; -class ValidateLoginPage extends Component { - componentDidMount() { - const accountID = lodashGet(this.props.route.params, 'accountID', ''); - const validateCode = lodashGet(this.props.route.params, 'validateCode', ''); - - User.validateLogin(accountID, validateCode); - } - - render() { - return ; - } -} - -ValidateLoginPage.propTypes = propTypes; -ValidateLoginPage.defaultProps = defaultProps; - -export default ValidateLoginPage; diff --git a/src/pages/ValidateLoginPage/index.js b/src/pages/ValidateLoginPage/index.js new file mode 100644 index 000000000000..d76ef47c9856 --- /dev/null +++ b/src/pages/ValidateLoginPage/index.js @@ -0,0 +1,65 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import { + propTypes as validateLinkPropTypes, + defaultProps as validateLinkDefaultProps, +} from './validateLinkPropTypes'; +import * as User from '../../libs/actions/User'; +import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as Session from '../../libs/actions/Session'; +import Permissions from '../../libs/Permissions'; +import Navigation from '../../libs/Navigation/Navigation'; + +const propTypes = { + /** The accountID and validateCode are passed via the URL */ + route: validateLinkPropTypes, + + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), +}; + +const defaultProps = { + route: validateLinkDefaultProps, + betas: [], +}; + +class ValidateLoginPage extends Component { + componentDidMount() { + if (Permissions.canUsePasswordlessLogins(this.props.betas)) { + if (lodashGet(this.props, 'session.authToken', null)) { + // If already signed in, do not show the validate code if not on web, + // because we don't want to block the user with the interstitial page. + Navigation.goBack(false); + } else { + Session.signInWithValidateCodeAndNavigate(this.accountID(), this.validateCode()); + } + } else { + User.validateLogin(this.accountID(), this.validateCode()); + } + } + + /** + * @returns {String} + */ + accountID = () => lodashGet(this.props.route.params, 'accountID', ''); + + /** + * @returns {String} + */ + validateCode = () => lodashGet(this.props.route.params, 'validateCode', ''); + + render() { + return ; + } +} + +ValidateLoginPage.propTypes = propTypes; +ValidateLoginPage.defaultProps = defaultProps; + +export default withOnyx({ + betas: {key: ONYXKEYS.BETAS}, + session: {key: ONYXKEYS.SESSION}, +})(ValidateLoginPage); diff --git a/src/pages/ValidateLoginPage/index.website.js b/src/pages/ValidateLoginPage/index.website.js new file mode 100644 index 000000000000..ee8e1f38b04c --- /dev/null +++ b/src/pages/ValidateLoginPage/index.website.js @@ -0,0 +1,116 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import { + propTypes as validateLinkPropTypes, + defaultProps as validateLinkDefaultProps, +} from './validateLinkPropTypes'; +import * as User from '../../libs/actions/User'; +import compose from '../../libs/compose'; +import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; +import ValidateCodeModal from '../../components/ValidateCodeModal'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as Session from '../../libs/actions/Session'; +import Permissions from '../../libs/Permissions'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; + +const propTypes = { + /** The accountID and validateCode are passed via the URL */ + route: validateLinkPropTypes, + + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + route: validateLinkDefaultProps, + betas: [], +}; + +class ValidateLoginPage extends Component { + constructor(props) { + super(props); + + this.state = {justSignedIn: false}; + } + + componentDidMount() { + // Validate login if + // - The user is not on passwordless beta + if (!this.isOnPasswordlessBeta()) { + User.validateLogin(this.accountID(), this.validateCode()); + return; + } + + // Sign in if + // - The user is on the passwordless beta + // - AND the user is not authenticated + // - AND the user has initiated the sign in process in another tab + if (this.isOnPasswordlessBeta() && !this.isAuthenticated() && this.isSignInInitiated()) { + Session.signInWithValidateCode(this.accountID(), this.validateCode()); + } + } + + componentDidUpdate(prevProps) { + if (!(prevProps.credentials && !prevProps.credentials.validateCode && this.props.credentials.validateCode)) { + return; + } + this.setState({justSignedIn: true}); + } + + /** + * @returns {Boolean} + */ + isOnPasswordlessBeta = () => Permissions.canUsePasswordlessLogins(this.props.betas); + + /** + * @returns {String} + */ + accountID = () => lodashGet(this.props.route.params, 'accountID', ''); + + /** + * @returns {String} + */ + validateCode = () => lodashGet(this.props.route.params, 'validateCode', ''); + + /** + * @returns {Boolean} + */ + isAuthenticated = () => Boolean(lodashGet(this.props, 'session.authToken', null)); + + /** + * Where SignIn was initiated on the current browser. + * @returns {Boolean} + */ + isSignInInitiated = () => !this.isAuthenticated() && this.props.credentials && this.props.credentials.login; + + render() { + return ( + this.isOnPasswordlessBeta() + ? ( + Session.signInWithValidateCodeAndNavigate(this.accountID(), this.validateCode())} + /> + ) + : + ); + } +} + +ValidateLoginPage.propTypes = propTypes; +ValidateLoginPage.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + betas: {key: ONYXKEYS.BETAS}, + session: {key: ONYXKEYS.SESSION}, + credentials: {key: ONYXKEYS.CREDENTIALS}, + }), +)(ValidateLoginPage); diff --git a/src/pages/validateLinkPropTypes.js b/src/pages/ValidateLoginPage/validateLinkPropTypes.js similarity index 100% rename from src/pages/validateLinkPropTypes.js rename to src/pages/ValidateLoginPage/validateLinkPropTypes.js diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 1be75b3e1d90..467ce7b70a70 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -20,7 +20,6 @@ import CONST from '../../CONST'; import ReportActionsSkeletonView from '../../components/ReportActionsSkeletonView'; import reportActionPropTypes from './report/reportActionPropTypes'; import toggleReportActionComposeView from '../../libs/toggleReportActionComposeView'; -import addViewportResizeListener from '../../libs/VisualViewport'; import {withNetwork} from '../../components/OnyxProvider'; import compose from '../../libs/compose'; import networkPropTypes from '../../components/networkPropTypes'; @@ -33,6 +32,7 @@ import withLocalize from '../../components/withLocalize'; import reportPropTypes from '../reportPropTypes'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; import ReportHeaderSkeletonView from '../../components/ReportHeaderSkeletonView'; +import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -73,6 +73,7 @@ const propTypes = { ...windowDimensionsPropTypes, ...withDrawerPropTypes, + ...viewportOffsetTopPropTypes, }; const defaultProps = { @@ -107,14 +108,11 @@ class ReportScreen extends React.Component { super(props); this.onSubmitComment = this.onSubmitComment.bind(this); - this.updateViewportOffsetTop = this.updateViewportOffsetTop.bind(this); this.chatWithAccountManager = this.chatWithAccountManager.bind(this); this.dismissBanner = this.dismissBanner.bind(this); - this.removeViewportResizeListener = () => {}; this.state = { skeletonViewContainerHeight: reportActionsListViewHeight, - viewportOffsetTop: 0, isBannerVisible: true, }; } @@ -122,7 +120,7 @@ class ReportScreen extends React.Component { componentDidMount() { this.fetchReportIfNeeded(); toggleReportActionComposeView(true); - this.removeViewportResizeListener = addViewportResizeListener(this.updateViewportOffsetTop); + Navigation.setIsReportScreenIsReady(); } componentDidUpdate(prevProps) { @@ -134,10 +132,6 @@ class ReportScreen extends React.Component { toggleReportActionComposeView(true); } - componentWillUnmount() { - this.removeViewportResizeListener(); - } - /** * @param {String} text */ @@ -171,14 +165,6 @@ class ReportScreen extends React.Component { Report.openReport(reportIDFromPath); } - /** - * @param {SyntheticEvent} e - */ - updateViewportOffsetTop(e) { - const viewportOffsetTop = lodashGet(e, 'target.offsetTop', 0); - this.setState({viewportOffsetTop}); - } - dismissBanner() { this.setState({isBannerVisible: false}); } @@ -205,7 +191,7 @@ class ReportScreen extends React.Component { const reportID = getReportID(this.props.route); const addWorkspaceRoomOrChatPendingAction = lodashGet(this.props.report, 'pendingFields.addWorkspaceRoom') || lodashGet(this.props.report, 'pendingFields.createChat'); const addWorkspaceRoomOrChatErrors = lodashGet(this.props.report, 'errorFields.addWorkspaceRoom') || lodashGet(this.props.report, 'errorFields.createChat'); - const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: this.state.viewportOffsetTop}]; + const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: this.props.viewportOffsetTop}]; // There are no reportActions at all to display and we are still in the process of loading the next set of actions. const isLoadingInitialReportActions = _.isEmpty(this.props.reportActions) && this.props.report.isLoadingReportActions; @@ -323,6 +309,7 @@ ReportScreen.propTypes = propTypes; ReportScreen.defaultProps = defaultProps; export default compose( + withViewportOffsetTop, withLocalize, withWindowDimensions, withDrawerState, diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index a75e3963e715..949286358000 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -112,11 +112,7 @@ export default [ if (!Clipboard.canSetHtml()) { Clipboard.setString(parser.htmlToMarkdown(content)); } else { - // Thanks to how browsers work, when text is highlighted and CTRL+c is pressed, browsers end up doubling the amount of newlines. Since the code in this file is - // triggered from a context menu and not CTRL+c, the newlines need to be doubled so that the content that goes into the clipboard is consistent with CTRL+c behavior. - // The extra newlines are stripped when the contents are pasted into the compose input, but if the contents are pasted outside of the comment composer, it will - // contain extra newlines and that's OK because it is consistent with CTRL+c behavior. - const plainText = Str.htmlDecode(parser.htmlToText(content)).replace(/\n/g, '\n\n'); + const plainText = Str.htmlDecode(parser.htmlToText(content)); Clipboard.setHtml(content, plainText); } } diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 7ef7fd1d7a83..4075e50b3b37 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -294,14 +294,15 @@ class ReportActionCompose extends React.Component { ]; } - // DM chats and workspace chats that only have 2 people will see the Send / Request money options. + // DM chats that only have 2 people will see the Send / Request money options. + // Workspace chats should only see the Request money option, as "easy overages" is not available. return [ { icon: Expensicons.MoneyCircle, text: this.props.translate('iou.requestMoney'), onSelected: () => Navigation.navigate(ROUTES.getIouRequestRoute(this.props.reportID)), }, - ...(Permissions.canUseIOUSend(this.props.betas) + ...((Permissions.canUseIOUSend(this.props.betas) && !ReportUtils.isPolicyExpenseChat(this.props.report)) ? [ { icon: Expensicons.Send, @@ -471,7 +472,7 @@ class ReportActionCompose extends React.Component { const trimmedComment = this.comment.trim(); // Don't submit empty comments or comments that exceed the character limit - if (this.state.isCommentEmpty || trimmedComment.length > CONST.MAX_COMMENT_LENGTH) { + if (this.state.isCommentEmpty || ReportUtils.getCommentLength(trimmedComment) > CONST.MAX_COMMENT_LENGTH) { return ''; } @@ -534,7 +535,8 @@ class ReportActionCompose extends React.Component { const isComposeDisabled = this.props.isDrawerOpen && this.props.isSmallScreenWidth; const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge); const inputPlaceholder = this.getInputPlaceholder(); - const hasExceededMaxCommentLength = this.comment.length > CONST.MAX_COMMENT_LENGTH; + const encodedCommentLength = ReportUtils.getCommentLength(this.comment); + const hasExceededMaxCommentLength = encodedCommentLength > CONST.MAX_COMMENT_LENGTH; return ( {!this.props.isSmallScreenWidth && } - + {this.state.isDraggingOver && }
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 97fffebebcd3..5f6b5e36632e 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -15,6 +15,7 @@ import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFo import compose from '../../../libs/compose'; import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; +import * as ReportUtils from '../../../libs/ReportUtils'; import * as EmojiUtils from '../../../libs/EmojiUtils'; import reportPropTypes from '../../reportPropTypes'; import ExceededCommentLength from '../../../components/ExceededCommentLength'; @@ -154,7 +155,7 @@ class ReportActionItemMessageEdit extends React.Component { */ publishDraft() { // Do nothing if draft exceed the character limit - if (this.state.draft.length > CONST.MAX_COMMENT_LENGTH) { + if (ReportUtils.getCommentLength(this.state.draft) > CONST.MAX_COMMENT_LENGTH) { return; } @@ -214,7 +215,8 @@ class ReportActionItemMessageEdit extends React.Component { } render() { - const hasExceededMaxCommentLength = this.state.draft.length > CONST.MAX_COMMENT_LENGTH; + const draftLength = ReportUtils.getCommentLength(this.state.draft); + const hasExceededMaxCommentLength = draftLength > CONST.MAX_COMMENT_LENGTH; return ( - + ); diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 1df707541092..19d2cc05f58c 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -19,6 +19,7 @@ import Tooltip from '../../../components/Tooltip'; import ControlSelection from '../../../libs/ControlSelection'; import * as ReportUtils from '../../../libs/ReportUtils'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; +import CONST from '../../../CONST'; const propTypes = { /** All the data of the action */ @@ -51,13 +52,14 @@ const showUserDetails = (email) => { }; const ReportActionItemSingle = (props) => { + const actorEmail = props.action.actorEmail.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const { avatar, displayName, login, pendingFields, - } = props.personalDetails[props.action.actorEmail] || {}; - const avatarSource = ReportUtils.getAvatar(avatar, props.action.actorEmail); + } = props.personalDetails[actorEmail] || {}; + const avatarSource = ReportUtils.getAvatar(avatar, actorEmail); // Since the display name for a report action message is delivered with the report history as an array of fragments // we'll need to take the displayName from personal details and have it be in the same format for now. Eventually, @@ -72,9 +74,9 @@ const ReportActionItemSingle = (props) => { style={[styles.alignSelfStart, styles.mr3]} onPressIn={ControlSelection.block} onPressOut={ControlSelection.unblock} - onPress={() => showUserDetails(props.action.actorEmail)} + onPress={() => showUserDetails(actorEmail)} > - + @@ -92,13 +94,13 @@ const ReportActionItemSingle = (props) => { style={[styles.flexShrink1, styles.mr1]} onPressIn={ControlSelection.block} onPressOut={ControlSelection.unblock} - onPress={() => showUserDetails(props.action.actorEmail)} + onPress={() => showUserDetails(actorEmail)} > {_.map(personArray, (fragment, index) => ( this.sortedAndFilteredReportActions.length) { - this.setState({newMarkerReportActionID: ReportUtils.getNewMarkerReportActionID(this.props.report, this.sortedAndFilteredReportActions)}); + // If the report action marking the unread point is deleted we need to recalculate which action should be the unread marker + if (this.state.newMarkerReportActionID && _.isEmpty(lodashGet(this.props.reportActions[this.state.newMarkerReportActionID], 'message[0].html'))) { + this.setState({ + newMarkerReportActionID: ReportUtils.getNewMarkerReportActionID(this.props.report, this.sortedAndFilteredReportActions), + }); } // When the user navigates to the LHN the ReportActionsView doesn't unmount and just remains hidden. @@ -264,6 +270,7 @@ class ReportActionsView extends React.Component { } if (String(reportAction.sequenceNumber) === key) { + Log.info('Front-end filtered out reportAction keyed by sequenceNumber!', false, reportAction); return false; } diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js index bcca70f3f354..0e99d72926f5 100644 --- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js +++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js @@ -9,7 +9,6 @@ import Timing from '../../../../libs/actions/Timing'; import CONST from '../../../../CONST'; import Performance from '../../../../libs/Performance'; import withDrawerState from '../../../../components/withDrawerState'; -import KeyboardShortcutsModal from '../../../../components/KeyboardShortcutsModal'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; import compose from '../../../../libs/compose'; import sidebarPropTypes from './sidebarPropTypes'; @@ -66,7 +65,6 @@ class BaseSidebarScreen extends Component { onLayout={this.props.onLayout} /> - {this.props.children} )} diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index e78cbd48e16f..1d4524be7d66 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -113,18 +113,23 @@ class IOUCurrencySelection extends Component { const headerMessage = this.state.searchValue.trim() && !this.state.currencyData.length ? this.props.translate('common.noResultsFound') : ''; return ( - - + {({safeAreaPaddingBottomStyle}) => ( + <> + + + + )} ); } diff --git a/src/pages/iou/IOUDetailsModal.js b/src/pages/iou/IOUDetailsModal.js index 4da6d6792f47..e3380122db7a 100644 --- a/src/pages/iou/IOUDetailsModal.js +++ b/src/pages/iou/IOUDetailsModal.js @@ -154,49 +154,52 @@ class IOUDetailsModal extends Component { const pendingAction = this.findPendingAction(); const iouReportStateNum = lodashGet(this.props.iouReport, 'stateNum'); const hasOutstandingIOU = lodashGet(this.props.iouReport, 'hasOutstandingIOU'); + const hasFixedFooter = hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail; return ( - - - - {this.props.iou.loading ? : ( - - - - - - {(hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail && ( - - this.payMoneyRequest(paymentMethodType)} - shouldShowPaypal={Boolean(lodashGet(this.props, 'iouReport.submitterPayPalMeAddress'))} - currency={lodashGet(this.props, 'iouReport.currency')} - enablePaymentsRoute={ROUTES.IOU_DETAILS_ENABLE_PAYMENTS} - addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} - addDebitCardRoute={ROUTES.IOU_DETAILS_ADD_DEBIT_CARD} + + {({safeAreaPaddingBottomStyle}) => ( + + + {this.props.iou.loading ? : ( + + + - - ))} - - )} - + + + {(hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail && ( + + this.payMoneyRequest(paymentMethodType)} + shouldShowPaypal={Boolean(lodashGet(this.props, 'iouReport.submitterPayPalMeAddress'))} + currency={lodashGet(this.props, 'iouReport.currency')} + enablePaymentsRoute={ROUTES.IOU_DETAILS_ENABLE_PAYMENTS} + addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} + addDebitCardRoute={ROUTES.IOU_DETAILS_ADD_DEBIT_CARD} + chatReportID={this.props.route.params.chatReportID} + /> + + ))} + + )} + + )} ); } diff --git a/src/pages/iou/IOUModal.js b/src/pages/iou/IOUModal.js index c733ce6f8989..6acc2fb35330 100755 --- a/src/pages/iou/IOUModal.js +++ b/src/pages/iou/IOUModal.js @@ -453,6 +453,7 @@ class IOUModal extends Component { hasMultipleParticipants={this.props.hasMultipleParticipants} onAddParticipants={this.addParticipants} onStepComplete={this.navigateToNextStep} + safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} /> )} diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js index ac719cda4c31..38d5c7a9d46d 100644 --- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js +++ b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js @@ -41,11 +41,18 @@ const propTypes = { /** Whether or not the IOU step is loading (retrieving participants) */ loading: PropTypes.bool, }), + + /** padding bottom style of safe area */ + safeAreaPaddingBottomStyle: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.object), + PropTypes.object, + ]), }; const defaultProps = { iou: {}, participants: [], + safeAreaPaddingBottomStyle: {}, }; const IOUParticipantsPage = (props) => { @@ -63,12 +70,14 @@ const IOUParticipantsPage = (props) => { onStepComplete={props.onStepComplete} participants={props.participants} onAddParticipants={props.onAddParticipants} + safeAreaPaddingBottomStyle={props.safeAreaPaddingBottomStyle} /> ) : ( ) ); diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js index 0b29c1df14dd..c7f86ace9b4d 100755 --- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js +++ b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js @@ -27,9 +27,19 @@ const propTypes = { /** All reports shared with the user */ reports: PropTypes.objectOf(reportPropTypes).isRequired, + /** padding bottom style of safe area */ + safeAreaPaddingBottomStyle: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.object), + PropTypes.object, + ]), + ...withLocalizePropTypes, }; +const defaultProps = { + safeAreaPaddingBottomStyle: {}, +}; + class IOUParticipantsRequest extends Component { constructor(props) { super(props); @@ -141,12 +151,14 @@ class IOUParticipantsRequest extends Component { headerMessage={headerMessage} placeholderText={this.props.translate('optionsSelector.nameEmailOrPhoneNumber')} boldStyle + safeAreaPaddingBottomStyle={this.props.safeAreaPaddingBottomStyle} /> ); } } IOUParticipantsRequest.propTypes = propTypes; +IOUParticipantsRequest.defaultProps = defaultProps; export default compose( withLocalize, diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js index 7da28e971cdc..f43325b6bdc8 100755 --- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js +++ b/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js @@ -13,7 +13,6 @@ import compose from '../../../../libs/compose'; import Text from '../../../../components/Text'; import personalDetailsPropType from '../../../personalDetailsPropType'; import reportPropTypes from '../../../reportPropTypes'; -import SafeAreaConsumer from '../../../../components/SafeAreaConsumer'; const propTypes = { /** Beta features list */ @@ -43,11 +42,18 @@ const propTypes = { /** All reports shared with the user */ reports: PropTypes.objectOf(reportPropTypes).isRequired, + /** padding bottom style of safe area */ + safeAreaPaddingBottomStyle: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.object), + PropTypes.object, + ]), + ...withLocalizePropTypes, }; const defaultProps = { participants: [], + safeAreaPaddingBottomStyle: {}, }; class IOUParticipantsSplit extends Component { @@ -210,29 +216,26 @@ class IOUParticipantsSplit extends Component { maxParticipantsReached, ); return ( - - {({safeAreaPaddingBottomStyle}) => ( - 0 ? safeAreaPaddingBottomStyle : {})]}> - - {this.props.translate('common.to')} - - - - )} - + 0 ? this.props.safeAreaPaddingBottomStyle : {})]}> + + {this.props.translate('common.to')} + + + ); } } diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js index a9a809c6d0a2..7c7840fdbbdb 100644 --- a/src/pages/settings/AboutPage/AboutPage.js +++ b/src/pages/settings/AboutPage/AboutPage.js @@ -62,80 +62,85 @@ const AboutPage = (props) => { return ( - Navigation.navigate(ROUTES.SETTINGS)} - onCloseButtonPress={() => Navigation.dismissModal(true)} - /> - - - - - + {({safeAreaPaddingBottomStyle}) => ( + <> + Navigation.navigate(ROUTES.SETTINGS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + + + + + + v + {pkg.version} + + + {props.translate('initialSettingsPage.aboutPage.description')} + + + + {_.map(menuItems, item => ( + item.action()} + shouldShowRightIcon + /> + ))} + + - v - {pkg.version} - - - {props.translate('initialSettingsPage.aboutPage.description')} + {props.translate( + 'initialSettingsPage.readTheTermsAndPrivacy.phrase1', + )} + {' '} + + {props.translate( + 'initialSettingsPage.readTheTermsAndPrivacy.phrase2', + )} + + {' '} + {props.translate( + 'initialSettingsPage.readTheTermsAndPrivacy.phrase3', + )} + {' '} + + {props.translate( + 'initialSettingsPage.readTheTermsAndPrivacy.phrase4', + )} + + . - - {_.map(menuItems, item => ( - item.action()} - shouldShowRightIcon - /> - ))} - - - - {props.translate( - 'initialSettingsPage.readTheTermsAndPrivacy.phrase1', - )} - {' '} - - {props.translate( - 'initialSettingsPage.readTheTermsAndPrivacy.phrase2', - )} - - {' '} - {props.translate( - 'initialSettingsPage.readTheTermsAndPrivacy.phrase3', - )} - {' '} - - {props.translate( - 'initialSettingsPage.readTheTermsAndPrivacy.phrase4', - )} - - . - - - + + + )} ); }; diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 0c55f189bf1a..f10bca4c6037 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -244,57 +244,61 @@ class InitialSettingsPage extends React.Component { return ( - Navigation.dismissModal(true)} - /> - - - - - - - - - - - - - - {this.props.currentUserPersonalDetails.displayName - ? this.props.currentUserPersonalDetails.displayName - : Str.removeSMSDomain(this.props.session.email)} - - - {this.props.currentUserPersonalDetails.displayName && ( - - {Str.removeSMSDomain(this.props.session.email)} - - )} - - {_.map(this.getDefaultMenuItems(), (item, index) => this.getMenuItem(item, index))} - - this.signOut(true)} - onCancel={() => this.toggleSignoutConfirmModal(false)} + {({safeAreaPaddingBottomStyle}) => ( + <> + Navigation.dismissModal(true)} /> - - + + + + + + + + + + + + + + {this.props.currentUserPersonalDetails.displayName + ? this.props.currentUserPersonalDetails.displayName + : Str.removeSMSDomain(this.props.session.email)} + + + {this.props.currentUserPersonalDetails.displayName && ( + + {Str.removeSMSDomain(this.props.session.email)} + + )} + + {_.map(this.getDefaultMenuItems(), (item, index) => this.getMenuItem(item, index))} + + this.signOut(true)} + onCancel={() => this.toggleSignoutConfirmModal(false)} + /> + + + + )} ); } diff --git a/src/pages/settings/Payments/AddDebitCardPage.js b/src/pages/settings/Payments/AddDebitCardPage.js index 542979a2b279..cb55aee243c2 100644 --- a/src/pages/settings/Payments/AddDebitCardPage.js +++ b/src/pages/settings/Payments/AddDebitCardPage.js @@ -21,6 +21,7 @@ import ONYXKEYS from '../../../ONYXKEYS'; import AddressSearch from '../../../components/AddressSearch'; import * as ComponentUtils from '../../../libs/ComponentUtils'; import Form from '../../../components/Form'; +import Permissions from '../../../libs/Permissions'; const propTypes = { /* Onyx Props */ @@ -28,6 +29,9 @@ const propTypes = { setupComplete: PropTypes.bool, }), + /** List of betas available to current user */ + betas: PropTypes.arrayOf(PropTypes.string), + ...withLocalizePropTypes, }; @@ -35,6 +39,7 @@ const defaultProps = { formData: { setupComplete: false, }, + betas: [], }; class DebitCardPage extends Component { @@ -95,7 +100,7 @@ class DebitCardPage extends Component { errors.addressState = this.props.translate('addDebitCardPage.error.addressState'); } - if (!values.password || _.isEmpty(values.password.trim())) { + if (!Permissions.canUsePasswordlessLogins(this.props.betas) && (!values.password || _.isEmpty(values.password.trim()))) { errors.password = this.props.translate('addDebitCardPage.error.password'); } @@ -176,15 +181,17 @@ class DebitCardPage extends Component { /> - - - + {!Permissions.canUsePasswordlessLogins(this.props.betas) && ( + + + + )} ( @@ -212,5 +219,8 @@ export default compose( formData: { key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, }, + betas: { + key: ONYXKEYS.BETAS, + }, }), )(DebitCardPage); diff --git a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js index 9a480454eaf3..afc47c160ba4 100644 --- a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js +++ b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js @@ -49,6 +49,7 @@ class BasePaymentsPage extends React.Component { formattedSelectedPaymentMethod: { title: '', }, + selectedPaymentMethodType: null, anchorPositionTop: 0, anchorPositionBottom: 0, anchorPositionRight: 0, @@ -87,6 +88,27 @@ class BasePaymentsPage extends React.Component { this.debounceSetShouldShowLoadingSpinner(); } + if (this.state.shouldShowDefaultDeleteMenu || this.state.shouldShowPasswordPrompt) { + // We should reset selected payment method state values and close corresponding modals if the selected payment method is deleted + let shouldResetPaymentMethodData = false; + + if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.BANK_ACCOUNT && _.isEmpty(this.props.bankAccountList[this.state.methodID])) { + shouldResetPaymentMethodData = true; + } else if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD && _.isEmpty(this.props.cardList[this.state.methodID])) { + shouldResetPaymentMethodData = true; + } else if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.PAYPAL && this.props.payPalMeData !== prevProps.payPalMeData && _.isEmpty(this.props.payPalMeData)) { + shouldResetPaymentMethodData = true; + } + if (shouldResetPaymentMethodData) { + // Close corresponding selected payment method modals which are open + if (this.state.shouldShowDefaultDeleteMenu) { + this.hideDefaultDeleteMenu(); + } else if (this.state.shouldShowPasswordPrompt) { + this.hidePasswordPrompt(); + } + } + } + // previously online OR currently offline, skip fetch if (!prevProps.network.isOffline || this.props.network.isOffline) { return; @@ -138,6 +160,23 @@ class BasePaymentsPage extends React.Component { }); } + resetSelectedPaymentMethodData() { + // The below state values are used by payment method modals and we reset them while closing the modals. + // We should only reset the values when the modal animation is completed and so using InteractionManager.runAfterInteractions which fires after all animaitons are complete + InteractionManager.runAfterInteractions(() => { + // Reset to same values as in the constructor + this.setState({ + isSelectedPaymentMethodDefault: false, + selectedPaymentMethod: {}, + formattedSelectedPaymentMethod: { + title: '', + }, + methodID: null, + selectedPaymentMethodType: null, + }); + }); + } + /** * Display the delete/default menu, or the add payment method menu * @@ -235,18 +274,25 @@ class BasePaymentsPage extends React.Component { /** * Hide the default / delete modal + * @param {boolean} shouldClearSelectedData - Clear selected payment method data if true */ - hideDefaultDeleteMenu() { + hideDefaultDeleteMenu(shouldClearSelectedData = true) { this.setState({shouldShowDefaultDeleteMenu: false}); InteractionManager.runAfterInteractions(() => { this.setState({ showConfirmDeleteContent: false, }); + if (shouldClearSelectedData) { + this.resetSelectedPaymentMethodData(); + } }); } - hidePasswordPrompt() { + hidePasswordPrompt(shouldClearSelectedData = true) { this.setState({shouldShowPasswordPrompt: false}); + if (shouldClearSelectedData) { + this.resetSelectedPaymentMethodData(); + } // Due to iOS modal freeze issue, password modal freezes the app when closed. // LayoutAnimation undoes the running animation. @@ -267,6 +313,7 @@ class BasePaymentsPage extends React.Component { } else if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) { PaymentMethods.makeDefaultPaymentMethod(password, null, this.state.selectedPaymentMethod.fundID, previousPaymentMethod, currentPaymentMethod); } + this.resetSelectedPaymentMethodData(); } deletePaymentMethod() { @@ -277,6 +324,7 @@ class BasePaymentsPage extends React.Component { } else if (this.state.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) { PaymentMethods.deletePaymentCard(this.state.selectedPaymentMethod.fundID); } + this.resetSelectedPaymentMethodData(); } navigateToTransferBalancePage() { @@ -439,30 +487,10 @@ class BasePaymentsPage extends React.Component { ) : ( { - this.setState({ - shouldShowDefaultDeleteMenu: false, - }); - InteractionManager.runAfterInteractions(() => { - this.setState({ - showConfirmDeleteContent: false, - }); - }); + this.hideDefaultDeleteMenu(false); this.deletePaymentMethod(); }} - onCancel={() => { - this.setState({ - shouldShowDefaultDeleteMenu: false, - }); - InteractionManager.runAfterInteractions( - () => { - this.setState( - { - showConfirmDeleteContent: false, - }, - ); - }, - ); - }} + onCancel={this.hideDefaultDeleteMenu} contentStyles={!this.props.isSmallScreenWidth ? [styles.sidebarPopover] : undefined} title={this.props.translate('paymentsPage.deleteAccount')} prompt={this.props.translate('paymentsPage.deleteConfirmation')} @@ -485,7 +513,7 @@ class BasePaymentsPage extends React.Component { right: this.state.anchorPositionRight, }} onSubmit={(password) => { - this.hidePasswordPrompt(); + this.hidePasswordPrompt(false); this.makeDefaultPaymentMethod(password); }} submitButtonText={this.state.passwordButtonText} @@ -521,6 +549,9 @@ export default compose( walletTerms: { key: ONYXKEYS.WALLET_TERMS, }, + payPalMeData: { + key: ONYXKEYS.PAYPAL, + }, isLoadingPaymentMethods: { key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS, }, diff --git a/src/pages/settings/Preferences/LanguagePage.js b/src/pages/settings/Preferences/LanguagePage.js new file mode 100644 index 000000000000..c411e1de0123 --- /dev/null +++ b/src/pages/settings/Preferences/LanguagePage.js @@ -0,0 +1,70 @@ +import _ from 'underscore'; +import React from 'react'; +import PropTypes from 'prop-types'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; +import OptionsList from '../../../components/OptionsList'; +import styles from '../../../styles/styles'; +import themeColors from '../../../styles/themes/default'; +import * as Expensicons from '../../../components/Icon/Expensicons'; +import * as App from '../../../libs/actions/App'; + +const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; + +const propTypes = { + ...withLocalizePropTypes, + + /** The preferred language of the App */ + preferredLocale: PropTypes.string.isRequired, +}; + +const LanguagePage = (props) => { + const localesToLanguages = _.map(props.translate('languagePage.languages'), + (language, key) => ( + { + value: key, + text: language.label, + keyForList: key, + + // Include the green checkmark icon to indicate the currently selected value + customIcon: props.preferredLocale === key ? greenCheckmark : undefined, + + // This property will make the currently selected value have bold text + boldStyle: props.preferredLocale === key, + } + )); + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PREFERENCES)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + App.setLocaleAndNavigate(language.value)} + hideSectionHeaders + optionHoveredStyle={ + { + ...styles.hoveredComponentBG, + ...styles.mhn5, + ...styles.ph5, + } + } + shouldHaveOptionSeparator + shouldDisableRowInnerPadding + contentContainerStyles={[styles.ph5]} + /> + + ); +}; + +LanguagePage.displayName = 'LanguagePage'; +LanguagePage.propTypes = propTypes; + +export default withLocalize(LanguagePage); diff --git a/src/pages/settings/Preferences/PreferencesPage.js b/src/pages/settings/Preferences/PreferencesPage.js new file mode 100755 index 000000000000..86345c16f7bd --- /dev/null +++ b/src/pages/settings/Preferences/PreferencesPage.js @@ -0,0 +1,112 @@ +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import React from 'react'; +import {View, ScrollView} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; +import ONYXKEYS from '../../../ONYXKEYS'; +import styles from '../../../styles/styles'; +import Text from '../../../components/Text'; +import CONST from '../../../CONST'; +import * as User from '../../../libs/actions/User'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import Switch from '../../../components/Switch'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import compose from '../../../libs/compose'; +import withEnvironment, {environmentPropTypes} from '../../../components/withEnvironment'; +import TestToolMenu from '../../../components/TestToolMenu'; +import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription'; + +const propTypes = { + /** The chat priority mode */ + priorityMode: PropTypes.string, + + /** The details about the user that is signed in */ + user: PropTypes.shape({ + /** Whether or not the user is subscribed to news updates */ + isSubscribedToNewsletter: PropTypes.bool, + }), + + /** The preferred language of the App */ + preferredLocale: PropTypes.string.isRequired, + + ...withLocalizePropTypes, + ...environmentPropTypes, +}; + +const defaultProps = { + priorityMode: CONST.PRIORITY_MODE.DEFAULT, + user: {}, +}; + +const PreferencesPage = (props) => { + const priorityModes = props.translate('priorityModePage.priorityModes'); + const languages = props.translate('languagePage.languages'); + + // Enable additional test features in the staging or dev environments + const shouldShowTestToolMenu = _.contains([CONST.ENVIRONMENT.STAGING, CONST.ENVIRONMENT.DEV], props.environment); + + return ( + + Navigation.navigate(ROUTES.SETTINGS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + + + {props.translate('common.notifications')} + + + + + {props.translate('preferencesPage.receiveRelevantFeatureUpdatesAndExpensifyNews')} + + + + + + + Navigation.navigate(ROUTES.SETTINGS_PRIORITY_MODE)} + /> + Navigation.navigate(ROUTES.SETTINGS_LANGUAGE)} + /> + {shouldShowTestToolMenu && } + + + + ); +}; + +PreferencesPage.propTypes = propTypes; +PreferencesPage.defaultProps = defaultProps; +PreferencesPage.displayName = 'PreferencesPage'; + +export default compose( + withEnvironment, + withLocalize, + withOnyx({ + priorityMode: { + key: ONYXKEYS.NVP_PRIORITY_MODE, + }, + user: { + key: ONYXKEYS.USER, + }, + }), +)(PreferencesPage); diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js new file mode 100644 index 000000000000..16af2569b824 --- /dev/null +++ b/src/pages/settings/Preferences/PriorityModePage.js @@ -0,0 +1,80 @@ +import _, {compose} from 'underscore'; +import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; +import OptionsList from '../../../components/OptionsList'; +import styles from '../../../styles/styles'; +import Text from '../../../components/Text'; +import themeColors from '../../../styles/themes/default'; +import * as Expensicons from '../../../components/Icon/Expensicons'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as User from '../../../libs/actions/User'; + +const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; + +const propTypes = { + ...withLocalizePropTypes, +}; + +const PriorityModePage = (props) => { + const priorityModes = _.map(props.translate('priorityModePage.priorityModes'), + (mode, key) => ( + { + value: key, + text: mode.label, + alternateText: mode.description, + keyForList: key, + + // Include the green checkmark icon to indicate the currently selected value + customIcon: props.priorityMode === key ? greenCheckmark : undefined, + + // This property will make the currently selected value have bold text + boldStyle: props.priorityMode === key, + } + )); + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PREFERENCES)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + {props.translate('priorityModePage.explainerText')} + + User.updateChatPriorityMode(mode.value)} + hideSectionHeaders + optionHoveredStyle={ + { + ...styles.hoveredComponentBG, + ...styles.mhn5, + ...styles.ph5, + } + } + shouldHaveOptionSeparator + shouldDisableRowInnerPadding + contentContainerStyles={[styles.ph5]} + /> + + ); +}; + +PriorityModePage.displayName = 'PriorityModePage'; +PriorityModePage.propTypes = propTypes; + +export default compose( + withLocalize, + withOnyx({ + priorityMode: { + key: ONYXKEYS.NVP_PRIORITY_MODE, + }, + }), +)(PriorityModePage); diff --git a/src/pages/settings/PreferencesPage.js b/src/pages/settings/PreferencesPage.js deleted file mode 100755 index ee4a39ddc404..000000000000 --- a/src/pages/settings/PreferencesPage.js +++ /dev/null @@ -1,125 +0,0 @@ -import _ from 'underscore'; -import lodashGet from 'lodash/get'; -import React from 'react'; -import {View, ScrollView} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import PropTypes from 'prop-types'; - -import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; -import LocalePicker from '../../components/LocalePicker'; -import Navigation from '../../libs/Navigation/Navigation'; -import ROUTES from '../../ROUTES'; -import ONYXKEYS from '../../ONYXKEYS'; -import styles from '../../styles/styles'; -import Text from '../../components/Text'; -import CONST from '../../CONST'; -import * as User from '../../libs/actions/User'; -import ScreenWrapper from '../../components/ScreenWrapper'; -import Switch from '../../components/Switch'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import compose from '../../libs/compose'; -import Picker from '../../components/Picker'; -import withEnvironment, {environmentPropTypes} from '../../components/withEnvironment'; -import TestToolMenu from '../../components/TestToolMenu'; - -const propTypes = { - /** The chat priority mode */ - priorityMode: PropTypes.string, - - /** The details about the user that is signed in */ - user: PropTypes.shape({ - /** Whether or not the user is subscribed to news updates */ - isSubscribedToNewsletter: PropTypes.bool, - shouldUseStagingServer: PropTypes.bool, - }), - - ...withLocalizePropTypes, - ...environmentPropTypes, -}; - -const defaultProps = { - priorityMode: CONST.PRIORITY_MODE.DEFAULT, - user: {}, -}; - -const PreferencesPage = (props) => { - const priorityModes = { - default: { - value: CONST.PRIORITY_MODE.DEFAULT, - label: props.translate('preferencesPage.mostRecent'), - description: props.translate('preferencesPage.mostRecentModeDescription'), - }, - gsd: { - value: CONST.PRIORITY_MODE.GSD, - label: props.translate('preferencesPage.focus'), - description: props.translate('preferencesPage.focusModeDescription'), - }, - }; - - return ( - - Navigation.navigate(ROUTES.SETTINGS)} - onCloseButtonPress={() => Navigation.dismissModal(true)} - /> - - - - {props.translate('common.notifications')} - - - - - {props.translate('preferencesPage.receiveRelevantFeatureUpdatesAndExpensifyNews')} - - - - - - - - User.updateChatPriorityMode(mode) - } - items={_.values(priorityModes)} - value={props.priorityMode} - /> - - - {priorityModes[props.priorityMode].description} - - - - - - {/* If we are in the staging environment then we enable additional test features */} - {_.contains([CONST.ENVIRONMENT.STAGING, CONST.ENVIRONMENT.DEV], props.environment) && } - - - - ); -}; - -PreferencesPage.propTypes = propTypes; -PreferencesPage.defaultProps = defaultProps; -PreferencesPage.displayName = 'PreferencesPage'; - -export default compose( - withEnvironment, - withLocalize, - withOnyx({ - priorityMode: { - key: ONYXKEYS.NVP_PRIORITY_MODE, - }, - user: { - key: ONYXKEYS.USER, - }, - }), -)(PreferencesPage); diff --git a/src/pages/settings/Profile/DisplayNamePage.js b/src/pages/settings/Profile/DisplayNamePage.js index 3d0336b45358..9e4e948237f4 100644 --- a/src/pages/settings/Profile/DisplayNamePage.js +++ b/src/pages/settings/Profile/DisplayNamePage.js @@ -1,12 +1,10 @@ import lodashGet from 'lodash/get'; -import _ from 'underscore'; import React, {Component} from 'react'; import {View} from 'react-native'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../../components/withCurrentUserPersonalDetails'; import ScreenWrapper from '../../../components/ScreenWrapper'; import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import * as Localize from '../../../libs/Localize'; import ROUTES from '../../../ROUTES'; import Form from '../../../components/Form'; import ONYXKEYS from '../../../ONYXKEYS'; @@ -58,59 +56,21 @@ class DisplayNamePage extends Component { validate(values) { const errors = {}; - // Check for invalid characters in first and last name - const [firstNameInvalidCharacter, lastNameInvalidCharacter] = ValidationUtils.findInvalidSymbols( - [values.firstName, values.lastName], - ); - this.assignError( - errors, - 'firstName', - !_.isEmpty(firstNameInvalidCharacter), - Localize.translateLocal( - 'personalDetails.error.hasInvalidCharacter', - {invalidCharacter: firstNameInvalidCharacter}, - ), - ); - this.assignError( - errors, - 'lastName', - !_.isEmpty(lastNameInvalidCharacter), - Localize.translateLocal( - 'personalDetails.error.hasInvalidCharacter', - {invalidCharacter: lastNameInvalidCharacter}, - ), - ); - if (!_.isEmpty(errors)) { - return errors; + // First we validate the first name field + if (!ValidationUtils.isValidDisplayName(values.firstName)) { + errors.firstName = this.props.translate('personalDetails.error.hasInvalidCharacter'); + } else if (ValidationUtils.doesContainReservedWord(values.firstName, CONST.DISPLAY_NAME.RESERVED_FIRST_NAMES)) { + errors.firstName = this.props.translate('personalDetails.error.containsReservedWord'); } - // Check the character limit for first and last name - const characterLimitError = Localize.translateLocal('personalDetails.error.characterLimit', {limit: CONST.FORM_CHARACTER_LIMIT}); - const [hasFirstNameError, hasLastNameError] = ValidationUtils.doesFailCharacterLimitAfterTrim( - CONST.FORM_CHARACTER_LIMIT, - [values.firstName, values.lastName], - ); - this.assignError(errors, 'firstName', hasFirstNameError, characterLimitError); - this.assignError(errors, 'lastName', hasLastNameError, characterLimitError); + // Then we validate the last name field + if (!ValidationUtils.isValidDisplayName(values.lastName)) { + errors.lastName = this.props.translate('personalDetails.error.hasInvalidCharacter'); + } return errors; } - /** - * @param {Object} errors - * @param {String} errorKey - * @param {Boolean} hasError - * @param {String} errorCopy - * @returns {Object} - An object containing the errors for each inputID - */ - assignError(errors, errorKey, hasError, errorCopy) { - const validateErrors = errors; - if (hasError) { - validateErrors[errorKey] = errorCopy; - } - return validateErrors; - } - render() { const currentUserDetails = this.props.currentUserPersonalDetails || {}; @@ -140,6 +100,7 @@ class DisplayNamePage extends Component { label={this.props.translate('common.firstName')} defaultValue={lodashGet(currentUserDetails, 'firstName', '')} placeholder={this.props.translate('displayNamePage.john')} + maxLength={CONST.DISPLAY_NAME.MAX_LENGTH} /> @@ -149,6 +110,7 @@ class DisplayNamePage extends Component { label={this.props.translate('common.lastName')} defaultValue={lodashGet(currentUserDetails, 'lastName', '')} placeholder={this.props.translate('displayNamePage.doe')} + maxLength={CONST.DISPLAY_NAME.MAX_LENGTH} /> diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js new file mode 100644 index 000000000000..848373fe2cc9 --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js @@ -0,0 +1,226 @@ +import lodashGet from 'lodash/get'; +import _ from 'underscore'; +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; +import {withOnyx} from 'react-native-onyx'; +import ScreenWrapper from '../../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import ROUTES from '../../../../ROUTES'; +import Form from '../../../../components/Form'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import CONST from '../../../../CONST'; +import TextInput from '../../../../components/TextInput'; +import styles from '../../../../styles/styles'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; +import compose from '../../../../libs/compose'; +import AddressSearch from '../../../../components/AddressSearch'; +import CountryPicker from '../../../../components/CountryPicker'; +import StatePicker from '../../../../components/StatePicker'; + +const propTypes = { + /* Onyx Props */ + + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + /** User's home address */ + address: PropTypes.shape({ + street: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + zip: PropTypes.string, + country: PropTypes.string, + }), + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + privatePersonalDetails: { + address: { + street: '', + city: '', + state: '', + zip: '', + country: '', + }, + }, +}; + +class AddressPage extends Component { + constructor(props) { + super(props); + + this.validate = this.validate.bind(this); + this.updateAddress = this.updateAddress.bind(this); + this.onCountryUpdate = this.onCountryUpdate.bind(this); + + const currentCountry = lodashGet(props.privatePersonalDetails, 'address.country') || ''; + this.state = { + isUsaForm: currentCountry === CONST.USA_COUNTRY_NAME, + }; + } + + /** + * @param {String} newCountry - new country selected in form + */ + onCountryUpdate(newCountry) { + if (newCountry === CONST.USA_COUNTRY_NAME) { + this.setState({isUsaForm: true}); + } else { + this.setState({isUsaForm: false}); + } + } + + /** + * Submit form to update user's first and last legal name + * @param {Object} values - form input values + */ + updateAddress(values) { + PersonalDetails.updateAddress( + values.addressLine1.trim(), + values.addressLine2.trim(), + values.city.trim(), + values.state.trim(), + values.zipPostCode, + values.country, + ); + } + + /** + * @param {Object} values - form input values + * @returns {Object} - An object containing the errors for each inputID + */ + validate(values) { + const errors = {}; + + const requiredFields = [ + 'addressLine1', + 'city', + 'zipPostCode', + 'country', + 'state', + ]; + + // Check "State" dropdown is a valid state if selected Country is USA. + if (this.state.isUsaForm && !COMMON_CONST.STATES[values.state]) { + errors.state = this.props.translate('common.error.fieldRequired'); + } + + // Add "Field required" errors if any required field is empty + _.each(requiredFields, (fieldKey) => { + if (!_.isEmpty(values[fieldKey])) { + return; + } + errors[fieldKey] = this.props.translate('common.error.fieldRequired'); + }); + + return errors; + } + + render() { + const address = lodashGet(this.props.privatePersonalDetails, 'address') || {}; + const [street1, street2] = (address.street || '').split('\n'); + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> +
+ + + + + + + + + + + + {this.state.isUsaForm ? ( + + ) : ( + + )} + + + + + + + + +
+
+ ); + } +} + +AddressPage.propTypes = propTypes; +AddressPage.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + }), +)(AddressPage); diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js new file mode 100644 index 000000000000..8ca38db07014 --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js @@ -0,0 +1,119 @@ +import _ from 'underscore'; +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import ScreenWrapper from '../../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import ROUTES from '../../../../ROUTES'; +import Form from '../../../../components/Form'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import * as ValidationUtils from '../../../../libs/ValidationUtils'; +import styles from '../../../../styles/styles'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; +import compose from '../../../../libs/compose'; +import DatePicker from '../../../../components/DatePicker'; + +const propTypes = { + /* Onyx Props */ + + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + dob: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + privatePersonalDetails: { + dob: '', + }, +}; + +class DateOfBirthPage extends Component { + constructor(props) { + super(props); + + this.validate = this.validate.bind(this); + this.updateDateOfBirth = this.updateDateOfBirth.bind(this); + } + + /** + * Submit form to update user's first and last legal name + * @param {Object} values + * @param {String} values.dob - date of birth + */ + updateDateOfBirth(values) { + PersonalDetails.updateDateOfBirth( + values.dob.trim(), + ); + } + + /** + * @param {Object} values + * @param {String} values.dob - date of birth + * @returns {Object} - An object containing the errors for each inputID + */ + validate(values) { + const errors = {}; + const minimumAge = 5; + const maximumAge = 150; + + if (_.isEmpty(values.dob)) { + errors.dob = this.props.translate('common.error.fieldRequired'); + } + const dateError = ValidationUtils.getAgeRequirementError(values.dob, minimumAge, maximumAge); + if (dateError) { + errors.dob = dateError; + } + + return errors; + } + + render() { + const privateDetails = this.props.privatePersonalDetails || {}; + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> +
+ + + +
+
+ ); + } +} + +DateOfBirthPage.propTypes = propTypes; +DateOfBirthPage.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + }), +)(DateOfBirthPage); diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js new file mode 100644 index 000000000000..dee460123f1e --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js @@ -0,0 +1,137 @@ +import _ from 'underscore'; +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import ScreenWrapper from '../../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import ROUTES from '../../../../ROUTES'; +import Form from '../../../../components/Form'; +import ONYXKEYS from '../../../../ONYXKEYS'; +import CONST from '../../../../CONST'; +import * as ValidationUtils from '../../../../libs/ValidationUtils'; +import TextInput from '../../../../components/TextInput'; +import styles from '../../../../styles/styles'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; +import compose from '../../../../libs/compose'; + +const propTypes = { + /* Onyx Props */ + + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + privatePersonalDetails: { + legalFirstName: '', + legalLastName: '', + }, +}; + +class LegalNamePage extends Component { + constructor(props) { + super(props); + + this.validate = this.validate.bind(this); + this.updateLegalName = this.updateLegalName.bind(this); + } + + /** + * Submit form to update user's legal first and last name + * @param {Object} values + * @param {String} values.legalFirstName + * @param {String} values.legalLastName + */ + updateLegalName(values) { + PersonalDetails.updateLegalName( + values.legalFirstName.trim(), + values.legalLastName.trim(), + ); + } + + /** + * @param {Object} values + * @param {String} values.legalFirstName + * @param {String} values.legalLastName + * @returns {Object} - An object containing the errors for each inputID + */ + validate(values) { + const errors = {}; + + if (!ValidationUtils.isValidDisplayName(values.legalFirstName)) { + errors.legalFirstName = this.props.translate('personalDetails.error.hasInvalidCharacter'); + } else if (_.isEmpty(values.legalFirstName)) { + errors.legalFirstName = this.props.translate('common.error.fieldRequired'); + } + + if (!ValidationUtils.isValidDisplayName(values.legalLastName)) { + errors.legalLastName = this.props.translate('personalDetails.error.hasInvalidCharacter'); + } else if (_.isEmpty(values.legalLastName)) { + errors.legalLastName = this.props.translate('common.error.fieldRequired'); + } + + return errors; + } + + render() { + const privateDetails = this.props.privatePersonalDetails || {}; + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> +
+ + + + + + +
+
+ ); + } +} + +LegalNamePage.propTypes = propTypes; +LegalNamePage.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + }), +)(LegalNamePage); diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js new file mode 100644 index 000000000000..4da6ec6134e5 --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js @@ -0,0 +1,141 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import ScreenWrapper from '../../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import ROUTES from '../../../../ROUTES'; +import Text from '../../../../components/Text'; +import styles from '../../../../styles/styles'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import compose from '../../../../libs/compose'; +import MenuItemWithTopDescription from '../../../../components/MenuItemWithTopDescription'; +import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; +import ONYXKEYS from '../../../../ONYXKEYS'; + +const propTypes = { + /* Onyx Props */ + + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + dob: PropTypes.string, + + /** User's home address */ + address: PropTypes.shape({ + street: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + zip: PropTypes.string, + country: PropTypes.string, + }), + }), + + ...withLocalizePropTypes, +}; + +const defaultProps = { + privatePersonalDetails: { + legalFirstName: '', + legalLastName: '', + dob: '', + address: { + street: '', + street2: '', + city: '', + state: '', + zip: '', + country: '', + }, + }, +}; + +const PersonalDetailsInitialPage = (props) => { + PersonalDetails.openPersonalDetailsPage(); + + const privateDetails = props.privatePersonalDetails || {}; + const address = privateDetails.address || {}; + const legalName = `${privateDetails.legalFirstName || ''} ${privateDetails.legalLastName || ''}`.trim(); + + /** + * Applies common formatting to each piece of an address + * + * @param {String} piece + * @returns {String} + */ + const formatPiece = piece => (piece ? `${piece}, ` : ''); + + /** + * Formats an address object into an easily readable string + * + * @returns {String} + */ + const getFormattedAddress = () => { + const [street1, street2] = (address.street || '').split('\n'); + const formattedAddress = formatPiece(street1) + + formatPiece(street2) + + formatPiece(address.city) + + formatPiece(address.state) + + formatPiece(address.zip) + + formatPiece(address.country); + + // Remove the last comma of the address + return formattedAddress.trim().replace(/,$/, ''); + }; + + return ( + + Navigation.navigate(ROUTES.SETTINGS_PROFILE)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + + + + {props.translate('privatePersonalDetails.privateDataMessage')} + + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME)} + /> + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH)} + /> + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)} + /> + + + + ); +}; + +PersonalDetailsInitialPage.propTypes = propTypes; +PersonalDetailsInitialPage.defaultProps = defaultProps; +PersonalDetailsInitialPage.displayName = 'PersonalDetailsInitialPage'; + +export default compose( + withLocalize, + withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + }), +)(PersonalDetailsInitialPage); diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 7057bf514190..3af2527a7b3f 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -8,6 +8,7 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import AvatarWithImagePicker from '../../../components/AvatarWithImagePicker'; import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import MenuItem from '../../../components/MenuItem'; import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import ScreenWrapper from '../../../components/ScreenWrapper'; @@ -22,6 +23,7 @@ import ONYXKEYS from '../../../ONYXKEYS'; import ROUTES from '../../../ROUTES'; import styles from '../../../styles/styles'; import LoginField from './LoginField'; +import * as Expensicons from '../../../components/Icon/Expensicons'; const propTypes = { /* Onyx Props */ @@ -133,7 +135,7 @@ class ProfilePage extends Component { }, ]; return ( - + + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)} + shouldShowRightIcon + /> ); diff --git a/src/pages/settings/Profile/PronounsPage.js b/src/pages/settings/Profile/PronounsPage.js index 86651877898d..e6692a107b1f 100644 --- a/src/pages/settings/Profile/PronounsPage.js +++ b/src/pages/settings/Profile/PronounsPage.js @@ -53,23 +53,27 @@ const PronounsPage = (props) => { return ( - Navigation.navigate(ROUTES.SETTINGS_PROFILE)} - onCloseButtonPress={() => Navigation.dismissModal(true)} - /> - - {props.translate('pronounsPage.isShownOnProfile')} - - updatePronouns(option.value)} - hideSectionHeaders - optionHoveredStyle={styles.hoveredComponentBG} - shouldHaveOptionSeparator - contentContainerStyles={[styles.ph5]} - /> + {({safeAreaPaddingBottomStyle}) => ( + <> + Navigation.navigate(ROUTES.SETTINGS_PROFILE)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + {props.translate('pronounsPage.isShownOnProfile')} + + updatePronouns(option.value)} + hideSectionHeaders + optionHoveredStyle={styles.hoveredComponentBG} + shouldHaveOptionSeparator + contentContainerStyles={[styles.ph5, safeAreaPaddingBottomStyle]} + /> + + )} ); }; diff --git a/src/pages/settings/Profile/TimezoneSelectPage.js b/src/pages/settings/Profile/TimezoneSelectPage.js index 7a89ac8ce73d..fd91f9a2e91f 100644 --- a/src/pages/settings/Profile/TimezoneSelectPage.js +++ b/src/pages/settings/Profile/TimezoneSelectPage.js @@ -76,21 +76,26 @@ class TimezoneSelectPage extends Component { render() { return ( - Navigation.navigate(ROUTES.SETTINGS_TIMEZONE)} - onCloseButtonPress={() => Navigation.dismissModal(true)} - /> - + {({safeAreaPaddingBottomStyle}) => ( + <> + Navigation.navigate(ROUTES.SETTINGS_TIMEZONE)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + + )} ); } diff --git a/src/pages/signin/ChangeExpensifyLoginLink.js b/src/pages/signin/ChangeExpensifyLoginLink.js index 8289a754deaf..fdc721317c9b 100755 --- a/src/pages/signin/ChangeExpensifyLoginLink.js +++ b/src/pages/signin/ChangeExpensifyLoginLink.js @@ -1,6 +1,7 @@ import React from 'react'; import {TouchableOpacity, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; import PropTypes from 'prop-types'; import Str from 'expensify-common/lib/str'; import Text from '../../components/Text'; @@ -30,14 +31,16 @@ const defaultProps = { const ChangeExpensifyLoginLink = props => ( - - {props.translate('common.not')} -   - {Str.isSMSLogin(props.credentials.login || '') - ? props.toLocalPhone(Str.removeSMSDomain(props.credentials.login || '')) - : Str.removeSMSDomain(props.credentials.login || '')} - {'? '} - + {!_.isEmpty(props.credentials.login) && ( + + {props.translate('common.not')} +   + {Str.isSMSLogin(props.credentials.login || '') + ? props.toLocalPhone(Str.removeSMSDomain(props.credentials.login || '')) + : Str.removeSMSDomain(props.credentials.login || '')} + {'? '} + + )} @@ -183,6 +183,7 @@ class LoginForm extends React.Component { autoCapitalize="none" autoCorrect={false} keyboardType={CONST.KEYBOARD_TYPE.EMAIL_ADDRESS} + errorText={formErrorText} /> {!_.isEmpty(this.props.account.success) && ( @@ -203,8 +204,8 @@ class LoginForm extends React.Component { buttonText={this.props.translate('common.continue')} isLoading={this.props.account.isLoading} onSubmit={this.validateAndSubmitForm} - message={error} - isAlertVisible={!_.isEmpty(error)} + message={serverErrorText} + isAlertVisible={!_.isEmpty(serverErrorText)} containerStyles={[styles.mh0]} /> diff --git a/src/pages/signin/PasswordForm.js b/src/pages/signin/PasswordForm.js index 30ec93d71ec5..7d8813313089 100755 --- a/src/pages/signin/PasswordForm.js +++ b/src/pages/signin/PasswordForm.js @@ -24,6 +24,7 @@ import * as ErrorUtils from '../../libs/ErrorUtils'; import {withNetwork} from '../../components/OnyxProvider'; import networkPropTypes from '../../components/networkPropTypes'; import OfflineIndicator from '../../components/OfflineIndicator'; +import FormHelpMessage from '../../components/FormHelpMessage'; const propTypes = { /* Onyx Props */ @@ -56,7 +57,7 @@ class PasswordForm extends React.Component { this.clearSignInData = this.clearSignInData.bind(this); this.state = { - formError: false, + formError: {}, password: '', twoFactorAuthCode: '', }; @@ -84,6 +85,23 @@ class PasswordForm extends React.Component { } } + /** + * Handle text input and clear formError upon text change + * + * @param {String} text + * @param {String} key + */ + onTextInput(text, key) { + this.setState({ + [key]: text, + formError: {[key]: ''}, + }); + + if (this.props.account.errors) { + Session.clearAccountMessages(); + } + } + /** * Clear Password from the state */ @@ -98,7 +116,7 @@ class PasswordForm extends React.Component { if (this.input2FA) { this.setState({twoFactorAuthCode: ''}, this.input2FA.clear); } - this.setState({formError: false}); + this.setState({formError: {}}); Session.resetPassword(); } @@ -106,7 +124,7 @@ class PasswordForm extends React.Component { * Clears local and Onyx sign in states */ clearSignInData() { - this.setState({twoFactorAuthCode: '', formError: false}); + this.setState({twoFactorAuthCode: '', formError: {}}); Session.clearSignInData(); } @@ -118,33 +136,28 @@ class PasswordForm extends React.Component { const twoFactorCode = this.state.twoFactorAuthCode.trim(); const requiresTwoFactorAuth = this.props.account.requiresTwoFactorAuth; - if (!password && requiresTwoFactorAuth && !twoFactorCode) { - this.setState({formError: 'passwordForm.pleaseFillOutAllFields'}); - return; - } - if (!password) { - this.setState({formError: 'passwordForm.pleaseFillPassword'}); + this.setState({formError: {password: 'passwordForm.pleaseFillPassword'}}); return; } if (!ValidationUtils.isValidPassword(password)) { - this.setState({formError: 'passwordForm.error.incorrectPassword'}); + this.setState({formError: {password: 'passwordForm.error.incorrectPassword'}}); return; } if (requiresTwoFactorAuth && !twoFactorCode) { - this.setState({formError: 'passwordForm.pleaseFillTwoFactorAuth'}); + this.setState({formError: {twoFactorAuthCode: 'passwordForm.pleaseFillTwoFactorAuth'}}); return; } if (requiresTwoFactorAuth && !ValidationUtils.isValidTwoFactorCode(twoFactorCode)) { - this.setState({formError: 'passwordForm.error.incorrect2fa'}); + this.setState({formError: {twoFactorAuthCode: 'passwordForm.error.incorrect2fa'}}); return; } this.setState({ - formError: null, + formError: {}, }); Session.signIn(password, '', twoFactorCode); @@ -163,9 +176,10 @@ class PasswordForm extends React.Component { nativeID="password" name="password" value={this.state.password} - onChangeText={text => this.setState({password: text})} + onChangeText={text => this.onTextInput(text, 'password')} onSubmitEditing={this.validateAndSubmitForm} blurOnSubmit={false} + errorText={this.state.formError.password ? this.props.translate(this.state.formError.password) : ''} /> this.setState({twoFactorAuthCode: text})} + onChangeText={text => this.onTextInput(text, 'twoFactorAuthCode')} onSubmitEditing={this.validateAndSubmitForm} keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} blurOnSubmit={false} maxLength={CONST.TFA_CODE_LENGTH} + errorText={this.state.formError.twoFactorAuthCode ? this.props.translate(this.state.formError.twoFactorAuthCode) : ''} /> )} - {!this.state.formError && this.props.account && !_.isEmpty(this.props.account.errors) && ( - - {ErrorUtils.getLatestErrorMessage(this.props.account)} - - )} - - {this.state.formError && ( - - {this.props.translate(this.state.formError)} - + {this.props.account && !_.isEmpty(this.props.account.errors) && ( + )}