diff --git a/.eslintrc.js b/.eslintrc.js index 35a7d397b09c..6c85f82df864 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,4 +16,18 @@ module.exports = { globals: { __DEV__: 'readonly', }, + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react-native', + importNames: ['useWindowDimensions'], + message: 'Please use useWindowDimensions from src/hooks/useWindowDimensions instead', + }, + ], + }, + ], + }, }; diff --git a/.gitignore b/.gitignore index 8265d5fd272b..94c646a04246 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,4 @@ storybook-static # E2E test reports tests/e2e/results/ +.reassure diff --git a/.prettierignore b/.prettierignore index 5cad6e04b900..5f6292b551c1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,17 @@ # The GH actions don't seem to compile and verify themselves well when Prettier is applied to them .github/actions/javascript/**/index.js +.well-known desktop/dist/**/*.js dist/**/*.js +assets/animations +android +ios +vendor +package.json +package-lock.json +*.html +*.yml +*.yaml +*.css +*.scss +*.md diff --git a/android/app/build.gradle b/android/app/build.gradle index ab09e0cb2cf0..9227a6b382b9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -106,8 +106,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001031606 - versionName "1.3.16-6" + versionCode 1001032200 + versionName "1.3.22-0" } splits { diff --git a/android/app/src/main/java/com/expensify/chat/MainActivity.java b/android/app/src/main/java/com/expensify/chat/MainActivity.java index b4eb483f8de6..ff4945f7f71f 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.java +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.java @@ -3,6 +3,9 @@ import android.os.Bundle; import android.content.pm.ActivityInfo; import android.view.KeyEvent; +import android.view.View; +import android.view.WindowInsets; + import com.expensify.chat.bootsplash.BootSplash; import com.expensify.reactnativekeycommand.KeyCommandModule; import com.facebook.react.ReactActivity; @@ -45,6 +48,19 @@ protected void onCreate(Bundle savedInstanceState) { if (getResources().getBoolean(R.bool.portrait_only)) { setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } + + // Sets translucent status bar. This code is based on what the react-native StatusBar + // module does, but we need to do it here to avoid the splash screen jumping on app start. + View decorView = getWindow().getDecorView(); + decorView.setOnApplyWindowInsetsListener( + (v, insets) -> { + WindowInsets defaultInsets = v.onApplyWindowInsets(insets); + return defaultInsets.replaceSystemWindowInsets( + defaultInsets.getSystemWindowInsetLeft(), + 0, + defaultInsets.getSystemWindowInsetRight(), + defaultInsets.getSystemWindowInsetBottom()); + }); } /** @@ -54,15 +70,15 @@ protected void onCreate(Bundle savedInstanceState) { @Override public boolean onKeyDown(int keyCode, KeyEvent event) { // Disabling hardware ESCAPE support which is handled by Android - if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { - return false; + if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { + return false; } KeyCommandModule.getInstance().onKeyDownEvent(keyCode, event); return super.onKeyDown(keyCode, event); } @Override - public boolean onKeyLongPress(int keyCode, KeyEvent event) { + public boolean onKeyLongPress(int keyCode, KeyEvent event) { // Disabling hardware ESCAPE support which is handled by Android if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { return false; } KeyCommandModule.getInstance().onKeyDownEvent(keyCode, event); @@ -70,10 +86,10 @@ public boolean onKeyLongPress(int keyCode, KeyEvent event) { } @Override - public boolean onKeyUp(int keyCode, KeyEvent event) { + public boolean onKeyUp(int keyCode, KeyEvent event) { // Disabling hardware ESCAPE support which is handled by Android if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { return false; } KeyCommandModule.getInstance().onKeyDownEvent(keyCode, event); return super.onKeyUp(keyCode, event); } -} \ No newline at end of file +} diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index 07a41cec581f..b4d8c2181b0b 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,6 +1,5 @@ #03D47C - #061B09 #FFFFFF #03D47C #0b1b34 diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index c789cdfef09f..34d33d240458 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -6,7 +6,7 @@ diff --git a/assets/images/flag.svg b/assets/images/flag.svg new file mode 100644 index 000000000000..9b6737459fbd --- /dev/null +++ b/assets/images/flag.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/assets/images/flag_level_01.svg b/assets/images/flag_level_01.svg new file mode 100644 index 000000000000..a4259deb0d2c --- /dev/null +++ b/assets/images/flag_level_01.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/assets/images/flag_level_02.svg b/assets/images/flag_level_02.svg new file mode 100644 index 000000000000..9d7010dbb7f9 --- /dev/null +++ b/assets/images/flag_level_02.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/assets/images/flag_level_03.svg b/assets/images/flag_level_03.svg new file mode 100644 index 000000000000..14fc80792cc2 --- /dev/null +++ b/assets/images/flag_level_03.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/assets/images/qrcode.svg b/assets/images/qrcode.svg index d6a5201512cc..8851a69a03d7 100644 --- a/assets/images/qrcode.svg +++ b/assets/images/qrcode.svg @@ -1,5 +1,5 @@ - + + /> diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index fb256bf7e955..a6b9a3404ac4 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -43,9 +43,9 @@ Note: if you are hired for an Upwork job and have any job-specific questions, pl If you've found a vulnerability, please email security@expensify.com with the subject `Vulnerability Report` instead of creating an issue. ## Payment for Contributions -We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted. +We hire and pay external contributors via Upwork.com. If you'd like to be paid for contributing or reporting a bug, please create an Upwork account, apply for an available job in [GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22), and finally apply for the job in Upwork once your proposal gets selected in GitHub. If you think your compensation should be increased for a specific job, you can request a reevaluation by commenting in the Github issue where the Upwork job was posted. -Payment for your contributions will be made no less than 7 days after the pull request is deployed to production to allow for regression testing. If a regression occurs, payment will be issued 7 days after all regressions are fixed. If you have not received payment after 8 days of the PR being deployed to production and there being no regressions, please email contributors@expensify.com referencing the GH issue and your GH handle. +Payment for your contributions and bug reports will be made no less than 7 days after the pull request is deployed to production to allow for regression testing. If a regression occurs, payment will be issued 7 days after all regressions are fixed. If you have not received payment after 8 days of the PR being deployed to production and there being no regressions, please email contributors@expensify.com referencing the GH issue and your GH handle. New contributors are limited to working on one job at a time, however experienced contributors may work on numerous jobs simultaneously. @@ -67,21 +67,21 @@ A job could be fixing a bug or working on a new feature. There are two ways you #### Finding a job that Expensify posted This is the most common scenario for contributors. The Expensify team posts new jobs to the Upwork job list [here](https://www.upwork.com/ab/jobs/search/?q=Expensify%20React%20Native&sort=recency&user_location_match=2) (you must be signed in to Upwork to view jobs). Each job in Upwork has a corresponding GitHub issue, which will include instructions to follow. You can also view all open jobs in the Expensify/App GH repository by searching for GH issues with the [`Help Wanted` label](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22). Lastly, you can follow the [@ExpensifyOSS](https://twitter.com/ExpensifyOSS) Twitter account to see a live feed of jobs that are posted. -#### Proposing a job that Expensify hasn't posted -It’s possible that you found a new bug that we haven’t posted as a job to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to propose a job. If it's a valid job proposal that we choose to implement by deploying it to production — either internally or via an external contributor — then we will compensate you $250 for identifying and proposing the bug (we do not compensate for reporting new feature requests). If the bug is fixed by a PR that is not associated with your bug report, then you will not be eligible for the corresponding compensation unless you can find the PR that fixed it and prove your bug report came first. +#### Raising jobs and bugs +It’s possible that you found a new bug that we haven’t posted as a job to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to raise it and claim the bug bounty. If it's a valid bug that we choose to resolve by deploying it to production — either internally or via an external contributor — then we will compensate you $250 for identifying the bug (we do not compensate for reporting new feature requests). If the bug is fixed by a PR that is not associated with your bug report, then you will not be eligible for the corresponding compensation unless you can find the PR that fixed it and prove your bug report came first. - Note: If you get assigned the job you proposed **and** you complete the job, this $250 for identifying the improvement is *in addition to* the reward you will be paid for completing the job. - Note about proposed bugs: Expensify has the right not to pay the $250 reward if the suggested bug has already been reported. Following, if more than one contributor proposes the same bug, the contributor who posted it first in the [#expensify-bugs](https://expensify.slack.com/archives/C049HHMV9SM) Slack channel is the one who is eligible for the bonus. - Note: whilst you may optionally propose a solution for that job on Slack, solutions are ultimately reviewed in GitHub. The onus is on you to propose the solution on GitHub, and/or ensure the issue creator will include a link to your proposal. -Please follow these steps to propose a job: +Please follow these steps to propose a job or raise a bug: 1. Check to ensure a GH issue does not already exist for this job in the [New Expensify Issue list](https://github.com/Expensify/App/issues). 2. Check to ensure the `Bug:` or `Feature Request:` was not already posted in Slack (specifically the #expensify-bugs or #expensify-open-source [Slack channels](https://github.com/Expensify/App/blob/main/contributingGuides/CONTRIBUTING.md#slack-channels)). Use your best judgement by searching for similar titles and issue descriptions. 3. If your bug or new feature matches with an existing issue, please comment on that Slack thread or GitHub issue with your findings if you think it will help solve the issue. 4. If there is no existing GitHub issue or Upwork job, check if the issue is happening on prod (as opposed to only happening on dev) 5. If the issue is just in dev then it means it's a new issue and has not been deployed to production. In this case, you should try to find the offending PR and comment in the issue tied to the PR and ask the assigned users to add the `DeployBlockerCash` label. If you can't find it, follow the reporting instructions in the next item, but note that the issue is a regression only found in dev and not in prod. -6. If the issue happens in main, staging, or production then report the issue(s) in the #expensify-bugs Slack channel, prefixed with `Bug:` or `Feature Request:`. Please use the templates for bugs and feature requests that are bookmarked in the #expensify-bugs channel. View [this guide](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_CREATE_A_PLAN.md) for help creating a plan when proposing a feature request. - - **Important note/reminder**: never share any information pertaining to a customer of Expensify when describing the bug. This includes, and is not limited to, a customer's name, email, and contact information. +6. If the issue happens in main, staging, or production then report the issue(s) in the #expensify-bugs Slack channel, using the report bug workflow. You can do this by clicking 'Workflow > report Bug', or typing `/Report bug`. View [this guide](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_CREATE_A_PLAN.md) for help creating a plan when proposing a feature request. + - **Important note/reminder**: never share any information pertaining to a customer of Expensify when describing the bug. This includes, and is not limited to, a customer's name, email, and contact information. 7. The Expensify team will review your job proposal in the appropriate slack channel. If you've provided a quality proposal that we choose to implement, a GitHub issue will be created and your Slack handle will be included in the original post after `Issue reported by:` 8. If an external contributor other than yourself is hired to work on the issue, you will also be hired for the same job in Upwork to receive your payout. No additional work is required. If the issue is fixed internally, a dedicated job will be created to hire and pay you after the issue is fixed. 9. Payment will be made 7 days after code is deployed to production if there are no regressions. If a regression is discovered, payment will be issued 7 days after all regressions are fixed. @@ -181,9 +181,19 @@ Follow all the above above steps and processes. When you find a job you'd like t #### Guide on Acronyms used within Expensify Communication During communication with Expensify, you will come across a variety of acronyms used by our team. While acronyms can be useful, they cease to be the moment they are not known to the receiver. As such, we wanted to create a list here of our most commonly used acronyms and what they're referring to. Lastly, please never hesitate to ask in slack or the GH issue if there are any that are not understood/known! -- BZ: Bug Zero (Expensify internal team in charge of managing the GH issues related to our open-source project) -- LHN: Left Hand Navigation (Primary navigation modal in Expensify Chat, docked on the left hand side) -- OP: Original Post (Most commonly the post in E/App GH issues that reports the bug) -- GBR: Green Brick Road (UX Design Principle that utlizes green indicators on action items to encourage the user down the optimal path for a given process or task) -- VBA: Verified Bank Account (Bank account that has been verified as real and belonging to the correct business/individual) -- NAB: Not a Blocker (An issue that doesn't block progress, but would be nice to not have) +- **ND/NewDot:** new.expensify.com +- **OD/OldDot:** expensify.com +- **BZ:** Bug Zero (Expensify internal team in charge of managing the GH issues related to our open-source project) +- **LHN:** Left Hand Navigation (Primary navigation modal in Expensify Chat, docked on the left hand side) +- **OP:** Original Post (Most commonly the post in E/App GH issues that reports the bug) +- **GBR:** Green Brick Road (UX Design Principle that utlizes green indicators on action items to encourage the user down the optimal path for a given process or task) +- **RBR:** Red Brick Road (UX Design Principle that utlizes red indicators on action items to encourage the user down the optimal path for handling and discovering errors) +- **VBA:** Verified Bank Account (Bank account that has been verified as real and belonging to the correct business/individual) +- **NAB:** Not a Blocker (An issue that doesn't block progress, but would be nice to not have) +- **LHN:** Left Hand Navigation +- **IOU:** I owe you (used to describe payment requests between users) +- **OTP:** One time password, or magic sign-in +- **RHP:** Right Hand Panel (on larger screens, pages are often displayed docked to the right side of the screen) +- **QA:** Quality Assurance +- **GH:** GitHub +- **LGTM:*** Looks good to me \ No newline at end of file diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index fdfb97d25eae..b8aac1e38baa 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -272,6 +272,7 @@ Form.js will automatically provide the following props to any input with the inp - value: The input value. - errorText: The translated error text that is returned by validate for that specific input. - onBlur: An onBlur handler that calls validate. +- onTouched: An onTouched handler that marks the input as touched. - onInputChange: An onChange handler that saves draft values and calls validate for that input (inputA). Passing an inputID as a second param allows inputA to manipulate the input value of the provided inputID (inputB). ## Dynamic Form Inputs diff --git a/docs/articles/other/Everything-About-Chat.md b/docs/articles/other/Everything-About-Chat.md index 7a8bf5cf6554..8d4ad5f8740c 100644 --- a/docs/articles/other/Everything-About-Chat.md +++ b/docs/articles/other/Everything-About-Chat.md @@ -6,47 +6,47 @@ description: Everything you need to know about Expensify's Chat Features! # What is Expensify Chat? -Expensify Chat is the ideal way to collaborate on expenses or payment requests by communicating in real time with your accountant, clients, employees, friends, and family. +Expensify Chat is an ideal way to collaborate on expenses or payment requests by communicating in real-time with your accountant, clients, employees, or, friends. -With Expensify Chat, you can start a conversation about that missing receipt your employee forgot to submit or chat about splitting that electric bill with your roommates. Through eChat, you can even request money from your friends! +With Expensify Chat, you can start a conversation about that missing receipt your employee forgot to submit or chat about splitting that electric bill with your roommates. Through eChat, you can even request money from your friends after a night out on the town! # How to use Chat in Expensify Download NewExpensify from the [App Store](https://apps.apple.com/us/app/expensify-cash/id1530278510) or [Google Play](https://play.google.com/store/apps/details?id=com.expensify.chat) to use the chat function. You can also access your account at new.expensify.com from your favorite web browser. -After downloading the app, log into your new.expensify.com account (you’ll use the same username and password for your standard Expensify account). From there, you can customize your profile and start chatting immediately. +After downloading the app, log into your new.expensify.com account (you’ll use the same login information as your Expensify Classic account). From there, you can customize your profile and start chatting immediately. ## Start Chatting Select **New Chat** to chat one-on-one or **New Group** to start a group chat. ## Workspace Chat Rooms -In addition to 1:1 and group chat, members of a Workspace will have access to two additional rooms; the #announce and #admins rooms. +In addition to 1:1 and group chat, members of a Workspace or Policy will have access to two additional rooms; the #announce and #admins rooms. All workspace members are added to the #announce room by default. The #announce room lets you share important company announcements and have conversations between workspace members. -Workspace admins will have access to the #admins room. Use the #admins room to collaborate between admins and your dedicated Expensify Guide! +All workspace admins can access the #admins room. Use the #admins room to collaborate with the other admins on your policy, and chat with your dedicated Expensify Onboarding Guide. If you have a subscription of 10 or more users, you're automatically assigned an Account Manager. You can ask for help and collaborate with your Account Manager in this same #admins room. Anytime someone on your team, your dedicated setup specialist, or your dedicated account manager makes any changes to your Workspace settings, that update is logged in the #admins room. # FAQs ## How do I add more than one person to a chat? -Creating a Group chat with multiple people is easy. Start by clicking the green chat **+** button and select **New Group**. Search for the people you want to invite and check the circle to the far right. Once you’ve selected everyone, click the **Create Group** button at the bottom of your screen. +Start by clicking the green chat **+** button and select **New Group**. Search for the people you want to invite and check the circle to the far right. Once you’ve selected everyone you want in the group chat, click the **Create Group** button at the bottom of your screen. ## Can I add people to an existing Group chat? -Adding people to an existing group chat isn’t possible right now, so you’ll need to make a new group chat with the latest additions. +Adding people to an existing group chat isn’t possible right now, so you’ll want to make a new group chat instead. ## Someone I don’t recognize is in my #admins room for my workspace; who is it? -After creating your workspace, you’ll have a dedicated Expensify specialist who will help you onboard and answer your questions. You can chat with them directly in the #admins room or request a call to talk to them over the phone. +After creating your workspace, you’ll have a dedicated Expensify specialist who will help you onboard and answer your questions. You can chat with them directly in the #admins room or request a call to talk to them over the phone. Later, once you've finished onboarding, if you have a subscription of 10 or more users, a dedicated Account Manager is added to your #admins room for ongoing product support. ## Can I force a chat to stay at the top of the chats list? -You sure can! Click on the chat you want to keep at the top of the list, and then click the small **pin** icon. From now on, your chat will stay pinned to the top of the chat list. If you want to unpin a chat, just click the **pin** icon again. +You sure can! Click on the chat you want to keep at the top of the list, and then click the small **pin** icon. If you want to unpin a chat, just click the **pin** icon again. # Deep Dive ## Chat display, aka Priority Mode -The way your chats display in the left-hand menu is customizable, and we offer two different options; Most Recent mode and _#focus_ mode. +The way your chats display in the left-hand menu is customizable. We offer two different options; Most Recent mode and _#focus_ mode. -- Most Recent mode will display all chats by default, sorted by most recent, with your pinned chats at the top of the list. -- #focus mode will display only unread and pinned chats, all sorted alphabetically. This setting is perfect for when you need to heads down to focus on a crucial project. +- Most Recent mode will display all chats by default, sort them by the most recent, and keep your pinned chats at the top of the list. +- #focus mode will display only unread and pinned chats, and will sort them alphabetically. This setting is perfect for when you need to cut distractions and focus on a crucial project. You can find your display mode by clicking on your User Icon > Preferences > Priority Mode. ## Inviting someone to Expensify Chat -If the person you want to chat with doesn’t appear in your contact list, simply type their email or phone number to invite them to chat! They will receive an email with instructions and can reply directly to it to start chatting with you. +If the person you want to chat with doesn’t appear in your contact list, simply type their email or phone number to invite them to chat! From there, they will receive an email with instructions and a link to create an account. -All they have to do is click the link, and a new.expensify.com account will be set up automatically for them (if they don't have one already), and they can start chatting immediately! +Once they click the link, a new.expensify.com account is set up for them automatically (if they don't have one already), and they can start chatting with you immediately! diff --git a/docs/articles/other/Your-Expensify-Account-Manager.md b/docs/articles/other/Your-Expensify-Account-Manager.md index 318d03b510d8..70e0435e00e1 100644 --- a/docs/articles/other/Your-Expensify-Account-Manager.md +++ b/docs/articles/other/Your-Expensify-Account-Manager.md @@ -6,24 +6,25 @@ description: Everything you need to know about Having an Expensify account manag # What is an account manager? -An account manager is a dedicated point of contact to support customers with questions about their Expensify account. They will actively monitor open technical issues and be proactive with recommendations to increase efficiency and minimize time spent on expense management. +An account manager is a dedicated point of contact to support policy admins with questions about their Expensify account. They are available to help you and other policy admins review your account, advise on best practices, and make changes to your policy on your behalf whenever you need a hand. They will actively monitor open technical issues and be proactive with recommendations to increase efficiency and minimize time spent on expense management. Unlike Concierge, an account manager’s support will not be real-time, 24 hours a day. A benefit of Concierge is that you get real-time support every day. Your account manager will be super responsive when online, but anything sent when they’re offline will not be responded to until they’re online again. For real-time responses and simple troubleshooting issues, you can always message our general support by writing to Concierge via the in-product chat or by emailing concierge@expensify.com. # How do I know if I have an account manager? -If you are a policy or domain admin, you will hear from your account manager as soon as one gets assigned to your company. You’ll also have the option to contact them when you log in to your account and click the Concierge icon. +If you are a policy admin or domain admin, you will also hear from your account manager as soon as one gets assigned to your company. If you'd like a reminder who your account manager is, just click the Support link on the left side of Expensify - you'll see your account manager's name and photo, with an option to contact them for help. ## How do I contact my account manager? -You can contact your account manager by: -- Logging in to your Expensify account, opening Concierge, and clicking the “Chat with your account manager” button; -- Replying to or clicking the chat link on any email you get from your account manager; -- Signing in to new.expensify.com and searching for your account manager (this is still in a test phase, so it might not work for every customer). +We make it easy to contact your account manager: + +1. Log in to your Expensify account, click "Support" along the left side of the page, and click the “Account Manager” option +2. Reply to (or click the chat link on) any email you get from your account manager +3. Sign in to new.expensify.com and go to the #admins room for any of your policies. Your account manager is in your #admin rooms ready to help you, so you can ask for help here and your account manager will respond in the chat. # FAQs -## How can I request an account manager? -Not every customer will automatically be assigned an account manager. If you think you would benefit from having a dedicated account manager, please email a request to concierge@expensify.com, and we’ll do our best to assign one as soon as possible. +## Who gets an account manager? +Every customer with 10 or more paid subscribers is automatically assigned a dedicated account manager. If you have fewer than 10 active users each month, you can still get an account manager by increasing your subscription to 10 or more users, To get assigned an account manager immediately, log into your Expensify account and go to Settings > Policies > Group, then click Subscription and increase your subscription size to 10 or more. ## How do I know if my account manager is online? You will be able to see if they are online via their status, which will either say something like “online” or have their working hours. diff --git a/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md b/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md index d26d85e90cd3..d7bdef860cf7 100644 --- a/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md +++ b/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md @@ -14,7 +14,7 @@ As a small to medium-sized business owner, your main aim is to achieve success a This playbook is built 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 your dedicated Setup Specialist is 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. +If you don't already have one, go to *[new.expensify.com](https://new.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. > _Employees really appreciate how easy it is to use, and the fact that the reimbursement drops right into their bank account. Since most employees are submitting expenses from their phones, the ease of use of the app is critical_ > @@ -37,7 +37,7 @@ To create your Control Policy: 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](https://new.expensify.com), and in your company’s policy settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider. +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 in your company’s policy settings in the *Overview* tab, where you can chat with them and schedule an onboarding call to walk through any setup questions. The Control Plan bundled with the Expensify Card is only *$9 per user per month* (not taking into account cash back your earn) when you commit annually. That’s a 75% discount off the unbundled price point if you choose to use a different Corporate Card (or no) provider. ### Step 3: Connect your accounting system As a small to medium-sized business, it's important to maintain proper spend management to ensure the success and stability of your organization. This requires paying close attention to your expenses, streamlining your financial processes, and making sure that your financial information is accurate, compliant, and transparent. Include best practices such as: @@ -280,4 +280,4 @@ Now that we’ve gone through all of the steps for setting up your account, let 4. Click *Accept Terms* ## You’re all set! -Congrats, you are all set up! If you need any assistance with anything mentioned above or would like to understand other features available in Expensify, reach out to your Setup Specialist 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. +Congrats, you are all set up! If you need any assistance with anything mentioned above or would like to understand other features available in Expensify, reach out to your Setup Specialist directly in *[new.expensify.com](https://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/articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups.md b/docs/articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups.md index 6c8d5314f718..089ad16834ac 100644 --- a/docs/articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups.md +++ b/docs/articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups.md @@ -5,7 +5,7 @@ description: Best practices for how to deploy Expensify for your business This playbook details best practices on how Bootstrapped Startups with less than 5 employees can use Expensify to prioritize product development while capturing business-related receipts for future reimbursement. - See our *[Playbook for VC-Backed Startups](https://help.expensify.com/articles/playbooks/Expensify-Playbook-for-US-based-VC-Backed-Startups)* if you have taken venture capital investment and are more concerned with prioritizing top-line revenue growth than achieving near-term profitability -- See our *[Playbook for Small to Medium-Sized Businesses]([url](https://help.expensify.com/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses))* if you are more concerned with maintaining profitability than growing top-line revenue. +- See our *[Playbook for Small to Medium-Sized Businesses](https://help.expensify.com/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses)* if you are more concerned with maintaining profitability than growing top-line revenue. # Who you are As a bootstrapped startup, you have surrounded yourself with a small handful of people you trust, and are focused on developing your concept to officially launch and possibly take to outside investors. You are likely running your business with your own money, and possibly a small amount of funding from friends and family. You are either paying yourself a little, or not at all, but at this stage, the company isn’t profitable. And for now, you are capturing receipts so that you can reimburse yourself for startup costs when you either take on investment or start to turn a profit. @@ -14,7 +14,7 @@ As a bootstrapped startup, you have surrounded yourself with a small handful of 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 *[new.expensify.com](new.expensifiy.com)* and sign up for an account with your business email address. +If you don't already have one, go to *[new.expensify.com](https://new.expensify.com)* and sign up for an account with your business email address. ## Step 2: Create a Free Plan Workspace There are three plans (Free, Collect, and Control), but for your needs, we recommend the *Free Plan* for the following reasons: @@ -25,13 +25,13 @@ There are three plans (Free, Collect, and Control), but for your needs, we recom To create your Free Plan Workspace: -1. Go to *new.expensify.com* +1. Go to *[new.expensify.com](https://new.expensify.com)* 2. Select *New Workspace* 3. Select the avatar in the top left corner 4. Click on the workspace name and rename it 5. Express yourself by adding an avatar image, preferably an image of yourself -The Free Plan also gives you direct access to lightning-fast 24/7 support via Concierge. Within new.expensify.com, you can start a direct message (DM) with Concierge anytime. +The Free Plan also gives you direct access to lightning-fast 24/7 support via Concierge. Within *[new.expensify.com](https://new.expensify.com/concierge)*, you can start a direct message (DM) with Concierge anytime. ## Step 3: Invite Your Team As a bootstrapped startup, you communicate with your team all day. Similarly, if you are a co-founder, you will have multiple people that will need to capture receipts for your project. @@ -39,7 +39,7 @@ As a bootstrapped startup, you communicate with your team all day. Similarly, if 1. Click on your avatar 2. Select *Workspaces* 3. Click on your workspace -4. Select *Manage Members* +4. Select *Members* 5. Click *Invite*, and enter each team member’s email address By inviting your team, all members of your team will have access to unlimited receipt capture via SmartScan, and you’ll all have access to our free chat tool, which makes it easy to chat about your project and the expenses you’ve captured to help accelerate your project. @@ -52,7 +52,7 @@ Here’s how to set it up: 1. Click on your *avatar* 2. Select *Workspaces* 3. Click on your workspace name -4. At the bottom, select *Connect bank account* +4. At the bottom, select *Bank account* 5. Select your bank and enter your online login credentials Once this is done, you are all set to begin the process of enabling the Expensify Card. Not just for you, but if you have a co-founder, you can also issue them a card. @@ -65,7 +65,7 @@ Here’s how to enable the Expensify Card: 1. Click on your *avatar* 2. Select *Workspaces* 3. Click on your workspace -4. Select *Issue Cards* +4. Select *Cards* 5. Next, you’ll be redirected to expensify.com 6. Set a SmartLimit > $0 7. We’ll also ask you for your mailing address to send you a physical Expensify Card @@ -82,10 +82,10 @@ To view and pay bills: 1. Click on your *avatar* 2. Select *Workspaces* 3. Click on your workspace -4. Select *Pay bills* +4. Select *Bills* When you have bills to pay you can click *View all bills* under the *Manage your bills* box and we’ll keep a neatly organized list of all of the bills you can pay via ACH directly from your Expensify account. # You’re all set! -Congrats, you are all set up! If you need any assistance with anything mentioned above, reach out to either your Concierge directly in new.expensify.com, or email concierge@expensify.com. Create a Collect or Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you. +Congrats, you are all set up! If you need any assistance with anything mentioned above, reach out to either your Concierge directly in *[new.expensify.com](https://new.expensify.com/concierge)*, or email concierge@expensify.com. Create a Collect or Control Policy, and we’ll automatically assign a dedicated Setup Specialist to you. 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 index 78d47083f4c2..501d2f1538ef 100644 --- 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 @@ -15,7 +15,7 @@ As a VC-backed business focused on growth and efficiency, you are looking for a 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. +If you don't already have one, go to *[new.expensify.com](https://new.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: @@ -29,7 +29,7 @@ To create your Control Policy: 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 at new.expensify.com, and chatting with them there. The Control plan is 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. +The Control plan also gives you access to a dedicated Setup Specialist. You can find yours by looking at your policy's #admins room at *[new.expensify.com](https://new.expensify.com)*, and chatting with them there. The Control plan is 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: @@ -205,4 +205,4 @@ Now that we’ve gone through all of the steps for setting up your account, let 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. +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](https://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/articles/send-money/workspaces/The-Free-Plan.md b/docs/articles/send-money/workspaces/The-Free-Plan.md index 3037f71f5a5d..45c9d09d4777 100644 --- a/docs/articles/send-money/workspaces/The-Free-Plan.md +++ b/docs/articles/send-money/workspaces/The-Free-Plan.md @@ -25,12 +25,12 @@ Once you’ve created your Workspace, you will receive a message from Concierge Once you’ve completed your company setup, you should have completed the following tasks: -- Connected a business bank account (Settings menu > Click **_Connect bank account_** and follow the prompts). +- Connected a business bank account (Settings menu > Click **_Bank account_** and follow the prompts). - Invited members to the workspace - Assigned Expensify Cards # Inviting Members to the Free Plan: -- Navigate to the Settings Menu and click **_Manage members_** to invite your team. You can invite employees one at a time, or you can invite multiple users by listing out their email addresses separated by a comma +- Navigate to the Settings Menu and click **_Members_** to invite your team. You can invite employees one at a time, or you can invite multiple users by listing out their email addresses separated by a comma - To use the Expensify Card, you must invite them to your workspace via your company email address (i.e., admin@companyemail.com and NOT admin@gmail.com). # Managing the Free Plan diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 49f15a3b12c0..60d60934c2ba 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -218,7 +218,11 @@ platform :ios do contact_phone: ENV["APPLE_CONTACT_PHONE"], demo_account_name: ENV["APPLE_DEMO_EMAIL"], demo_account_password: ENV["APPLE_DEMO_PASSWORD"], - notes: "Use the account provided. Thank you for the review." + notes: "1. Log into the Expensify app using the provided email + 2. Now, you have to log in to this gmail account on https://mail.google.com/ so you can retrieve a One-Time-Password + 3. To log in to the gmail account, use the password above (That's NOT a password for the Expensify app but for the Gmail account) + 4. At the Gmail inbox, you should have received a one-time 6 digit magic code + 5. Use that to sign in" } ) rescue Exception => e diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 3069a08c7d90..323e7336b19f 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.16 + 1.3.22 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.3.16.6 + 1.3.22.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index a7cbdc0cebf3..28a5b85a6a97 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.16 + 1.3.22 CFBundleSignature ???? CFBundleVersion - 1.3.16.6 + 1.3.22.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 43c64fa15997..65bf60c3bd89 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -16,7 +16,7 @@ PODS: - Airship/Core - Airship/PreferenceCenter (16.11.3): - Airship/Core - - AirshipFrameworkProxy (2.0.5): + - AirshipFrameworkProxy (2.0.8): - Airship (= 16.11.3) - Airship/MessageCenter (= 16.11.3) - Airship/PreferenceCenter (= 16.11.3) @@ -495,8 +495,8 @@ PODS: - React-jsinspector (0.71.2-alpha.3) - React-logger (0.71.2-alpha.3): - glog - - react-native-airship (15.2.3): - - AirshipFrameworkProxy (= 2.0.5) + - react-native-airship (15.2.6): + - AirshipFrameworkProxy (= 2.0.8) - React-Core - react-native-blob-util (0.17.3): - React-Core @@ -514,7 +514,7 @@ PODS: - React - react-native-image-picker (5.1.0): - React-Core - - react-native-key-command (1.0.0): + - react-native-key-command (1.0.1): - React-Core - react-native-netinfo (9.3.10): - React-Core @@ -1018,8 +1018,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Airship: c70eed50e429f97f5adb285423c7291fb7a032ae - AirshipFrameworkProxy: 2eefb77bb77b5120b0f48814b0d44439aa3ad415 - boost: 57d2868c099736d80fcd648bf211b4431e51a558 + AirshipFrameworkProxy: 7bc4130c668c6c98e2d4c60fe4c9eb61a999be99 + boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: ff54429f0110d3c722630a98096ba689c39f6d5f @@ -1062,12 +1062,12 @@ SPEC CHECKSUMS: Permission-LocationWhenInUse: 3ba99e45c852763f730eabecec2870c2382b7bd4 Plaid: 7d340abeadb46c7aa1a91f896c5b22395a31fcf2 PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef - RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 + RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCTRequired: e9e7b8b45aa9bedb2fdad71740adf07a7265b9be RCTTypeSafety: 9ae0e9206625e995f0df4d5b9ddc94411929fb30 React: a71c8e1380f07e01de721ccd52bcf9c03e81867d React-callinvoker: fc9f36c92c287c012d3fb45ea0f1b523c4f5aaa8 - React-Codegen: 47ad49a58fd95a9560a25f6054ee8984ff3afadb + React-Codegen: 7dcbda38b5b38a9354ef0ef00c420d6921d7bbb7 React-Core: aab8ea7f615a86b3a73ce87aa9be4c563e49648b React-CoreModules: f2a86b01c227e0137c83c13dd645fe69270cef80 React-cxxreact: 8adcafaeb0f02ae1282698c482ffa4c73fca4a35 @@ -1076,7 +1076,7 @@ SPEC CHECKSUMS: React-jsiexecutor: 6f986feb67cf66edff7f98090ca797a67d0a44fb React-jsinspector: 31517b1de3fadf93ad8558840a8974c7a7160bd3 React-logger: b90aa6ed0dbc30717dc72d843af3cf4550297b22 - react-native-airship: 25045092934bf6eabf483e803af0a6e31826b8b9 + react-native-airship: 5d19f4ba303481cf4101ff9dee9249ef6a8a6b64 react-native-blob-util: 99f4d79189252f597fe0d810c57a3733b1b1dea6 react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151 react-native-config: 7cd105e71d903104e8919261480858940a6b9c0e @@ -1084,7 +1084,7 @@ SPEC CHECKSUMS: react-native-flipper: dc5290261fbeeb2faec1bdc57ae6dd8d562e1de4 react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b - react-native-key-command: 0b3aa7c9f5c052116413e81dce33a3b2153a6c5d + react-native-key-command: c2645ec01eb1fa664606c09480c05cb4220ef67b react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2 react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 diff --git a/package-lock.json b/package-lock.json index d109913c48b0..85a793a313c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.16-6", + "version": "1.3.22-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.16-6", + "version": "1.3.22-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -35,13 +35,13 @@ "@react-navigation/native": "6.0.13", "@react-navigation/stack": "6.3.1", "@react-ng/bounds-observer": "^0.2.1", - "@ua/react-native-airship": "^15.2.3", + "@ua/react-native-airship": "^15.2.6", "awesome-phonenumber": "^5.4.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#c898563fe851d9a4d594fa9afbdd1ddab5971636", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", @@ -75,10 +75,10 @@ "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972", - "react-native-key-command": "^1.0.0", + "react-native-key-command": "^1.0.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.41", + "react-native-onyx": "1.0.43", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", @@ -171,6 +171,7 @@ "react-native-performance-flipper-reporter": "^2.0.0", "react-native-svg-transformer": "^1.0.0", "react-test-renderer": "18.1.0", + "reassure": "^0.9.0", "setimmediate": "^1.0.5", "shellcheck": "^1.1.0", "style-loader": "^2.0.0", @@ -2043,10 +2044,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.18.9", - "license": "MIT", + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz", + "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==", "dependencies": { - "regenerator-runtime": "^0.13.4" + "regenerator-runtime": "^0.13.11" }, "engines": { "node": ">=6.9.0" @@ -2131,6 +2133,252 @@ "integrity": "sha512-iZf+UWfL+DogJVpd/xMQyP6X6McYd6ArdYoPMiv/zlOTzeXXfQbYxBNJJBF6tThvsjLMbA8tLjkCdm9RWMFCCw==", "dev": true }, + "node_modules/@callstack/reassure-cli": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.9.0.tgz", + "integrity": "sha512-auoxqyilxkT5mDdEPJqRRY+ZGlrihJjFQpopcFd/15ng76OPVka3L48RMEY2wXkFXLaOOs6enNGb596jYPuEtQ==", + "dev": true, + "dependencies": { + "@callstack/reassure-compare": "0.5.0", + "@callstack/reassure-logger": "0.3.0", + "chalk": "4.1.2", + "simple-git": "^3.16.0", + "yargs": "^17.6.2" + }, + "bin": { + "reassure": "lib/commonjs/bin.js" + } + }, + "node_modules/@callstack/reassure-cli/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@callstack/reassure-cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@callstack/reassure-cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@callstack/reassure-cli/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@callstack/reassure-cli/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@callstack/reassure-cli/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@callstack/reassure-cli/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@callstack/reassure-cli/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@callstack/reassure-cli/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@callstack/reassure-cli/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@callstack/reassure-compare": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.5.0.tgz", + "integrity": "sha512-3sBeJ/+Hxjdb01KVb8LszO1kcJ8TXcrVnerUj+LYn2dkBOohAMqGYaOvCeoWsVEHJ+MIOzmvAGBJQRu69RoJdQ==", + "dev": true, + "dependencies": { + "@callstack/reassure-logger": "0.3.0", + "markdown-builder": "^0.9.0", + "markdown-table": "^2.0.0", + "zod": "^3.20.2" + } + }, + "node_modules/@callstack/reassure-danger": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@callstack/reassure-danger/-/reassure-danger-0.1.1.tgz", + "integrity": "sha512-lfza+qBdvVYtP7WvMTT+LfjBfuYsXZ4RxuBldsL8wJArGeCl3OZwUg+9bTo8v6kk/nY8memk5HxrCwWDSO24UA==", + "dev": true + }, + "node_modules/@callstack/reassure-logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.0.tgz", + "integrity": "sha512-JX5o+8qkIbIRL+cQn9XlQYdv9p/3L6J70zZX6NYi9j0VrSS9PZIRfo8ujMdLSqUNV6HZN1ay59RzuncLjVu0aQ==", + "dev": true, + "dependencies": { + "chalk": "4.1.2" + } + }, + "node_modules/@callstack/reassure-logger/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@callstack/reassure-logger/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@callstack/reassure-logger/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@callstack/reassure-logger/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@callstack/reassure-logger/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@callstack/reassure-logger/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@callstack/reassure-measure": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.5.0.tgz", + "integrity": "sha512-KwlmNYcspBOp7FIw6XOz5O9mnKB4cWCCyM6vG4nFUPHSWQ6yVdRkawVvoPIV5qJ2hw7zCzdtqRrLWQSTF4eKlg==", + "dev": true, + "dependencies": { + "@callstack/reassure-logger": "0.3.0", + "mathjs": "^11.5.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/@cnakazawa/watch": { "version": "1.0.4", "dev": true, @@ -4230,6 +4478,21 @@ "react-native": "*" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "dev": true + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "dev": true, @@ -15634,9 +15897,9 @@ } }, "node_modules/@ua/react-native-airship": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.3.tgz", - "integrity": "sha512-94fgHJcxc4qUy9OmBqt6VuuWggoeCMNIosz3/qwiUNsUVoE1deWcOEG9vULmXcTKX+O9Wal/mKsuPGiFCALTDQ==", + "version": "15.2.6", + "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.6.tgz", + "integrity": "sha512-dVlBPPYXD/4SEshv/X7mmt3xF8WfnNqiSNzCyqJSLAZ1aJuPpP9Z5WemCYsa2iv6goRZvtJSE4P79QKlfoTwXw==", "engines": { "node": ">= 16.0.0" }, @@ -19378,6 +19641,19 @@ "node": ">=0.10.0" } }, + "node_modules/complex.js": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.1.1.tgz", + "integrity": "sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, "node_modules/component-emitter": { "version": "1.3.0", "license": "MIT" @@ -21920,6 +22196,12 @@ "version": "1.0.3", "license": "MIT" }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "dev": true + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "license": "MIT", @@ -23520,8 +23802,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", - "integrity": "sha512-xFCTXFz295JjSSYOjcDvR1FeN6rx+t2vCz64ahqm+TohI0eCWWJSzdUq/2jvrDm72e509rjdYwYcLWy4PumeYA==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#c898563fe851d9a4d594fa9afbdd1ddab5971636", + "integrity": "sha512-WjxHYpqebNsPKJC+SBhgsYNSib+8LptZv/BKt8hc67psJjO9JdrTpAHuoZ0n1lCTQ2DhpDERjqTsQbpUqWbgIg==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -24512,6 +24794,19 @@ "node": ">= 0.6" } }, + "node_modules/fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, "node_modules/fragment-cache": { "version": "0.2.1", "license": "MIT", @@ -25566,6 +25861,290 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/husky/-/husky-1.3.1.tgz", + "integrity": "sha512-86U6sVVVf4b5NYSZ0yvv88dRgBSSXXmHaiq5pP4KDj5JVzdwKgBjEtUPOm8hcoytezFwbU+7gotXNhpHdystlg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "cosmiconfig": "^5.0.7", + "execa": "^1.0.0", + "find-up": "^3.0.0", + "get-stdin": "^6.0.0", + "is-ci": "^2.0.0", + "pkg-dir": "^3.0.0", + "please-upgrade-node": "^3.1.1", + "read-pkg": "^4.0.1", + "run-node": "^1.0.0", + "slash": "^2.0.0" + }, + "bin": { + "husky-upgrade": "lib/upgrader/bin.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/husky/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "node_modules/husky/node_modules/cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "dependencies": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/husky/node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/husky/node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/husky/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/husky/node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/husky/node_modules/import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==", + "dev": true, + "dependencies": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/husky/node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/husky/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/husky/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/husky/node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/husky/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/husky/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/husky/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/husky/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/husky/node_modules/pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/husky/node_modules/resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/husky/node_modules/semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/husky/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/husky/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/husky/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/husky/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/hyphenate-style-name": { "version": "1.0.4", "license": "BSD-3-Clause" @@ -26748,6 +27327,12 @@ "node": ">=8" } }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "dev": true + }, "node_modules/jest": { "version": "29.4.1", "license": "MIT", @@ -30596,6 +31181,15 @@ "node": ">=0.10.0" } }, + "node_modules/markdown-builder": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/markdown-builder/-/markdown-builder-0.9.0.tgz", + "integrity": "sha512-UovCyEEzMeKE7l88fbOk9SIJkOG7KXkg+TdudN8rvOtCtBO5uu1X27HSnM7LS/xH+vaShJLGpkBcYYcojWNx/g==", + "dev": true, + "dependencies": { + "husky": "^1.0.0-rc.14" + } + }, "node_modules/markdown-escapes": { "version": "1.0.4", "dev": true, @@ -30605,6 +31199,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "dev": true, + "dependencies": { + "repeat-string": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/matcher": { "version": "3.0.0", "dev": true, @@ -30616,6 +31223,29 @@ "node": ">=10" } }, + "node_modules/mathjs": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.8.0.tgz", + "integrity": "sha512-I7r8HCoqUGyEiHQdeOCF2m2k9N+tcOHO3cZQ3tyJkMMBQMFqMR7dMQEboBMJAiFW2Um3PEItGPwcOc4P6KRqwg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.21.0", + "complex.js": "^2.1.1", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^4.2.0", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.1.0" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/md5.js": { "version": "1.3.5", "license": "MIT", @@ -33841,6 +34471,15 @@ "node": ">=6" } }, + "node_modules/please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "dependencies": { + "semver-compare": "^1.0.0" + } + }, "node_modules/plist": { "version": "3.0.6", "license": "MIT", @@ -35021,9 +35660,9 @@ "license": "MIT" }, "node_modules/react-native-key-command": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.0.tgz", - "integrity": "sha512-gjtzvJmgssKQ6YWoSiIIM37N/8fxtEUpvrwMZL9YTOg+WSTyJP5C9jIkHiT0KgMmfBylxwoJOCjche9TiNcdDQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.1.tgz", + "integrity": "sha512-vubmDxRnRQh+t2IqBUrbXOCVjkXYSQKJ+YAD1attcOV4mDHcQ0MB/Q4kxXzqVcLAlNPWMETFsJNShvt2cwO03Q==", "dependencies": { "events": "^3.3.0", "underscore": "^1.13.4" @@ -35067,9 +35706,9 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.41", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.41.tgz", - "integrity": "sha512-+TJlUIEWzy4yGnw4IJx9RvDYC77PL7bLGAejALzv3rXnvtz70dCP/kxb+Cn5dPto1+795c50TxGTW5X48xjpZw==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.43.tgz", + "integrity": "sha512-NwS1SxZJWhk/7FUAAE9HrnupQR1yrSAheuhggdeA3+oFLn9X6UJM7n7w9DodFqCQbUIUy9biKtYk29sChfk9hQ==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -36223,6 +36862,17 @@ "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==" }, + "node_modules/reassure": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.9.0.tgz", + "integrity": "sha512-FIf0GPchyPGItsrW5Wwff/NWVrfOcCUuJJSs4Nur6iRdQt8yvmCpcba4UyemdZ1KaFTIW1gKbAV3u2tuA7zmtQ==", + "dev": true, + "dependencies": { + "@callstack/reassure-cli": "0.9.0", + "@callstack/reassure-danger": "0.1.1", + "@callstack/reassure-measure": "0.5.0" + } + }, "node_modules/recast": { "version": "0.20.5", "resolved": "https://registry.npmjs.org/recast/-/recast-0.20.5.tgz", @@ -36282,8 +36932,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.9", - "license": "MIT" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/regenerator-transform": { "version": "0.15.0", @@ -36822,6 +37473,18 @@ "node": ">=0.12.0" } }, + "node_modules/run-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/run-node/-/run-node-1.0.0.tgz", + "integrity": "sha512-kc120TBlQ3mih1LSzdAJXo4xn/GWS2ec0l3S+syHDXP9uRr0JAT8Qd3mdMuyjqCzeZktgP3try92cEgf9Nks8A==", + "dev": true, + "bin": { + "run-node": "run-node" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "dev": true, @@ -37614,6 +38277,21 @@ "version": "3.0.7", "license": "ISC" }, + "node_modules/simple-git": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.19.0.tgz", + "integrity": "sha512-hyH2p9Ptxjf/xPuL7HfXbpYt9gKhC1yWDh3KYIAYJJePAKV7AEjLN4xhp7lozOdNiaJ9jlVvAbBymVlcS2jRiA==", + "dev": true, + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/simple-plist": { "version": "1.3.1", "license": "MIT", @@ -39527,6 +40205,15 @@ "node": ">= 0.6" } }, + "node_modules/typed-function": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.0.tgz", + "integrity": "sha512-DGwUl6cioBW5gw2L+6SMupGwH/kZOqivy17E4nsh1JI9fKF87orMmlQx3KISQPmg3sfnOUGlwVkroosvgddrlg==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/typedarray": { "version": "0.0.6", "dev": true, @@ -41739,6 +42426,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "1.0.5", "dev": true, @@ -42851,9 +43547,11 @@ } }, "@babel/runtime": { - "version": "7.18.9", + "version": "7.22.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.3.tgz", + "integrity": "sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==", "requires": { - "regenerator-runtime": "^0.13.4" + "regenerator-runtime": "^0.13.11" } }, "@babel/runtime-corejs3": { @@ -42920,6 +43618,196 @@ "integrity": "sha512-iZf+UWfL+DogJVpd/xMQyP6X6McYd6ArdYoPMiv/zlOTzeXXfQbYxBNJJBF6tThvsjLMbA8tLjkCdm9RWMFCCw==", "dev": true }, + "@callstack/reassure-cli": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@callstack/reassure-cli/-/reassure-cli-0.9.0.tgz", + "integrity": "sha512-auoxqyilxkT5mDdEPJqRRY+ZGlrihJjFQpopcFd/15ng76OPVka3L48RMEY2wXkFXLaOOs6enNGb596jYPuEtQ==", + "dev": true, + "requires": { + "@callstack/reassure-compare": "0.5.0", + "@callstack/reassure-logger": "0.3.0", + "chalk": "4.1.2", + "simple-git": "^3.16.0", + "yargs": "^17.6.2" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true + } + } + }, + "@callstack/reassure-compare": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@callstack/reassure-compare/-/reassure-compare-0.5.0.tgz", + "integrity": "sha512-3sBeJ/+Hxjdb01KVb8LszO1kcJ8TXcrVnerUj+LYn2dkBOohAMqGYaOvCeoWsVEHJ+MIOzmvAGBJQRu69RoJdQ==", + "dev": true, + "requires": { + "@callstack/reassure-logger": "0.3.0", + "markdown-builder": "^0.9.0", + "markdown-table": "^2.0.0", + "zod": "^3.20.2" + } + }, + "@callstack/reassure-danger": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@callstack/reassure-danger/-/reassure-danger-0.1.1.tgz", + "integrity": "sha512-lfza+qBdvVYtP7WvMTT+LfjBfuYsXZ4RxuBldsL8wJArGeCl3OZwUg+9bTo8v6kk/nY8memk5HxrCwWDSO24UA==", + "dev": true + }, + "@callstack/reassure-logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@callstack/reassure-logger/-/reassure-logger-0.3.0.tgz", + "integrity": "sha512-JX5o+8qkIbIRL+cQn9XlQYdv9p/3L6J70zZX6NYi9j0VrSS9PZIRfo8ujMdLSqUNV6HZN1ay59RzuncLjVu0aQ==", + "dev": true, + "requires": { + "chalk": "4.1.2" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "@callstack/reassure-measure": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@callstack/reassure-measure/-/reassure-measure-0.5.0.tgz", + "integrity": "sha512-KwlmNYcspBOp7FIw6XOz5O9mnKB4cWCCyM6vG4nFUPHSWQ6yVdRkawVvoPIV5qJ2hw7zCzdtqRrLWQSTF4eKlg==", + "dev": true, + "requires": { + "@callstack/reassure-logger": "0.3.0", + "mathjs": "^11.5.0" + } + }, "@cnakazawa/watch": { "version": "1.0.4", "dev": true, @@ -44339,6 +45227,21 @@ "version": "2.3.1", "requires": {} }, + "@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "requires": { + "debug": "^4.1.1" + } + }, + "@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "dev": true + }, "@leichtgewicht/ip-codec": { "version": "2.0.4", "dev": true @@ -52105,9 +53008,9 @@ } }, "@ua/react-native-airship": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.3.tgz", - "integrity": "sha512-94fgHJcxc4qUy9OmBqt6VuuWggoeCMNIosz3/qwiUNsUVoE1deWcOEG9vULmXcTKX+O9Wal/mKsuPGiFCALTDQ==", + "version": "15.2.6", + "resolved": "https://registry.npmjs.org/@ua/react-native-airship/-/react-native-airship-15.2.6.tgz", + "integrity": "sha512-dVlBPPYXD/4SEshv/X7mmt3xF8WfnNqiSNzCyqJSLAZ1aJuPpP9Z5WemCYsa2iv6goRZvtJSE4P79QKlfoTwXw==", "requires": {} }, "@vercel/ncc": { @@ -54662,6 +55565,12 @@ "version": "0.1.2", "dev": true }, + "complex.js": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.1.1.tgz", + "integrity": "sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg==", + "dev": true + }, "component-emitter": { "version": "1.3.0" }, @@ -56367,6 +57276,12 @@ "escape-html": { "version": "1.0.3" }, + "escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "dev": true + }, "escape-string-regexp": { "version": "4.0.0" }, @@ -57401,9 +58316,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", - "integrity": "sha512-xFCTXFz295JjSSYOjcDvR1FeN6rx+t2vCz64ahqm+TohI0eCWWJSzdUq/2jvrDm72e509rjdYwYcLWy4PumeYA==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#c898563fe851d9a4d594fa9afbdd1ddab5971636", + "integrity": "sha512-WjxHYpqebNsPKJC+SBhgsYNSib+8LptZv/BKt8hc67psJjO9JdrTpAHuoZ0n1lCTQ2DhpDERjqTsQbpUqWbgIg==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#c898563fe851d9a4d594fa9afbdd1ddab5971636", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -58066,6 +58981,12 @@ "version": "0.2.0", "dev": true }, + "fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "dev": true + }, "fragment-cache": { "version": "0.2.1", "requires": { @@ -58736,6 +59657,219 @@ "human-signals": { "version": "2.1.0" }, + "husky": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/husky/-/husky-1.3.1.tgz", + "integrity": "sha512-86U6sVVVf4b5NYSZ0yvv88dRgBSSXXmHaiq5pP4KDj5JVzdwKgBjEtUPOm8hcoytezFwbU+7gotXNhpHdystlg==", + "dev": true, + "requires": { + "cosmiconfig": "^5.0.7", + "execa": "^1.0.0", + "find-up": "^3.0.0", + "get-stdin": "^6.0.0", + "is-ci": "^2.0.0", + "pkg-dir": "^3.0.0", + "please-upgrade-node": "^3.1.1", + "read-pkg": "^4.0.1", + "run-node": "^1.0.0", + "slash": "^2.0.0" + }, + "dependencies": { + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "requires": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==", + "dev": true, + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + } + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true + }, + "slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "hyphenate-style-name": { "version": "1.0.4" }, @@ -59401,6 +60535,12 @@ } } }, + "javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "dev": true + }, "jest": { "version": "29.4.1", "requires": { @@ -61973,10 +63113,28 @@ "object-visit": "^1.0.0" } }, + "markdown-builder": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/markdown-builder/-/markdown-builder-0.9.0.tgz", + "integrity": "sha512-UovCyEEzMeKE7l88fbOk9SIJkOG7KXkg+TdudN8rvOtCtBO5uu1X27HSnM7LS/xH+vaShJLGpkBcYYcojWNx/g==", + "dev": true, + "requires": { + "husky": "^1.0.0-rc.14" + } + }, "markdown-escapes": { "version": "1.0.4", "dev": true }, + "markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "dev": true, + "requires": { + "repeat-string": "^1.0.0" + } + }, "matcher": { "version": "3.0.0", "dev": true, @@ -61984,6 +63142,23 @@ "escape-string-regexp": "^4.0.0" } }, + "mathjs": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-11.8.0.tgz", + "integrity": "sha512-I7r8HCoqUGyEiHQdeOCF2m2k9N+tcOHO3cZQ3tyJkMMBQMFqMR7dMQEboBMJAiFW2Um3PEItGPwcOc4P6KRqwg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.21.0", + "complex.js": "^2.1.1", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^4.2.0", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.1.0" + } + }, "md5.js": { "version": "1.3.5", "requires": { @@ -64173,6 +65348,15 @@ } } }, + "please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "requires": { + "semver-compare": "^1.0.0" + } + }, "plist": { "version": "3.0.6", "requires": { @@ -65076,9 +66260,9 @@ "from": "react-native-image-size@git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972" }, "react-native-key-command": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.0.tgz", - "integrity": "sha512-gjtzvJmgssKQ6YWoSiIIM37N/8fxtEUpvrwMZL9YTOg+WSTyJP5C9jIkHiT0KgMmfBylxwoJOCjche9TiNcdDQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-1.0.1.tgz", + "integrity": "sha512-vubmDxRnRQh+t2IqBUrbXOCVjkXYSQKJ+YAD1attcOV4mDHcQ0MB/Q4kxXzqVcLAlNPWMETFsJNShvt2cwO03Q==", "requires": { "events": "^3.3.0", "underscore": "^1.13.4" @@ -65098,9 +66282,9 @@ } }, "react-native-onyx": { - "version": "1.0.41", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.41.tgz", - "integrity": "sha512-+TJlUIEWzy4yGnw4IJx9RvDYC77PL7bLGAejALzv3rXnvtz70dCP/kxb+Cn5dPto1+795c50TxGTW5X48xjpZw==", + "version": "1.0.43", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.43.tgz", + "integrity": "sha512-NwS1SxZJWhk/7FUAAE9HrnupQR1yrSAheuhggdeA3+oFLn9X6UJM7n7w9DodFqCQbUIUy9biKtYk29sChfk9hQ==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -65752,6 +66936,17 @@ "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==" }, + "reassure": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.9.0.tgz", + "integrity": "sha512-FIf0GPchyPGItsrW5Wwff/NWVrfOcCUuJJSs4Nur6iRdQt8yvmCpcba4UyemdZ1KaFTIW1gKbAV3u2tuA7zmtQ==", + "dev": true, + "requires": { + "@callstack/reassure-cli": "0.9.0", + "@callstack/reassure-danger": "0.1.1", + "@callstack/reassure-measure": "0.5.0" + } + }, "recast": { "version": "0.20.5", "resolved": "https://registry.npmjs.org/recast/-/recast-0.20.5.tgz", @@ -65794,7 +66989,9 @@ } }, "regenerator-runtime": { - "version": "0.13.9" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "regenerator-transform": { "version": "0.15.0", @@ -66142,6 +67339,12 @@ "version": "2.4.1", "dev": true }, + "run-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/run-node/-/run-node-1.0.0.tgz", + "integrity": "sha512-kc120TBlQ3mih1LSzdAJXo4xn/GWS2ec0l3S+syHDXP9uRr0JAT8Qd3mdMuyjqCzeZktgP3try92cEgf9Nks8A==", + "dev": true + }, "run-parallel": { "version": "1.2.0", "dev": true, @@ -66697,6 +67900,17 @@ "signal-exit": { "version": "3.0.7" }, + "simple-git": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.19.0.tgz", + "integrity": "sha512-hyH2p9Ptxjf/xPuL7HfXbpYt9gKhC1yWDh3KYIAYJJePAKV7AEjLN4xhp7lozOdNiaJ9jlVvAbBymVlcS2jRiA==", + "dev": true, + "requires": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.4" + } + }, "simple-plist": { "version": "1.3.1", "requires": { @@ -67974,6 +69188,12 @@ "mime-types": "~2.1.24" } }, + "typed-function": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.1.0.tgz", + "integrity": "sha512-DGwUl6cioBW5gw2L+6SMupGwH/kZOqivy17E4nsh1JI9fKF87orMmlQx3KISQPmg3sfnOUGlwVkroosvgddrlg==", + "dev": true + }, "typedarray": { "version": "0.0.6", "dev": true @@ -69389,6 +70609,12 @@ "yocto-queue": { "version": "0.1.0" }, + "zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "dev": true + }, "zwitch": { "version": "1.0.5", "dev": true diff --git a/package.json b/package.json index 8299a83c6844..118c8e1dabdb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.16-6", + "version": "1.3.22-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -32,7 +32,7 @@ "lint": "eslint . --max-warnings=0 --cache --cache-location=node_modules/.cache/eslint", "lint-watch": "npx eslint-watch --watch --changed", "shellcheck": "./scripts/shellCheck.sh", - "prettier": "prettier --write \"**/*.js\"", + "prettier": "prettier --write .", "prettier-watch": "onchange \"**/*.js\" -- prettier --write --ignore-unknown {{changed}}", "print-version": "echo $npm_package_version", "storybook": "start-storybook -p 6006", @@ -70,13 +70,13 @@ "@react-navigation/native": "6.0.13", "@react-navigation/stack": "6.3.1", "@react-ng/bounds-observer": "^0.2.1", - "@ua/react-native-airship": "^15.2.3", + "@ua/react-native-airship": "^15.2.6", "awesome-phonenumber": "^5.4.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#c898563fe851d9a4d594fa9afbdd1ddab5971636", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", @@ -110,10 +110,10 @@ "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972", - "react-native-key-command": "^1.0.0", + "react-native-key-command": "^1.0.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.41", + "react-native-onyx": "1.0.43", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", @@ -206,6 +206,7 @@ "react-native-performance-flipper-reporter": "^2.0.0", "react-native-svg-transformer": "^1.0.0", "react-test-renderer": "18.1.0", + "reassure": "^0.9.0", "setimmediate": "^1.0.5", "shellcheck": "^1.1.0", "style-loader": "^2.0.0", diff --git a/src/CONST.js b/src/CONST.js index a04c20192037..4557c66a42cc 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -22,6 +22,8 @@ const keyInputEscape = lodashGet(KeyCommand, 'constants.keyInputEscape', 'keyInp const keyInputEnter = lodashGet(KeyCommand, 'constants.keyInputEnter', 'keyInputEnter'); const keyInputUpArrow = lodashGet(KeyCommand, 'constants.keyInputUpArrow', 'keyInputUpArrow'); const keyInputDownArrow = lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow'); +const keyInputLeftArrow = lodashGet(KeyCommand, 'constants.keyInputLeftArrow', 'keyInputLeftArrow'); +const keyInputRightArrow = lodashGet(KeyCommand, 'constants.keyInputRightArrow', 'keyInputRightArrow'); // describes if a shortcut key can cause navigation const KEYBOARD_SHORTCUT_NAVIGATION_TYPE = 'NAVIGATION_SHORTCUT'; @@ -398,6 +400,26 @@ const CONST = { [PLATFORM_IOS]: {input: keyInputDownArrow}, }, }, + ARROW_LEFT: { + descriptionKey: null, + shortcutKey: 'ArrowLeft', + modifiers: [], + trigger: { + DEFAULT: {input: keyInputLeftArrow}, + [PLATFORM_OS_MACOS]: {input: keyInputLeftArrow}, + [PLATFORM_IOS]: {input: keyInputLeftArrow}, + }, + }, + ARROW_RIGHT: { + descriptionKey: null, + shortcutKey: 'ArrowRight', + modifiers: [], + trigger: { + DEFAULT: {input: keyInputRightArrow}, + [PLATFORM_OS_MACOS]: {input: keyInputRightArrow}, + [PLATFORM_IOS]: {input: keyInputRightArrow}, + }, + }, TAB: { descriptionKey: null, shortcutKey: 'Tab', @@ -579,6 +601,11 @@ const CONST = { DAILY: 'daily', ALWAYS: 'always', }, + // Options for which room members can post + WRITE_CAPABILITIES: { + ALL: 'all', + ADMINS: 'admins', + }, VISIBILITY: { PUBLIC: 'public', PUBLIC_ANNOUNCE: 'public_announce', @@ -701,6 +728,13 @@ const CONST = { DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, DEFAULT_CLOSE_ACCOUNT_DATA: {error: '', success: '', isLoading: false}, + FORMS: { + LOGIN_FORM: 'LoginForm', + VALIDATE_CODE_FORM: 'ValidateCodeForm', + VALIDATE_TFA_CODE_FORM: 'ValidateTfaCodeForm', + RESEND_VALIDATION_FORM: 'ResendValidationForm', + UNLINK_LOGIN_FORM: 'UnlinkLoginForm', + }, APP_STATE: { ACTIVE: 'active', BACKGROUND: 'background', @@ -765,6 +799,8 @@ const CONST = { }, ATTACHMENT_MESSAGE_TEXT: '[Attachment]', + // This is a placeholder for attachment which is uploading + ATTACHMENT_UPLOADING_MESSAGE_HTML: 'Uploading attachment...', ATTACHMENT_SOURCE_ATTRIBUTE: 'data-expensify-source', ATTACHMENT_PREVIEW_ATTRIBUTE: 'src', ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE: 'data-name', @@ -801,6 +837,7 @@ const CONST = { SMALL_CONTAINER_HEIGHT_FACTOR: 2.5, MIN_AMOUNT_OF_ITEMS: 3, MAX_AMOUNT_OF_ITEMS: 5, + HERE_TEXT: '@here', }, COMPOSER_MAX_HEIGHT: 125, CHAT_FOOTER_MIN_HEIGHT: 65, @@ -1049,7 +1086,7 @@ const CONST = { SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g, DIGITS_AND_PLUS: /^\+?[0-9]*$/, ALPHABETIC_CHARS: /[a-zA-Z]+/, - ALPHABETIC_CHARS_WITH_NUMBER: /^[a-zA-Z0-9 ]*$/, + ALPHABETIC_CHARS_WITH_NUMBER: /^[a-zA-ZÀ-ÿ0-9 ]*$/, POSITIVE_INTEGER: /^\d+$/, PO_BOX: /\b[P|p]?(OST|ost)?\.?\s*[O|o|0]?(ffice|FFICE)?\.?\s*[B|b][O|o|0]?[X|x]?\.?\s+[#]?(\d+)\b/, ANY_VALUE: /^.+$/, @@ -1080,16 +1117,23 @@ const CONST = { HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/, HAS_AT_MOST_TWO_AT_SIGNS: /^@[^@]*@?[^@]*$/, - // eslint-disable-next-line no-misleading-character-class - NEW_LINE_OR_WHITE_SPACE_OR_EMOJI: /[\n\s\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, + SPECIAL_CHAR_OR_EMOJI: + // eslint-disable-next-line no-misleading-character-class + /[\n\s,/?"{}[\]()&^%$#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, // Define the regular expression pattern to match a string starting with a colon and ending with a space or newline character EMOJI_REPLACER: /^:[^\n\r]+?(?=$|\s)/, // Define the regular expression pattern to match a string starting with an at sign and ending with a space or newline character - MENTION_REPLACER: /^@[^\n\r]*?(?=$|\s)/, + MENTION_REPLACER: + // eslint-disable-next-line no-misleading-character-class + /^@[^\n\r]*?(?=$|[\s,/?"{}[\]()&^%$#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3)/u, MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/, + + ROUTES: { + VALIDATE_LOGIN: /\/v($|(\/\/*))/, + }, }, PRONOUNS: { @@ -1165,6 +1209,7 @@ const CONST = { MEMBERS: 'member', SETTINGS: 'settings', LEAVE_ROOM: 'leaveRoom', + WELCOME_MESSAGE: 'welcomeMessage', }, FOOTER: { @@ -2381,10 +2426,23 @@ const CONST = { ACTIVE: 'active', DISABLED: 'disabled', }, + SPACE_CHARACTER_WIDTH: 4, // This ID is used in SelectionScraper.js to query the DOM for UnreadActionIndicator's // div and then remove it from copied contents in the getHTMLOfSelection() method. UNREAD_ACTION_INDICATOR_ID: 'no-copy-area-unread-action-indicator', + MODERATION: { + MODERATOR_DECISION_PENDING: 'pending', + MODERATOR_DECISION_PENDING_HIDE: 'pendingHide', + MODERATOR_DECISION_APPROVED: 'approved', + MODERATOR_DECISION_HIDDEN: 'hidden', + FLAG_SEVERITY_SPAM: 'spam', + FLAG_SEVERITY_INCONSIDERATE: 'inconsiderate', + FLAG_SEVERITY_INTIMIDATION: 'intimidation', + FLAG_SEVERITY_BULLYING: 'bullying', + FLAG_SEVERITY_HARASSMENT: 'harassment', + FLAG_SEVERITY_ASSAULT: 'assault', + }, }; export default CONST; diff --git a/src/Expensify.js b/src/Expensify.js index 4fc000302ce7..e7c830ff2029 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -70,6 +70,9 @@ const propTypes = { roomName: PropTypes.string, }), + /** Whether the app is waiting for the server's response to determine if a room is public */ + isCheckingPublicRoom: PropTypes.bool, + ...withLocalizePropTypes, }; @@ -81,6 +84,7 @@ const defaultProps = { updateAvailable: false, isSidebarLoaded: false, screenShareRequest: null, + isCheckingPublicRoom: true, }; function Expensify(props) { @@ -88,9 +92,15 @@ function Expensify(props) { const [isNavigationReady, setIsNavigationReady] = useState(false); const [isOnyxMigrated, setIsOnyxMigrated] = useState(false); const [isSplashHidden, setIsSplashHidden] = useState(false); + const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false); + + useEffect(() => { + if (props.isCheckingPublicRoom) return; + setAttemptedToOpenPublicRoom(true); + }, [props.isCheckingPublicRoom]); const isAuthenticated = useMemo(() => Boolean(lodashGet(props.session, 'authToken', null)), [props.session]); - const shouldInit = isNavigationReady && (!isAuthenticated || props.isSidebarLoaded); + const shouldInit = isNavigationReady && (!isAuthenticated || props.isSidebarLoaded) && hasAttemptedToOpenPublicRoom; const shouldHideSplash = shouldInit && !isSplashHidden; const initializeClient = () => { @@ -151,10 +161,10 @@ function Expensify(props) { appStateChangeListener.current = AppState.addEventListener('change', initializeClient); // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report - Linking.getInitialURL().then((url) => Report.openReportFromDeepLink(url)); + Linking.getInitialURL().then((url) => Report.openReportFromDeepLink(url, isAuthenticated)); // Open chat report from a deep link (only mobile native) - Linking.addEventListener('url', (state) => Report.openReportFromDeepLink(state.url)); + Linking.addEventListener('url', (state) => Report.openReportFromDeepLink(state.url, isAuthenticated)); return () => { if (!appStateChangeListener.current) { @@ -193,10 +203,12 @@ function Expensify(props) { )} - + {hasAttemptedToOpenPublicRoom && ( + + )} {shouldHideSplash && } @@ -208,6 +220,10 @@ Expensify.defaultProps = defaultProps; export default compose( withLocalize, withOnyx({ + isCheckingPublicRoom: { + key: ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, + initWithStoredValues: false, + }, session: { key: ONYXKEYS.SESSION, }, diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 1805db98f1d5..8cbf4a726f95 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -194,10 +194,12 @@ export default { ADD_DEBIT_CARD_FORM: 'addDebitCardForm', REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', + WORKSPACE_RATE_AND_UNIT_FORM: 'workspaceRateAndUnitForm', CLOSE_ACCOUNT_FORM: 'closeAccount', PROFILE_SETTINGS_FORM: 'profileSettingsForm', DISPLAY_NAME_FORM: 'displayNameForm', ROOM_NAME_FORM: 'roomNameForm', + WELCOME_MESSAGE_FORM: 'welcomeMessageForm', LEGAL_NAME_FORM: 'legalNameForm', WORKSPACE_INVITE_MESSAGE_FORM: 'workspaceInviteMessageForm', DATE_OF_BIRTH_FORM: 'dateOfBirthForm', @@ -219,6 +221,12 @@ export default { // Whether the auth token is valid IS_TOKEN_VALID: 'isTokenValid', + // Whether we're checking if the room is public or not + IS_CHECKING_PUBLIC_ROOM: 'isCheckingPublicRoom', + // A map of the user's security group IDs they belong to in specific domains MY_DOMAIN_SECURITY_GROUPS: 'myDomainSecurityGroups', + + // Report ID of the last report the user viewed as anonymous user + LAST_OPENED_PUBLIC_ROOM_ID: 'lastOpenedPublicRoomID', }; diff --git a/src/ROUTES.js b/src/ROUTES.js index 2dc2338f2e05..c9075c42d8e1 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -9,7 +9,6 @@ const REPORT = 'r'; const IOU_REQUEST = 'request/new'; const IOU_BILL = 'split/new'; const IOU_SEND = 'send/new'; -const IOU_DETAILS = 'iou/details'; const IOU_REQUEST_CURRENCY = `${IOU_REQUEST}/currency`; const IOU_BILL_CURRENCY = `${IOU_BILL}/currency`; const IOU_SEND_CURRENCY = `${IOU_SEND}/currency`; @@ -94,12 +93,8 @@ export default { getIouRequestCurrencyRoute: (reportID, currency, backTo) => `${IOU_REQUEST_CURRENCY}/${reportID}?currency=${currency}&backTo=${backTo}`, getIouBillCurrencyRoute: (reportID, currency, backTo) => `${IOU_BILL_CURRENCY}/${reportID}?currency=${currency}&backTo=${backTo}`, getIouSendCurrencyRoute: (reportID, currency, backTo) => `${IOU_SEND_CURRENCY}/${reportID}?currency=${currency}&backTo=${backTo}`, - IOU_DETAILS, - IOU_DETAILS_ADD_BANK_ACCOUNT: `${IOU_DETAILS}/add-bank-account`, - IOU_DETAILS_ADD_DEBIT_CARD: `${IOU_DETAILS}/add-debit-card`, - IOU_DETAILS_ENABLE_PAYMENTS: `${IOU_DETAILS}/enable-payments`, - IOU_DETAILS_WITH_IOU_REPORT_ID: `${IOU_DETAILS}/:chatReportID/:iouReportID/`, - getIouDetailsRoute: (chatReportID, iouReportID) => `iou/details/${chatReportID}/${iouReportID}`, + SPLIT_BILL_DETAILS: `r/:reportID/split/:reportActionID`, + getSplitBillDetailsRoute: (reportID, reportActionID) => `r/${reportID}/split/${reportActionID}`, getNewTaskRoute: (reportID) => `${NEW_TASK}/${reportID}`, NEW_TASK_WITH_REPORT_ID: `${NEW_TASK}/:reportID?`, TASK_TITLE: 'r/:reportID/title', @@ -113,6 +108,8 @@ export default { NEW_TASK_DETAILS: `${NEW_TASK}/details`, NEW_TASK_TITLE: `${NEW_TASK}/title`, NEW_TASK_DESCRIPTION: `${NEW_TASK}/description`, + FLAG_COMMENT: `flag/:reportID/:reportActionID`, + getFlagCommentRoute: (reportID, reportActionID) => `flag/${reportID}/${reportActionID}`, SEARCH: 'search', SET_PASSWORD_WITH_VALIDATE_CODE: 'setpassword/:accountID/:validateCode', DETAILS: 'details', @@ -124,11 +121,15 @@ export default { REPORT_WITH_ID_DETAILS: 'r/:reportID/details', getReportDetailsRoute: (reportID) => `r/${reportID}/details`, REPORT_SETTINGS: 'r/:reportID/settings', - REPORT_SETTINGS_ROOM_NAME: 'r/:reportID/settings/room-name', - REPORT_SETTINGS_NOTIFICATION_PREFERENCES: 'r/:reportID/settings/notification-preferences', getReportSettingsRoute: (reportID) => `r/${reportID}/settings`, + REPORT_SETTINGS_ROOM_NAME: 'r/:reportID/settings/room-name', getReportSettingsRoomNameRoute: (reportID) => `r/${reportID}/settings/room-name`, + REPORT_SETTINGS_NOTIFICATION_PREFERENCES: 'r/:reportID/settings/notification-preferences', getReportSettingsNotificationPreferencesRoute: (reportID) => `r/${reportID}/settings/notification-preferences`, + REPORT_WELCOME_MESSAGE: 'r/:reportID/welcomeMessage', + getReportWelcomeMessageRoute: (reportID) => `r/${reportID}/welcomeMessage`, + REPORT_SETTINGS_WRITE_CAPABILITY: 'r/:reportID/settings/who-can-post', + getReportSettingsWriteCapabilityRoute: (reportID) => `r/${reportID}/settings/who-can-post`, TRANSITION_FROM_OLD_DOT: 'transition', VALIDATE_LOGIN: 'v/:accountID/:validateCode', GET_ASSISTANCE: 'get-assistance/:taskID', @@ -147,6 +148,7 @@ export default { WORKSPACE_SETTINGS: 'workspace/:policyID/settings', WORKSPACE_CARD: 'workspace/:policyID/card', WORKSPACE_REIMBURSE: 'workspace/:policyID/reimburse', + WORKSPACE_RATE_AND_UNIT: 'workspace/:policyID/rateandunit', WORKSPACE_BILLS: 'workspace/:policyID/bills', WORKSPACE_INVOICES: 'workspace/:policyID/invoices', WORKSPACE_TRAVEL: 'workspace/:policyID/travel', @@ -158,6 +160,7 @@ export default { getWorkspaceSettingsRoute: (policyID) => `workspace/${policyID}/settings`, getWorkspaceCardRoute: (policyID) => `workspace/${policyID}/card`, getWorkspaceReimburseRoute: (policyID) => `workspace/${policyID}/reimburse`, + getWorkspaceRateAndUnitRoute: (policyID) => `workspace/${policyID}/rateandunit`, getWorkspaceBillsRoute: (policyID) => `workspace/${policyID}/bills`, getWorkspaceInvoicesRoute: (policyID) => `workspace/${policyID}/invoices`, getWorkspaceTravelRoute: (policyID) => `workspace/${policyID}/travel`, diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js index 58935ee9d3a6..3886b8fab88e 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js @@ -44,13 +44,14 @@ const BaseAnchorForCommentsOnly = (props) => { linkProps.href = props.href; } const defaultTextStyle = DeviceCapabilities.canUseTouchScreen() || props.isSmallScreenWidth ? {} : styles.userSelectText; + const isEmail = Str.isValidEmailMarkdown(props.href.replace(/mailto:/i, '')); return ( { ReportActionContextMenu.showContextMenu( - Str.isValidEmailMarkdown(props.displayName) ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, + isEmail ? ContextMenuActions.CONTEXT_MENU_TYPES.EMAIL : ContextMenuActions.CONTEXT_MENU_TYPES.LINK, event, props.href, lodashGet(linkRef, 'current'), @@ -60,17 +61,14 @@ const BaseAnchorForCommentsOnly = (props) => { onPressIn={props.onPressIn} onPressOut={props.onPressOut} > - + (linkRef = el)} style={StyleSheet.flatten([props.style, defaultTextStyle])} accessibilityRole="link" hrefAttrs={{ rel: props.rel, - target: props.target, + target: isEmail ? '_self' : props.target, }} href={linkProps.href} // Add testID so it gets selected as an anchor tag by SelectionScraper diff --git a/src/components/AnonymousReportFooter.js b/src/components/AnonymousReportFooter.js new file mode 100644 index 000000000000..2b92c20d127c --- /dev/null +++ b/src/components/AnonymousReportFooter.js @@ -0,0 +1,58 @@ +import React from 'react'; +import {View, Text} from 'react-native'; +import Button from './Button'; +import AvatarWithDisplayName from './AvatarWithDisplayName'; +import ExpensifyWordmark from './ExpensifyWordmark'; +import compose from '../libs/compose'; +import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; +import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import reportPropTypes from '../pages/reportPropTypes'; +import CONST from '../CONST'; +import styles from '../styles/styles'; +import * as Session from '../libs/actions/Session'; + +const propTypes = { + /** The report currently being looked at */ + report: reportPropTypes, + + ...windowDimensionsPropTypes, + ...withLocalizePropTypes, +}; + +const defaultProps = { + report: {}, +}; + +const AnonymousReportFooter = (props) => ( + + + + + + + + + + {props.translate('anonymousReportFooter.logoTagline')} + + + + + +); + +AnonymousReportFooter.propTypes = propTypes; +AnonymousReportFooter.defaultProps = defaultProps; +AnonymousReportFooter.displayName = 'AnonymousReportFooter'; + +export default compose(withWindowDimensions, withLocalize)(AnonymousReportFooter); diff --git a/src/components/AttachmentCarousel/CarouselActions/index.js b/src/components/AttachmentCarousel/CarouselActions/index.js index 9144f0c7d0d1..4ec551daa252 100644 --- a/src/components/AttachmentCarousel/CarouselActions/index.js +++ b/src/components/AttachmentCarousel/CarouselActions/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import {useCallback, useEffect} from 'react'; import PropTypes from 'prop-types'; const propTypes = { @@ -6,42 +6,36 @@ const propTypes = { onCycleThroughAttachments: PropTypes.func.isRequired, }; -class Carousel extends React.Component { - constructor(props) { - super(props); - - this.handleKeyPress = this.handleKeyPress.bind(this); - } - - componentDidMount() { - document.addEventListener('keydown', this.handleKeyPress); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.handleKeyPress); - } - +const Carousel = (props) => { /** * Listens for keyboard shortcuts and applies the action * * @param {Object} e */ - handleKeyPress(e) { + const handleKeyPress = useCallback((e) => { // prevents focus from highlighting around the modal e.target.blur(); + if (e.key === 'ArrowLeft') { - this.props.onCycleThroughAttachments(-1); + props.onCycleThroughAttachments(-1); } if (e.key === 'ArrowRight') { - this.props.onCycleThroughAttachments(1); + props.onCycleThroughAttachments(1); } - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + document.addEventListener('keydown', handleKeyPress); - render() { - // This component is only used to listen for keyboard events - return null; - } -} + return () => { + document.removeEventListener('keydown', handleKeyPress); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +}; Carousel.propTypes = propTypes; diff --git a/src/components/AttachmentCarousel/CarouselActions/index.native.js b/src/components/AttachmentCarousel/CarouselActions/index.native.js index 69df7784141c..d12cd6bfbb60 100644 --- a/src/components/AttachmentCarousel/CarouselActions/index.native.js +++ b/src/components/AttachmentCarousel/CarouselActions/index.native.js @@ -1,4 +1,45 @@ -// No need to implement this in native, because all the native actions (swiping) are handled by the parent component -const Carousel = () => {}; +import {useEffect} from 'react'; +import PropTypes from 'prop-types'; +import KeyboardShortcut from '../../../libs/KeyboardShortcut'; +import CONST from '../../../CONST'; + +const propTypes = { + /** Callback to cycle through attachments */ + onCycleThroughAttachments: PropTypes.func.isRequired, +}; + +const Carousel = (props) => { + useEffect(() => { + const shortcutLeftConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT; + const unsubscribeLeftKey = KeyboardShortcut.subscribe( + shortcutLeftConfig.shortcutKey, + () => { + props.onCycleThroughAttachments(-1); + }, + shortcutLeftConfig.descriptionKey, + shortcutLeftConfig.modifiers, + ); + + const shortcutRightConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT; + const unsubscribeRightKey = KeyboardShortcut.subscribe( + shortcutRightConfig.shortcutKey, + () => { + props.onCycleThroughAttachments(1); + }, + shortcutRightConfig.descriptionKey, + shortcutRightConfig.modifiers, + ); + + return () => { + unsubscribeLeftKey(); + unsubscribeRightKey(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +}; + +Carousel.propTypes = propTypes; export default Carousel; diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js index 380f865394ce..6ae3214cd32a 100644 --- a/src/components/AttachmentCarousel/index.js +++ b/src/components/AttachmentCarousel/index.js @@ -3,6 +3,7 @@ import {View, FlatList, PixelRatio} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import {Parser as HtmlParser} from 'htmlparser2'; import * as Expensicons from '../Icon/Expensicons'; import styles from '../../styles/styles'; import themeColors from '../../styles/themes/default'; @@ -10,7 +11,6 @@ import CarouselActions from './CarouselActions'; import Button from '../Button'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; import AttachmentView from '../AttachmentView'; -import addEncryptedAuthTokenToURL from '../../libs/addEncryptedAuthTokenToURL'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import CONST from '../../CONST'; import ONYXKEYS from '../../ONYXKEYS'; @@ -62,28 +62,13 @@ class AttachmentCarousel extends React.Component { this.updateZoomState = this.updateZoomState.bind(this); this.toggleArrowsVisibility = this.toggleArrowsVisibility.bind(this); - this.state = this.makeInitialState(); + this.state = this.createInitialState(); } componentDidMount() { this.autoHideArrow(); } - /** - * Helps to navigate between next/previous attachments - * @param {Object} attachmentItem - * @returns {Object} - */ - getAttachment(attachmentItem) { - const source = _.get(attachmentItem, 'source', ''); - const file = _.get(attachmentItem, 'file', {name: ''}); - - return { - source, - file, - }; - } - /** * Calculate items layout information to optimize scrolling performance * @param {*} data @@ -159,39 +144,39 @@ class AttachmentCarousel extends React.Component { } /** - * Map report actions to attachment items and sets the initial carousel state + * Constructs the initial component state from report actions * @returns {{page: Number, attachments: Array, shouldShowArrow: Boolean, containerWidth: Number, isZoomed: Boolean}} */ - makeInitialState() { - let page = 0; - const actions = ReportActionsUtils.getSortedReportActions(_.values(this.props.reportActions), true); - - /** - * Looping to filter out attachments and retrieve the src URL and name of attachments. - */ + createInitialState() { + const actions = ReportActionsUtils.getSortedReportActions(_.values(this.props.reportActions)); const attachments = []; - _.forEach(actions, ({originalMessage, message}) => { - // Check for attachment which hasn't been deleted - if (!originalMessage || !originalMessage.html || _.some(message, (m) => m.isEdited)) { - return; - } - const matches = [...originalMessage.html.matchAll(CONST.REGEX.ATTACHMENT_DATA)]; - - // matchAll captured both source url and name of the attachment - if (matches.length === 2) { - const [originalSource, name] = _.map(matches, (m) => m[2]); - - // Update the image URL so the images can be accessed depending on the config environment. - // Eg: while using Ngrok the image path is from an Ngrok URL and not an Expensify URL. - const source = tryResolveUrlFromApiRoot(originalSource); - if (source === this.props.source) { - page = attachments.length; + + const htmlParser = new HtmlParser({ + onopentag: (name, attribs) => { + if (name !== 'img' || !attribs.src) { + return; } - attachments.push({source, file: {name}}); - } + const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; + + // By iterating actions in chronological order and prepending each attachment + // we ensure correct order of attachments even across actions with multiple attachments. + attachments.unshift({ + source: tryResolveUrlFromApiRoot(expensifySource || attribs.src), + isAuthTokenRequired: Boolean(expensifySource), + file: {name: attribs[CONST.ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE] || attribs.src.split('/').pop()}, + }); + }, }); + _.forEach(actions, (action) => htmlParser.write(_.get(action, ['message', 0, 'html']))); + htmlParser.end(); + + const page = _.findIndex(attachments, (a) => a.source === this.props.source); + if (page === -1) { + throw new Error('Attachment not found'); + } + return { page, attachments, @@ -209,7 +194,7 @@ class AttachmentCarousel extends React.Component { const nextIndex = this.state.page - deltaSlide; const nextItem = this.state.attachments[nextIndex]; - if (!nextItem) { + if (!nextItem || !this.scrollRef.current) { return; } @@ -220,7 +205,7 @@ class AttachmentCarousel extends React.Component { /** * Updates the page state when the user navigates between attachments - * @param {Array<{item: *, index: Number}>} viewableItems + * @param {Array<{item: {source, file}, index: Number}>} viewableItems */ updatePage({viewableItems}) { // Since we can have only one item in view at a time, we can use the first item in the array @@ -231,8 +216,7 @@ class AttachmentCarousel extends React.Component { } const page = entry.index; - const {source, file} = this.getAttachment(entry.item); - this.props.onNavigate({source: addEncryptedAuthTokenToURL(source), file}); + this.props.onNavigate(entry.item); this.setState({page, isZoomed: false}); } @@ -258,26 +242,17 @@ class AttachmentCarousel extends React.Component { /** * Defines how a single attachment should be rendered - * @param {{ source: String, file: { name: String } }} item + * @param {{ isAuthTokenRequired: Boolean, source: String, file: { name: String } }} item * @returns {JSX.Element} */ renderItem({item}) { - const authSource = addEncryptedAuthTokenToURL(item.source); - if (!this.canUseTouchScreen) { - return ( - - ); - } - return ( ); } @@ -296,8 +271,8 @@ class AttachmentCarousel extends React.Component { {this.state.shouldShowArrow && ( <> {!isBackDisabled && ( - - + + - - + + )} {!isForwardDisabled && ( - - + + - - + + )} )} diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index ea5f5baa1558..24a6ecfb3152 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -22,6 +22,7 @@ import withLocalize, {withLocalizePropTypes} from './withLocalize'; import ConfirmModal from './ConfirmModal'; import HeaderGap from './HeaderGap'; import SafeAreaConsumer from './SafeAreaConsumer'; +import addEncryptedAuthTokenToURL from '../libs/addEncryptedAuthTokenToURL'; /** * Modal render prop component that exposes modal launching triggers that can be used @@ -84,6 +85,7 @@ class AttachmentModal extends PureComponent { isModalOpen: false, shouldLoadAttachment: false, isAttachmentInvalid: false, + isAuthTokenRequired: props.isAuthTokenRequired, attachmentInvalidReasonTitle: null, attachmentInvalidReason: null, source: props.source, @@ -100,17 +102,17 @@ class AttachmentModal extends PureComponent { this.submitAndClose = this.submitAndClose.bind(this); this.closeConfirmModal = this.closeConfirmModal.bind(this); this.onNavigate = this.onNavigate.bind(this); + this.downloadAttachment = this.downloadAttachment.bind(this); this.validateAndDisplayFileToUpload = this.validateAndDisplayFileToUpload.bind(this); this.updateConfirmButtonVisibility = this.updateConfirmButtonVisibility.bind(this); } /** - * Helps to navigate between next/previous attachments - * by setting sourceURL and file in state - * @param {Object} attachmentData + * Keeps the attachment source in sync with the attachment displayed currently in the carousel. + * @param {{ source: String, isAuthTokenRequired: Boolean, file: { name: string } }} attachment */ - onNavigate(attachmentData) { - this.setState(attachmentData); + onNavigate(attachment) { + this.setState(attachment); } /** @@ -126,11 +128,15 @@ class AttachmentModal extends PureComponent { } /** - * @param {String} sourceURL + * Download the currently viewed attachment. */ - downloadAttachment(sourceURL) { - const originalFileName = lodashGet(this.state, 'file.name') || this.props.originalFileName; - fileDownload(sourceURL, originalFileName); + downloadAttachment() { + let sourceURL = this.state.source; + if (this.state.isAuthTokenRequired) { + sourceURL = addEncryptedAuthTokenToURL(sourceURL); + } + + fileDownload(sourceURL, this.state.file.name); // At ios, if the keyboard is open while opening the attachment, then after downloading // the attachment keyboard will show up. So, to fix it we need to dismiss the keyboard. @@ -250,7 +256,7 @@ class AttachmentModal extends PureComponent { } render() { - const source = this.state.source; + const source = this.props.source || this.state.source; return ( <> this.downloadAttachment(source)} + onDownloadButtonPress={this.downloadAttachment} onCloseButtonPress={() => this.setState({isModalOpen: false})} /> @@ -286,12 +292,12 @@ class AttachmentModal extends PureComponent { onToggleKeyboard={this.updateConfirmButtonVisibility} /> ) : ( - Boolean(this.state.source) && + Boolean(source) && this.state.shouldLoadAttachment && ( diff --git a/src/components/AttachmentPicker/index.js b/src/components/AttachmentPicker/index.js index 42c8e6928961..fb2e95b88317 100644 --- a/src/components/AttachmentPicker/index.js +++ b/src/components/AttachmentPicker/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useRef} from 'react'; import CONST from '../../CONST'; import {propTypes, defaultProps} from './attachmentPickerPropTypes'; import * as FileUtils from '../../libs/fileDownload/FileUtils'; @@ -22,44 +22,46 @@ function getAcceptableFileTypes(type) { * a callback. This is the web/mWeb/desktop version since * on a Browser we must append a hidden input to the DOM * and listen to onChange event. + * @param {propTypes} props + * @returns {JSX.Element} */ -class AttachmentPicker extends React.Component { - render() { - return ( - <> - + )} {props.shouldShowLoadingSpinnerIcon && ( diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js index 6a3e766175a4..b5758680619a 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js @@ -44,6 +44,7 @@ const BaseAutoCompleteSuggestions = (props) => { ); const rowHeight = measureHeightOfSuggestionRows(props.suggestions.length, props.isSuggestionPickerLarge); + const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.ITEM_HEIGHT * props.suggestions.length; return ( { renderItem={renderSuggestionMenuItem} keyExtractor={props.keyExtractor} removeClippedSubviews={false} - style={{height: rowHeight}} + showsVerticalScrollIndicator={innerHeight > rowHeight} + style={{flex: 1}} /> ); diff --git a/src/components/Avatar.js b/src/components/Avatar.js index b2d90bddee17..66a1b60c3cef 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -11,7 +11,7 @@ import * as Expensicons from './Icon/Expensicons'; import Image from './Image'; import styles from '../styles/styles'; import * as ReportUtils from '../libs/ReportUtils'; -import useOnNetworkReconnect from './hooks/useOnNetworkReconnect'; +import useOnNetworkReconnect from '../hooks/useOnNetworkReconnect'; const propTypes = { /** Source for the avatar. Can be a URL or an icon. */ diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js index 7f5104e22130..025a0aa697ea 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.js +++ b/src/components/AvatarCropModal/AvatarCropModal.js @@ -410,13 +410,15 @@ const AvatarCropModal = (props) => { text={props.translate('common.rotate')} shiftVertical={-2} > - + + + diff --git a/src/components/AvatarWithDisplayName.js b/src/components/AvatarWithDisplayName.js index df6dc10b2cf8..6da7f00f5637 100644 --- a/src/components/AvatarWithDisplayName.js +++ b/src/components/AvatarWithDisplayName.js @@ -16,6 +16,7 @@ import DisplayNames from './DisplayNames'; import compose from '../libs/compose'; import * as OptionsListUtils from '../libs/OptionsListUtils'; import Text from './Text'; +import * as StyleUtils from '../styles/StyleUtils'; const propTypes = { /** The report currently being looked at */ @@ -33,6 +34,9 @@ const propTypes = { /** Personal details of all the users */ personalDetails: PropTypes.objectOf(participantPropTypes), + /** Whether if it's an unauthenticated user */ + isAnonymous: PropTypes.bool, + ...windowDimensionsPropTypes, ...withLocalizePropTypes, }; @@ -40,17 +44,19 @@ const propTypes = { const defaultProps = { personalDetails: {}, policies: {}, - report: null, + report: {}, + isAnonymous: false, size: CONST.AVATAR_SIZE.DEFAULT, }; const AvatarWithDisplayName = (props) => { - const title = ReportUtils.getDisplayNameForParticipant(props.report.ownerEmail, true); + const title = props.isAnonymous ? props.report.displayName : ReportUtils.getDisplayNameForParticipant(props.report.ownerEmail, true); const subtitle = ReportUtils.getChatRoomSubtitle(props.report); const isExpenseReport = ReportUtils.isExpenseReport(props.report); const icons = ReportUtils.getIcons(props.report, props.personalDetails, props.policies); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForLogins([props.report.ownerEmail], props.personalDetails); const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(ownerPersonalDetails, false); + const avatarContainerStyle = StyleUtils.getEmptyAvatarStyle(props.size) || styles.emptyAvatar; return ( {Boolean(props.report && title) && ( @@ -70,7 +76,7 @@ const AvatarWithDisplayName = (props) => { source={icons[0].source} type={icons[0].type} name={icons[0].name} - containerStyles={props.size === CONST.AVATAR_SIZE.SMALL ? styles.emptyAvatarSmall : styles.emptyAvatar} + containerStyles={avatarContainerStyle} /> )} @@ -79,8 +85,8 @@ const AvatarWithDisplayName = (props) => { displayNamesWithTooltips={displayNamesWithTooltips} tooltipEnabled numberOfLines={1} - textStyles={[styles.headerText, styles.pre]} - shouldUseFullTitle={isExpenseReport} + textStyles={[props.isAnonymous ? styles.headerAnonymousFooter : styles.headerText, styles.pre]} + shouldUseFullTitle={isExpenseReport || props.isAnonymous} /> {!_.isEmpty(subtitle) && ( this.setState({isMenuVisible: true})}> - {this.props.source ? ( - - ) : ( - - )} + + {this.props.source ? ( + + ) : ( + + )} + {({openPicker}) => ( <> - + { const indicatorStyles = [styles.alignItemsCenter, styles.justifyContentCenter, styles.statusIndicator(indicatorColor)]; return ( - - - + + + {(shouldShowErrorIndicator || shouldShowInfoIndicator) && } - - + + ); }; diff --git a/src/components/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.js index d8453cc8a91f..0cee6396c801 100644 --- a/src/components/BlockingViews/BlockingView.js +++ b/src/components/BlockingViews/BlockingView.js @@ -25,22 +25,26 @@ const propTypes = { /** Link message below the subtitle */ link: PropTypes.string, - /** Whether we should show a go back home link */ - shouldShowBackHomeLink: PropTypes.bool, + /** Whether we should show a link to navigate elsewhere */ + shouldShowLink: PropTypes.bool, /** The custom icon width */ iconWidth: PropTypes.number, /** The custom icon height */ iconHeight: PropTypes.number, + + /** Function to call when pressing the navigation link */ + onLinkPress: PropTypes.func, }; const defaultProps = { iconColor: themeColors.offline, - shouldShowBackHomeLink: false, + shouldShowLink: false, link: 'notFound.goBackHome', iconWidth: variables.iconSizeSuperLarge, iconHeight: variables.iconSizeSuperLarge, + onLinkPress: () => Navigation.dismissModal(true), }; const BlockingView = (props) => ( @@ -53,9 +57,9 @@ const BlockingView = (props) => ( /> {props.title} {props.subtitle} - {props.shouldShowBackHomeLink ? ( + {props.shouldShowLink ? ( Navigation.dismissModal(true)} + onPress={props.onLinkPress} style={[styles.link, styles.mt2]} > {props.link} diff --git a/src/components/BlockingViews/FullPageNotFoundView.js b/src/components/BlockingViews/FullPageNotFoundView.js index 9a6e4ebd3ee3..144d85176fe7 100644 --- a/src/components/BlockingViews/FullPageNotFoundView.js +++ b/src/components/BlockingViews/FullPageNotFoundView.js @@ -31,14 +31,17 @@ const propTypes = { /** Whether we should show a close button */ shouldShowCloseButton: PropTypes.bool, - /** Whether we should show a go back home link */ - shouldShowBackHomeLink: PropTypes.bool, + /** Whether we should show a link to navigate elsewhere */ + shouldShowLink: PropTypes.bool, /** The key in the translations file to use for the go back link */ linkKey: PropTypes.string, /** Method to trigger when pressing the back button of the header */ onBackButtonPress: PropTypes.func, + + /** Function to call when pressing the navigation link */ + onLinkPress: PropTypes.func, }; const defaultProps = { @@ -48,9 +51,10 @@ const defaultProps = { subtitleKey: 'notFound.pageNotFound', linkKey: 'notFound.goBackHome', shouldShowBackButton: true, - shouldShowBackHomeLink: false, + shouldShowLink: false, shouldShowCloseButton: true, onBackButtonPress: () => Navigation.dismissModal(), + onLinkPress: () => Navigation.dismissModal(true), }; // eslint-disable-next-line rulesdir/no-negated-variables @@ -72,7 +76,8 @@ const FullPageNotFoundView = (props) => { title={props.translate(props.titleKey)} subtitle={props.translate(props.subtitleKey)} link={props.translate(props.linkKey)} - shouldShowBackHomeLink={props.shouldShowBackHomeLink} + shouldShowLink={props.shouldShowLink} + onLinkPress={props.onLinkPress} /> diff --git a/src/components/Button/index.js b/src/components/Button/index.js index f9200d085b28..da72356bf53d 100644 --- a/src/components/Button/index.js +++ b/src/components/Button/index.js @@ -111,7 +111,8 @@ const propTypes = { accessibilityLabel: PropTypes.string, /** A ref to forward the button */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.oneOfType([PropTypes.instanceOf(React.Component), PropTypes.func])})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), }; const defaultProps = { @@ -253,7 +254,7 @@ class Button extends Component { if (this.props.shouldEnableHapticFeedback) { HapticFeedback.press(); } - this.props.onPress(e); + return this.props.onPress(e); }} onLongPress={(e) => { if (this.props.shouldEnableHapticFeedback) { diff --git a/src/components/ButtonWithDropdownMenu.js b/src/components/ButtonWithDropdownMenu.js index a518ce647c75..0d7cfc570670 100644 --- a/src/components/ButtonWithDropdownMenu.js +++ b/src/components/ButtonWithDropdownMenu.js @@ -52,7 +52,7 @@ const ButtonWithDropdownMenu = (props) => { const [selectedItemIndex, setSelectedItemIndex] = useState(0); const [isMenuVisible, setIsMenuVisible] = useState(false); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); - const {width: windowWidth, height: windowHeight} = useWindowDimensions(); + const {windowWidth, windowHeight} = useWindowDimensions(); const caretButton = useRef(null); useEffect(() => { if (!caretButton.current) { diff --git a/src/components/CalendarPicker/calendarPickerPropTypes.js b/src/components/CalendarPicker/calendarPickerPropTypes.js index 03c142e4736d..7ccefc784d25 100644 --- a/src/components/CalendarPicker/calendarPickerPropTypes.js +++ b/src/components/CalendarPicker/calendarPickerPropTypes.js @@ -3,8 +3,8 @@ import moment from 'moment'; import CONST from '../../CONST'; const propTypes = { - /** An initial value of date */ - value: PropTypes.objectOf(Date), + /** An initial value of date string */ + value: PropTypes.string, /** A minimum date (oldest) allowed to select */ minDate: PropTypes.objectOf(Date), diff --git a/src/components/CalendarPicker/index.js b/src/components/CalendarPicker/index.js index 39762a10d1d9..cd3679f1b8c6 100644 --- a/src/components/CalendarPicker/index.js +++ b/src/components/CalendarPicker/index.js @@ -19,7 +19,9 @@ class CalendarPicker extends React.PureComponent { constructor(props) { super(props); - let currentDateView = props.value; + let currentSelection = moment(props.value, CONST.DATE.MOMENT_FORMAT_STRING); + let currentDateView = currentSelection.toDate(); + if (props.selectedYear) { currentDateView = moment(currentDateView).set('year', props.selectedYear).toDate(); } @@ -28,12 +30,17 @@ class CalendarPicker extends React.PureComponent { } if (props.maxDate < currentDateView) { currentDateView = props.maxDate; + currentSelection = moment(props.maxDate); } else if (props.minDate > currentDateView) { currentDateView = props.minDate; + currentSelection = moment(props.minDate); } this.state = { currentDateView, + selectedYear: currentSelection.get('year').toString(), + selectedMonth: this.getNumberStringWithLeadingZero(currentSelection.get('month') + 1), + selectedDay: this.getNumberStringWithLeadingZero(currentSelection.get('date')), }; this.moveToPrevMonth = this.moveToPrevMonth.bind(this); @@ -56,7 +63,19 @@ class CalendarPicker extends React.PureComponent { } // If the selectedYear prop has changed, update the currentDateView state with the new year value - this.setState((prev) => ({currentDateView: moment(prev.currentDateView).set('year', this.props.selectedYear).toDate()})); + this.setState( + (prev) => { + const newMomentDate = moment(prev.currentDateView).set('year', this.props.selectedYear); + + return { + selectedYear: this.props.selectedYear, + currentDateView: this.clampDate(newMomentDate.toDate()), + }; + }, + () => { + this.props.onSelected(this.getSelectedDateString()); + }, + ); } /** @@ -67,7 +86,7 @@ class CalendarPicker extends React.PureComponent { onYearPickerPressed() { const minYear = moment(this.props.minDate).year(); const maxYear = moment(this.props.maxDate).year(); - const currentYear = this.state.currentDateView.getFullYear(); + const currentYear = parseInt(this.state.selectedYear, 10); Navigation.navigate(ROUTES.getYearSelectionRoute(minYear, maxYear, currentYear, Navigation.getActiveRoute())); this.props.onYearPickerOpen(this.state.currentDateView); } @@ -77,16 +96,102 @@ class CalendarPicker extends React.PureComponent { * @param {Number} day - The day of the month that was selected. */ onDayPressed(day) { - const selectedDate = new Date(this.state.currentDateView.getFullYear(), this.state.currentDateView.getMonth(), day); - this.props.onSelected(selectedDate); + this.setState( + (prev) => { + const momentDate = moment(prev.currentDateView).date(day); + + return { + selectedDay: this.getNumberStringWithLeadingZero(day), + currentDateView: this.clampDate(momentDate.toDate()), + }; + }, + () => { + this.props.onSelected(this.getSelectedDateString()); + }, + ); + } + + /** + * Gets the date string build from state values of selected year, month and day. + * @returns {string} - Date string in the 'YYYY-MM-DD' format. + */ + getSelectedDateString() { + // can't use moment.format() method here because it won't allow incorrect dates + return `${this.state.selectedYear}-${this.state.selectedMonth}-${this.state.selectedDay}`; } + /** + * Returns the string converted from the given number. If the number is lower than 10, + * it will add zero at the beginning of the string. + * @param {Number} number - The number to be converted. + * @returns {string} - Converted string prefixed by zero if necessary. + */ + getNumberStringWithLeadingZero(number) { + return `${number < 10 ? `0${number}` : number}`; + } + + /** + * Gives the new version of the state object, + * changing both selected month and year based on the given moment date. + * @param {moment.Moment} momentDate - Moment date object. + * @returns {{currentDateView: Date, selectedMonth: string, selectedYear: string}} - The new version of the state. + */ + getMonthState(momentDate) { + const clampedDate = this.clampDate(momentDate.toDate()); + const month = clampedDate.getMonth() + 1; + + return { + selectedMonth: this.getNumberStringWithLeadingZero(month), + selectedYear: clampedDate.getFullYear().toString(), // year might have changed too + currentDateView: clampedDate, + }; + } + + /** + * Handles the user pressing the previous month arrow of the calendar picker. + */ moveToPrevMonth() { - this.setState((prev) => ({currentDateView: moment(prev.currentDateView).subtract(1, 'M').toDate()})); + this.setState( + (prev) => { + const momentDate = moment(prev.currentDateView).subtract(1, 'M'); + + return this.getMonthState(momentDate); + }, + () => { + this.props.onSelected(this.getSelectedDateString()); + }, + ); } + /** + * Handles the user pressing the next month arrow of the calendar picker. + */ moveToNextMonth() { - this.setState((prev) => ({currentDateView: moment(prev.currentDateView).add(1, 'M').toDate()})); + this.setState( + (prev) => { + const momentDate = moment(prev.currentDateView).add(1, 'M'); + + return this.getMonthState(momentDate); + }, + () => { + this.props.onSelected(this.getSelectedDateString()); + }, + ); + } + + /** + * Checks whether the given date is in the min/max date range and returns the limit value if not. + * @param {Date} date - The date object to check. + * @returns {Date} - The date that is within the min/max date range. + */ + clampDate(date) { + if (this.props.maxDate < date) { + return this.props.maxDate; + } + if (this.props.minDate > date) { + return this.props.minDate; + } + return date; } render() { diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js index 417734eaa25a..a11af52f6887 100644 --- a/src/components/CheckboxWithLabel.js +++ b/src/components/CheckboxWithLabel.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useState, useEffect} from 'react'; import PropTypes from 'prop-types'; import {View} from 'react-native'; import _ from 'underscore'; @@ -9,6 +9,11 @@ import FormHelpMessage from './FormHelpMessage'; import variables from '../styles/variables'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; +/** + * Returns an error if the required props are not provided + * @param {Object} props + * @returns {Error|null} + */ const requiredPropsCheck = (props) => { if (!props.label && !props.LabelComponent) { return new Error('One of "label" or "LabelComponent" must be provided'); @@ -73,54 +78,55 @@ const defaultProps = { forwardedRef: () => {}, }; -class CheckboxWithLabel extends React.Component { - constructor(props) { - super(props); - - // We need to pick the first value that is strictly a boolean - // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065 - this.isChecked = _.find([props.value, props.defaultValue, props.isChecked], (value) => _.isBoolean(value)); - - this.LabelComponent = props.LabelComponent; - - this.toggleCheckbox = this.toggleCheckbox.bind(this); - } - - toggleCheckbox() { - this.props.onInputChange(!this.isChecked); - this.isChecked = !this.isChecked; - } - - render() { - return ( - - - - - {this.props.label && {this.props.label}} - {this.LabelComponent && } - - - +const CheckboxWithLabel = (props) => { + // We need to pick the first value that is strictly a boolean + // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065 + const [isChecked, setIsChecked] = useState(_.find([props.value, props.defaultValue, props.isChecked], (value) => _.isBoolean(value))); + + const toggleCheckbox = () => { + const newState = !isChecked; + props.onInputChange(newState); + setIsChecked(newState); + }; + + useEffect(() => { + setIsChecked(props.isChecked); + }, [props.isChecked]); + + const LabelComponent = props.LabelComponent; + + return ( + + + + + {props.label && {props.label}} + {LabelComponent && } + - ); - } -} + + + ); +}; CheckboxWithLabel.propTypes = propTypes; CheckboxWithLabel.defaultProps = defaultProps; +CheckboxWithLabel.displayName = 'CheckboxWithLabel'; export default React.forwardRef((props, ref) => ( { disabled={props.disabled} /> ); - return {props.toggleTooltip ? {checkbox} : checkbox}; + return ( + + {props.toggleTooltip ? ( + + {checkbox} + + ) : ( + checkbox + )} + + ); }; CheckboxWithTooltip.propTypes = propTypes; diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js index 311ab6ca3ce8..c043ab86381f 100644 --- a/src/components/Composer/index.android.js +++ b/src/components/Composer/index.android.js @@ -4,10 +4,12 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; import RNTextInput from '../RNTextInput'; import themeColors from '../../styles/themes/default'; -import CONST from '../../CONST'; import * as ComposerUtils from '../../libs/ComposerUtils'; const propTypes = { + /** Maximum number of lines in the text input */ + maxLines: PropTypes.number, + /** If the input should clear, it actually gets intercepted instead of .clear() */ shouldClear: PropTypes.bool, @@ -55,6 +57,7 @@ const defaultProps = { end: 0, }, isFullComposerAvailable: false, + maxLines: -1, setIsFullComposerAvailable: () => {}, isComposerFullSize: false, style: null, @@ -96,10 +99,10 @@ class Composer extends React.Component { autoComplete="off" placeholderTextColor={themeColors.placeholderText} ref={(el) => (this.textInput = el)} - maxHeight={this.props.isComposerFullSize ? '100%' : CONST.COMPOSER_MAX_HEIGHT} onContentSizeChange={(e) => ComposerUtils.updateNumberOfLines(this.props, e)} rejectResponderTermination={false} textAlignVertical="center" + maximumNumberOfLines={!this.props.isComposerFullSize ? this.props.maxLines : undefined} style={this.state.propStyles} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...this.props} diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js index f75c331968c1..3ca2d7edf58a 100644 --- a/src/components/Composer/index.ios.js +++ b/src/components/Composer/index.ios.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; import RNTextInput from '../RNTextInput'; import themeColors from '../../styles/themes/default'; -import CONST from '../../CONST'; import * as ComposerUtils from '../../libs/ComposerUtils'; const propTypes = { @@ -33,6 +32,9 @@ const propTypes = { /** Whether the full composer can be opened */ isFullComposerAvailable: PropTypes.bool, + /** Maximum number of lines in the text input */ + maxLines: PropTypes.number, + /** Allow the full composer to be opened */ setIsFullComposerAvailable: PropTypes.func, @@ -54,6 +56,7 @@ const defaultProps = { start: 0, end: 0, }, + maxLines: -1, isFullComposerAvailable: false, setIsFullComposerAvailable: () => {}, isComposerFullSize: false, @@ -100,12 +103,14 @@ class Composer extends React.Component { (this.textInput = el)} - maxHeight={this.props.isComposerFullSize ? '100%' : CONST.COMPOSER_MAX_HEIGHT} onContentSizeChange={(e) => ComposerUtils.updateNumberOfLines(this.props, e)} rejectResponderTermination={false} textAlignVertical="center" + smartInsertDelete={false} style={this.state.propStyles} + maximumNumberOfLines={!this.props.isComposerFullSize ? this.props.maxLines : undefined} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...propsToPass} editable={!this.props.isDisabled} diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index 0e198f03d036..eab4abe8a6fe 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -1,9 +1,8 @@ import React from 'react'; -import {StyleSheet} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; -import Str from 'expensify-common/lib/str'; import RNTextInput from '../RNTextInput'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import Growl from '../../libs/Growl'; @@ -11,11 +10,12 @@ import themeColors from '../../styles/themes/default'; import updateIsFullComposerAvailable from '../../libs/ComposerUtils/updateIsFullComposerAvailable'; import * as ComposerUtils from '../../libs/ComposerUtils'; import * as Browser from '../../libs/Browser'; -import Clipboard from '../../libs/Clipboard'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import compose from '../../libs/compose'; import styles from '../../styles/styles'; +import Text from '../Text'; import isEnterWhileComposition from '../../libs/KeyboardShortcut/isEnterWhileComposition'; +import CONST from '../../CONST'; const propTypes = { /** Maximum number of lines in the text input */ @@ -74,6 +74,9 @@ const propTypes = { /** Whether the composer is full size */ isComposerFullSize: PropTypes.bool, + /** Should we calculate the caret position */ + shouldCalculateCaretPosition: PropTypes.bool, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -100,6 +103,7 @@ const defaultProps = { isFullComposerAvailable: false, setIsFullComposerAvailable: () => {}, isComposerFullSize: false, + shouldCalculateCaretPosition: false, }; const IMAGE_EXTENSIONS = { @@ -128,6 +132,7 @@ class Composer extends React.Component { start: initialValue.length, end: initialValue.length, }, + valueBeforeCaret: '', }; this.paste = this.paste.bind(this); @@ -135,8 +140,9 @@ class Composer extends React.Component { this.handlePaste = this.handlePaste.bind(this); this.handlePastedHTML = this.handlePastedHTML.bind(this); this.handleWheel = this.handleWheel.bind(this); - this.putSelectionInClipboard = this.putSelectionInClipboard.bind(this); this.shouldCallUpdateNumberOfLines = this.shouldCallUpdateNumberOfLines.bind(this); + this.addCursorPositionToSelectionChange = this.addCursorPositionToSelectionChange.bind(this); + this.textRef = React.createRef(null); } componentDidMount() { @@ -155,7 +161,6 @@ class Composer extends React.Component { if (this.textInput) { this.textInput.addEventListener('paste', this.handlePaste); this.textInput.addEventListener('wheel', this.handleWheel); - this.textInput.addEventListener('keydown', this.putSelectionInClipboard); } } @@ -192,6 +197,57 @@ class Composer extends React.Component { this.textInput.removeEventListener('wheel', this.handleWheel); } + // Get characters from the cursor to the next space or new line + getNextChars(str, cursorPos) { + // Get the substring starting from the cursor position + const substr = str.substring(cursorPos); + + // Find the index of the next space or new line character + const spaceIndex = substr.search(/[ \n]/); + + if (spaceIndex === -1) { + return substr; + } + + // If there is a space or new line, return the substring up to the space or new line + return substr.substring(0, spaceIndex); + } + + /** + * Adds the cursor position to the selection change event. + * + * @param {Event} event + */ + addCursorPositionToSelectionChange(event) { + if (this.props.shouldCalculateCaretPosition) { + const newValueBeforeCaret = event.target.value.slice(0, event.nativeEvent.selection.start); + + this.setState( + { + valueBeforeCaret: newValueBeforeCaret, + caretContent: this.getNextChars(this.props.value, event.nativeEvent.selection.start), + }, + + () => { + const customEvent = { + nativeEvent: { + selection: { + start: event.nativeEvent.selection.start, + end: event.nativeEvent.selection.end, + positionX: this.textRef.current.offsetLeft - CONST.SPACE_CHARACTER_WIDTH, + positionY: this.textRef.current.offsetTop, + }, + }, + }; + this.props.onSelectionChange(customEvent); + }, + ); + return; + } + + this.props.onSelectionChange(event); + } + // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed handleKeyPress(e) { if (!this.props.onKeyPress || isEnterWhileComposition(e)) { @@ -257,7 +313,7 @@ class Composer extends React.Component { // If HTML has emoji, then treat this as plain text. if (embeddedImages[0].dataset && embeddedImages[0].dataset.stringifyType === 'emoji') { const plainText = event.clipboardData.getData('text/plain'); - this.paste(Str.htmlDecode(plainText)); + this.paste(plainText); return; } fetch(embeddedImages[0].src) @@ -297,7 +353,7 @@ class Composer extends React.Component { const plainText = event.clipboardData.getData('text/plain'); - this.paste(Str.htmlDecode(plainText)); + this.paste(plainText); } /** @@ -314,19 +370,6 @@ class Composer extends React.Component { event.stopPropagation(); } - putSelectionInClipboard(event) { - // If anything happens that isn't cmd+c or cmd+x, ignore the event because it's not a copy command - if (!event.metaKey || (event.key !== 'c' && event.key !== 'x')) { - return; - } - - // The user might have only highlighted a portion of the message to copy, so using the selection will ensure that - // 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); - - Clipboard.setHtml(selectedText, selectedText); - } - /** * We want to call updateNumberOfLines only when the parent doesn't provide value in props * as updateNumberOfLines is already being called when value changes in componentDidUpdate @@ -359,6 +402,7 @@ class Composer extends React.Component { updateIsFullComposerAvailable(this.props, numberOfLines); this.setState({ numberOfLines, + width: computedStyle.width, }); this.props.onNumberOfLinesChange(numberOfLines); }); @@ -369,29 +413,57 @@ class Composer extends React.Component { propStyles.outline = 'none'; const propsWithoutStyles = _.omit(this.props, 'style'); + // This code creates a hidden text component that helps track the caret position in the visible input. + const renderElementForCaretPosition = ( + + + {`${this.state.valueBeforeCaret} `} + + {`${this.state.caretContent}`} + + + + ); + // We're disabling autoCorrect for iOS Safari until Safari fixes this issue. See https://github.com/Expensify/App/issues/8592 return ( - (this.textInput = el)} - selection={this.state.selection} - onChange={this.shouldCallUpdateNumberOfLines} - onSelectionChange={this.onSelectionChange} - style={[ - propStyles, - - // We are hiding the scrollbar to prevent it from reducing the text input width, - // so we can get the correct scroll height while calculating the number of lines. - this.state.numberOfLines < this.props.maxLines ? styles.overflowHidden : {}, - ]} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...propsWithoutStyles} - numberOfLines={this.state.numberOfLines} - disabled={this.props.isDisabled} - onKeyPress={this.handleKeyPress} - /> + <> + (this.textInput = el)} + selection={this.state.selection} + onChange={this.shouldCallUpdateNumberOfLines} + style={[ + propStyles, + + // We are hiding the scrollbar to prevent it from reducing the text input width, + // so we can get the correct scroll height while calculating the number of lines. + this.state.numberOfLines < this.props.maxLines ? styles.overflowHidden : {}, + ]} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...propsWithoutStyles} + onSelectionChange={this.addCursorPositionToSelectionChange} + numberOfLines={this.state.numberOfLines} + disabled={this.props.isDisabled} + onKeyPress={this.handleKeyPress} + /> + {this.props.shouldCalculateCaretPosition && renderElementForCaretPosition} + ); } } diff --git a/src/components/ConfirmationPage.js b/src/components/ConfirmationPage.js index aa8cccaa36e4..68110d6765fc 100644 --- a/src/components/ConfirmationPage.js +++ b/src/components/ConfirmationPage.js @@ -10,7 +10,8 @@ import FixedFooter from './FixedFooter'; const propTypes = { /** The asset to render */ - animation: PropTypes.string, + // eslint-disable-next-line react/forbid-prop-types + animation: PropTypes.object, /** Heading of the confirmation page */ heading: PropTypes.string, diff --git a/src/components/CurrencySymbolButton.js b/src/components/CurrencySymbolButton.js index 8b73cabc1b23..b5929271031d 100644 --- a/src/components/CurrencySymbolButton.js +++ b/src/components/CurrencySymbolButton.js @@ -1,10 +1,10 @@ import React from 'react'; -import {TouchableOpacity} from 'react-native'; import PropTypes from 'prop-types'; import Text from './Text'; import styles from '../styles/styles'; import Tooltip from './Tooltip'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; const propTypes = { /** Currency symbol of selected currency */ @@ -19,9 +19,13 @@ const propTypes = { function CurrencySymbolButton(props) { return ( - + {props.currencySymbol} - + ); } diff --git a/src/components/CustomStatusBar/index.android.js b/src/components/CustomStatusBar/index.android.js index fa3f8921e82a..a7bf509114e6 100644 --- a/src/components/CustomStatusBar/index.android.js +++ b/src/components/CustomStatusBar/index.android.js @@ -1,18 +1,10 @@ -import React from 'react'; -import {StatusBar} from 'react-native'; -import themeColors from '../../styles/themes/default'; - /** - * Only the Android platform supports "setBackgroundColor" + * On Android we setup the status bar in native code. */ -export default class CustomStatusBar extends React.Component { - componentDidMount() { - StatusBar.setBarStyle('light-content'); - StatusBar.setBackgroundColor(themeColors.appBG); - } - - render() { - return ; - } +export default function CustomStatusBar() { + // Prefer to not render the StatusBar component in Android as it can cause + // issues with edge to edge display. We setup the status bar appearance in + // MainActivity.java and styles.xml. + return null; } diff --git a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js new file mode 100644 index 000000000000..7557519dce1d --- /dev/null +++ b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.js @@ -0,0 +1,51 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import TextLink from '../TextLink'; +import Text from '../Text'; +import Icon from '../Icon'; +import * as Illustrations from '../Icon/Illustrations'; +import * as Expensicons from '../Icon/Expensicons'; +import colors from '../../styles/colors'; +import styles from '../../styles/styles'; +import withLocalize, {withLocalizePropTypes} from '../withLocalize'; + +const propTypes = { + openLinkInBrowser: PropTypes.func.isRequired, + + ...withLocalizePropTypes, +}; + +const DeeplinkRedirectLoadingIndicator = (props) => ( + + + + + + {props.translate('deeplinkWrapper.launching')} + + {props.translate('deeplinkWrapper.redirectedToDesktopApp')} + + {props.translate('deeplinkWrapper.youCanAlso')} {props.translate('deeplinkWrapper.openLinkInBrowser')}. + + + + + + + +); + +DeeplinkRedirectLoadingIndicator.propTypes = propTypes; +DeeplinkRedirectLoadingIndicator.displayName = 'DeeplinkRedirectLoadingIndicator'; + +export default withLocalize(DeeplinkRedirectLoadingIndicator); diff --git a/src/components/DeeplinkWrapper/index.website.js b/src/components/DeeplinkWrapper/index.website.js index 856e73b2c794..deaac25ba794 100644 --- a/src/components/DeeplinkWrapper/index.website.js +++ b/src/components/DeeplinkWrapper/index.website.js @@ -8,6 +8,7 @@ import CONFIG from '../../CONFIG'; import * as Browser from '../../libs/Browser'; import ONYXKEYS from '../../ONYXKEYS'; import * as Authentication from '../../libs/Authentication'; +import DeeplinkRedirectLoadingIndicator from './DeeplinkRedirectLoadingIndicator'; const propTypes = { /** Children to render. */ @@ -37,8 +38,10 @@ class DeeplinkWrapper extends PureComponent { this.state = { appInstallationCheckStatus: this.isMacOSWeb() && CONFIG.ENVIRONMENT !== CONST.ENVIRONMENT.DEV ? CONST.DESKTOP_DEEPLINK_APP_STATE.CHECKING : CONST.DESKTOP_DEEPLINK_APP_STATE.NOT_INSTALLED, + shouldOpenLinkInBrowser: false, }; this.focused = true; + this.openLinkInBrowser = this.openLinkInBrowser.bind(this); } componentDidMount() { @@ -118,11 +121,24 @@ class DeeplinkWrapper extends PureComponent { return !Browser.isMobile() && typeof navigator === 'object' && typeof navigator.userAgent === 'string' && /Mac/i.test(navigator.userAgent) && !/Electron/i.test(navigator.userAgent); } + openLinkInBrowser() { + this.setState({shouldOpenLinkInBrowser: true}); + } + + shouldShowDeeplinkLoadingIndicator() { + const routeRegex = new RegExp(CONST.REGEX.ROUTES.VALIDATE_LOGIN); + return routeRegex.test(window.location.pathname); + } + render() { if (this.state.appInstallationCheckStatus === CONST.DESKTOP_DEEPLINK_APP_STATE.CHECKING) { return ; } + if (this.state.appInstallationCheckStatus === CONST.DESKTOP_DEEPLINK_APP_STATE.INSTALLED && this.shouldShowDeeplinkLoadingIndicator() && !this.state.shouldOpenLinkInBrowser) { + return ; + } + return this.props.children; } } diff --git a/src/components/DisplayNames/index.js b/src/components/DisplayNames/index.js index 0b80aa4084cf..9eb7d0bc501f 100644 --- a/src/components/DisplayNames/index.js +++ b/src/components/DisplayNames/index.js @@ -91,7 +91,6 @@ class DisplayNames extends PureComponent { this.getTooltipShiftX(index)} > {/* // We need to get the refs to all the names which will be used to correct diff --git a/src/components/DotIndicatorMessage.js b/src/components/DotIndicatorMessage.js index 9c2afdff8eb9..db8c66f311b8 100644 --- a/src/components/DotIndicatorMessage.js +++ b/src/components/DotIndicatorMessage.js @@ -7,6 +7,7 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import colors from '../styles/colors'; import Text from './Text'; +import * as Localize from '../libs/Localize'; const propTypes = { /** @@ -48,6 +49,7 @@ const DotIndicatorMessage = (props) => { // Using uniq here since some fields are wrapped by the same OfflineWithFeedback component (e.g. WorkspaceReimburseView) // and can potentially pass the same error. .uniq() + .map((message) => Localize.translateIfPhraseKey(message)) .value(); return ( diff --git a/src/components/EmojiPicker/CategoryShortcutButton.js b/src/components/EmojiPicker/CategoryShortcutButton.js index fc306ef74e01..567521637f19 100644 --- a/src/components/EmojiPicker/CategoryShortcutButton.js +++ b/src/components/EmojiPicker/CategoryShortcutButton.js @@ -33,21 +33,19 @@ class CategoryShortcutButton extends PureComponent { 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, - ]} + - this.setState({isHighlighted: true})} + onHoverOut={() => this.setState({isHighlighted: false})} + style={({pressed}) => [ + StyleUtils.getButtonBackgroundColorStyle(getButtonState(false, pressed)), + styles.categoryShortcutButton, + this.state.isHighlighted && styles.emojiItemHighlighted, + ]} > - - + + ); } } diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index a9cb139ee4be..d2f65d5866cd 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -28,10 +28,7 @@ const defaultProps = { const EmojiPickerButton = (props) => { let emojiPopoverAnchor = null; return ( - + (emojiPopoverAnchor = el)} style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} diff --git a/src/components/EmojiPicker/EmojiSkinToneList.js b/src/components/EmojiPicker/EmojiSkinToneList.js index bfd144b8309f..b305a7876deb 100644 --- a/src/components/EmojiPicker/EmojiSkinToneList.js +++ b/src/components/EmojiPicker/EmojiSkinToneList.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {Component} from 'react'; +import React, {useState, useCallback} from 'react'; import {View, Pressable} from 'react-native'; import PropTypes from 'prop-types'; import styles from '../../styles/styles'; @@ -20,77 +20,57 @@ const propTypes = { ...withLocalizePropTypes, }; -class EmojiSkinToneList extends Component { - constructor(props) { - super(props); +function EmojiSkinToneList(props) { + const [highlightedIndex, setHighlightedIndex] = useState(null); + const [isSkinToneListVisible, setIsSkinToneListVisible] = useState(false); - this.updateSelectedSkinTone = this.updateSelectedSkinTone.bind(this); - - this.state = { - highlightedIndex: -1, - isSkinToneListVisible: false, - }; - } - - componentDidMount() { - // Get the selected skinToneEmoji based on the index - const selectedEmoji = getSkinToneEmojiFromIndex(this.props.preferredSkinTone); - this.setState({highlightedIndex: selectedEmoji.skinTone}); - } - - componentDidUpdate(prevProps) { - // Update the highlighted skin tone only if the selected one changes - if (prevProps.preferredSkinTone === this.props.preferredSkinTone) { - return; - } - - const selectedEmoji = getSkinToneEmojiFromIndex(this.props.preferredSkinTone); - this.setState({highlightedIndex: selectedEmoji.skinTone}); - } + const toggleIsSkinToneListVisible = useCallback(() => { + setIsSkinToneListVisible((prev) => !prev); + }, []); /** * Pass the skinTone to props and hide the picker * @param {object} skinToneEmoji */ - updateSelectedSkinTone(skinToneEmoji) { - this.setState((prev) => ({isSkinToneListVisible: !prev.isSkinToneListVisible, highlightedIndex: skinToneEmoji.skinTone})); - this.props.updatePreferredSkinTone(skinToneEmoji.skinTone); + function updateSelectedSkinTone(skinToneEmoji) { + toggleIsSkinToneListVisible(); + setHighlightedIndex(skinToneEmoji.skinTone); + props.updatePreferredSkinTone(skinToneEmoji.skinTone); } - render() { - const selectedEmoji = getSkinToneEmojiFromIndex(this.props.preferredSkinTone); - return ( - - {!this.state.isSkinToneListVisible && ( - this.setState((prev) => ({isSkinToneListVisible: !prev.isSkinToneListVisible}))} - style={[styles.flex1, styles.flexRow, styles.alignSelfCenter, styles.justifyContentStart, styles.alignItemsCenter]} - > - - {selectedEmoji.code} - - {this.props.translate('emojiPicker.skinTonePickerLabel')} - - )} - {this.state.isSkinToneListVisible && ( - - {_.map(Emojis.skinTones, (skinToneEmoji) => ( - this.updateSelectedSkinTone(skinToneEmoji)} - onHoverIn={() => this.setState({highlightedIndex: skinToneEmoji.skinTone})} - onHoverOut={() => this.setState({highlightedIndex: selectedEmoji.skinTone})} - key={skinToneEmoji.code} - emoji={skinToneEmoji.code} - isHighlighted={skinToneEmoji.skinTone === this.state.highlightedIndex || skinToneEmoji.skinTone === selectedEmoji.skinTone} - /> - ))} + const currentSkinTone = getSkinToneEmojiFromIndex(props.preferredSkinTone); + return ( + + {!isSkinToneListVisible && ( + + + {currentSkinTone.code} - )} - - ); - } + {props.translate('emojiPicker.skinTonePickerLabel')} + + )} + {isSkinToneListVisible && ( + + {_.map(Emojis.skinTones, (skinToneEmoji) => ( + updateSelectedSkinTone(skinToneEmoji)} + onHoverIn={() => setHighlightedIndex(skinToneEmoji.skinTone)} + onHoverOut={() => setHighlightedIndex(null)} + key={skinToneEmoji.code} + emoji={skinToneEmoji.code} + isHighlighted={skinToneEmoji.skinTone === highlightedIndex || skinToneEmoji.skinTone === currentSkinTone.skinTone} + /> + ))} + + )} + + ); } EmojiSkinToneList.propTypes = propTypes; +EmojiSkinToneList.displayName = 'EmojiSkinToneList'; export default withLocalize(EmojiSkinToneList); diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.js index 4ef6a5027e73..c403aa63c172 100644 --- a/src/components/ExceededCommentLength.js +++ b/src/components/ExceededCommentLength.js @@ -1,4 +1,4 @@ -import React, {PureComponent} from 'react'; +import React, {useEffect, useState, useMemo} from 'react'; import PropTypes from 'prop-types'; import {debounce} from 'lodash'; import CONST from '../CONST'; @@ -14,47 +14,27 @@ const propTypes = { onExceededMaxCommentLength: PropTypes.func.isRequired, }; -class ExceededCommentLength extends PureComponent { - constructor(props) { - super(props); - - this.state = { - commentLength: 0, - }; - - // By debouncing, we defer the calculation until there is a break in typing - this.updateCommentLength = debounce(this.updateCommentLength.bind(this), CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME); - } - - componentDidMount() { - this.updateCommentLength(); +function ExceededCommentLength(props) { + const [commentLength, setCommentLength] = useState(0); + const updateCommentLength = useMemo( + () => + debounce((comment, onExceededMaxCommentLength) => { + const newCommentLength = ReportUtils.getCommentLength(comment); + setCommentLength(newCommentLength); + onExceededMaxCommentLength(newCommentLength > CONST.MAX_COMMENT_LENGTH); + }, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME), + [], + ); + + useEffect(() => { + updateCommentLength(props.comment, props.onExceededMaxCommentLength); + }, [props.comment, props.onExceededMaxCommentLength, updateCommentLength]); + + if (commentLength <= CONST.MAX_COMMENT_LENGTH) { + return null; } - componentDidUpdate(prevProps) { - if (prevProps.comment === this.props.comment) { - return; - } - - this.updateCommentLength(); - } - - updateCommentLength() { - const commentLength = ReportUtils.getCommentLength(this.props.comment); - this.setState({commentLength}); - this.props.onExceededMaxCommentLength(commentLength > CONST.MAX_COMMENT_LENGTH); - } - - render() { - if (this.state.commentLength <= CONST.MAX_COMMENT_LENGTH) { - return null; - } - - return ( - - {`${this.state.commentLength}/${CONST.MAX_COMMENT_LENGTH}`} - - ); - } + return {`${commentLength}/${CONST.MAX_COMMENT_LENGTH}`}; } ExceededCommentLength.propTypes = propTypes; diff --git a/src/components/ExpensifyWordmark.js b/src/components/ExpensifyWordmark.js index 376f83f886d4..a2766dc0bcc4 100644 --- a/src/components/ExpensifyWordmark.js +++ b/src/components/ExpensifyWordmark.js @@ -1,5 +1,7 @@ import React from 'react'; import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; import ProductionLogo from '../../assets/images/expensify-wordmark.svg'; import DevLogo from '../../assets/images/expensify-logo--dev.svg'; import StagingLogo from '../../assets/images/expensify-logo--staging.svg'; @@ -14,10 +16,17 @@ import * as StyleUtils from '../styles/StyleUtils'; import variables from '../styles/variables'; const propTypes = { + /** Additional styles to add to the component */ + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + ...environmentPropTypes, ...windowDimensionsPropTypes, }; +const defaultProps = { + style: {}, +}; + const logoComponents = { [CONST.ENVIRONMENT.DEV]: DevLogo, [CONST.ENVIRONMENT.STAGING]: StagingLogo, @@ -34,6 +43,7 @@ const ExpensifyWordmark = (props) => { StyleUtils.getSignInWordmarkWidthStyle(props.environment, props.isSmallScreenWidth), StyleUtils.getHeight(props.isSmallScreenWidth ? variables.signInLogoHeightSmallScreen : variables.signInLogoHeight), props.isSmallScreenWidth && (props.environment === CONST.ENVIRONMENT.DEV || props.environment === CONST.ENVIRONMENT.STAGING) ? styles.ml3 : {}, + ...(_.isArray(props.style) ? props.style : [props.style]), ]} > @@ -43,5 +53,6 @@ const ExpensifyWordmark = (props) => { }; ExpensifyWordmark.displayName = 'ExpensifyWordmark'; +ExpensifyWordmark.defaultProps = defaultProps; ExpensifyWordmark.propTypes = propTypes; export default compose(withEnvironment, withWindowDimensions)(ExpensifyWordmark); diff --git a/src/components/FloatingActionButton.js b/src/components/FloatingActionButton.js index 5e58206e2fe9..b91492750933 100644 --- a/src/components/FloatingActionButton.js +++ b/src/components/FloatingActionButton.js @@ -71,10 +71,7 @@ class FloatingActionButton extends PureComponent { }); return ( - + (this.fabPressable = el)} @@ -85,6 +82,7 @@ class FloatingActionButton extends PureComponent { this.fabPressable.blur(); this.props.onPress(e); }} + onLongPress={() => {}} style={[styles.floatingActionButton, StyleUtils.getAnimatedFABStyle(rotate, backgroundColor)]} > { + const [errors, setErrors] = useState({}); + const [inputValues, setInputValues] = useState({...props.draftValues}); + const formRef = useRef(null); + const formContentRef = useRef(null); + const inputRefs = useRef({}); + const touchedInputs = useRef({}); - // Update the error messages if the language changes - this.validate(this.state.inputValues); - } + const {validate, translate, onSubmit, children} = props; - getErrorMessage() { - const latestErrorMessage = ErrorUtils.getLatestErrorMessage(this.props.formState); - return this.props.formState.error || (typeof latestErrorMessage === 'string' ? latestErrorMessage : ''); - } + /** + * @param {Object} values - An object containing the value of each inputID, e.g. {inputID1: value1, inputID2: value2} + * @returns {Object} - An object containing the errors for each inputID, e.g. {inputID1: error1, inputID2: error2} + */ + const onValidate = useCallback( + (values) => { + const trimmedStringValues = {}; + _.each(values, (inputValue, inputID) => { + if (_.isString(inputValue)) { + trimmedStringValues[inputID] = inputValue.trim(); + } else { + trimmedStringValues[inputID] = inputValue; + } + }); - getFirstErroredInput() { - const hasStateErrors = !_.isEmpty(this.state.errors); - const hasErrorFields = !_.isEmpty(this.props.formState.errorFields); + FormActions.setErrors(props.formID, null); + FormActions.setErrorFields(props.formID, null); - if (!hasStateErrors && !hasErrorFields) { - return; - } + // Run any validations passed as a prop + const validationErrors = validate(trimmedStringValues); + + // Validate the input for html tags. It should supercede any other error + _.each(trimmedStringValues, (inputValue, inputID) => { + // Return early if there is no value OR the value is not a string OR there are no HTML characters + if (!inputValue || !_.isString(inputValue) || inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX) === -1) { + return; + } + + // Add a validation error here because it is a string value that contains HTML characters + validationErrors[inputID] = translate('common.error.invalidCharacter'); + }); + + if (!_.isObject(validationErrors)) { + throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}'); + } + + const touchedInputErrors = _.pick(validationErrors, (inputValue, inputID) => Boolean(touchedInputs.current[inputID])); + + if (!_.isEqual(errors, touchedInputErrors)) { + setErrors(touchedInputErrors); + } + + return touchedInputErrors; + }, + [errors, touchedInputs, props.formID, validate, translate], + ); - return _.first(_.keys(hasStateErrors ? this.state.erorrs : this.props.formState.errorFields)); - } + useEffect(() => { + onValidate(inputValues); + }, [onValidate, inputValues]); + + const getErrorMessage = useCallback(() => { + const latestErrorMessage = ErrorUtils.getLatestErrorMessage(props.formState); + return props.formState.error || (typeof latestErrorMessage === 'string' ? latestErrorMessage : ''); + }, [props.formState]); /** * @param {String} inputID - The inputID of the input being touched */ - setTouchedInput(inputID) { - this.touchedInputs[inputID] = true; - } + const setTouchedInput = useCallback( + (inputID) => { + touchedInputs.current[inputID] = true; + }, + [touchedInputs], + ); - submit() { + const submit = useCallback(() => { // Return early if the form is already submitting to avoid duplicate submission - if (this.props.formState.isLoading) { + if (props.formState.isLoading) { return; } // Touches all form inputs so we can validate the entire form - _.each(this.inputRefs, (inputRef, inputID) => (this.touchedInputs[inputID] = true)); + _.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true)); // Validate form and return early if any errors are found - if (!_.isEmpty(this.validate(this.state.inputValues))) { + if (!_.isEmpty(onValidate(inputValues))) { return; } // Call submit handler - this.props.onSubmit(this.state.inputValues); - } - - /** - * @param {Object} values - An object containing the value of each inputID, e.g. {inputID1: value1, inputID2: value2} - * @returns {Object} - An object containing the errors for each inputID, e.g. {inputID1: error1, inputID2: error2} - */ - validate(values) { - const trimmedStringValues = {}; - _.each(values, (inputValue, inputID) => { - if (_.isString(inputValue)) { - trimmedStringValues[inputID] = inputValue.trim(); - } else { - trimmedStringValues[inputID] = inputValue; - } - }); - - FormActions.setErrors(this.props.formID, null); - FormActions.setErrorFields(this.props.formID, null); - - // Run any validations passed as a prop - const validationErrors = this.props.validate(trimmedStringValues); - - // Validate the input for html tags. It should supercede any other error - _.each(trimmedStringValues, (inputValue, inputID) => { - // Return early if there is no value OR the value is not a string OR there are no HTML characters - if (!inputValue || !_.isString(inputValue) || inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX) === -1) { - return; - } - - // Add a validation error here because it is a string value that contains HTML characters - validationErrors[inputID] = this.props.translate('common.error.invalidCharacter'); - }); - - if (!_.isObject(validationErrors)) { - throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}'); - } - - const errors = _.pick(validationErrors, (inputValue, inputID) => Boolean(this.touchedInputs[inputID])); - - if (!_.isEqual(errors, this.state.errors)) { - this.setState({errors}); - } - - return errors; - } + onSubmit(inputValues); + }, [props.formState, onSubmit, inputRefs, inputValues, onValidate, touchedInputs]); /** * Loops over Form's children and automatically supplies Form props to them @@ -208,151 +187,152 @@ class Form extends React.Component { * @param {Array | Function | Node} children - An array containing all Form children * @returns {React.Component} */ - childrenWrapperWithProps(children) { - return React.Children.map(children, (child) => { - // Just render the child if it is not a valid React element, e.g. text within a component - if (!React.isValidElement(child)) { - return child; - } + const childrenWrapperWithProps = useCallback( + (childNodes) => + React.Children.map(childNodes, (child) => { + // Just render the child if it is not a valid React element, e.g. text within a component + if (!React.isValidElement(child)) { + return child; + } - // Depth first traversal of the render tree as the input element is likely to be the last node - if (child.props.children) { - return React.cloneElement(child, { - children: this.childrenWrapperWithProps(child.props.children), - }); - } + // Depth first traversal of the render tree as the input element is likely to be the last node + if (child.props.children) { + return React.cloneElement(child, { + children: childrenWrapperWithProps(child.props.children), + }); + } + + // Look for any inputs nested in a custom component, e.g AddressForm or IdentityForm + if (_.isFunction(child.type)) { + const childNode = new child.type(child.props); - // Look for any inputs nested in a custom component, e.g AddressForm or IdentityForm - if (_.isFunction(child.type)) { - const childNode = new child.type(child.props); + // If the custom component has a render method, use it to get the nested children + const nestedChildren = _.isFunction(childNode.render) ? childNode.render() : childNode; - // If the custom component has a render method, use it to get the nested children - const nestedChildren = _.isFunction(childNode.render) ? childNode.render() : childNode; + // Render the custom component if it's a valid React element + // If the custom component has nested children, Loop over them and supply From props + if (React.isValidElement(nestedChildren) || lodashGet(nestedChildren, 'props.children')) { + return childrenWrapperWithProps(nestedChildren); + } - // Render the custom component if it's a valid React element - // If the custom component has nested children, Loop over them and supply From props - if (React.isValidElement(nestedChildren) || lodashGet(nestedChildren, 'props.children')) { - return this.childrenWrapperWithProps(nestedChildren); + // Just render the child if it's custom component not a valid React element, or if it hasn't children + return child; } - // Just render the child if it's custom component not a valid React element, or if it hasn't children - return child; - } + // We check if the child has the inputID prop. + // We don't want to pass form props to non form components, e.g. View, Text, etc + if (!child.props.inputID) { + return child; + } - // We check if the child has the inputID prop. - // We don't want to pass form props to non form components, e.g. View, Text, etc - if (!child.props.inputID) { - return child; - } + // We clone the child passing down all form props + const inputID = child.props.inputID; + let defaultValue; + + // We need to make sure that checkboxes have correct + // value assigned from the list of draft values + // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065 + if (_.isBoolean(props.draftValues[inputID])) { + defaultValue = props.draftValues[inputID]; + } else { + defaultValue = props.draftValues[inputID] || child.props.defaultValue; + } - // We clone the child passing down all form props - const inputID = child.props.inputID; - let defaultValue; - - // We need to make sure that checkboxes have correct - // value assigned from the list of draft values - // https://github.com/Expensify/App/issues/16885#issuecomment-1520846065 - if (_.isBoolean(this.props.draftValues[inputID])) { - defaultValue = this.props.draftValues[inputID]; - } else { - defaultValue = this.props.draftValues[inputID] || child.props.defaultValue; - } + // We want to initialize the input value if it's undefined + if (_.isUndefined(inputValues[inputID])) { + inputValues[inputID] = defaultValue; + } - // We want to initialize the input value if it's undefined - if (_.isUndefined(this.state.inputValues[inputID])) { - this.state.inputValues[inputID] = defaultValue; - } + // We force the form to set the input value from the defaultValue props if there is a saved valid value + if (child.props.shouldUseDefaultValue) { + inputValues[inputID] = child.props.defaultValue; + } - // We force the form to set the input value from the defaultValue props if there is a saved valid value - if (child.props.shouldUseDefaultValue) { - this.state.inputValues[inputID] = child.props.defaultValue; - } + if (!_.isUndefined(child.props.value)) { + inputValues[inputID] = child.props.value; + } - if (!_.isUndefined(child.props.value)) { - this.state.inputValues[inputID] = child.props.value; - } + const errorFields = lodashGet(props.formState, 'errorFields', {}); + const fieldErrorMessage = + _.chain(errorFields[inputID]) + .keys() + .sortBy() + .reverse() + .map((key) => errorFields[inputID][key]) + .first() + .value() || ''; - const errorFields = lodashGet(this.props.formState, 'errorFields', {}); - const fieldErrorMessage = - _.chain(errorFields[inputID]) - .keys() - .sortBy() - .reverse() - .map((key) => errorFields[inputID][key]) - .first() - .value() || ''; - - return React.cloneElement(child, { - ref: (node) => { - this.inputRefs[inputID] = node; - - const {ref} = child; - if (_.isFunction(ref)) { - ref(node); - } - }, - value: this.state.inputValues[inputID], - errorText: this.state.errors[inputID] || fieldErrorMessage, - onBlur: (event) => { - // We delay the validation in order to prevent Checkbox loss of focus when - // the user are focusing a TextInput and proceeds to toggle a CheckBox in - // web and mobile web platforms. - setTimeout(() => { - this.setTouchedInput(inputID); - this.validate(this.state.inputValues); - }, 200); - - if (_.isFunction(child.props.onBlur)) { - child.props.onBlur(event); - } - }, - onInputChange: (value, key) => { - const inputKey = key || inputID; - this.setState( - (prevState) => ({ - inputValues: { - ...prevState.inputValues, + return React.cloneElement(child, { + ref: (node) => { + inputRefs.current[inputID] = node; + + const {ref} = child; + if (_.isFunction(ref)) { + ref(node); + } + }, + value: inputValues[inputID], + errorText: errors[inputID] || fieldErrorMessage, + onBlur: (event) => { + // We delay the validation in order to prevent Checkbox loss of focus when + // the user are focusing a TextInput and proceeds to toggle a CheckBox in + // web and mobile web platforms. + setTimeout(() => { + setTouchedInput(inputID); + onValidate(inputValues); + }, 200); + + if (_.isFunction(child.props.onBlur)) { + child.props.onBlur(event); + } + }, + onTouched: () => { + setTouchedInput(inputID); + }, + onInputChange: (value, key) => { + const inputKey = key || inputID; + setInputValues((prevState) => { + const newState = { + ...prevState, [inputKey]: value, - }, - }), - () => this.validate(this.state.inputValues), - ); - - if (child.props.shouldSaveDraft) { - FormActions.setDraftValues(this.props.formID, {[inputKey]: value}); - } - - if (child.props.onValueChange) { - child.props.onValueChange(value); - } - }, - }); - }); - } + }; + onValidate(newState); + return newState; + }); + + if (child.props.shouldSaveDraft) { + FormActions.setDraftValues(props.formID, {[inputKey]: value}); + } + + if (child.props.onValueChange) { + child.props.onValueChange(value); + } + }, + }); + }), + [errors, inputRefs, inputValues, onValidate, props.draftValues, props.formID, props.formState, setTouchedInput], + ); - render() { - const scrollViewContent = (safeAreaPaddingBottomStyle) => ( + const scrollViewContent = useCallback( + (safeAreaPaddingBottomStyle) => ( - {this.childrenWrapperWithProps(_.isFunction(this.props.children) ? this.props.children({inputValues: this.state.inputValues}) : this.props.children)} - {this.props.isSubmitButtonVisible && ( + {childrenWrapperWithProps(_.isFunction(children) ? children({inputValues}) : children)} + {props.isSubmitButtonVisible && ( 0 || Boolean(this.getErrorMessage()) || !_.isEmpty(this.props.formState.errorFields)} - isLoading={this.props.formState.isLoading} - message={_.isEmpty(this.props.formState.errorFields) ? this.getErrorMessage() : null} - onSubmit={this.submit} - footerContent={this.props.footerContent} + buttonText={props.submitButtonText} + isAlertVisible={_.size(errors) > 0 || Boolean(getErrorMessage()) || !_.isEmpty(props.formState.errorFields)} + isLoading={props.formState.isLoading} + message={_.isEmpty(props.formState.errorFields) ? getErrorMessage() : null} + onSubmit={submit} + footerContent={props.footerContent} onFixTheErrorsLinkPressed={() => { - const errors = !_.isEmpty(this.state.errors) ? this.state.errors : this.props.formState.errorFields; - const focusKey = _.find(_.keys(this.inputRefs), (key) => _.keys(errors).includes(key)); - const focusInput = this.inputRefs[focusKey]; - - const formRef = this.formRef.current; - const formContentRef = this.formContentRef.current; + const errorFields = !_.isEmpty(errors) ? errors : props.formState.errorFields; + const focusKey = _.find(_.keys(inputRefs.current), (key) => _.keys(errorFields).includes(key)); + const focusInput = inputRefs.current[focusKey]; // Start with dismissing the keyboard, so when we focus a non-text input, the keyboard is hidden Keyboard.dismiss(); @@ -361,7 +341,7 @@ class Form extends React.Component { if (focusInput.measureLayout && typeof focusInput.measureLayout === 'function') { // We measure relative to the content root, not the scroll view, as that gives // consistent results across mobile and web - focusInput.measureLayout(formContentRef, (x, y) => formRef.scrollTo({y: y - 10, animated: false})); + focusInput.measureLayout(formContentRef.current, (x, y) => formRef.current.scrollTo({y: y - 10, animated: false})); } // Focus the input after scrolling, as on the Web it gives a slightly better visual result @@ -370,42 +350,61 @@ class Form extends React.Component { } }} containerStyles={[styles.mh0, styles.mt5, styles.flex1]} - enabledWhenOffline={this.props.enabledWhenOffline} - isSubmitActionDangerous={this.props.isSubmitActionDangerous} + enabledWhenOffline={props.enabledWhenOffline} + isSubmitActionDangerous={props.isSubmitActionDangerous} disablePressOnEnter /> )} - ); - - return ( - - {({safeAreaPaddingBottomStyle}) => - this.props.scrollContextEnabled ? ( - - {scrollViewContent(safeAreaPaddingBottomStyle)} - - ) : ( - - {scrollViewContent(safeAreaPaddingBottomStyle)} - - ) - } - - ); - } -} + ), + [ + childrenWrapperWithProps, + errors, + formContentRef, + formRef, + getErrorMessage, + inputRefs, + inputValues, + submit, + props.style, + children, + props.formState, + props.footerContent, + props.enabledWhenOffline, + props.isSubmitActionDangerous, + props.isSubmitButtonVisible, + props.submitButtonText, + ], + ); + + return ( + + {({safeAreaPaddingBottomStyle}) => + props.scrollContextEnabled ? ( + + {scrollViewContent(safeAreaPaddingBottomStyle)} + + ) : ( + + {scrollViewContent(safeAreaPaddingBottomStyle)} + + ) + } + + ); +}; +Form.displayName = 'Form'; Form.propTypes = propTypes; Form.defaultProps = defaultProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js index 478b03b40f0d..b79ca3f54dbf 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js @@ -15,13 +15,14 @@ const propTypes = { const EditedRenderer = (props) => { const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style', 'tnode']); + const isPendingDelete = Boolean(props.tnode.attributes.deleted !== undefined); return ( {/* Native devices do not support margin between nested text */} ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js index 4b8449d4982e..81f723e66aef 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js @@ -36,15 +36,12 @@ const MentionUserRenderer = (props) => { return ( - + showUserDetails(loginWhithoutLeadingAt)} > diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js index 8226a11b8d09..ab6147b7615d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/BasePreRenderer.js @@ -1,6 +1,6 @@ import React, {forwardRef} from 'react'; import {ScrollView} from 'react-native-gesture-handler'; -import {TouchableWithoutFeedback, View} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import htmlRendererPropTypes from '../htmlRendererPropTypes'; @@ -8,6 +8,7 @@ import withLocalize from '../../../withLocalize'; import {ShowContextMenuContext, showContextMenuForReport} from '../../../ShowContextMenuContext'; import styles from '../../../../styles/styles'; import * as ReportUtils from '../../../../libs/ReportUtils'; +import PressableWithoutFeedback from '../../../Pressable/PressableWithoutFeedback'; const propTypes = { /** Press in handler for the code block */ @@ -37,16 +38,18 @@ const BasePreRenderer = forwardRef((props, ref) => { > {({anchor, report, action, checkIfContextMenuActive}) => ( - showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + accessibilityRole="text" + accessibilityLabel={props.translate('accessibilityHints.prestyledText')} > {/* eslint-disable-next-line react/jsx-props-no-spreading */} - + )} diff --git a/src/components/HeaderWithCloseButton.js b/src/components/HeaderWithCloseButton.js index f6e979cbde7c..cc037b6094dc 100755 --- a/src/components/HeaderWithCloseButton.js +++ b/src/components/HeaderWithCloseButton.js @@ -18,6 +18,7 @@ import withKeyboardState, {keyboardStatePropTypes} from './withKeyboardState'; import AvatarWithDisplayName from './AvatarWithDisplayName'; import iouReportPropTypes from '../pages/iouReportPropTypes'; import participantPropTypes from './participantPropTypes'; +import PinButton from './PinButton'; const propTypes = { /** Title of the Header */ @@ -50,6 +51,9 @@ const propTypes = { /** Whether we should show a get assistance (question mark) button */ shouldShowGetAssistanceButton: PropTypes.bool, + /** Whether we should show a pin button */ + shouldShowPinButton: PropTypes.bool, + /** Whether we should show a more options (threedots) button */ shouldShowThreeDotsButton: PropTypes.bool, @@ -83,6 +87,9 @@ const propTypes = { /** Whether we should show an avatar */ shouldShowAvatarWithDisplay: PropTypes.bool, + /** Parent report, if provided it will override props.report for AvatarWithDisplay */ + parentReport: iouReportPropTypes, + /** Report, if we're showing the details for one and using AvatarWithDisplay */ report: iouReportPropTypes, @@ -112,10 +119,12 @@ const defaultProps = { shouldShowDownloadButton: false, shouldShowGetAssistanceButton: false, shouldShowThreeDotsButton: false, + shouldShowPinButton: false, shouldShowCloseButton: true, shouldShowStepCounter: true, shouldShowAvatarWithDisplay: false, report: null, + parentReport: null, policies: {}, personalDetails: {}, guidesCallTaskID: '', @@ -168,7 +177,7 @@ class HeaderWithCloseButton extends Component { )} {this.props.shouldShowAvatarWithDisplay && ( @@ -212,6 +221,8 @@ class HeaderWithCloseButton extends Component { )} + {this.props.shouldShowPinButton && } + {this.props.shouldShowThreeDotsButton && ( {}, onHoverOut: () => {}, }; diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js index 2a144f45205e..c3b26d674f86 100644 --- a/src/components/Hoverable/index.js +++ b/src/components/Hoverable/index.js @@ -1,6 +1,5 @@ import _ from 'underscore'; import React, {Component} from 'react'; -import {View} from 'react-native'; import {propTypes, defaultProps} from './hoverablePropTypes'; /** @@ -33,6 +32,16 @@ class Hoverable extends Component { document.addEventListener('touchmove', this.enableHover); } + componentDidUpdate(prevProps) { + if (prevProps.disabled === this.props.disabled) { + return; + } + + if (this.props.disabled && this.state.isHovered) { + this.setState({isHovered: false}); + } + } + componentWillUnmount() { document.removeEventListener('touchstart', this.disableHover); document.removeEventListener('touchmove', this.enableHover); @@ -44,6 +53,10 @@ class Hoverable extends Component { * @param {Boolean} isHovered - Whether or not this component is hovered. */ setIsHovered(isHovered) { + if (this.props.disabled) { + return; + } + if (isHovered !== this.state.isHovered && !(isHovered && this.hoverDisabled)) { this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut); } @@ -55,61 +68,51 @@ class Hoverable extends Component { } render() { - if (this.props.absolute && React.isValidElement(this.props.children)) { - return React.cloneElement(React.Children.only(this.props.children), { - ref: (el) => { - this.wrapperView = el; - - // Call the original ref, if any - const {ref} = this.props.children; - if (_.isFunction(ref)) { - ref(el); - } - }, - onMouseEnter: (el) => { - this.setIsHovered(true); - - if (_.isFunction(this.props.children.props.onMouseEnter)) { - this.props.children.props.onMouseEnter(el); - } - }, - onMouseLeave: (el) => { - this.setIsHovered(false); + const child = _.isFunction(this.props.children) ? this.props.children(this.state.isHovered) : this.props.children; - if (_.isFunction(this.props.children.props.onMouseLeave)) { - this.props.children.props.onMouseLeave(el); - } - }, - onBlur: (el) => { - if (!this.wrapperView.contains(el.relatedTarget)) { - this.setIsHovered(false); - } - - if (_.isFunction(this.props.children.props.onBlur)) { - this.props.children.props.onBlur(el); - } - }, - }); + if (!React.isValidElement(child)) { + throw Error('Children is not a valid element.'); } - return ( - (this.wrapperView = el)} - onMouseEnter={() => this.setIsHovered(true)} - onMouseLeave={() => this.setIsHovered(false)} - onBlur={(el) => { - if (this.wrapperView.contains(el.relatedTarget)) { - return; - } + + return React.cloneElement(React.Children.only(child), { + ref: (el) => { + this.wrapperView = el; + + // Call the original ref, if any + const {ref} = child; + if (_.isFunction(ref)) { + ref(el); + return; + } + + if (_.isObject(ref)) { + ref.current = el; + } + }, + onMouseEnter: (el) => { + this.setIsHovered(true); + + if (_.isFunction(child.props.onMouseEnter)) { + child.props.onMouseEnter(el); + } + }, + onMouseLeave: (el) => { + this.setIsHovered(false); + + if (_.isFunction(child.props.onMouseLeave)) { + child.props.onMouseLeave(el); + } + }, + onBlur: (el) => { + if (!this.wrapperView.contains(el.relatedTarget)) { this.setIsHovered(false); - }} - > - { - // If this.props.children is a function, call it to provide the hover state to the children. - _.isFunction(this.props.children) ? this.props.children(this.state.isHovered) : this.props.children } - - ); + + if (_.isFunction(child.props.onBlur)) { + child.props.onBlur(el); + } + }, + }); } } diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index 70503706f112..db093d2bc4df 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -41,6 +41,10 @@ import ExpensifyWordmark from '../../../assets/images/expensify-wordmark.svg'; import Expand from '../../../assets/images/expand.svg'; import Eye from '../../../assets/images/eye.svg'; import EyeDisabled from '../../../assets/images/eye-disabled.svg'; +import Flag from '../../../assets/images/flag.svg'; +import FlagLevelOne from '../../../assets/images/flag_level_01.svg'; +import FlagLevelTwo from '../../../assets/images/flag_level_02.svg'; +import FlagLevelThree from '../../../assets/images/flag_level_03.svg'; import Gallery from '../../../assets/images/gallery.svg'; import Gear from '../../../assets/images/gear.svg'; import Globe from '../../../assets/images/globe.svg'; @@ -162,6 +166,10 @@ export { EyeDisabled, FallbackAvatar, FallbackWorkspaceAvatar, + Flag, + FlagLevelOne, + FlagLevelTwo, + FlagLevelThree, Gallery, Gear, Globe, diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js index f1ec25485991..7138f4087ed4 100644 --- a/src/components/ImageView/index.js +++ b/src/components/ImageView/index.js @@ -282,6 +282,7 @@ class ImageView extends PureComponent { > { const optionItem = SidebarUtils.getOptionData(props.reportID); + + React.useEffect(() => { + ReportActionContextMenu.hideContextMenu(false); + }, [optionItem.isPinned]); + if (!optionItem) { return null; } - let touchableRef = null; + let popoverAnchor = null; const textStyle = props.isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; const textUnreadStyle = optionItem.isUnread ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; const displayNameStyle = StyleUtils.combineStyles([styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, ...textUnreadStyle], props.style); @@ -77,13 +85,34 @@ const OptionRowLHN = (props) => { const hoveredBackgroundColor = props.hoverStyle && props.hoverStyle.backgroundColor ? props.hoverStyle.backgroundColor : themeColors.sidebar; const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; - const avatarTooltips = !optionItem.isChatRoom && !optionItem.isArchivedRoom ? _.pluck(optionItem.displayNamesWithTooltips, 'tooltip') : undefined; const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; const shouldShowGreenDotIndicator = !hasBrickError && (optionItem.isUnreadWithMention || (optionItem.hasOutstandingIOU && !optionItem.isIOUReportOwner)); // If the item is a thread within a workspace, we will show the subtitle as the second line instead of in a pill const alternativeText = optionItem.isThread && optionItem.subtitle ? optionItem.subtitle : optionItem.alternateText; + /** + * Show the ReportActionContextMenu modal popover. + * + * @param {Object} [event] - A press event. + */ + const showPopover = (event) => { + ReportActionContextMenu.showContextMenu( + ContextMenuActions.CONTEXT_MENU_TYPES.REPORT, + event, + '', + popoverAnchor, + props.reportID, + {}, + '', + () => {}, + () => {}, + false, + false, + optionItem.isPinned, + ); + }; + return ( { > {(hovered) => ( - (touchableRef = el)} + (popoverAnchor = el)} onPress={(e) => { if (e) { e.preventDefault(); } - props.onSelectRow(optionItem, touchableRef); + props.onSelectRow(optionItem, popoverAnchor); }} + onSecondaryInteraction={(e) => showPopover(e)} + withoutFocusOnSecondaryInteraction activeOpacity={0.8} style={[ styles.flexRow, @@ -138,7 +169,7 @@ const OptionRowLHN = (props) => { props.isFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, hovered && !props.isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, ]} - avatarTooltips={optionItem.isPolicyExpenseChat ? [optionItem.subtitle] : avatarTooltips} + shouldShowTooltip={!optionItem.isChatRoom && !optionItem.isArchivedRoom} /> ))} @@ -214,7 +245,7 @@ const OptionRowLHN = (props) => { )} - + )} diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 249cd938889b..32c7ec21a920 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import styles from '../styles/styles'; +import * as StyleUtils from '../styles/StyleUtils'; import * as ValidationUtils from '../libs/ValidationUtils'; import CONST from '../CONST'; import Text from './Text'; @@ -10,7 +11,7 @@ import TextInput from './TextInput'; import FormHelpMessage from './FormHelpMessage'; import {withNetwork} from './OnyxProvider'; import networkPropTypes from './networkPropTypes'; -import useOnNetworkReconnect from './hooks/useOnNetworkReconnect'; +import useOnNetworkReconnect from '../hooks/useOnNetworkReconnect'; import * as Browser from '../libs/Browser'; const propTypes = { @@ -100,6 +101,11 @@ function MagicCodeInput(props) { const [focusedIndex, setFocusedIndex] = useState(0); const [editIndex, setEditIndex] = useState(0); + const blurMagicCodeInput = () => { + inputRefs.current[editIndex].blur(); + setFocusedIndex(undefined); + }; + useImperativeHandle(props.innerRef, () => ({ focus() { setFocusedIndex(0); @@ -112,6 +118,9 @@ function MagicCodeInput(props) { inputRefs.current[0].focus(); props.onChangeText(''); }, + blur() { + blurMagicCodeInput(); + }, })); const validateAndSubmit = () => { @@ -121,8 +130,7 @@ function MagicCodeInput(props) { } // Blurs the input and removes focus from the last input and, if it should submit // on complete, it will call the onFulfill callback. - inputRefs.current[editIndex].blur(); - setFocusedIndex(undefined); + blurMagicCodeInput(); props.onFulfill(props.value); }; @@ -163,7 +171,6 @@ function MagicCodeInput(props) { */ const onFocus = (event) => { event.preventDefault(); - setInput(''); }; /** @@ -294,7 +301,14 @@ function MagicCodeInput(props) { key={index} style={[styles.w15]} > - + {decomposeString(props.value, props.maxLength)[index] || ''} diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 94e9913a7366..5dbcdf6d7be3 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -3,6 +3,7 @@ import React from 'react'; import {View} from 'react-native'; import Text from './Text'; import styles from '../styles/styles'; +import themeColors from '../styles/themes/default'; import * as StyleUtils from '../styles/StyleUtils'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; @@ -20,6 +21,7 @@ import PressableWithSecondaryInteraction from './PressableWithSecondaryInteracti import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; import * as DeviceCapabilities from '../libs/DeviceCapabilities'; import ControlSelection from '../libs/ControlSelection'; +import variables from '../styles/variables'; const propTypes = { ...menuItemPropTypes, @@ -36,6 +38,8 @@ const defaultProps = { wrapperStyle: [], style: styles.popoverMenuItem, titleStyle: {}, + shouldShowTitleIcon: false, + titleIcon: () => {}, descriptionTextStyle: styles.breakWord, success: false, icon: undefined, @@ -60,6 +64,9 @@ const defaultProps = { avatarSize: undefined, shouldBlockSelection: false, shouldShowMultilineTitle: false, + hoverAndPressStyle: [], + furtherDetails: '', + furtherDetailsIcon: undefined, }; const MenuItem = (props) => { @@ -67,6 +74,7 @@ const MenuItem = (props) => { const descriptionVerticalMargin = props.shouldShowDescriptionOnTop ? styles.mb1 : styles.mt1; const titleTextStyle = StyleUtils.combineStyles( [ + styles.flexShrink1, styles.popoverMenuText, props.icon ? styles.ml3 : undefined, props.shouldShowBasicTitle ? undefined : styles.textStrong, @@ -93,7 +101,7 @@ const MenuItem = (props) => { return ( { - if (props.disabled) { + if (props.disabled || !props.interactive) { return; } @@ -108,7 +116,9 @@ const MenuItem = (props) => { onSecondaryInteraction={props.onSecondaryInteraction} style={({hovered, pressed}) => [ props.style, + !props.interactive && styles.cursorDefault, StyleUtils.getButtonBackgroundColorStyle(getButtonState(props.focused || hovered, pressed, props.success, props.disabled, props.interactive), true), + (hovered || pressed) && props.hoverAndPressStyle, ...(_.isArray(props.wrapperStyle) ? props.wrapperStyle : [props.wrapperStyle]), ]} disabled={props.disabled} @@ -158,14 +168,24 @@ const MenuItem = (props) => { {props.description} )} - {Boolean(props.title) && ( - - {convertToLTR(props.title)} - - )} + + {Boolean(props.title) && ( + + {convertToLTR(props.title)} + + )} + {Boolean(props.shouldShowTitleIcon) && ( + + + + )} + {Boolean(props.description) && !props.shouldShowDescriptionOnTop && ( { {props.description} )} + {Boolean(props.furtherDetails) && ( + + + + {props.furtherDetails} + + + )} diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js index 710743bb0edb..7dac263e453c 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.js @@ -1,5 +1,5 @@ import React, {PureComponent} from 'react'; -import {StatusBar, View} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import ReactNativeModal from 'react-native-modal'; import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; @@ -127,9 +127,7 @@ class BaseModal extends PureComponent { hasBackdrop={this.props.fullscreen} coverScreen={this.props.fullscreen} style={modalStyle} - // When `statusBarTranslucent` is true on Android, the modal fully covers the status bar. - // Since `windowHeight` doesn't include status bar height, it should be added in the `deviceHeight` calculation. - deviceHeight={this.props.windowHeight + ((this.props.statusBarTranslucent && StatusBar.currentHeight) || 0)} + deviceHeight={this.props.windowHeight} deviceWidth={this.props.windowWidth} animationIn={this.props.animationIn || animationIn} animationOut={this.props.animationOut || animationOut} @@ -147,7 +145,7 @@ class BaseModal extends PureComponent { paddingBottom: safeAreaPaddingBottom, paddingLeft: safeAreaPaddingLeft, paddingRight: safeAreaPaddingRight, - } = StyleUtils.getSafeAreaPadding(insets, this.props.statusBarTranslucent); + } = StyleUtils.getSafeAreaPadding(insets); const modalPaddingStyles = StyleUtils.getModalPaddingStyles({ safeAreaPaddingTop, diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 8b6a43f47215..4e53f6967df0 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -23,10 +23,10 @@ import * as CurrencyUtils from '../libs/CurrencyUtils'; const propTypes = { /** Callback to inform parent modal of success */ - onConfirm: PropTypes.func.isRequired, + onConfirm: PropTypes.func, /** Callback to parent modal to send money */ - onSendMoney: PropTypes.func.isRequired, + onSendMoney: PropTypes.func, /** Should we request a single or multiple participant selection from user */ hasMultipleParticipants: PropTypes.bool.isRequired, @@ -40,9 +40,18 @@ const propTypes = { /** Selected participants from MoneyRequestModal with login */ participants: PropTypes.arrayOf(optionPropTypes).isRequired, + /** Payee of the money request with login */ + payeePersonalDetails: optionPropTypes, + /** Can the participants be modified or not */ canModifyParticipants: PropTypes.bool, + /** Should the list be read only, and not editable? */ + isReadOnly: PropTypes.bool, + + /** Depending on expense report or personal IOU report, respective bank account route */ + bankAccountRoute: PropTypes.string, + ...windowDimensionsPropTypes, ...withLocalizePropTypes, @@ -66,21 +75,28 @@ const propTypes = { }), /** Callback function to navigate to a provided step in the MoneyRequestModal flow */ - navigateToStep: PropTypes.func.isRequired, + navigateToStep: PropTypes.func, /** The policyID of the request */ - policyID: PropTypes.string.isRequired, + policyID: PropTypes.string, }; const defaultProps = { + onConfirm: () => {}, + onSendMoney: () => {}, + navigateToStep: () => {}, iou: { selectedCurrencyCode: CONST.CURRENCY.USD, }, iouType: CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, + payeePersonalDetails: null, canModifyParticipants: false, + isReadOnly: false, + bankAccountRoute: '', session: { email: null, }, + policyID: '', ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -154,6 +170,15 @@ class MoneyRequestConfirmationList extends Component { return _.map(participants, (option) => _.omit(option, 'descriptiveText')); } + /** + * Returns the personalDetails object for the payee. Use the payee prop if passed, else fallback to current user + * + * @returns {Object} personalDetails + */ + getPayeePersonalDetails() { + return this.props.payeePersonalDetails || this.props.currentUserPersonalDetails; + } + /** * Returns the sections needed for the OptionsSelector * @@ -170,15 +195,15 @@ class MoneyRequestConfirmationList extends Component { const formattedParticipants = _.union(formattedSelectedParticipants, formattedUnselectedParticipants); const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, this.props.iouAmount, true); - const formattedMyPersonalDetails = OptionsListUtils.getIOUConfirmationOptionsFromMyPersonalDetail( - this.props.currentUserPersonalDetails, + const formattedPayeePersonalDetails = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail( + this.getPayeePersonalDetails(), CurrencyUtils.convertToDisplayString(myIOUAmount, this.props.iou.selectedCurrencyCode), ); sections.push( { title: this.props.translate('moneyRequestConfirmationList.whoPaid'), - data: [formattedMyPersonalDetails], + data: [formattedPayeePersonalDetails], shouldShow: true, indexOffset: 0, isDisabled: true, @@ -202,6 +227,36 @@ class MoneyRequestConfirmationList extends Component { return sections; } + getFooterContent() { + if (this.props.isReadOnly) { + return; + } + + const selectedParticipants = this.getSelectedParticipants(); + const shouldShowSettlementButton = this.props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND; + const shouldDisableButton = selectedParticipants.length === 0; + const recipient = this.state.participants[0]; + + return shouldShowSettlementButton ? ( + + ) : ( + this.confirm(value)} + options={this.getSplitOrRequestOptions()} + /> + ); + } + /** * Returns selected options -- there is checkmark for every row in List for split flow * @returns {Array} @@ -211,7 +266,7 @@ class MoneyRequestConfirmationList extends Component { return []; } const selectedParticipants = this.getSelectedParticipants(); - return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromMyPersonalDetail(this.props.currentUserPersonalDetails)]; + return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(this.getPayeePersonalDetails())]; } /** @@ -259,11 +314,7 @@ class MoneyRequestConfirmationList extends Component { } render() { - const selectedParticipants = this.getSelectedParticipants(); - const shouldShowSettlementButton = this.props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND; - const shouldDisableButton = selectedParticipants.length === 0; - const recipient = this.state.participants[0]; - const canModifyParticipants = this.props.canModifyParticipants && this.props.hasMultipleParticipants; + const canModifyParticipants = !this.props.isReadOnly && this.props.canModifyParticipants && this.props.hasMultipleParticipants; const formattedAmount = CurrencyUtils.convertToDisplayString(this.props.iouAmount, this.props.iou.selectedCurrencyCode); return ( @@ -281,43 +332,24 @@ class MoneyRequestConfirmationList extends Component { shouldShowTextInput={false} shouldUseStyleForChildren={false} optionHoveredStyle={canModifyParticipants ? styles.hoveredComponentBG : {}} - footerContent={ - shouldShowSettlementButton ? ( - - ) : ( - this.confirm(value)} - options={this.getSplitOrRequestOptions()} - /> - ) - } + footerContent={this.getFooterContent()} > this.props.navigateToStep(0)} style={[styles.moneyRequestMenuItem, styles.mt2]} titleStyle={styles.moneyRequestConfirmationAmount} - disabled={this.state.didConfirm} + disabled={this.state.didConfirm || this.props.isReadOnly} /> Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION)} style={[styles.moneyRequestMenuItem, styles.mb2]} - disabled={this.state.didConfirm} + disabled={this.state.didConfirm || this.props.isReadOnly} /> ); diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index 3f8a074e0bbb..2db66e50370b 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -27,6 +27,7 @@ import * as CurrencyUtils from '../libs/CurrencyUtils'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import DateUtils from '../libs/DateUtils'; import reportPropTypes from '../pages/reportPropTypes'; +import * as UserUtils from '../libs/UserUtils'; const propTypes = { /** The report currently being looked at */ @@ -82,15 +83,18 @@ const MoneyRequestHeader = (props) => { const payeeName = isExpenseReport ? ReportUtils.getPolicyName(moneyRequestReport, props.policies) : ReportUtils.getDisplayNameForParticipant(moneyRequestReport.managerEmail); const payeeAvatar = isExpenseReport ? ReportUtils.getWorkspaceAvatar(moneyRequestReport) - : ReportUtils.getAvatar(lodashGet(props.personalDetails, [moneyRequestReport.managerEmail, 'avatar']), moneyRequestReport.managerEmail); + : UserUtils.getAvatar(lodashGet(props.personalDetails, [moneyRequestReport.managerEmail, 'avatar']), moneyRequestReport.managerEmail); const policy = props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`]; - const isPayer = Policy.isAdminOfFreePolicy([policy]) || (ReportUtils.isMoneyRequestReport(props.report) && lodashGet(props.session, 'email', null) === props.report.managerEmail); + const isPayer = + Policy.isAdminOfFreePolicy([policy]) || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(props.session, 'email', null) === moneyRequestReport.managerEmail); const shouldShowSettlementButton = !isSettled && !props.isSingleTransactionView && isPayer; + const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport); return ( { }, ]} threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(props.windowWidth)} - report={moneyRequestReport} + report={props.report} + parentReport={moneyRequestReport} policies={props.policies} personalDetails={props.personalDetails} shouldShowCloseButton={false} @@ -135,7 +140,7 @@ const MoneyRequestHeader = (props) => { {!props.isSingleTransactionView && {formattedAmount}} - {isSettled && ( + {!props.isSingleTransactionView && isSettled && ( { iouReport={props.report} onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.report)} enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} - addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} + addBankAccountRoute={bankAccountRoute} shouldShowPaymentOptions /> @@ -169,7 +174,7 @@ const MoneyRequestHeader = (props) => { iouReport={props.report} onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.report)} enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} - addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} + addBankAccountRoute={bankAccountRoute} shouldShowPaymentOptions /> )} @@ -178,7 +183,9 @@ const MoneyRequestHeader = (props) => { <> { let avatarContainerStyles = props.size === CONST.AVATAR_SIZE.SMALL ? [styles.emptyAvatarSmall, styles.emptyAvatarMarginSmall] : [styles.emptyAvatar, styles.emptyAvatarMargin]; const singleAvatarStyles = props.size === CONST.AVATAR_SIZE.SMALL ? styles.singleAvatarSmall : styles.singleAvatar; const secondAvatarStyles = [props.size === CONST.AVATAR_SIZE.SMALL ? styles.secondAvatarSmall : styles.secondAvatar, ...props.secondAvatarStyle]; + const tooltipTexts = props.shouldShowTooltip ? _.pluck(props.icons, 'name') : []; if (!props.icons.length) { return null; @@ -73,8 +74,8 @@ const MultipleAvatars = (props) => { if (props.icons.length === 1 && !props.shouldStackHorizontally) { return ( - - + + { name={props.icons[0].name} type={props.icons[0].type} /> - - + + ); } @@ -113,7 +114,7 @@ const MultipleAvatars = (props) => { {_.map([...props.icons].splice(0, 4), (icon, index) => ( { ))} {props.icons.length > 4 && ( { ) : ( - + {/* View is necessary for tooltip to show for multiple avatars in LHN */} { {props.icons.length === 2 ? ( - + { ) : ( - + { - this.props.onInputChange(moment(selectedDate).format(CONST.DATE.MOMENT_FORMAT_STRING)); + this.props.onTouched(); + this.props.onInputChange(selectedDate); }); } diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 2c4cc0d9e176..c8a38976cb2d 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -133,8 +133,6 @@ class OptionRow extends Component { // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((this.props.option.participantsList || []).slice(0, 10), isMultipleParticipant); - const avatarTooltips = this.props.showTitleTooltip && !this.props.option.isChatRoom && !this.props.option.isArchivedRoom ? _.pluck(displayNamesWithTooltips, 'tooltip') : undefined; - let subscriptColor = themeColors.appBG; if (this.props.optionIsFocused) { subscriptColor = focusedBackgroundColor; @@ -146,7 +144,7 @@ class OptionRow extends Component { errors={this.props.option.allReportErrors} shouldShowErrorMessages={false} > - + {(hovered) => ( (touchableRef = el)} @@ -197,7 +195,7 @@ class OptionRow extends Component { this.props.optionIsFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, hovered && !this.props.optionIsFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, ]} - avatarTooltips={this.props.option.isPolicyExpenseChat ? [this.props.option.subtitle] : avatarTooltips} + shouldShowTooltip={this.props.showTitleTooltip && !this.props.option.isChatRoom && !this.props.option.isArchivedRoom} /> ))} @@ -208,7 +206,9 @@ class OptionRow extends Component { tooltipEnabled={this.props.showTitleTooltip} numberOfLines={1} textStyles={displayNameStyle} - shouldUseFullTitle={this.props.option.isChatRoom || this.props.option.isPolicyExpenseChat || this.props.option.isMoneyRequestReport} + shouldUseFullTitle={ + this.props.option.isChatRoom || this.props.option.isPolicyExpenseChat || this.props.option.isMoneyRequestReport || this.props.option.isThread + } /> {this.props.option.alternateText ? ( ( + + Report.togglePinnedState(props.report.reportID, props.report.isPinned))} + style={[styles.touchableButtonImage]} + > + + + +); + +PinButton.displayName = 'PinButton'; +PinButton.propTypes = propTypes; +PinButton.defaultProps = defaultProps; + +export default withLocalize(PinButton); diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js index 8600ee3ac807..6cdb83bbb81a 100644 --- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js @@ -91,6 +91,9 @@ const GenericPressable = forwardRef((props, ref) => { if (isDisabled) { return; } + if (!onPress) { + return; + } if (shouldUseHapticsOnPress) { HapticFeedback.press(); } diff --git a/src/components/Pressable/GenericPressable/PropTypes.js b/src/components/Pressable/GenericPressable/PropTypes.js index 950c443f8e96..588161031e10 100644 --- a/src/components/Pressable/GenericPressable/PropTypes.js +++ b/src/components/Pressable/GenericPressable/PropTypes.js @@ -21,7 +21,7 @@ const pressablePropTypes = { /** * onPress callback */ - onPress: PropTypes.func.isRequired, + onPress: PropTypes.func, /** * Specifies keyboard shortcut to trigger onPressHandler @@ -121,6 +121,7 @@ const pressablePropTypes = { }; const defaultProps = { + onPress: undefined, keyboardShortcut: undefined, shouldUseHapticsOnPress: false, shouldUseHapticsOnLongPress: false, diff --git a/src/components/Pressable/PressableWithoutFeedback.js b/src/components/Pressable/PressableWithoutFeedback.js index 5b25f207d2a3..92e704550dec 100644 --- a/src/components/Pressable/PressableWithoutFeedback.js +++ b/src/components/Pressable/PressableWithoutFeedback.js @@ -5,11 +5,16 @@ import GenericPressableProps from './GenericPressable/PropTypes'; const omittedProps = ['pressStyle', 'hoverStyle', 'focusStyle', 'activeStyle', 'disabledStyle', 'screenReaderActiveStyle', 'shouldUseHapticsOnPress', 'shouldUseHapticsOnLongPress']; -const PressableWithoutFeedback = (props) => { +const PressableWithoutFeedback = React.forwardRef((props, ref) => { const propsWithoutStyling = _.omit(props, omittedProps); - // eslint-disable-next-line react/jsx-props-no-spreading - return ; -}; + return ( + + ); +}); PressableWithoutFeedback.displayName = 'PressableWithoutFeedback'; PressableWithoutFeedback.propTypes = _.omit(GenericPressableProps.pressablePropTypes, omittedProps); diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index 037fb3afae73..7f43451cbadd 100644 --- a/src/components/PressableWithSecondaryInteraction/index.js +++ b/src/components/PressableWithSecondaryInteraction/index.js @@ -17,8 +17,12 @@ class PressableWithSecondaryInteraction extends Component { } componentDidMount() { - if (this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) { - this.props.forwardedRef(this.pressableRef); + if (this.props.forwardedRef) { + if (_.isFunction(this.props.forwardedRef)) { + this.props.forwardedRef(this.pressableRef); + } else if (_.isObject(this.props.forwardedRef)) { + this.props.forwardedRef.current = this.pressableRef; + } } this.pressableRef.addEventListener('contextmenu', this.executeSecondaryInteractionOnContextMenu); } @@ -76,6 +80,7 @@ class PressableWithSecondaryInteraction extends Component { style={StyleUtils.combineStyles(this.props.inline ? styles.dInline : this.props.style)} onPressIn={this.props.onPressIn} onLongPress={this.props.onSecondaryInteraction ? this.executeSecondaryInteraction : undefined} + activeOpacity={this.props.activeOpacity} onPressOut={this.props.onPressOut} onPress={this.props.onPress} ref={(el) => (this.pressableRef = el)} diff --git a/src/components/PressableWithSecondaryInteraction/index.native.js b/src/components/PressableWithSecondaryInteraction/index.native.js index 7199223bf18d..7768430c363d 100644 --- a/src/components/PressableWithSecondaryInteraction/index.native.js +++ b/src/components/PressableWithSecondaryInteraction/index.native.js @@ -28,6 +28,7 @@ const PressableWithSecondaryInteraction = (props) => { }} onPressIn={props.onPressIn} onPressOut={props.onPressOut} + activeOpacity={props.activeOpacity} // eslint-disable-next-line react/jsx-props-no-spreading {..._.omit(props, 'onLongPress')} > diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js index e54598cb1394..de0adbe81297 100644 --- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js +++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import refPropTypes from '../refPropTypes'; import stylePropTypes from '../../styles/stylePropTypes'; const propTypes = { @@ -18,7 +19,7 @@ const propTypes = { children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, /** The ref to the search input (may be null on small screen widths) */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + forwardedRef: refPropTypes, /** Prevent the default ContextMenu on web/Desktop */ preventDefaultContextMenu: PropTypes.bool, @@ -28,6 +29,7 @@ const propTypes = { * * - No support for delayLongPress. * - No support for pressIn and pressOut events. + * - No support for opacity * * Note: Web uses styling instead of Text due to no support of LongPress. Thus above pointers are not valid for web. */ @@ -36,6 +38,9 @@ const propTypes = { /** Disable focus trap for the element on secondary interaction */ withoutFocusOnSecondaryInteraction: PropTypes.bool, + /** Opacity to reduce to when active */ + activeOpacity: PropTypes.number, + /** Used to apply styles to the Pressable */ style: stylePropTypes, }; @@ -47,6 +52,7 @@ const defaultProps = { preventDefaultContextMenu: true, inline: false, withoutFocusOnSecondaryInteraction: false, + activeOpacity: 1, enableLongPressWithHover: false, }; diff --git a/src/components/QRCode/index.js b/src/components/QRCode/index.js index 41686f7c8502..06503d5987e7 100644 --- a/src/components/QRCode/index.js +++ b/src/components/QRCode/index.js @@ -10,9 +10,9 @@ const propTypes = { url: PropTypes.string.isRequired, /** * The logo which will be displayed in the middle of the QR code. - * Follows `ImageSourcePropType` from react-native. + * Follows ImageProps href from react-native-svg that is used by react-native-qrcode-svg. */ - logo: PropTypes.oneOfType([PropTypes.shape({uri: PropTypes.string}), PropTypes.number]), + logo: PropTypes.oneOfType([PropTypes.shape({uri: PropTypes.string}), PropTypes.number, PropTypes.string]), /** * The QRCode size */ diff --git a/src/components/QRShare/index.js b/src/components/QRShare/index.js index d6999952a4d6..784e32d5410e 100644 --- a/src/components/QRShare/index.js +++ b/src/components/QRShare/index.js @@ -47,13 +47,7 @@ class QRShare extends Component { style={styles.shareCodeContainer} onLayout={this.onLayout} > - + {this.props.title} @@ -80,9 +74,9 @@ class QRShare extends Component { {this.props.subtitle && ( {this.props.subtitle} diff --git a/src/components/QRShare/propTypes.js b/src/components/QRShare/propTypes.js index bcb55f99ea69..4b53ce512597 100644 --- a/src/components/QRShare/propTypes.js +++ b/src/components/QRShare/propTypes.js @@ -16,7 +16,7 @@ const qrSharePropTypes = { /** * The logo which will be display in the middle of the QR code */ - logo: PropTypes.string, + logo: PropTypes.oneOfType([PropTypes.shape({uri: PropTypes.string}), PropTypes.number, PropTypes.string]), }; const defaultProps = { diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.js index e60712d45c6f..7704ee4c25c4 100644 --- a/src/components/Reactions/AddReactionBubble.js +++ b/src/components/Reactions/AddReactionBubble.js @@ -11,6 +11,7 @@ import getButtonState from '../../libs/getButtonState'; import * as EmojiPickerAction from '../../libs/actions/EmojiPickerAction'; import variables from '../../styles/variables'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import * as Session from '../../libs/actions/Session'; const propTypes = { /** Whether it is for context menu so we can modify its style */ @@ -66,14 +67,11 @@ const AddReactionBubble = (props) => { }; return ( - + [styles.emojiReactionBubble, styles.userSelectNone, StyleUtils.getEmojiReactionBubbleStyle(hovered || pressed, false, props.isContextMenu)]} - onPress={onPress} + onPress={Session.checkIfActionIsAllowed(onPress)} // Prevent text input blur when Add reaction is clicked onMouseDown={(e) => e.preventDefault()} > diff --git a/src/components/Reactions/MiniQuickEmojiReactions.js b/src/components/Reactions/MiniQuickEmojiReactions.js index e4bc4664a70c..373444022e46 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.js +++ b/src/components/Reactions/MiniQuickEmojiReactions.js @@ -16,7 +16,8 @@ import {baseQuickEmojiReactionsPropTypes} from './QuickEmojiReactions/BaseQuickE import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import compose from '../../libs/compose'; import ONYXKEYS from '../../ONYXKEYS'; -import getPreferredEmojiCode from './getPreferredEmojiCode'; +import * as EmojiUtils from '../../libs/EmojiUtils'; +import * as Session from '../../libs/actions/Session'; const propTypes = { ...baseQuickEmojiReactionsPropTypes, @@ -65,14 +66,14 @@ const MiniQuickEmojiReactions = (props) => { key={emoji.name} isDelayButtonStateComplete={false} tooltipText={`:${emoji.name}:`} - onPress={() => props.onEmojiSelected(emoji)} + onPress={Session.checkIfActionIsAllowed(() => props.onEmojiSelected(emoji))} > - {getPreferredEmojiCode(emoji, props.preferredSkinTone)} + {EmojiUtils.getPreferredEmojiCode(emoji, props.preferredSkinTone)} ))} diff --git a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js index 705897b4893e..e5b7e062a0a9 100644 --- a/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js +++ b/src/components/Reactions/QuickEmojiReactions/BaseQuickEmojiReactions.js @@ -8,8 +8,9 @@ import AddReactionBubble from '../AddReactionBubble'; import CONST from '../../../CONST'; import styles from '../../../styles/styles'; import ONYXKEYS from '../../../ONYXKEYS'; -import getPreferredEmojiCode from '../getPreferredEmojiCode'; import Tooltip from '../../Tooltip'; +import * as EmojiUtils from '../../../libs/EmojiUtils'; +import * as Session from '../../../libs/actions/Session'; const baseQuickEmojiReactionsPropTypes = { /** @@ -48,19 +49,17 @@ const defaultProps = { const BaseQuickEmojiReactions = (props) => ( {_.map(CONST.QUICK_REACTIONS, (emoji) => ( - // Note: focus is handled by the Pressable component in EmojiReactionBubble - { - props.onEmojiSelected(emoji); - }} - /> + + props.onEmojiSelected(emoji))} + /> + ))} { - const emojiCodes = []; - _.forEach(users, (user) => { - const emojiCode = getPreferredEmojiCode(emoji, user.skinTone); - - if (emojiCode && !emojiCodes.includes(emojiCode)) { - emojiCodes.push(emojiCode); - } - }); - return emojiCodes; -}; +import * as EmojiUtils from '../../libs/EmojiUtils'; const propTypes = { /** @@ -79,7 +59,7 @@ const ReportActionItemReactions = (props) => { const reactionCount = reaction.users.length; const reactionUsers = _.map(reaction.users, (sender) => sender.accountID.toString()); const emoji = _.find(emojis, (e) => e.name === reaction.emoji); - const emojiCodes = getUniqueEmojiCodes(emoji, reaction.users); + const emojiCodes = EmojiUtils.getUniqueEmojiCodes(emoji, reaction.users); const hasUserReacted = Report.hasAccountIDReacted(props.currentUserPersonalDetails.accountID, reactionUsers); const onPress = () => { @@ -103,15 +83,17 @@ const ReportActionItemReactions = (props) => { renderTooltipContentKey={[...reactionUsers, ...emojiCodes]} key={reaction.emoji} > - + + + ); })} diff --git a/src/components/Reactions/getPreferredEmojiCode.js b/src/components/Reactions/getPreferredEmojiCode.js deleted file mode 100644 index 00c3184b59dc..000000000000 --- a/src/components/Reactions/getPreferredEmojiCode.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Given an emoji object it returns the correct emoji code - * based on the users preferred skin tone. - * @param {Object} emoji - * @param {String | Number} preferredSkinTone - * @returns {String} - */ -export default function getPreferredEmojiCode(emoji, preferredSkinTone) { - if (emoji.types) { - const emojiCodeWithSkinTone = emoji.types[preferredSkinTone]; - - // Note: it can happen that preferredSkinTone has a outdated format, - // so it makes sense to check if we actually got a valid emoji code back - if (emojiCodeWithSkinTone) { - return emojiCodeWithSkinTone; - } - } - - return emoji.code; -} diff --git a/src/components/RenderHTML.js b/src/components/RenderHTML.js index de3b15b8567e..203c78de4b25 100644 --- a/src/components/RenderHTML.js +++ b/src/components/RenderHTML.js @@ -1,7 +1,7 @@ import React from 'react'; -import {useWindowDimensions} from 'react-native'; import PropTypes from 'prop-types'; import {RenderHTMLSource} from 'react-native-render-html'; +import useWindowDimensions from '../hooks/useWindowDimensions'; const propTypes = { /** HTML string to render */ @@ -13,10 +13,10 @@ const propTypes = { // context to RenderHTMLSource components. See https://git.io/JRcZb // The provider is available at src/components/HTMLEngineProvider/ const RenderHTML = (props) => { - const {width} = useWindowDimensions(); + const {windowWidth} = useWindowDimensions(); return ( ); diff --git a/src/components/ReportActionItem/IOUPreview.js b/src/components/ReportActionItem/IOUPreview.js index 9e8863fe9953..902c724aa614 100644 --- a/src/components/ReportActionItem/IOUPreview.js +++ b/src/components/ReportActionItem/IOUPreview.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import _ from 'underscore'; -import Str from 'expensify-common/lib/str'; import compose from '../../libs/compose'; import styles from '../../styles/styles'; import ONYXKEYS from '../../ONYXKEYS'; @@ -27,6 +26,7 @@ import * as OptionsListUtils from '../../libs/OptionsListUtils'; import * as CurrencyUtils from '../../libs/CurrencyUtils'; import * as IOUUtils from '../../libs/IOUUtils'; import * as ReportUtils from '../../libs/ReportUtils'; +import refPropTypes from '../refPropTypes'; const propTypes = { /** The active IOUReport, used for Onyx subscription */ @@ -43,7 +43,7 @@ const propTypes = { action: PropTypes.shape(reportActionPropTypes), /** Popover context menu anchor, used for showing context menu */ - contextMenuAnchor: PropTypes.shape({current: PropTypes.elementType}), + contextMenuAnchor: refPropTypes, /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: PropTypes.func, @@ -75,9 +75,6 @@ const propTypes = { /** True if this is this IOU is a split instead of a 1:1 request */ isBillSplit: PropTypes.bool.isRequired, - /** True if the IOU Preview is rendered within a single IOUAction */ - isIOUAction: PropTypes.bool, - /** True if the IOU Preview card is hovered */ isHovered: PropTypes.bool, @@ -118,7 +115,6 @@ const defaultProps = { containerStyles: [], walletTerms: {}, pendingAction: null, - isIOUAction: true, isHovered: false, personalDetails: {}, session: { @@ -134,9 +130,6 @@ const IOUPreview = (props) => { const sessionEmail = lodashGet(props.session, 'email', null); const managerEmail = props.iouReport.managerEmail || ''; const ownerEmail = props.iouReport.ownerEmail || ''; - - // When displaying within a IOUDetailsModal we cannot guarantee that participants are included in the originalMessage data - // Because an IOUPreview of type split can never be rendered within the IOUDetailsModal, manually building the email array is only needed for non-billSplit ious const participantEmails = props.isBillSplit ? lodashGet(props.action, 'originalMessage.participants', []) : [managerEmail, ownerEmail]; const participantAvatars = OptionsListUtils.getAvatarsForLogins(participantEmails, props.personalDetails); @@ -145,10 +138,9 @@ const IOUPreview = (props) => { const moneyRequestAction = ReportUtils.getMoneyRequestAction(props.action); - // If props.action is undefined then we are displaying within IOUDetailsModal and should use the full report amount - const requestAmount = props.isIOUAction ? moneyRequestAction.amount : ReportUtils.getMoneyRequestTotal(props.iouReport); - const requestCurrency = props.isIOUAction ? moneyRequestAction.currency : props.iouReport.currency; - const requestComment = Str.htmlDecode(moneyRequestAction.comment).trim(); + const requestAmount = moneyRequestAction.amount; + const requestCurrency = moneyRequestAction.currency; + const requestComment = moneyRequestAction.comment.trim(); const getSettledMessage = () => { switch (lodashGet(props.action, 'originalMessage.paymentType', '')) { @@ -164,12 +156,6 @@ const IOUPreview = (props) => { }; const showContextMenu = (event) => { - // Use action prop to check if we are in IOUDetailsModal, - // if it's true, do nothing when user long press, otherwise show context menu. - if (!props.action) { - return; - } - showContextMenuForReport(event, props.contextMenuAnchor, props.chatReportID, props.action, props.checkIfContextMenuActive); }; @@ -222,7 +208,6 @@ const IOUPreview = (props) => { size="small" isHovered={props.isHovered} shouldUseCardBackground - avatarTooltips={participantEmails} /> )} diff --git a/src/components/ReportActionItem/MoneyRequestAction.js b/src/components/ReportActionItem/MoneyRequestAction.js index 03a5f047275e..1f11eeae780e 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.js +++ b/src/components/ReportActionItem/MoneyRequestAction.js @@ -20,6 +20,7 @@ import * as ReportUtils from '../../libs/ReportUtils'; import * as Report from '../../libs/actions/Report'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; +import refPropTypes from '../refPropTypes'; const propTypes = { /** All the data of the action */ @@ -35,7 +36,7 @@ const propTypes = { isMostRecentIOUReportAction: PropTypes.bool.isRequired, /** Popover context menu anchor, used for showing context menu */ - contextMenuAnchor: PropTypes.shape({current: PropTypes.elementType}), + contextMenuAnchor: refPropTypes, /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: PropTypes.func, @@ -90,10 +91,12 @@ const defaultProps = { }; const MoneyRequestAction = (props) => { - const hasMultipleParticipants = lodashGet(props.chatReport, 'participants', []).length > 1; + const isSplitBillAction = lodashGet(props.action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT; + const onIOUPreviewPressed = () => { - if (lodashGet(props.action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT && hasMultipleParticipants) { - Navigation.navigate(ROUTES.getReportParticipantsRoute(props.chatReportID)); + if (isSplitBillAction) { + const reportActionID = lodashGet(props.action, 'reportActionID', '0'); + Navigation.navigate(ROUTES.getSplitBillDetailsRoute(props.chatReportID, reportActionID)); return; } @@ -144,7 +147,7 @@ const MoneyRequestAction = (props) => { { const managerEmail = props.iouReport.managerEmail || ''; const managerName = ReportUtils.isPolicyExpenseChat(props.chatReport) ? ReportUtils.getPolicyName(props.chatReport) : ReportUtils.getDisplayNameForParticipant(managerEmail, true); const isCurrentUserManager = managerEmail === lodashGet(props.session, 'email', null); + const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport); return ( {_.map(props.action.message, (message, index) => ( @@ -123,11 +125,13 @@ const ReportPreview = (props) => { {lodashGet(message, 'html', props.translate('iou.payerSettled', {amount: reportAmount}))} {!props.iouReport.hasOutstandingIOU && ( - + + + )} )} @@ -146,7 +150,7 @@ const ReportPreview = (props) => { iouReport={props.iouReport} onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)} enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} - addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} + addBankAccountRoute={bankAccountRoute} style={[styles.requestPreviewBox]} /> )} diff --git a/src/components/ReportActionsSkeletonView/index.js b/src/components/ReportActionsSkeletonView/index.js index 43b1896d0433..7cce81ff5eae 100644 --- a/src/components/ReportActionsSkeletonView/index.js +++ b/src/components/ReportActionsSkeletonView/index.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import {View} from 'react-native'; import SkeletonViewLines from './SkeletonViewLines'; import CONST from '../../CONST'; @@ -50,7 +51,7 @@ const ReportActionsSkeletonView = (props) => { ); } } - return <>{skeletonViewLines}; + return {skeletonViewLines}; }; ReportActionsSkeletonView.displayName = 'ReportActionsSkeletonView'; diff --git a/src/components/ReportTransaction.js b/src/components/ReportTransaction.js deleted file mode 100644 index 166921cb3845..000000000000 --- a/src/components/ReportTransaction.js +++ /dev/null @@ -1,84 +0,0 @@ -import React, {Component} from 'react'; -import PropTypes from 'prop-types'; -import {View} from 'react-native'; -import styles from '../styles/styles'; -import * as IOU from '../libs/actions/IOU'; -import * as ReportActions from '../libs/actions/ReportActions'; -import reportActionPropTypes from '../pages/home/report/reportActionPropTypes'; -import ReportActionItemSingle from '../pages/home/report/ReportActionItemSingle'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import OfflineWithFeedback from './OfflineWithFeedback'; -import Text from './Text'; -import Button from './Button'; - -const propTypes = { - /** The chatReport which the transaction is associated with */ - /* eslint-disable-next-line react/no-unused-prop-types */ - chatReportID: PropTypes.string.isRequired, - - /** ID for the IOU report */ - /* eslint-disable-next-line react/no-unused-prop-types */ - iouReportID: PropTypes.string.isRequired, - - /** The report action which we are displaying */ - action: PropTypes.shape(reportActionPropTypes).isRequired, - - /** Can this transaction be deleted? */ - canBeDeleted: PropTypes.bool, - - /** Indicates whether pressing the delete button should hide the details sidebar */ - shouldCloseOnDelete: PropTypes.bool, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - canBeDeleted: false, - shouldCloseOnDelete: false, -}; - -class ReportTransaction extends Component { - constructor(props) { - super(props); - - this.deleteMoneyRequest = this.deleteMoneyRequest.bind(this); - } - - deleteMoneyRequest() { - IOU.deleteMoneyRequest(this.props.chatReportID, this.props.iouReportID, this.props.action, this.props.shouldCloseOnDelete); - } - - render() { - return ( - ReportActions.clearReportActionErrors(this.props.chatReportID, this.props.action)} - pendingAction={this.props.action.pendingAction} - errors={this.props.action.errors} - errorRowStyles={[styles.ml10, styles.mr2]} - > - - - {this.props.action.message[0].text} - - {this.props.canBeDeleted && ( - - - - )} - - - ); - } -} - -ReportTransaction.defaultProps = defaultProps; -ReportTransaction.propTypes = propTypes; -export default withLocalize(ReportTransaction); diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js index 07b7b16bedfd..a954fe3d8730 100644 --- a/src/components/ReportWelcomeText.js +++ b/src/components/ReportWelcomeText.js @@ -94,10 +94,7 @@ const ReportWelcomeText = (props) => { {props.translate('reportActionsView.beginningOfChatHistory')} {_.map(displayNamesWithTooltips, ({displayName, pronouns, tooltip}, index) => ( - + Navigation.navigate(ROUTES.getDetailsRoute(participants[index]))} diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index 1c9c5ed3bcd7..6d27723ae4e6 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -49,7 +49,7 @@ const propTypes = { nvp_lastPaymentMethod: PropTypes.objectOf(PropTypes.string), /** The policyID of the report we are paying */ - policyID: PropTypes.string.isRequired, + policyID: PropTypes.string, /** Additional styles to add to the component */ style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), @@ -66,6 +66,7 @@ const defaultProps = { nvp_lastPaymentMethod: {}, style: [], iouReport: {}, + policyID: '', }; class SettlementButton extends React.Component { diff --git a/src/components/SplashScreenHider/index.native.js b/src/components/SplashScreenHider/index.native.js index 8e544b7da2a1..6b626ceaaf1f 100644 --- a/src/components/SplashScreenHider/index.native.js +++ b/src/components/SplashScreenHider/index.native.js @@ -1,6 +1,6 @@ import {useCallback, useRef} from 'react'; import PropTypes from 'prop-types'; -import {StatusBar, StyleSheet} from 'react-native'; +import {StyleSheet} from 'react-native'; import Reanimated, {useSharedValue, withTiming, Easing, useAnimatedStyle, runOnJS} from 'react-native-reanimated'; import BootSplash from '../../libs/BootSplash'; import Logo from '../../../assets/images/new-expensify-dark.svg'; @@ -64,7 +64,6 @@ const SplashScreenHider = (props) => { opacityStyle, { // Apply negative margins to center the logo on window (instead of screen) - marginTop: -(StatusBar.currentHeight || 0), marginBottom: -(BootSplash.navigationBarHeight || 0), }, ]} diff --git a/src/components/SubscriptAvatar.js b/src/components/SubscriptAvatar.js index 65c016e29ff3..6c65bb17ce69 100644 --- a/src/components/SubscriptAvatar.js +++ b/src/components/SubscriptAvatar.js @@ -47,7 +47,7 @@ const SubscriptAvatar = (props) => { const containerStyle = props.size === CONST.AVATAR_SIZE.SMALL ? styles.emptyAvatarSmall : styles.emptyAvatar; // Default the margin style to what is normal for small or normal sized avatars - let marginStyle = props.size === CONST.AVATAR_SIZE.SMALL ? styles.emptyAvatarMargin : styles.emptyAvatarMarginSmall; + let marginStyle = props.size === CONST.AVATAR_SIZE.SMALL ? styles.emptyAvatarMarginSmall : styles.emptyAvatarMargin; // Some views like the chat view require that there be no margins if (props.noMargin) { @@ -56,27 +56,31 @@ const SubscriptAvatar = (props) => { return ( - + + + - + + + ); diff --git a/src/components/TaskHeader.js b/src/components/TaskHeader.js index 7f4990d393d5..b99c03ec9847 100644 --- a/src/components/TaskHeader.js +++ b/src/components/TaskHeader.js @@ -2,6 +2,7 @@ import React, {useEffect} from 'react'; import {View, TouchableOpacity} from 'react-native'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; +import _ from 'underscore'; import reportPropTypes from '../pages/reportPropTypes'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import * as ReportUtils from '../libs/ReportUtils'; @@ -20,6 +21,7 @@ import Icon from './Icon'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import Button from './Button'; import * as TaskUtils from '../libs/actions/Task'; +import * as UserUtils from '../libs/UserUtils'; const propTypes = { /** The report currently being looked at */ @@ -34,7 +36,7 @@ const propTypes = { function TaskHeader(props) { const title = ReportUtils.getReportName(props.report); const assigneeName = ReportUtils.getDisplayNameForParticipant(props.report.managerEmail); - const assigneeAvatar = ReportUtils.getAvatar(lodashGet(props.personalDetails, [props.report.managerEmail, 'avatar']), props.report.managerEmail); + const assigneeAvatar = UserUtils.getAvatar(lodashGet(props.personalDetails, [props.report.managerEmail, 'avatar']), props.report.managerEmail); const isOpen = props.report.stateNum === CONST.REPORT.STATE_NUM.OPEN && props.report.statusNum === CONST.REPORT.STATUS.OPEN; const isCompleted = props.report.stateNum === CONST.REPORT.STATE_NUM.SUBMITTED && props.report.statusNum === CONST.REPORT.STATUS.APPROVED; const parentReportID = props.report.parentReportID; @@ -54,7 +56,7 @@ function TaskHeader(props) { > - {props.report.managerEmail && ( + {!_.isEmpty(props.report.managerEmail) && ( <> { - const shortenedText = props.text.length > 35 ? `${props.text.substring(0, 35)}...` : props.text; const displayNameStyle = StyleUtils.combineStyles(styles.optionDisplayName, styles.pre); const alternateTextStyle = StyleUtils.combineStyles(styles.sidebarLinkText, styles.optionAlternateText, styles.textLabelSupporting, styles.pre); return ( @@ -63,33 +62,31 @@ const TaskSelectorLink = (props) => { > {props.icons.length !== 0 || props.text !== '' ? ( - + {props.translate(props.label)} - - - + + + - - - {props.alternateText ? ( - - {props.alternateText} - - ) : null} - + > + {props.alternateText} + + ) : null} diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index 1a6eec10fb7d..91ab2162674f 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import React, {Component} from 'react'; -import {Animated, View, TouchableWithoutFeedback, AppState, Keyboard, StyleSheet} from 'react-native'; +import {Animated, View, AppState, Keyboard, StyleSheet} from 'react-native'; import Str from 'expensify-common/lib/str'; import RNTextInput from '../RNTextInput'; import TextInputLabel from './TextInputLabel'; @@ -17,7 +17,10 @@ import Checkbox from '../Checkbox'; import getSecureEntryKeyboardType from '../../libs/getSecureEntryKeyboardType'; import CONST from '../../CONST'; import FormHelpMessage from '../FormHelpMessage'; +import isInputAutoFilled from '../../libs/isInputAutoFilled'; +import * as Pressables from '../Pressable'; +const PressableWithoutFeedback = Pressables.PressableWithoutFeedback; class BaseTextInput extends Component { constructor(props) { super(props); @@ -131,8 +134,7 @@ class BaseTextInput extends Component { // If the text has been supplied by Chrome autofill, the value state is not synced with the value // as Chrome doesn't trigger a change event. When there is autofill text, don't deactivate label. - const textWasAutoFilledOnChrome = this.input.matches && this.input.matches(':-webkit-autofill'); - if (!textWasAutoFilledOnChrome) { + if (!isInputAutoFilled(this.input)) { this.deactivateLabel(); } } @@ -182,12 +184,12 @@ class BaseTextInput extends Component { Animated.parallel([ Animated.spring(this.state.labelTranslateY, { toValue: translateY, - duration: 80, + duration: styleConst.LABEL_ANIMATION_DURATION, useNativeDriver: true, }), Animated.spring(this.state.labelScale, { toValue: scale, - duration: 80, + duration: styleConst.LABEL_ANIMATION_DURATION, useNativeDriver: true, }), ]).start(); @@ -226,144 +228,142 @@ class BaseTextInput extends Component { return ( <> - - { + if (!this.props.autoGrowHeight && this.props.multiline) { + return; + } + + const layout = event.nativeEvent.layout; + + this.setState((prevState) => ({ + width: this.props.autoGrowHeight ? layout.width : prevState.width, + height: !isMultiline ? layout.height : prevState.height, + })); + }} + style={[ + textInputContainerStyles, + + // When autoGrow is on and minWidth is not supplied, add a minWidth to allow the input to be focusable. + this.props.autoGrow && !textInputContainerStyles.minWidth && styles.mnw2, + ]} > - { - if (!this.props.autoGrowHeight && this.props.multiline) { - return; - } - - const layout = event.nativeEvent.layout; - - this.setState((prevState) => ({ - width: this.props.autoGrowHeight ? layout.width : prevState.width, - height: !isMultiline ? layout.height : prevState.height, - })); - }} - style={[ - textInputContainerStyles, - - // When autoGrow is on and minWidth is not supplied, add a minWidth to allow the input to be focusable. - this.props.autoGrow && !textInputContainerStyles.minWidth && styles.mnw2, - ]} - > - {hasLabel ? ( - <> - {/* Adding this background to the label only for multiline text input, + {hasLabel ? ( + <> + {/* Adding this background to the label only for multiline text input, to prevent text overlapping with label when scrolling */} - {isMultiline && ( - - )} - - - ) : null} - - {Boolean(this.props.prefixCharacter) && ( - - - {this.props.prefixCharacter} - - )} - { - if (typeof this.props.innerRef === 'function') { - this.props.innerRef(ref); - } else if (this.props.innerRef && _.has(this.props.innerRef, 'current')) { - this.props.innerRef.current = ref; - } - this.input = ref; - }} - // eslint-disable-next-line - {...inputProps} - autoCorrect={this.props.secureTextEntry ? false : this.props.autoCorrect} - placeholder={placeholder} - placeholderTextColor={themeColors.placeholderText} - underlineColorAndroid="transparent" - style={[ - styles.flex1, - styles.w100, - this.props.inputStyle, - (!hasLabel || isMultiline) && styles.pv0, - this.props.prefixCharacter && StyleUtils.getPaddingLeft(this.state.prefixWidth + styles.pl1.paddingLeft), - this.props.secureTextEntry && styles.secureInput, - - // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear - // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) - !isMultiline && {height: this.state.height, lineHeight: undefined}, - - // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. - this.props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(this.state.textInputHeight, maxHeight), - ]} - multiline={isMultiline} - maxLength={this.props.maxLength} - onFocus={this.onFocus} - onBlur={this.onBlur} - onChangeText={this.setValue} - secureTextEntry={this.state.passwordHidden} - onPressOut={this.props.onPress} - showSoftInputOnFocus={!this.props.disableKeyboard} - keyboardType={getSecureEntryKeyboardType(this.props.keyboardType, this.props.secureTextEntry, this.state.passwordHidden)} - value={this.state.value} - selection={this.state.selection} - editable={isEditable} - // FormSubmit Enter key handler does not have access to direct props. - // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. - dataSet={{submitOnEnter: isMultiline && this.props.submitOnEnter}} + - {Boolean(this.props.secureTextEntry) && ( - e.preventDefault()} + + ) : null} + + {Boolean(this.props.prefixCharacter) && ( + + - - - )} - {!this.props.secureTextEntry && Boolean(this.props.icon) && ( - - - - )} - + {this.props.prefixCharacter} + + + )} + { + if (typeof this.props.innerRef === 'function') { + this.props.innerRef(ref); + } else if (this.props.innerRef && _.has(this.props.innerRef, 'current')) { + this.props.innerRef.current = ref; + } + this.input = ref; + }} + // eslint-disable-next-line + {...inputProps} + autoCorrect={this.props.secureTextEntry ? false : this.props.autoCorrect} + placeholder={placeholder} + placeholderTextColor={themeColors.placeholderText} + underlineColorAndroid="transparent" + style={[ + styles.flex1, + styles.w100, + this.props.inputStyle, + (!hasLabel || isMultiline) && styles.pv0, + this.props.prefixCharacter && StyleUtils.getPaddingLeft(this.state.prefixWidth + styles.pl1.paddingLeft), + this.props.secureTextEntry && styles.secureInput, + + // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear + // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802) + !isMultiline && {height: this.state.height, lineHeight: undefined}, + + // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled. + this.props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(this.state.textInputHeight, maxHeight), + ]} + multiline={isMultiline} + maxLength={this.props.maxLength} + onFocus={this.onFocus} + onBlur={this.onBlur} + onChangeText={this.setValue} + secureTextEntry={this.state.passwordHidden} + onPressOut={this.props.onPress} + showSoftInputOnFocus={!this.props.disableKeyboard} + keyboardType={getSecureEntryKeyboardType(this.props.keyboardType, this.props.secureTextEntry, this.state.passwordHidden)} + value={this.state.value} + selection={this.state.selection} + editable={isEditable} + // FormSubmit Enter key handler does not have access to direct props. + // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. + dataSet={{submitOnEnter: isMultiline && this.props.submitOnEnter}} + /> + {Boolean(this.props.secureTextEntry) && ( + e.preventDefault()} + > + + + )} + {!this.props.secureTextEntry && Boolean(this.props.icon) && ( + + + + )} - - + + {!_.isEmpty(inputHelpText) && ( ); } diff --git a/src/components/TextInput/styleConst.js b/src/components/TextInput/styleConst.js index 116d0b086d78..f57b3f3ca56d 100644 --- a/src/components/TextInput/styleConst.js +++ b/src/components/TextInput/styleConst.js @@ -6,4 +6,6 @@ const ACTIVE_LABEL_SCALE = 0.8668; const INACTIVE_LABEL_TRANSLATE_Y = variables.INACTIVE_LABEL_TRANSLATE_Y; const INACTIVE_LABEL_SCALE = 1; -export {ACTIVE_LABEL_TRANSLATE_Y, ACTIVE_LABEL_SCALE, INACTIVE_LABEL_TRANSLATE_Y, INACTIVE_LABEL_SCALE}; +const LABEL_ANIMATION_DURATION = 80; + +export {ACTIVE_LABEL_TRANSLATE_Y, ACTIVE_LABEL_SCALE, INACTIVE_LABEL_TRANSLATE_Y, INACTIVE_LABEL_SCALE, LABEL_ANIMATION_DURATION}; diff --git a/src/components/TextLink.js b/src/components/TextLink.js index 4d4a9cdce307..f075002058ab 100644 --- a/src/components/TextLink.js +++ b/src/components/TextLink.js @@ -27,7 +27,7 @@ const defaultProps = { href: undefined, style: [], onPress: undefined, - onMouseDown: undefined, + onMouseDown: (event) => event.preventDefault(), }; const TextLink = (props) => { diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js index f18bc803aa45..616f548136a0 100644 --- a/src/components/Tooltip/index.js +++ b/src/components/Tooltip/index.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import React, {PureComponent} from 'react'; -import {Animated, View} from 'react-native'; +import {Animated} from 'react-native'; import {BoundsObserver} from '@react-ng/bounds-observer'; import TooltipRenderedOnPageBody from './TooltipRenderedOnPageBody'; import Hoverable from '../Hoverable'; @@ -9,8 +9,10 @@ import * as tooltipPropTypes from './tooltipPropTypes'; import TooltipSense from './TooltipSense'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; -// A "target" for the tooltip, i.e. an element that, when hovered over, triggers the tooltip to appear. The tooltip will -// point towards this target. +/** + * A component used to wrap an element intended for displaying a tooltip. The term "tooltip's target" refers to the + * wrapped element, which, upon hover, triggers the tooltip to be shown. + */ class Tooltip extends PureComponent { constructor(props) { super(props); @@ -49,6 +51,9 @@ class Tooltip extends PureComponent { * @param {Object} bounds - updated bounds */ updateBounds(bounds) { + if (bounds.width === 0) { + this.setState({isRendered: false}); + } this.setState({ wrapperWidth: bounds.width, wrapperHeight: bounds.height, @@ -113,37 +118,9 @@ class Tooltip extends PureComponent { if ((_.isEmpty(this.props.text) && this.props.renderTooltipContent == null) || !this.hasHoverSupport) { return this.props.children; } - let child = ( - (this.wrapperView = el)} - onBlur={this.hideTooltip} - focusable={this.props.focusable} - style={this.props.containerStyles} - > - {this.props.children} - - ); - if (this.props.absolute && React.isValidElement(this.props.children)) { - child = React.cloneElement(React.Children.only(this.props.children), { - ref: (el) => { - this.wrapperView = el; - - // Call the original ref, if any - const {ref} = this.props.children; - if (_.isFunction(ref)) { - ref(el); - } - }, - onBlur: (el) => { - this.hideTooltip(); - - if (_.isFunction(this.props.children.props.onBlur)) { - this.props.children.props.onBlur(el); - } - }, - focusable: true, - }); + if (!React.isValidElement(this.props.children)) { + throw Error('Children is not a valid element.'); } return ( @@ -172,12 +149,10 @@ class Tooltip extends PureComponent { onBoundsChange={this.updateBounds} > - {child} + {React.Children.only(this.props.children)} diff --git a/src/components/Tooltip/tooltipPropTypes.js b/src/components/Tooltip/tooltipPropTypes.js index c52815901dfc..f9a1847df242 100644 --- a/src/components/Tooltip/tooltipPropTypes.js +++ b/src/components/Tooltip/tooltipPropTypes.js @@ -4,18 +4,12 @@ import variables from '../../styles/variables'; import CONST from '../../CONST'; const propTypes = { - /** Enable support for the absolute positioned native(View|Text) children. It will only work for single native child */ - absolute: PropTypes.bool, - - /** The text to display in the tooltip. */ + /** The text to display in the tooltip. If text is ommitted, only children will be rendered. */ text: PropTypes.string, /** Maximum number of lines to show in tooltip */ numberOfLines: PropTypes.number, - /** Styles to be assigned to the Tooltip wrapper views */ - containerStyles: PropTypes.arrayOf(PropTypes.object), - /** Children to wrap with Tooltip. */ children: PropTypes.node.isRequired, @@ -33,9 +27,6 @@ const propTypes = { /** Number of pixels to set max-width on tooltip */ maxWidth: PropTypes.number, - /** Accessibility prop. Sets the tabindex to 0 if true. Default is true. */ - focusable: PropTypes.bool, - /** Render custom content inside the tooltip. Note: This cannot be used together with the text props. */ renderTooltipContent: PropTypes.func, @@ -44,16 +35,13 @@ const propTypes = { }; const defaultProps = { - absolute: false, shiftHorizontal: 0, shiftVertical: 0, - containerStyles: [], text: '', maxWidth: variables.sideBarWidth, numberOfLines: CONST.TOOLTIP_MAX_LINES, renderTooltipContent: undefined, renderTooltipContentKey: [], - focusable: true, }; export {propTypes, defaultProps}; diff --git a/src/components/ValidateCode/ValidateCodeModal.js b/src/components/ValidateCode/ValidateCodeModal.js index 9bd9dc49d51b..344c3107b8cf 100644 --- a/src/components/ValidateCode/ValidateCodeModal.js +++ b/src/components/ValidateCode/ValidateCodeModal.js @@ -39,7 +39,7 @@ const defaultProps = { }; function ValidateCodeModal(props) { - const signInHere = useCallback(() => Session.signInWithValidateCode(props.accountID, props.code), [props.accountID, props.code]); + const signInHere = useCallback(() => Session.signInWithValidateCode(props.accountID, props.code, props.preferredLocale), [props.accountID, props.code, props.preferredLocale]); return ( diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js index aac6043e7e97..cb80a1029d35 100755 --- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js +++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js @@ -16,6 +16,7 @@ import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import compose from '../../libs/compose'; import Tooltip from '../Tooltip'; import {propTypes as videoChatButtonAndMenuPropTypes, defaultProps} from './videoChatButtonAndMenuPropTypes'; +import * as Session from '../../libs/actions/Session'; const propTypes = { /** Link to open when user wants to create a new google meet meeting */ @@ -103,7 +104,7 @@ class BaseVideoChatButtonAndMenu extends Component { (this.videoChatButton = el)} - onPress={() => { + onPress={Session.checkIfActionIsAllowed(() => { // Drop focus to avoid blue focus ring. this.videoChatButton.blur(); @@ -113,7 +114,7 @@ class BaseVideoChatButtonAndMenu extends Component { return; } this.setMenuVisibility(true); - }} + })} style={[styles.touchableButtonImage]} > variables.mobileResponsiveWidthBreakpoint && initialDimensions.width <= variables.tabletResponsiveWidthBreakpoint; - const isLargeScreenWidth = !isSmallScreenWidth && !isMediumScreenWidth; this.dimensionsEventListener = null; this.state = { windowHeight: initialDimensions.height, windowWidth: initialDimensions.width, - isSmallScreenWidth, - isMediumScreenWidth, - isLargeScreenWidth, }; } @@ -69,20 +64,36 @@ class WindowDimensionsProvider extends React.Component { */ onDimensionChange(newDimensions) { const {window} = newDimensions; - const isSmallScreenWidth = window.width <= variables.mobileResponsiveWidthBreakpoint; - const isMediumScreenWidth = !isSmallScreenWidth && window.width <= variables.tabletResponsiveWidthBreakpoint; - const isLargeScreenWidth = !isSmallScreenWidth && !isMediumScreenWidth; + this.setState({ windowHeight: window.height, windowWidth: window.width, - isSmallScreenWidth, - isMediumScreenWidth, - isLargeScreenWidth, }); } render() { - return {this.props.children}; + return ( + + {(insets) => { + const isSmallScreenWidth = this.state.windowWidth <= variables.mobileResponsiveWidthBreakpoint; + const isMediumScreenWidth = !isSmallScreenWidth && this.state.windowWidth <= variables.tabletResponsiveWidthBreakpoint; + const isLargeScreenWidth = !isSmallScreenWidth && !isMediumScreenWidth; + return ( + + {this.props.children} + + ); + }} + + ); } } diff --git a/src/components/hooks/useOnNetworkReconnect.js b/src/hooks/useOnNetworkReconnect.js similarity index 93% rename from src/components/hooks/useOnNetworkReconnect.js rename to src/hooks/useOnNetworkReconnect.js index c186789c0727..cbd30897a2ba 100644 --- a/src/components/hooks/useOnNetworkReconnect.js +++ b/src/hooks/useOnNetworkReconnect.js @@ -1,5 +1,5 @@ import {useRef, useContext, useEffect} from 'react'; -import {NetworkContext} from '../OnyxProvider'; +import {NetworkContext} from '../components/OnyxProvider'; /** * @param {Function} onNetworkReconnect diff --git a/src/hooks/useWindowDimensions.js b/src/hooks/useWindowDimensions.js index ea46be38246a..e3ae09d80fcd 100644 --- a/src/hooks/useWindowDimensions.js +++ b/src/hooks/useWindowDimensions.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import {useWindowDimensions} from 'react-native'; import variables from '../styles/variables'; diff --git a/src/languages/en.js b/src/languages/en.js index 9f4426a2a441..77638749d904 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -140,6 +140,12 @@ export default { with: 'with', shareCode: 'Share code', share: 'Share', + per: 'per', + mi: 'mile', + km: 'kilometer', + }, + anonymousReportFooter: { + logoTagline: 'Join in on the discussion.', }, attachmentPicker: { cameraPermissionRequired: 'Camera access', @@ -173,6 +179,9 @@ export default { launching: 'Launching Expensify', expired: 'Your session has expired.', signIn: 'Please sign in again.', + redirectedToDesktopApp: "We've redirected you to the desktop app.", + youCanAlso: 'You can also', + openLinkInBrowser: 'open this link in your browser', }, validateCodeModal: { successfulSignInTitle: 'Abracadabra,\nyou are signed in!', @@ -261,6 +270,7 @@ export default { deleteConfirmation: ({action}) => `Are you sure you want to delete this ${ReportActionsUtils.isMoneyRequestAction(action) ? 'request' : 'comment'}?`, onlyVisible: 'Only visible to', replyInThread: 'Reply in thread', + flagAsOffensive: 'Flag as offensive', }, emojiReactions: { addReactionTooltip: 'Add reaction', @@ -285,6 +295,9 @@ export default { sayHello: 'Say hello!', usePlusButton: '\n\nYou can also use the + button below to send or request money!', }, + mentionSuggestions: { + hereAlternateText: 'Notify everyone online in this room', + }, newMessages: 'New messages', reportTypingIndicator: { isTyping: 'is typing...', @@ -300,6 +313,13 @@ export default { `This workspace chat is no longer active because ${displayName} is no longer a member of the ${policyName} workspace.`, [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}) => `This workspace chat is no longer active because ${policyName} is no longer an active workspace.`, }, + writeCapabilityPage: { + label: 'Who can post', + writeCapability: { + all: 'All members', + admins: 'Admins only', + }, + }, sidebarScreen: { fabAction: 'New chat', newChat: 'New chat', @@ -597,6 +617,8 @@ export default { transferDetailBankAccount: 'Your money should arrive in the next 1-3 business days.', transferDetailDebitCard: 'Your money should arrive immediately.', failedTransfer: 'Your balance isn’t fully settled. Please transfer to a bank account.', + notHereSubTitle: 'Please transfer your balance from the payments page', + goToPayment: 'Go to Payments', }, chooseTransferAccountPage: { chooseAccount: 'Choose account', @@ -627,6 +649,10 @@ export default { }, }, }, + welcomeMessagePage: { + welcomeMessage: 'Welcome message', + explainerText: 'Set a custom welcome message that will be sent to users when they join this room.', + }, languagePage: { language: 'Language', languages: { @@ -715,7 +741,7 @@ export default { error: { dateShouldBeBefore: ({dateString}) => `Date should be before ${dateString}.`, dateShouldBeAfter: ({dateString}) => `Date should be after ${dateString}.`, - hasInvalidCharacter: 'Name can only include letters and numbers.', + hasInvalidCharacter: 'Name can only include latin letters and numbers.', incorrectZipFormat: ({zipFormat}) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, }, }, @@ -746,7 +772,7 @@ export default { getMeOutOfHere: 'Get me out of here', iouReportNotFound: 'The payment details you are looking for cannot be found.', notHere: "Hmm... it's not here", - pageNotFound: 'That page is nowhere to be found.', + pageNotFound: 'Oops, this page cannot be found', noAccess: "You don't have access to this chat", goBackHome: 'Go back to Home page', }, @@ -1024,17 +1050,18 @@ export default { }, workspace: { common: { - card: 'Issue cards', + card: 'Cards', workspace: 'Workspace', edit: 'Edit workspace', delete: 'Delete workspace', - settings: 'General settings', - reimburse: 'Reimburse expenses', - bills: 'Pay bills', - invoices: 'Send invoices', - travel: 'Book travel', - members: 'Manage members', - bankAccount: 'Connect bank account', + settings: 'Settings', + reimburse: 'Reimbursements', + bills: 'Bills', + invoices: 'Invoices', + travel: 'Travel', + members: 'Members', + bankAccount: 'Bank account', + connectBankAccount: 'Connect bank account', testTransactions: 'Test transactions', issueAndManageCards: 'Issue and manage cards', reconcileCards: 'Reconcile cards', @@ -1044,6 +1071,7 @@ export default { growlMessageOnDeleteError: 'This workspace cannot be deleted right now because reports are actively being processed', unavailable: 'Unavailable workspace', memberNotFound: 'Member not found. To invite a new member to the workspace, please use the Invite button above.', + goToRoom: ({roomName}) => `Go to ${roomName} room`, }, emptyWorkspace: { title: 'Create a new workspace', @@ -1094,6 +1122,7 @@ export default { unlockNoVBACopy: 'Connect a bank account to reimburse your workspace members online.', fastReimbursementsVBACopy: "You're all set to reimburse receipts from your bank account!", updateCustomUnitError: "Your changes couldn't be saved. The workspace was modified while you were offline, please try again.", + invalidRateError: 'Please enter a valid rate', }, bills: { manageYourBills: 'Manage your bills', @@ -1363,6 +1392,7 @@ export default { workspaceName: 'Workspace name', chatUserDisplayNames: 'Chat user display names', scrollToNewestMessages: 'Scroll to newest messages', + prestyledText: 'Prestyled text', }, parentReportAction: { deletedMessage: '[Deleted message]', @@ -1376,4 +1406,26 @@ export default { copyUrlToClipboard: 'Copy URL to clipboard', copied: 'Copied!', }, + moderation: { + flagDescription: 'All flagged messages will be sent to a moderator for review.', + chooseAReason: 'Choose a reason for flagging below:', + spam: 'Spam', + spamDescription: 'Unsolicited off-topic promotion', + inconsiderate: 'Inconsiderate', + inconsiderateDescription: 'Insulting or disrespectful phrasing, with questionable intentions', + intimidation: 'Intimidation', + intimidationDescription: 'Aggressively pursuing an agenda over valid objections', + bullying: 'Bullying', + bullyingDescription: 'Targeting an individual to obtain obedience', + harassment: 'Harassment', + harassmentDescription: 'Racist, misogynistic, or other broadly discriminatory behavior', + assault: 'Assault', + assaultDescription: 'Specifically targeted emotional attack with the intention of harm', + flaggedContent: 'This message has been flagged as violating our community rules and the content has been hidden.', + hideMessage: 'Hide message', + revealMessage: 'Reveal message', + levelOneResult: 'Sends anonymous warning and message is reported for review.', + levelTwoResult: 'Message hidden from channel, plus anonymous warning and message is reported for review.', + levelThreeResult: 'Message removed from channel plus anonymous warning and message is reported for review.', + }, }; diff --git a/src/languages/es.js b/src/languages/es.js index 8a024d2f3e8a..2a0631bf9703 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -139,6 +139,12 @@ export default { with: 'con', shareCode: 'Compartir código', share: 'Compartir', + per: 'por', + mi: 'milla', + km: 'kilómetro', + }, + anonymousReportFooter: { + logoTagline: 'Únete a la discussion.', }, attachmentPicker: { cameraPermissionRequired: 'Permiso para acceder a la cámara', @@ -172,6 +178,9 @@ export default { launching: 'Cargando Expensify', expired: 'Tu sesión ha expirado.', signIn: 'Por favor, inicia sesión de nuevo.', + redirectedToDesktopApp: 'Te hemos redirigido a la aplicación de escritorio.', + youCanAlso: 'También puedes', + openLinkInBrowser: 'abrir este enlace en tu navegador', }, validateCodeModal: { successfulSignInTitle: 'Abracadabra,\n¡sesión iniciada!', @@ -260,6 +269,7 @@ export default { deleteConfirmation: ({action}) => `¿Estás seguro de que quieres eliminar este ${ReportActionsUtils.isMoneyRequestAction(action) ? 'pedido' : 'comentario'}`, onlyVisible: 'Visible sólo para', replyInThread: 'Responder en el hilo', + flagAsOffensive: 'Marcar como ofensivo', }, emojiReactions: { addReactionTooltip: 'Añadir una reacción', @@ -284,6 +294,9 @@ export default { sayHello: '¡Saluda!', usePlusButton: '\n\n¡También puedes usar el botón + de abajo para enviar o pedir dinero!', }, + mentionSuggestions: { + hereAlternateText: 'Notificar a todos los que estén en linea de esta sala', + }, newMessages: 'Mensajes nuevos', reportTypingIndicator: { isTyping: 'está escribiendo...', @@ -299,6 +312,13 @@ export default { `Este chat de espacio de trabajo esta desactivado porque ${displayName} ha dejado de ser miembro del espacio de trabajo ${policyName}.`, [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}) => `Este chat de espacio de trabajo esta desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, }, + writeCapabilityPage: { + label: 'Quién puede postear', + writeCapability: { + all: 'Todos los miembros', + admins: 'Solo administradores', + }, + }, sidebarScreen: { fabAction: 'Nuevo chat', newChat: 'Nuevo chat', @@ -597,6 +617,8 @@ export default { transferDetailBankAccount: 'Tu dinero debería llegar en 1-3 días laborables.', transferDetailDebitCard: 'Tu dinero debería llegar de inmediato.', failedTransfer: 'Tu saldo no se ha acreditado completamente. Por favor, transfiere los fondos a una cuenta bancaria.', + notHereSubTitle: 'Por favor, transfiere el saldo desde la página de pagos', + goToPayment: 'Ir a pagos', }, chooseTransferAccountPage: { chooseAccount: 'Elegir cuenta', @@ -628,6 +650,10 @@ export default { }, }, }, + welcomeMessagePage: { + welcomeMessage: 'Mensaje de bienvenida', + explainerText: 'Configura un mensaje de bienvenida privado y personalizado que se enviará cuando los usuarios se unan a esta sala de chat.', + }, languagePage: { language: 'Idioma', languages: { @@ -717,7 +743,7 @@ export default { dateShouldBeBefore: ({dateString}) => `La fecha debe ser anterior a ${dateString}.`, dateShouldBeAfter: ({dateString}) => `La fecha debe ser posterior a ${dateString}.`, incorrectZipFormat: ({zipFormat}) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, - hasInvalidCharacter: 'El nombre solo puede contener letras y números.', + hasInvalidCharacter: 'El nombre solo puede contener números y caracteres latinos.', }, }, resendValidationForm: { @@ -747,7 +773,7 @@ export default { getMeOutOfHere: 'Sácame de aquí', iouReportNotFound: 'Los detalles del pago que estás buscando no se pudieron encontrar.', notHere: 'Hmm… no está aquí', - pageNotFound: 'La página que buscas no existe.', + pageNotFound: 'Ups, no deberías estar aquí', noAccess: 'No tienes acceso a este chat', goBackHome: 'Volver a la página principal', }, @@ -1029,17 +1055,18 @@ export default { }, workspace: { common: { - card: 'Emitir tarjetas', + card: 'Tarjetas', workspace: 'Espacio de trabajo', edit: 'Editar espacio de trabajo', delete: 'Eliminar espacio de trabajo', - settings: 'Configuración general', - reimburse: 'Reembolsar gastos', + settings: 'Configuración', + reimburse: 'Reembolsos', bills: 'Pagar facturas', invoices: 'Enviar facturas', - travel: 'Reservar viaje', - members: 'Gestionar miembros', - bankAccount: 'Conectar cuenta bancaria', + travel: 'Viajes', + members: 'Miembros', + bankAccount: 'Cuenta bancaria', + connectBankAccount: 'Conectar cuenta bancaria', testTransactions: 'Transacciones de prueba', issueAndManageCards: 'Emitir y gestionar tarjetas', reconcileCards: 'Reconciliar tarjetas', @@ -1049,6 +1076,7 @@ export default { growlMessageOnDeleteError: 'No se puede eliminar el espacio de trabajo porque tiene informes que están siendo procesados', unavailable: 'Espacio de trabajo no disponible', memberNotFound: 'Miembro no encontrado. Para invitar a un nuevo miembro al espacio de trabajo, por favor, utiliza el botón Invitar que está arriba.', + goToRoom: ({roomName}) => `Ir a la sala ${roomName}`, }, emptyWorkspace: { title: 'Crear un nuevo espacio de trabajo', @@ -1100,6 +1128,7 @@ export default { unlockNoVBACopy: 'Conecta una cuenta bancaria para reembolsar online a los miembros de tu espacio de trabajo.', fastReimbursementsVBACopy: '¡Todo listo para reembolsar recibos desde tu cuenta bancaria!', updateCustomUnitError: 'Los cambios no han podido ser guardados. El espacio de trabajo ha sido modificado mientras estabas desconectado. Por favor, inténtalo de nuevo.', + invalidRateError: 'Por favor ingrese una tarifa válida', }, bills: { manageYourBills: 'Gestiona tus facturas', @@ -1829,6 +1858,7 @@ export default { workspaceName: 'Nombre del espacio de trabajo', chatUserDisplayNames: 'Nombres de los usuarios del chat', scrollToNewestMessages: 'Desplázate a los mensajes más recientes', + prestyledText: 'texto preestilizado', }, parentReportAction: { deletedMessage: '[Mensaje eliminado]', @@ -1842,4 +1872,26 @@ export default { copyUrlToClipboard: 'Copiar URL al portapapeles', copied: '¡Copiado!', }, + moderation: { + flagDescription: 'Todos los mensajes marcados se enviarán a un moderador para su revisión.', + chooseAReason: 'Elige abajo un motivo para reportarlo:', + spam: 'Spam', + spamDescription: 'Promoción fuera de tema no solicitada', + inconsiderate: 'Desconsiderado', + inconsiderateDescription: 'Frase insultante o irrespetuosa, con intenciones cuestionables', + intimidation: 'Intimidación', + intimidationDescription: 'Persigue agresivamente una agenda sobre objeciones válidas', + bullying: 'Bullying', + bullyingDescription: 'Apunta a un individuo para obtener obediencia', + harassment: 'Acoso', + harassmentDescription: 'Comportamiento racista, misógino u otro comportamiento discriminatorio', + assault: 'Agresion', + assaultDescription: 'Ataque emocional específicamente dirigido con la intención de hacer daño', + flaggedContent: 'Este mensaje ha sido marcado por violar las reglas de nuestra comunidad y el contenido se ha ocultado.', + hideMessage: 'Ocultar mensaje', + revealMessage: 'Revelar mensaje', + levelOneResult: 'Envia una advertencia anónima y el mensaje es reportado para revisión.', + levelTwoResult: 'Mensaje ocultado del canal, más advertencia anónima y mensaje reportado para revisión.', + levelThreeResult: 'Mensaje eliminado del canal, más advertencia anónima y mensaje reportado para revisión.', + }, }; diff --git a/src/libs/BootSplash/index.js b/src/libs/BootSplash/index.js index b9b8692f687c..ff7ab5562b1f 100644 --- a/src/libs/BootSplash/index.js +++ b/src/libs/BootSplash/index.js @@ -9,9 +9,10 @@ function hide() { return document.fonts.ready.then(() => { const splash = document.getElementById('splash'); - splash.style.opacity = 0; + if (splash) splash.style.opacity = 0; return resolveAfter(250).then(() => { + if (!splash || !splash.parentNode) return; splash.parentNode.removeChild(splash); }); }); diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js index a3133ce21006..a4325af27c9c 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -301,6 +301,47 @@ const getPreferredSkinToneIndex = (val) => { return CONST.EMOJI_DEFAULT_SKIN_TONE; }; +/** + * Given an emoji object it returns the correct emoji code + * based on the users preferred skin tone. + * @param {Object} emoji + * @param {String | Number} preferredSkinTone + * @returns {String} + */ +const getPreferredEmojiCode = (emoji, preferredSkinTone) => { + if (emoji.types) { + const emojiCodeWithSkinTone = emoji.types[preferredSkinTone]; + + // Note: it can happen that preferredSkinTone has a outdated format, + // so it makes sense to check if we actually got a valid emoji code back + if (emojiCodeWithSkinTone) { + return emojiCodeWithSkinTone; + } + } + + return emoji.code; +}; + +/** + * Given an emoji object and a list of senders it will return an + * array of emoji codes, that represents all used variations of the + * emoji. + * @param {{ name: string, code: string, types: string[] }} emoji + * @param {Array} users + * @return {string[]} + * */ +const getUniqueEmojiCodes = (emoji, users) => { + const emojiCodes = []; + _.forEach(users, (user) => { + const emojiCode = getPreferredEmojiCode(emoji, user.skinTone); + + if (emojiCode && !emojiCodes.includes(emojiCode)) { + emojiCodes.push(emojiCode); + } + }); + return emojiCodes; +}; + export { getHeaderEmojis, mergeEmojisWithFrequentlyUsedEmojis, @@ -311,4 +352,6 @@ export { trimEmojiUnicode, getEmojiCodeWithSkinColor, getPreferredSkinToneIndex, + getPreferredEmojiCode, + getUniqueEmojiCodes, }; diff --git a/src/libs/ErrorUtils.js b/src/libs/ErrorUtils.js index 73fbd30cb891..03a26d8fa14c 100644 --- a/src/libs/ErrorUtils.js +++ b/src/libs/ErrorUtils.js @@ -1,6 +1,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import CONST from '../CONST'; +import DateUtils from './DateUtils'; /** * @param {Object} response @@ -37,6 +38,16 @@ function getAuthenticateErrorMessage(response) { } } +/** + * Method used to get an error object with microsecond as the key. + * @param {String} error - error key or message to be saved + * @return {Object} + * + */ +function getMicroSecondOnyxError(error) { + return {[DateUtils.getMicroseconds()]: error}; +} + /** * @param {Object} onyxData * @param {Object} onyxData.errors @@ -95,4 +106,4 @@ function addErrorMessage(errors, inputID, message) { } } -export {getAuthenticateErrorMessage, getLatestErrorMessage, getLatestErrorField, addErrorMessage}; +export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, addErrorMessage}; diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js index 94add8525099..37d85c7bfbfc 100644 --- a/src/libs/KeyboardShortcut/index.js +++ b/src/libs/KeyboardShortcut/index.js @@ -18,7 +18,7 @@ const documentedShortcuts = {}; * @returns {Array} */ function getDocumentedShortcuts() { - return _.values(documentedShortcuts); + return _.sortBy(_.values(documentedShortcuts), 'displayName'); } /** @@ -43,6 +43,12 @@ function getDisplayName(key, modifiers) { if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow').toString().toLowerCase())) { return ['ARROWDOWN']; } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputLeftArrow', 'keyInputLeftArrow').toString().toLowerCase())) { + return ['ARROWLEFT']; + } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputRightArrow', 'keyInputRightArrow').toString().toLowerCase())) { + return ['ARROWRIGHT']; + } return [key.toUpperCase()]; })(); diff --git a/src/libs/Localize/index.js b/src/libs/Localize/index.js index db1e00f7615b..d9e1c2c21f26 100644 --- a/src/libs/Localize/index.js +++ b/src/libs/Localize/index.js @@ -91,6 +91,20 @@ function translateLocal(phrase, variables) { return translate(BaseLocaleListener.getPreferredLocale(), phrase, variables); } +/** + * Return translated string for given error. + * + * @param {String} phrase + * @returns {String} + */ +function translateIfPhraseKey(phrase) { + try { + return translateLocal(phrase); + } catch (error) { + return phrase; + } +} + /** * Format an array into a string with comma and "and" ("a dog, a cat and a chicken") * @@ -114,4 +128,4 @@ function getDevicePreferredLocale() { return lodashGet(RNLocalize.findBestAvailableLanguage([CONST.LOCALES.EN, CONST.LOCALES.ES]), 'languageTag', CONST.LOCALES.DEFAULT); } -export {translate, translateLocal, arrayToString, getDevicePreferredLocale}; +export {translate, translateLocal, translateIfPhraseKey, arrayToString, getDevicePreferredLocale}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 04bd397d5f64..397e970ed1f6 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -157,7 +157,7 @@ class AuthScreens extends React.Component { } shouldComponentUpdate(nextProps) { - return nextProps.isSmallScreenWidth !== this.props.isSmallScreenWidth; + return nextProps.windowHeight !== this.props.windowHeight || nextProps.isSmallScreenWidth !== this.props.isSmallScreenWidth; } componentWillUnmount() { @@ -184,7 +184,10 @@ class AuthScreens extends React.Component { }; const modalScreenOptions = { ...commonModalScreenOptions, - cardStyle: getNavigationModalCardStyle(this.props.isSmallScreenWidth), + cardStyle: getNavigationModalCardStyle({ + windowHeight: this.props.windowHeight, + isSmallScreenWidth: this.props.isSmallScreenWidth, + }), cardStyleInterpolator: (props) => modalCardStyleInterpolator(this.props.isSmallScreenWidth, false, props), cardOverlayEnabled: true, @@ -298,6 +301,12 @@ class AuthScreens extends React.Component { component={ModalStackNavigators.ReportSettingsModalStackNavigator} listeners={modalScreenListeners} /> + + 0; } + componentDidMount() { + if (!this.props.lastOpenedPublicRoomID || Session.isAnonymousUser()) { + return; + } + // Re-open the last opened public room if the user logged in + Report.setLastOpenedPublicRoom(''); + Report.openReport(this.props.lastOpenedPublicRoomID); + } + shouldComponentUpdate(nextProps) { const initialNextParams = getInitialReportScreenParams( nextProps.reports, @@ -172,4 +187,7 @@ export default withOnyx({ isFirstTimeNewExpensifyUser: { key: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, }, + lastOpenedPublicRoomID: { + key: ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID, + }, })(MainDrawerNavigator); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index de821060b832..966d40f9f7d0 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -111,34 +111,13 @@ const IOUSendModalStackNavigator = createModalStackNavigator([ }, ]); -const IOUDetailsModalStackNavigator = createModalStackNavigator([ +const SplitDetailsModalStackNavigator = createModalStackNavigator([ { getComponent: () => { - const IOUDetailsModal = require('../../../pages/iou/IOUDetailsModal').default; - return IOUDetailsModal; + const SplitBillDetailsPage = require('../../../pages/iou/SplitBillDetailsPage').default; + return SplitBillDetailsPage; }, - name: 'IOU_Details_Root', - }, - { - getComponent: () => { - const AddPersonalBankAccountPage = require('../../../pages/AddPersonalBankAccountPage').default; - return AddPersonalBankAccountPage; - }, - name: 'IOU_Details_Add_Bank_Account', - }, - { - getComponent: () => { - const AddDebitCardPage = require('../../../pages/settings/Payments/AddDebitCardPage').default; - return AddDebitCardPage; - }, - name: 'IOU_Details_Add_Debit_Card', - }, - { - getComponent: () => { - const EnablePaymentsPage = require('../../../pages/EnablePayments/EnablePaymentsPage').default; - return EnablePaymentsPage; - }, - name: 'IOU_Details_Enable_Payments', + name: 'SplitDetails_Root', }, ]); @@ -191,6 +170,13 @@ const ReportSettingsModalStackNavigator = createModalStackNavigator([ }, name: 'Report_Settings_Notification_Preferences', }, + { + getComponent: () => { + const WriteCapabilityPage = require('../../../pages/settings/Report/WriteCapabilityPage').default; + return WriteCapabilityPage; + }, + name: 'Report_Settings_Write_Capability', + }, ]); const TaskModalStackNavigator = createModalStackNavigator([ @@ -217,6 +203,16 @@ const TaskModalStackNavigator = createModalStackNavigator([ }, ]); +const ReportWelcomeMessageModalStackNavigator = createModalStackNavigator([ + { + getComponent: () => { + const ReportWelcomeMessagePage = require('../../../pages/ReportWelcomeMessagePage').default; + return ReportWelcomeMessagePage; + }, + name: 'Report_WelcomeMessage_Root', + }, +]); + const ReportParticipantsModalStackNavigator = createModalStackNavigator([ { getComponent: () => { @@ -548,6 +544,13 @@ const SettingsModalStackNavigator = createModalStackNavigator([ }, name: 'Workspace_Reimburse', }, + { + getComponent: () => { + const WorkspaceRateAndUnitPage = require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage').default; + return WorkspaceRateAndUnitPage; + }, + name: 'Workspace_RateAndUnit', + }, { getComponent: () => { const WorkspaceBillsPage = require('../../../pages/workspace/bills/WorkspaceBillsPage').default; @@ -699,15 +702,26 @@ const YearPickerStackNavigator = createModalStackNavigator([ }, ]); +const FlagCommentStackNavigator = createModalStackNavigator([ + { + getComponent: () => { + const FlagCommentPage = require('../../../pages/FlagCommentPage').default; + return FlagCommentPage; + }, + name: 'FlagComment_Root', + }, +]); + export { IOUBillStackNavigator, IOURequestModalStackNavigator, IOUSendModalStackNavigator, - IOUDetailsModalStackNavigator, + SplitDetailsModalStackNavigator, DetailsModalStackNavigator, ReportDetailsModalStackNavigator, TaskModalStackNavigator, ReportSettingsModalStackNavigator, + ReportWelcomeMessageModalStackNavigator, ReportParticipantsModalStackNavigator, SearchModalStackNavigator, NewGroupModalStackNavigator, @@ -719,4 +733,5 @@ export { ReimbursementAccountModalStackNavigator, WalletStatementStackNavigator, YearPickerStackNavigator, + FlagCommentStackNavigator, }; diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index ac324f4decea..47a8dbb29e96 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -4,7 +4,6 @@ import {Keyboard} from 'react-native'; import {CommonActions, DrawerActions, getPathFromState} from '@react-navigation/native'; import Onyx from 'react-native-onyx'; import Log from '../Log'; -import DomUtils from '../DomUtils'; import linkTo from './linkTo'; import ROUTES from '../../ROUTES'; import DeprecatedCustomActions from './DeprecatedCustomActions'; @@ -145,11 +144,6 @@ function navigate(route = ROUTES.HOME) { return; } - // A pressed navigation button will remain focused, keeping its tooltip visible, even if it's supposed to be out of view. - // To prevent that we blur the button manually (especially for Safari, where the mouse leave event is missing). - // More info: https://github.com/Expensify/App/issues/13146 - DomUtils.blurActiveElement(); - if (route === ROUTES.HOME) { if (isLoggedIn && pendingRoute === null) { openDrawer(); diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 1535880e8997..7a45266b0355 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -177,6 +177,9 @@ export default { Workspace_Reimburse: { path: ROUTES.WORKSPACE_REIMBURSE, }, + Workspace_RateAndUnit: { + path: ROUTES.WORKSPACE_RATE_AND_UNIT, + }, Workspace_Bills: { path: ROUTES.WORKSPACE_BILLS, }, @@ -224,6 +227,14 @@ export default { Report_Settings_Notification_Preferences: { path: ROUTES.REPORT_SETTINGS_NOTIFICATION_PREFERENCES, }, + Report_Settings_Write_Capability: { + path: ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY, + }, + }, + }, + Report_WelcomeMessage: { + screens: { + Report_WelcomeMessage_Root: ROUTES.REPORT_WELCOME_MESSAGE, }, }, NewGroup: { @@ -284,12 +295,9 @@ export default { IOU_Send_Add_Debit_Card: ROUTES.IOU_SEND_ADD_DEBIT_CARD, }, }, - IOU_Details: { + SplitDetails: { screens: { - IOU_Details_Root: ROUTES.IOU_DETAILS_WITH_IOU_REPORT_ID, - IOU_Details_Enable_Payments: ROUTES.IOU_DETAILS_ENABLE_PAYMENTS, - IOU_Details_Add_Bank_Account: ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT, - IOU_Details_Add_Debit_Card: ROUTES.IOU_DETAILS_ADD_DEBIT_CARD, + SplitDetails_Root: ROUTES.SPLIT_BILL_DETAILS, }, }, Task_Details: { @@ -319,6 +327,11 @@ export default { YearPicker_Root: ROUTES.SELECT_YEAR, }, }, + Flag_Comment: { + screens: { + FlagComment_Root: ROUTES.FLAG_COMMENT, + }, + }, [SCREENS.NOT_FOUND]: '*', }, }, diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 0b3c4bd35b51..93465cfceee4 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -14,6 +14,7 @@ import * as CollectionUtils from './CollectionUtils'; import Navigation from './Navigation/Navigation'; import * as LoginUtils from './LoginUtils'; import * as LocalePhoneNumber from './LocalePhoneNumber'; +import * as UserUtils from './UserUtils'; /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can @@ -152,7 +153,7 @@ function getAvatarsForLogins(logins, personalDetails) { return _.map(logins, (login) => { const userPersonalDetail = lodashGet(personalDetails, login, {login, avatar: ''}); return { - source: ReportUtils.getAvatar(userPersonalDetail.avatar, userPersonalDetail.login), + source: UserUtils.getAvatar(userPersonalDetail.avatar, userPersonalDetail.login), type: CONST.ICON_TYPE_AVATAR, name: userPersonalDetail.login, }; @@ -181,7 +182,7 @@ function getPersonalDetailsForLogins(logins, personalDetails) { personalDetail = { login, displayName: LocalePhoneNumber.formatPhoneNumber(login), - avatar: ReportUtils.getDefaultAvatar(login), + avatar: UserUtils.getDefaultAvatar(login), }; } @@ -220,7 +221,7 @@ function getParticipantsOptions(report, personalDetails) { alternateText: Str.isSMSLogin(details.login || '') ? LocalePhoneNumber.formatPhoneNumber(details.login) : details.login, icons: [ { - source: ReportUtils.getAvatar(details.avatar, details.login), + source: UserUtils.getAvatar(details.avatar, details.login), name: details.login, type: CONST.ICON_TYPE_AVATAR, }, @@ -291,9 +292,10 @@ function uniqFast(items) { * @param {String} reportName * @param {Array} personalDetailList * @param {Boolean} isChatRoomOrPolicyExpenseChat + * @param {Boolean} isThread * @return {String} */ -function getSearchText(report, reportName, personalDetailList, isChatRoomOrPolicyExpenseChat) { +function getSearchText(report, reportName, personalDetailList, isChatRoomOrPolicyExpenseChat, isThread) { let searchTerms = []; if (!isChatRoomOrPolicyExpenseChat) { @@ -309,7 +311,13 @@ function getSearchText(report, reportName, personalDetailList, isChatRoomOrPolic if (report) { Array.prototype.push.apply(searchTerms, reportName.split(/[,\s]/)); - if (isChatRoomOrPolicyExpenseChat) { + if (isThread) { + const title = ReportUtils.getReportName(report); + const chatRoomSubtitle = ReportUtils.getChatRoomSubtitle(report); + + Array.prototype.push.apply(searchTerms, title.split(/[,\s]/)); + Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split(/[,\s]/)); + } else if (isChatRoomOrPolicyExpenseChat) { const chatRoomSubtitle = ReportUtils.getChatRoomSubtitle(report); Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split(/[,\s]/)); @@ -474,8 +482,8 @@ function createOption(logins, personalDetails, report, reportActions = {}, {show } result.text = reportName; - result.searchText = getSearchText(report, reportName, personalDetailList, result.isChatRoom || result.isPolicyExpenseChat); - result.icons = ReportUtils.getIcons(report, personalDetails, ReportUtils.getAvatar(personalDetail.avatar, personalDetail.login)); + result.searchText = getSearchText(report, reportName, personalDetailList, result.isChatRoom || result.isPolicyExpenseChat, result.isThread); + result.icons = ReportUtils.getIcons(report, personalDetails, UserUtils.getAvatar(personalDetail.avatar, personalDetail.login)); result.subtitle = subtitle; return result; @@ -650,21 +658,22 @@ function getOptions( if (includeRecentReports) { for (let i = 0; i < allReportOptions.length; i++) { const reportOption = allReportOptions[i]; - const isCurrentUserOwnedPolicyExpenseChatThatShouldShow = - reportOption.isPolicyExpenseChat && reportOption.ownerEmail === currentUserLogin && includeOwnedWorkspaceChats && !reportOption.isArchivedRoom; // Stop adding options to the recentReports array when we reach the maxRecentReportsToShow value - if (!isCurrentUserOwnedPolicyExpenseChatThatShouldShow && recentReportOptions.length > 0 && recentReportOptions.length === maxRecentReportsToShow) { + if (recentReportOptions.length > 0 && recentReportOptions.length === maxRecentReportsToShow) { break; } + const isCurrentUserOwnedPolicyExpenseChatThatCouldShow = + reportOption.isPolicyExpenseChat && reportOption.ownerEmail === currentUserLogin && includeOwnedWorkspaceChats && !reportOption.isArchivedRoom; + // Skip if we aren't including multiple participant reports and this report has multiple participants - if (!isCurrentUserOwnedPolicyExpenseChatThatShouldShow && !includeMultipleParticipantReports && !reportOption.login) { + if (!isCurrentUserOwnedPolicyExpenseChatThatCouldShow && !includeMultipleParticipantReports && !reportOption.login) { continue; } - // Check the report to see if it has a single participant and if the participant is already selected - if (reportOption.login && _.some(loginOptionsToExclude, (option) => option.login === reportOption.login)) { + // If we're excluding threads, check the report to see if it has a single participant and if the participant is already selected + if (!includeThreads && reportOption.login && _.some(loginOptionsToExclude, (option) => option.login === reportOption.login)) { continue; } @@ -719,7 +728,7 @@ function getOptions( // If user doesn't exist, use a default avatar userToInvite.icons = [ { - source: ReportUtils.getAvatar('', searchValue), + source: UserUtils.getAvatar('', searchValue), name: searchValue, type: CONST.ICON_TYPE_AVATAR, }, @@ -781,29 +790,30 @@ function getSearchOptions(reports, personalDetails, searchValue = '', betas) { includePersonalDetails: true, forcePolicyNamePreview: true, includeOwnedWorkspaceChats: true, + includeThreads: true, }); } /** - * Build the IOUConfirmation options for showing MyPersonalDetail + * Build the IOUConfirmation options for showing the payee personalDetail * - * @param {Object} myPersonalDetail + * @param {Object} personalDetail * @param {String} amountText * @returns {Object} */ -function getIOUConfirmationOptionsFromMyPersonalDetail(myPersonalDetail, amountText) { +function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail, amountText) { return { - text: myPersonalDetail.displayName, - alternateText: myPersonalDetail.login, + text: personalDetail.displayName ? personalDetail.displayName : personalDetail.login, + alternateText: personalDetail.login, icons: [ { - source: ReportUtils.getAvatar(myPersonalDetail.avatar, myPersonalDetail.login), - name: myPersonalDetail.login, + source: UserUtils.getAvatar(personalDetail.avatar, personalDetail.login), + name: personalDetail.login, type: CONST.ICON_TYPE_AVATAR, }, ], descriptiveText: amountText, - login: myPersonalDetail.login, + login: personalDetail.login, }; } @@ -944,7 +954,7 @@ export { getMemberInviteOptions, getHeaderMessage, getPersonalDetailsForLogins, - getIOUConfirmationOptionsFromMyPersonalDetail, + getIOUConfirmationOptionsFromPayeePersonalDetail, getIOUConfirmationOptionsFromParticipants, getSearchText, getAllReportErrors, diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js index 84d0a3d0154b..d3e407260e20 100644 --- a/src/libs/Permissions.js +++ b/src/libs/Permissions.js @@ -102,14 +102,6 @@ function canUseTasks(betas) { return _.contains(betas, CONST.BETAS.TASKS) || _.contains(betas, CONST.BETAS.ALL); } -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseThreads(betas) { - return _.contains(betas, CONST.BETAS.THREADS) || canUseAllBetas(betas); -} - export default { canUseChronos, canUseIOU, @@ -122,5 +114,4 @@ export default { canUsePolicyExpenseChat, canUsePasswordlessLogins, canUseTasks, - canUseThreads, }; diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index d5551e06ba4a..6b6a9d5637c6 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -30,6 +30,14 @@ Onyx.connect({ callback: (val) => (isNetworkOffline = lodashGet(val, 'isOffline', false)), }); +/** + * @param {Object} reportAction + * @returns {Boolean} + */ +function isCreatedAction(reportAction) { + return lodashGet(reportAction, 'actionName') === CONST.REPORT.ACTIONS.TYPE.CREATED; +} + /** * @param {Object} reportAction * @returns {Boolean} @@ -229,6 +237,10 @@ function getLastVisibleMessageText(reportID, actionsToMerge = {}) { return CONST.ATTACHMENT_MESSAGE_TEXT; } + if (isCreatedAction(lastVisibleAction)) { + return ''; + } + const messageText = lodashGet(message, 'text', ''); return String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 6122701476f7..aeb170ddbd44 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -8,7 +8,6 @@ import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; import * as Localize from './Localize'; import * as Expensicons from '../components/Icon/Expensicons'; -import hashCode from './hashCode'; import Navigation from './Navigation/Navigation'; import ROUTES from '../ROUTES'; import * as NumberUtils from './NumberUtils'; @@ -17,11 +16,11 @@ 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'; import isReportMessageAttachment from './isReportMessageAttachment'; import * as defaultWorkspaceAvatars from '../components/Icon/WorkspaceDefaultAvatars'; import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as CurrencyUtils from './CurrencyUtils'; +import * as UserUtils from './UserUtils'; let sessionEmail; Onyx.connect({ @@ -193,6 +192,24 @@ function canEditReportAction(reportAction) { ); } +/** + * Can only flag if: + * + * - It was written by someone else + * - It's an ADDCOMMENT that is not an attachment + * + * @param {Object} reportAction + * @returns {Boolean} + */ +function canFlagReportAction(reportAction) { + return ( + reportAction.actorEmail !== sessionEmail && + reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && + !ReportActionsUtils.isDeletedAction(reportAction) && + !ReportActionsUtils.isCreatedTaskReportAction(reportAction) + ); +} + /** * Whether the Money Request report is settled * @@ -331,6 +348,16 @@ function getPolicyType(report, policies) { return lodashGet(policies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'type'], ''); } +/** + * If the report is a policy expense, the route should be for adding bank account for that policy + * else since the report is a personal IOU, the route should be for personal bank account. + * @param {Object} report + * @returns {String} + */ +function getBankAccountRoute(report) { + return isPolicyExpenseChat(report) ? ROUTES.getBankAccountRoute('', report.policyID) : ROUTES.SETTINGS_ADD_BANK_ACCOUNT; +} + /** * Returns true if there are any guides accounts (team.expensify.com) in emails * @param {Array} emails @@ -432,6 +459,31 @@ function getPolicyName(report) { return policy.name || report.oldPolicyName || Localize.translateLocal('workspace.common.unavailable'); } +/** + * Checks if the current user is allowed to comment on the given report. + * @param {Object} report + * @param {String} [report.writeCapability] + * @returns {Boolean} + */ +function isAllowedToComment(report) { + // Default to allowing all users to post + const capability = lodashGet(report, 'writeCapability', CONST.REPORT.WRITE_CAPABILITIES.ALL) || CONST.REPORT.WRITE_CAPABILITIES.ALL; + + if (capability === CONST.REPORT.WRITE_CAPABILITIES.ALL) { + return true; + } + + // If unauthenticated user opens public chat room using deeplink, they do not have policies available and they cannot comment + if (!allPolicies) { + return false; + } + + // If we've made it here, commenting on this report is restricted. + // If the user is an admin, allow them to post. + const policy = allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + return lodashGet(policy, 'role', '') === CONST.POLICY.ROLE.ADMIN; +} + /** * Checks if the current user is the admin of the policy given the policy expense chat. * @param {Object} report @@ -614,36 +666,6 @@ function formatReportLastMessageText(lastMessageText) { return Str.htmlDecode(String(lastMessageText)).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); } -/** - * Hashes provided string and returns a value between [0, range) - * @param {String} login - * @param {Number} range - * @returns {Number} - */ -function hashLogin(login, range) { - return Math.abs(hashCode(login.toLowerCase())) % range; -} - -/** - * Helper method to return the default avatar associated with the given login - * @param {String} [login] - * @returns {String} - */ -function getDefaultAvatar(login = '') { - if (!login) { - return Expensicons.FallbackAvatar; - } - if (login === CONST.EMAIL.CONCIERGE) { - return Expensicons.ConciergeAvatar; - } - - // There are 24 possible default avatars, so we choose which one this user has based - // on a simple hash of their login. Note that Avatar count starts at 1. - const loginHashBucket = hashLogin(login, CONST.DEFAULT_AVATAR_COUNT) + 1; - - return defaultAvatars[`Avatar${loginHashBucket}`]; -} - /** * Helper method to return the default avatar associated with the given login * @param {String} [workspaceName] @@ -668,102 +690,6 @@ function getWorkspaceAvatar(report) { return lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'avatar']) || getDefaultWorkspaceAvatar(workspaceName); } -/** - * Helper method to return old dot default avatar associated with login - * - * @param {String} [login] - * @returns {String} - */ -function getOldDotDefaultAvatar(login = '') { - if (login === CONST.EMAIL.CONCIERGE) { - return CONST.CONCIERGE_ICON_URL; - } - - // There are 8 possible old dot default avatars, so we choose which one this user has based - // on a simple hash of their login. Note that Avatar count starts at 1. - const loginHashBucket = hashLogin(login, CONST.OLD_DEFAULT_AVATAR_COUNT) + 1; - - return `${CONST.CLOUDFRONT_URL}/images/avatars/avatar_${loginHashBucket}.png`; -} - -/** - * Given a user's avatar path, returns true if user doesn't have an avatar or if URL points to a default avatar - * @param {String} [avatarURL] - the avatar source from user's personalDetails - * @returns {Boolean} - */ -function isDefaultAvatar(avatarURL) { - if ( - _.isString(avatarURL) && - (avatarURL.includes('images/avatars/avatar_') || avatarURL.includes('images/avatars/default-avatar_') || avatarURL.includes('images/avatars/user/default')) - ) { - return true; - } - - // If null URL, we should also use a default avatar - if (!avatarURL) { - return true; - } - return false; -} - -/** - * Provided a source URL, if source is a default avatar, return the associated SVG. - * Otherwise, return the URL pointing to a user-uploaded avatar. - * - * @param {String} [avatarURL] - the avatar source from user's personalDetails - * @param {String} [login] - the email of the user - * @returns {String|Function} - */ -function getAvatar(avatarURL, login) { - if (isDefaultAvatar(avatarURL)) { - return getDefaultAvatar(login); - } - return avatarURL; -} - -/** - * Avatars uploaded by users will have a _128 appended so that the asset server returns a small version. - * This removes that part of the URL so the full version of the image can load. - * - * @param {String} [avatarURL] - * @param {String} [login] - * @returns {String|Function} - */ -function getFullSizeAvatar(avatarURL, login) { - const source = getAvatar(avatarURL, login); - if (!_.isString(source)) { - return source; - } - return source.replace('_128', ''); -} - -/** - * Small sized avatars end with _128.. This adds the _128 at the end of the - * source URL (before the file type) if it doesn't exist there already. - * - * @param {String} avatarURL - * @param {String} login - * @returns {String|Function} - */ -function getSmallSizeAvatar(avatarURL, login) { - const source = getAvatar(avatarURL, login); - if (!_.isString(source)) { - return source; - } - - // Because other urls than CloudFront do not support dynamic image sizing (_SIZE suffix), the current source is already what we want to use here. - if (!CONST.CLOUDFRONT_DOMAIN_REGEX.test(source)) { - return source; - } - - // If image source already has _128 at the end, the given avatar URL is already what we want to use here. - const lastPeriodIndex = source.lastIndexOf('.'); - if (source.substring(lastPeriodIndex - 4, lastPeriodIndex) === '_128') { - return source; - } - return `${source.substring(0, lastPeriodIndex)}_128${source.substring(lastPeriodIndex)}`; -} - /** * Returns the appropriate icons for the given chat report using the stored personalDetails. * The Avatar sources can be URLs or Icon components according to the chat type. @@ -778,7 +704,7 @@ function getIconsForParticipants(participants, personalDetails) { for (let i = 0; i < participantsList.length; i++) { const login = participantsList[i]; - const avatarSource = getAvatar(lodashGet(personalDetails, [login, 'avatar'], ''), login); + const avatarSource = UserUtils.getAvatar(lodashGet(personalDetails, [login, 'avatar'], ''), login); participantDetails.push([login, lodashGet(personalDetails, [login, 'firstName'], ''), avatarSource]); } @@ -806,9 +732,10 @@ function getIconsForParticipants(participants, personalDetails) { * @param {Object} report * @param {Object} personalDetails * @param {*} [defaultIcon] + * @param {Boolean} [isPayer] * @returns {Array<*>} */ -function getIcons(report, personalDetails, defaultIcon = null) { +function getIcons(report, personalDetails, defaultIcon = null, isPayer = false) { const result = { source: '', type: CONST.ICON_TYPE_AVATAR, @@ -828,25 +755,16 @@ function getIcons(report, personalDetails, defaultIcon = null) { return [result]; } if (isThread(report)) { - const parentReport = lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]); const parentReportAction = ReportActionsUtils.getParentReportAction(report); - if (!parentReport) { - result.source = Expensicons.ActiveRoomAvatar; - return [result]; - } - - if (getChatType(parentReport)) { - result.source = getWorkspaceAvatar(parentReport); - result.type = CONST.ICON_TYPE_WORKSPACE; - result.name = getPolicyName(parentReport); - return [result]; - } - const actorEmail = lodashGet(parentReportAction, 'actorEmail', ''); - result.source = getAvatar(lodashGet(personalDetails, [actorEmail, 'avatar']), actorEmail); - result.name = actorEmail; - return [result]; + const actorIcon = { + source: UserUtils.getAvatar(lodashGet(personalDetails, [actorEmail, 'avatar']), actorEmail), + name: actorEmail, + type: CONST.ICON_TYPE_AVATAR, + }; + + return [actorIcon]; } if (isDomainRoom(report)) { result.source = Expensicons.DomainRoomAvatar; @@ -878,7 +796,7 @@ function getIcons(report, personalDetails, defaultIcon = null) { } const adminIcon = { - source: getAvatar(lodashGet(personalDetails, [report.ownerEmail, 'avatar']), report.ownerEmail), + source: UserUtils.getAvatar(lodashGet(personalDetails, [report.ownerEmail, 'avatar']), report.ownerEmail), name: report.ownerEmail, type: CONST.ICON_TYPE_AVATAR, }; @@ -894,10 +812,11 @@ function getIcons(report, personalDetails, defaultIcon = null) { return [adminIcon, workspaceIcon]; } if (isIOUReport(report)) { + const email = isPayer ? report.managerEmail : report.ownerEmail; return [ { - source: getAvatar(lodashGet(personalDetails, [report.ownerEmail, 'avatar']), report.ownerEmail), - name: report.ownerEmail, + source: UserUtils.getAvatar(lodashGet(personalDetails, [email, 'avatar']), email), + name: email, type: CONST.ICON_TYPE_AVATAR, }, ]; @@ -920,7 +839,7 @@ function getPersonalDetailsForLogin(login) { (allPersonalDetails && allPersonalDetails[login]) || { login, displayName: LocalePhoneNumber.formatPhoneNumber(login), - avatar: getDefaultAvatar(login), + avatar: UserUtils.getDefaultAvatar(login), } ); } @@ -1085,6 +1004,11 @@ function getReportName(report) { if (ReportActionsUtils.isTransactionThread(parentReportAction)) { return getTransactionReportName(parentReportAction); } + + const isAttachment = _.has(parentReportAction, 'isAttachment') ? parentReportAction.isAttachment : isReportMessageAttachment(_.last(lodashGet(parentReportAction, 'message', [{}]))); + if (isAttachment) { + return `[${Localize.translateLocal('common.attachment')}]`; + } const parentReportActionMessage = lodashGet(parentReportAction, ['message', 0, 'text'], '').replace(/(\r\n|\n|\r)/gm, ' '); return parentReportActionMessage || Localize.translateLocal('parentReportAction.deletedMessage'); } @@ -1179,7 +1103,7 @@ function buildOptimisticAddCommentReportAction(text, file) { const commentText = getParsedComment(text); const isAttachment = _.isEmpty(text) && file !== undefined; const attachmentInfo = isAttachment ? file : {}; - const htmlForNewComment = isAttachment ? 'Uploading attachment...' : commentText; + const htmlForNewComment = isAttachment ? CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML : commentText; // Remove HTML from text when applying optimistic offline comment const textForNewComment = isAttachment ? CONST.ATTACHMENT_MESSAGE_TEXT : parser.htmlToText(htmlForNewComment); @@ -1199,7 +1123,7 @@ function buildOptimisticAddCommentReportAction(text, file) { }, ], automatic: false, - avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], getDefaultAvatar(currentUserEmail)), + avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], UserUtils.getDefaultAvatar(currentUserEmail)), created: DateUtils.getDBTime(), message: [ { @@ -1363,7 +1287,7 @@ function getIOUReportActionMessage(type, total, comment, currency, paymentType = return [ { - html: getParsedComment(iouMessage), + html: iouMessage, text: iouMessage, isEdited: false, type: CONST.REPORT.MESSAGE.TYPE.COMMENT, @@ -1388,13 +1312,10 @@ function getIOUReportActionMessage(type, total, comment, currency, paymentType = */ function buildOptimisticIOUReportAction(type, amount, currency, comment, participants, transactionID, paymentType = '', iouReportID = '', isSettlingUp = false, isSendMoneyFlow = false) { const IOUReportID = iouReportID || generateReportID(); - const parser = new ExpensiMark(); - const commentText = getParsedComment(comment); - const textForNewComment = parser.htmlToText(commentText); - const textForNewCommentDecoded = Str.htmlDecode(textForNewComment); + const originalMessage = { amount, - comment: textForNewComment, + comment, currency, IOUTransactionID: transactionID, IOUReportID, @@ -1421,10 +1342,10 @@ function buildOptimisticIOUReportAction(type, amount, currency, comment, partici actorAccountID: currentUserAccountID, actorEmail: currentUserEmail, automatic: false, - avatar: lodashGet(currentUserPersonalDetails, 'avatar', getDefaultAvatar(currentUserEmail)), + avatar: lodashGet(currentUserPersonalDetails, 'avatar', UserUtils.getDefaultAvatar(currentUserEmail)), isAttachment: false, originalMessage, - message: getIOUReportActionMessage(type, amount, textForNewCommentDecoded, currency, paymentType, isSettlingUp), + message: getIOUReportActionMessage(type, amount, comment, currency, paymentType, isSettlingUp), person: [ { style: 'strong', @@ -1474,7 +1395,7 @@ function buildOptimisticTaskReportAction(taskReportID, actionName, message = '') actorAccountID: currentUserAccountID, actorEmail: currentUserEmail, automatic: false, - avatar: lodashGet(currentUserPersonalDetails, 'avatar', getDefaultAvatar(currentUserEmail)), + avatar: lodashGet(currentUserPersonalDetails, 'avatar', UserUtils.getDefaultAvatar(currentUserEmail)), isAttachment: false, originalMessage, message: [ @@ -1552,6 +1473,7 @@ function buildOptimisticChatReport( stateNum: 0, statusNum: 0, visibility, + welcomeMessage: '', }; } @@ -1587,7 +1509,7 @@ function buildOptimisticCreatedReportAction(ownerEmail) { }, ], automatic: false, - avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], getDefaultAvatar(currentUserEmail)), + avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], UserUtils.getDefaultAvatar(currentUserEmail)), created: DateUtils.getDBTime(), shouldShow: true, }; @@ -1627,7 +1549,7 @@ function buildOptimisticEditedTaskReportAction(ownerEmail) { }, ], automatic: false, - avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], getDefaultAvatar(currentUserEmail)), + avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], UserUtils.getDefaultAvatar(currentUserEmail)), created: DateUtils.getDBTime(), shouldShow: false, }; @@ -1646,7 +1568,7 @@ function buildOptimisticClosedReportAction(ownerEmail, policyName, reason = CONS actionName: CONST.REPORT.ACTIONS.TYPE.CLOSED, actorAccountID: currentUserAccountID, automatic: false, - avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], getDefaultAvatar(currentUserEmail)), + avatar: lodashGet(allPersonalDetails, [currentUserEmail, 'avatar'], UserUtils.getDefaultAvatar(currentUserEmail)), created: DateUtils.getDBTime(), message: [ { @@ -1903,10 +1825,6 @@ function shouldReportBeInOptionList(report, reportIDFromRoute, isInGSDMode, curr return false; } - if (isUserCreatedPolicyRoom(report) && !Permissions.canUsePolicyRooms(betas)) { - return false; - } - // Include the currently viewed report. If we excluded the currently viewed report, then there // would be no way to highlight it in the options list and it would be confusing to users because they lose // a sense of context. @@ -2213,11 +2131,25 @@ function isReportDataReady() { return !_.isEmpty(allReports) && _.some(_.keys(allReports), (key) => allReports[key].reportID); } +/** + * Returns the parentReport if the given report is a thread. + * + * @param {Object} report + * @returns {Object} + */ +function getParentReport(report) { + if (!report || !report.parentReportID) { + return {}; + } + return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, {}); +} + export { getReportParticipantsTitle, isReportMessageAttachment, findLastAccessedReport, canEditReportAction, + canFlagReportAction, canDeleteReportAction, canLeaveRoom, sortReportsByLastRead, @@ -2244,7 +2176,6 @@ export { formatReportLastMessageText, chatIncludesConcierge, isPolicyExpenseChat, - getDefaultAvatar, getIconsForParticipants, getIcons, getRoomWelcomeMessage, @@ -2282,17 +2213,11 @@ export { isTaskReport, isMoneyRequestReport, chatIncludesChronos, - getAvatar, - isDefaultAvatar, - getOldDotDefaultAvatar, getNewMarkerReportActionID, canSeeDefaultRoom, - hashLogin, getDefaultWorkspaceAvatar, getCommentLength, getParsedComment, - getFullSizeAvatar, - getSmallSizeAvatar, getMoneyRequestOptions, canRequestMoney, getWhisperDisplayNames, @@ -2303,5 +2228,8 @@ export { shouldReportShowSubscript, isReportDataReady, isSettled, + isAllowedToComment, getMoneyRequestAction, + getBankAccountRoute, + getParentReport, }; diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 93220ecf313b..7b3b15291e74 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -11,6 +11,7 @@ import CONST from '../CONST'; import * as OptionsListUtils from './OptionsListUtils'; import * as CollectionUtils from './CollectionUtils'; import * as LocalePhoneNumber from './LocalePhoneNumber'; +import * as UserUtils from './UserUtils'; // Note: It is very important that the keys subscribed to here are the same // keys that are connected to SidebarLinks withOnyx(). If there was a key missing from SidebarLinks and it's data was updated @@ -332,8 +333,8 @@ function getOptionData(reportID) { result.subtitle = subtitle; result.participantsList = participantPersonalDetailList; - result.icons = ReportUtils.getIcons(result.isTaskReport ? parentReport : report, personalDetails, policies, ReportUtils.getAvatar(personalDetail.avatar, personalDetail.login)); - result.searchText = OptionsListUtils.getSearchText(report, reportName, participantPersonalDetailList, result.isChatRoom || result.isPolicyExpenseChat); + result.icons = ReportUtils.getIcons(result.isTaskReport ? parentReport : report, personalDetails, UserUtils.getAvatar(personalDetail.avatar, personalDetail.login), true); + result.searchText = OptionsListUtils.getSearchText(report, reportName, participantPersonalDetailList, result.isChatRoom || result.isPolicyExpenseChat, result.isThread); result.displayNamesWithTooltips = displayNamesWithTooltips; return result; } diff --git a/src/libs/UserUtils.js b/src/libs/UserUtils.js index 1abeef19cd84..e7d8235f0004 100644 --- a/src/libs/UserUtils.js +++ b/src/libs/UserUtils.js @@ -1,6 +1,9 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import CONST from '../CONST'; +import hashCode from './hashCode'; +import * as Expensicons from '../components/Icon/Expensicons'; +import * as defaultAvatars from '../components/Icon/DefaultAvatars'; /** * Searches through given loginList for any contact method / login with an error. @@ -61,4 +64,153 @@ function getLoginListBrickRoadIndicator(loginList) { return ''; } -export {hasLoginListError, hasLoginListInfo, getLoginListBrickRoadIndicator}; +/** + * Hashes provided string and returns a value between [0, range) + * @param {String} login + * @param {Number} range + * @returns {Number} + */ +function hashLogin(login, range) { + return Math.abs(hashCode(login.toLowerCase())) % range; +} + +/** + * Helper method to return the default avatar associated with the given login + * @param {String} [login] + * @returns {String} + */ +function getDefaultAvatar(login = '') { + if (!login) { + return Expensicons.FallbackAvatar; + } + if (login === CONST.EMAIL.CONCIERGE) { + return Expensicons.ConciergeAvatar; + } + + // There are 24 possible default avatars, so we choose which one this user has based + // on a simple hash of their login. Note that Avatar count starts at 1. + const loginHashBucket = hashLogin(login, CONST.DEFAULT_AVATAR_COUNT) + 1; + + return defaultAvatars[`Avatar${loginHashBucket}`]; +} + +/** + * Helper method to return default avatar URL associated with login + * + * @param {String} [login] + * @param {Boolean} [isNewDot] + * @returns {String} + */ +function getDefaultAvatarURL(login = '', isNewDot = false) { + if (login === CONST.EMAIL.CONCIERGE) { + return CONST.CONCIERGE_ICON_URL; + } + + // The default avatar for a user is based on a simple hash of their login. + // Note that Avatar count starts at 1 which is why 1 has to be added to the result (or else 0 would result in a broken avatar link) + const loginHashBucket = hashLogin(login, isNewDot ? CONST.DEFAULT_AVATAR_COUNT : CONST.OLD_DEFAULT_AVATAR_COUNT) + 1; + const avatarPrefix = isNewDot ? `default-avatar` : `avatar`; + + return `${CONST.CLOUDFRONT_URL}/images/avatars/${avatarPrefix}_${loginHashBucket}.png`; +} + +/** + * Given a user's avatar path, returns true if user doesn't have an avatar or if URL points to a default avatar + * @param {String} [avatarURL] - the avatar source from user's personalDetails + * @returns {Boolean} + */ +function isDefaultAvatar(avatarURL) { + if ( + _.isString(avatarURL) && + (avatarURL.includes('images/avatars/avatar_') || avatarURL.includes('images/avatars/default-avatar_') || avatarURL.includes('images/avatars/user/default')) + ) { + return true; + } + + // If null URL, we should also use a default avatar + if (!avatarURL) { + return true; + } + return false; +} + +/** + * Provided a source URL, if source is a default avatar, return the associated SVG. + * Otherwise, return the URL pointing to a user-uploaded avatar. + * + * @param {String} avatarURL - the avatar source from user's personalDetails + * @param {String} login - the email of the user + * @returns {String|Function} + */ +function getAvatar(avatarURL, login) { + return isDefaultAvatar(avatarURL) ? getDefaultAvatar(login) : avatarURL; +} + +/** + * Provided an avatar URL, if avatar is a default avatar, return NewDot default avatar URL. + * Otherwise, return the URL pointing to a user-uploaded avatar. + * + * @param {String} avatarURL - the avatar source from user's personalDetails + * @param {String} login - the email of the user + * @returns {String} + */ +function getAvatarUrl(avatarURL, login) { + return isDefaultAvatar(avatarURL) ? getDefaultAvatarURL(login, true) : avatarURL; +} + +/** + * Avatars uploaded by users will have a _128 appended so that the asset server returns a small version. + * This removes that part of the URL so the full version of the image can load. + * + * @param {String} [avatarURL] + * @param {String} [login] + * @returns {String|Function} + */ +function getFullSizeAvatar(avatarURL, login) { + const source = getAvatar(avatarURL, login); + if (!_.isString(source)) { + return source; + } + return source.replace('_128', ''); +} + +/** + * Small sized avatars end with _128.. This adds the _128 at the end of the + * source URL (before the file type) if it doesn't exist there already. + * + * @param {String} avatarURL + * @param {String} login + * @returns {String|Function} + */ +function getSmallSizeAvatar(avatarURL, login) { + const source = getAvatar(avatarURL, login); + if (!_.isString(source)) { + return source; + } + + // Because other urls than CloudFront do not support dynamic image sizing (_SIZE suffix), the current source is already what we want to use here. + if (!CONST.CLOUDFRONT_DOMAIN_REGEX.test(source)) { + return source; + } + + // If image source already has _128 at the end, the given avatar URL is already what we want to use here. + const lastPeriodIndex = source.lastIndexOf('.'); + if (source.substring(lastPeriodIndex - 4, lastPeriodIndex) === '_128') { + return source; + } + return `${source.substring(0, lastPeriodIndex)}_128${source.substring(lastPeriodIndex)}`; +} + +export { + hashLogin, + hasLoginListError, + hasLoginListInfo, + getLoginListBrickRoadIndicator, + getDefaultAvatar, + getDefaultAvatarURL, + isDefaultAvatar, + getAvatar, + getAvatarUrl, + getSmallSizeAvatar, + getFullSizeAvatar, +}; diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js index 2a716801b4e8..8ee14e693ba7 100644 --- a/src/libs/actions/BankAccounts.js +++ b/src/libs/actions/BankAccounts.js @@ -2,8 +2,7 @@ import Onyx from 'react-native-onyx'; import CONST from '../../CONST'; import * as API from '../API'; import ONYXKEYS from '../../ONYXKEYS'; -import * as Localize from '../Localize'; -import DateUtils from '../DateUtils'; +import * as ErrorUtils from '../ErrorUtils'; import * as PlaidDataProps from '../../pages/ReimbursementAccount/plaidDataPropTypes'; import Navigation from '../Navigation/Navigation'; import ROUTES from '../../ROUTES'; @@ -80,9 +79,7 @@ function getVBBADataForOnyx() { key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, value: { isLoading: false, - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('paymentsPage.addBankAccountFailure'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('paymentsPage.addBankAccountFailure'), }, }, ], @@ -159,9 +156,7 @@ function addPersonalBankAccount(account) { key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, value: { isLoading: false, - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('paymentsPage.addBankAccountFailure'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('paymentsPage.addBankAccountFailure'), }, }, ], diff --git a/src/libs/actions/Device/index.js b/src/libs/actions/Device/index.js index 06b6bdc51ef8..8f548b75bd93 100644 --- a/src/libs/actions/Device/index.js +++ b/src/libs/actions/Device/index.js @@ -45,4 +45,20 @@ function setDeviceID() { .catch((err) => Log.info('Found existing deviceID', false, err.message)); } -export {getDeviceInfo, getDeviceID, setDeviceID}; +/** + * Returns a string object with device info and uniqueID + * @returns {Promise} + */ +function getDeviceInfoWithID() { + return new Promise((resolve) => { + getDeviceID().then((currentDeviceID) => + resolve( + JSON.stringify({ + ...getDeviceInfo(), + deviceID: currentDeviceID, + }), + ), + ); + }); +} +export {getDeviceID, setDeviceID, getDeviceInfoWithID}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index ccc133cc56bd..26ddd30bceb3 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -15,6 +15,7 @@ import * as IOUUtils from '../IOUUtils'; import * as OptionsListUtils from '../OptionsListUtils'; import DateUtils from '../DateUtils'; import TransactionUtils from '../TransactionUtils'; +import * as ErrorUtils from '../ErrorUtils'; const chatReports = {}; const iouReports = {}; @@ -186,9 +187,7 @@ function buildOnyxDataForMoneyRequest( ...(isNewChatReport ? { errorFields: { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, } : {}), @@ -201,9 +200,7 @@ function buildOnyxDataForMoneyRequest( key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, value: { errorFields: { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, }, }, @@ -213,9 +210,7 @@ function buildOnyxDataForMoneyRequest( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, { @@ -225,9 +220,7 @@ function buildOnyxDataForMoneyRequest( ...(isNewChatReport ? { [chatCreatedAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, } : {}), @@ -243,16 +236,12 @@ function buildOnyxDataForMoneyRequest( ...(isNewIOUReport ? { [iouCreatedAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, } : { [iouAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }), }, @@ -359,14 +348,13 @@ function requestMoney(report, amount, currency, payeeEmail, participant, comment ); // STEP 6: Make the request - const parsedComment = ReportUtils.getParsedComment(comment); API.write( 'RequestMoney', { debtorEmail: payerEmail, amount, currency, - comment: parsedComment, + comment, iouReportID: iouReport.reportID, chatReportID: chatReport.reportID, transactionID: optimisticTransaction.transactionID, @@ -488,9 +476,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`, value: { [groupIOUReportAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, }, @@ -498,9 +484,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${groupTransaction.transactionID}`, value: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, ]; @@ -511,9 +495,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`, value: { errorFields: { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, }, }); @@ -645,7 +627,6 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment */ function splitBill(participants, currentUserLogin, amount, comment, currency, existingGroupChatReportID = '') { const {groupData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, amount, comment, currency, existingGroupChatReportID); - const parsedComment = ReportUtils.getParsedComment(comment); API.write( 'SplitBill', @@ -654,7 +635,7 @@ function splitBill(participants, currentUserLogin, amount, comment, currency, ex amount, splits: JSON.stringify(splits), currency, - comment: parsedComment, + comment, transactionID: groupData.transactionID, reportActionID: groupData.reportActionID, createdReportActionID: groupData.createdReportActionID, @@ -674,7 +655,6 @@ function splitBill(participants, currentUserLogin, amount, comment, currency, ex */ function splitBillAndOpenReport(participants, currentUserLogin, amount, comment, currency) { const {groupData, splits, onyxData} = createSplitsAndOnyxData(participants, currentUserLogin, amount, comment, currency); - const parsedComment = ReportUtils.getParsedComment(comment); API.write( 'SplitBillAndOpenReport', @@ -683,7 +663,7 @@ function splitBillAndOpenReport(participants, currentUserLogin, amount, comment, amount, splits: JSON.stringify(splits), currency, - comment: parsedComment, + comment, transactionID: groupData.transactionID, reportActionID: groupData.reportActionID, createdReportActionID: groupData.createdReportActionID, @@ -770,9 +750,7 @@ function deleteMoneyRequest(chatReportID, iouReportID, moneyRequestAction, shoul key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, value: { [optimisticIOUAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericDeleteFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericDeleteFailureMessage'), }, }, }, @@ -844,12 +822,11 @@ function buildPayPalPaymentUrl(amount, submitterPayPalMeAddress, currency) { */ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType, managerEmail, recipient) { const recipientEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(recipient.login); - const parsedComment = ReportUtils.getParsedComment(comment); const newIOUReportDetails = JSON.stringify({ amount, currency, requestorEmail: recipientEmail, - comment: parsedComment, + comment, idempotencyKey: Str.guid(), }); @@ -939,9 +916,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.other'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), }, }, }, @@ -949,9 +924,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, value: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.other'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), }, }, ]; @@ -976,9 +949,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType key: optimisticChatReportData.key, value: { errorFields: { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, }, }); @@ -1101,9 +1072,7 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.other'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), }, }, }, @@ -1111,9 +1080,7 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, value: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, ]; @@ -1194,7 +1161,11 @@ function payMoneyRequest(paymentType, chatReport, iouReport) { }; const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentType); - API.write('PayMoneyRequest', params, {optimisticData, successData, failureData}); + // For now we need to call the PayMoneyRequestWithWallet API since PayMoneyRequest was not updated to work with + // Expensify Wallets. + const apiCommand = paymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY ? 'PayMoneyRequestWithWallet' : 'PayMoneyRequest'; + + API.write(apiCommand, params, {optimisticData, successData, failureData}); Navigation.navigate(ROUTES.getReportRoute(chatReport.reportID)); if (paymentType === CONST.IOU.PAYMENT_TYPE.PAYPAL_ME) { asyncOpenURL(Promise.resolve(), buildPayPalPaymentUrl(iouReport.total, recipient.payPalMeAddress, iouReport.currency)); diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index c9c5889c914b..e1d983eecf95 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -5,7 +5,7 @@ import _ from 'underscore'; import ONYXKEYS from '../../ONYXKEYS'; import CONST from '../../CONST'; import * as API from '../API'; -import * as ReportUtils from '../ReportUtils'; +import * as UserUtils from '../UserUtils'; import * as LocalePhoneNumber from '../LocalePhoneNumber'; import ROUTES from '../../ROUTES'; import Navigation from '../Navigation/Navigation'; @@ -373,7 +373,7 @@ function updateAvatar(file) { */ function deleteAvatar() { // We want to use the old dot avatar here as this affects both platforms. - const defaultAvatar = ReportUtils.getOldDotDefaultAvatar(currentUserEmail); + const defaultAvatar = UserUtils.getDefaultAvatarURL(currentUserEmail); API.write( 'DeleteUserAvatar', diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 89e542320d02..66bcf0ba1392 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -7,11 +7,10 @@ import {escapeRegExp} from 'lodash'; import * as API from '../API'; import ONYXKEYS from '../../ONYXKEYS'; import CONST from '../../CONST'; -import * as Localize from '../Localize'; -import Navigation from '../Navigation/Navigation'; +import Navigation, {navigationRef} from '../Navigation/Navigation'; import ROUTES from '../../ROUTES'; import * as OptionsListUtils from '../OptionsListUtils'; -import DateUtils from '../DateUtils'; +import * as ErrorUtils from '../ErrorUtils'; import * as ReportUtils from '../ReportUtils'; import Log from '../Log'; import Permissions from '../Permissions'; @@ -214,7 +213,7 @@ function removeMembers(members, policyID) { { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, - value: _.object(members, Array(members.length).fill({errors: {[DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.people.error.genericRemove')}})), + value: _.object(members, Array(members.length).fill({errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericRemove')})), }, ]; API.write( @@ -369,9 +368,7 @@ function addMembersToWorkspace(memberLogins, welcomeNote, policyID, betas) { value: _.object( logins, Array(logins.length).fill({ - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.people.error.genericAdd'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericAdd'), }), ), }, @@ -481,9 +478,7 @@ function deleteWorkspaceAvatar(policyID) { avatar: null, }, errorFields: { - avatar: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('avatarWithImagePicker.deleteWorkspaceError'), - }, + avatar: ErrorUtils.getMicroSecondOnyxError('avatarWithImagePicker.deleteWorkspaceError'), }, }, }, @@ -553,9 +548,7 @@ function updateGeneralSettings(policyID, name, currency) { generalSettings: null, }, errorFields: { - generalSettings: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.editor.genericFailureMessage'), - }, + generalSettings: ErrorUtils.getMicroSecondOnyxError('workspace.editor.genericFailureMessage'), }, }, }, @@ -627,7 +620,7 @@ function hideWorkspaceAlertMessage(policyID) { * @param {Object} newCustomUnit * @param {Number} lastModified */ -function updateWorkspaceCustomUnit(policyID, currentCustomUnit, newCustomUnit, lastModified) { +function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustomUnit, lastModified) { const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -636,77 +629,14 @@ function updateWorkspaceCustomUnit(policyID, currentCustomUnit, newCustomUnit, l customUnits: { [newCustomUnit.customUnitID]: { ...newCustomUnit, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - }, - }, - }, - }, - ]; - - const successData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [newCustomUnit.customUnitID]: { - pendingAction: null, - errors: null, - }, - }, - }, - }, - ]; - - const failureData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [currentCustomUnit.customUnitID]: { - customUnitID: currentCustomUnit.customUnitID, - name: currentCustomUnit.name, - attributes: currentCustomUnit.attributes, - }, - }, - }, - }, - ]; - - API.write( - 'UpdateWorkspaceCustomUnit', - { - policyID, - lastModified, - customUnit: JSON.stringify(newCustomUnit), - }, - {optimisticData, successData, failureData}, - ); -} - -/** - * @param {String} policyID - * @param {Object} currentCustomUnitRate - * @param {String} customUnitID - * @param {Object} newCustomUnitRate - * @param {Number} lastModified - */ -function updateCustomUnitRate(policyID, currentCustomUnitRate, customUnitID, newCustomUnitRate, lastModified) { - const optimisticData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - customUnits: { - [customUnitID]: { rates: { - [newCustomUnitRate.customUnitRateID]: { - ...newCustomUnitRate, + [newCustomUnit.rates.customUnitRateID]: { + ...newCustomUnit.rates, errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }, }, @@ -719,9 +649,11 @@ function updateCustomUnitRate(policyID, currentCustomUnitRate, customUnitID, new key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { - [customUnitID]: { + [newCustomUnit.customUnitID]: { + pendingAction: null, + errors: null, rates: { - [newCustomUnitRate.customUnitRateID]: { + [newCustomUnit.rates.customUnitRateID]: { pendingAction: null, }, }, @@ -737,13 +669,12 @@ function updateCustomUnitRate(policyID, currentCustomUnitRate, customUnitID, new key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { - [customUnitID]: { + [currentCustomUnit.customUnitID]: { + customUnitID: currentCustomUnit.customUnitID, rates: { - [currentCustomUnitRate.customUnitRateID]: { - ...currentCustomUnitRate, - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.reimburse.updateCustomUnitError'), - }, + [currentCustomUnit.rates.customUnitRateID]: { + ...currentCustomUnit.rates, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.reimburse.updateCustomUnitError'), }, }, }, @@ -753,12 +684,12 @@ function updateCustomUnitRate(policyID, currentCustomUnitRate, customUnitID, new ]; API.write( - 'UpdateWorkspaceCustomUnitRate', + 'UpdateWorkspaceCustomUnitAndRate', { policyID, - customUnitID, lastModified, - customUnitRate: JSON.stringify(newCustomUnitRate), + customUnit: JSON.stringify(newCustomUnit), + customUnitRate: JSON.stringify(newCustomUnit.rates), }, {optimisticData, successData, failureData}, ); @@ -1082,6 +1013,11 @@ function createWorkspace(ownerEmail = '', makeMeAdmin = false, policyName = '', if (transitionFromOldDot) { Navigation.dismissModal(); // Dismiss /transition route for OldDot to NewDot transitions } + + // Get the reportID associated with the newly created #admins room and route the user to that chat + const routeKey = lodashGet(navigationRef.getState(), 'routes[0].state.routes[0].key'); + Navigation.setParams({reportID: adminsChatReportID}, routeKey); + Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policyID)); }); } @@ -1156,8 +1092,7 @@ export { clearCustomUnitErrors, hideWorkspaceAlertMessage, deleteWorkspace, - updateWorkspaceCustomUnit, - updateCustomUnitRate, + updateWorkspaceCustomUnitAndRate, updateLastAccessedWorkspace, clearDeleteMemberError, clearAddMemberError, diff --git a/src/libs/actions/ReimbursementAccount/errors.js b/src/libs/actions/ReimbursementAccount/errors.js index 7b6547ae27f7..54d881cc4516 100644 --- a/src/libs/actions/ReimbursementAccount/errors.js +++ b/src/libs/actions/ReimbursementAccount/errors.js @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '../../../ONYXKEYS'; -import DateUtils from '../../DateUtils'; +import * as ErrorUtils from '../../ErrorUtils'; /** * Set the current fields with errors. @@ -40,10 +40,7 @@ function resetReimbursementAccount() { */ function showBankAccountFormValidationError(error) { Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, { - // eslint-disable-next-line rulesdir/prefer-localization - errors: { - [DateUtils.getMicroseconds()]: error, - }, + errors: ErrorUtils.getMicroSecondOnyxError(error), }); } diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 6a4cd9fe3050..7910f0e5e798 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -19,9 +19,10 @@ import * as ReportUtils from '../ReportUtils'; import DateUtils from '../DateUtils'; import * as ReportActionsUtils from '../ReportActionsUtils'; import * as OptionsListUtils from '../OptionsListUtils'; -import * as Localize from '../Localize'; import * as CollectionUtils from '../CollectionUtils'; import * as EmojiUtils from '../EmojiUtils'; +import * as ErrorUtils from '../ErrorUtils'; +import * as Welcome from './Welcome'; let currentUserEmail; let currentUserAccountID; @@ -58,6 +59,12 @@ Onyx.connect({ }, }); +let isNetworkOffline = false; +Onyx.connect({ + key: ONYXKEYS.NETWORK, + callback: (val) => (isNetworkOffline = lodashGet(val, 'isOffline', false)), +}); + const allReports = {}; let conciergeChatReportID; const typingWatchTimers = {}; @@ -262,7 +269,7 @@ function addActions(reportID, text = '', file) { key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: _.mapObject(optimisticReportActions, (action) => ({ ...action, - errors: {[DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericAddCommentFailureMessage')}, + errors: ErrorUtils.getMicroSecondOnyxError('report.genericAddCommentFailureMessage'), })), }, ]; @@ -322,8 +329,9 @@ function addComment(reportID, text) { * @param {Array} participantList The list of users that are included in a new chat, not including the user creating it * @param {Object} newReportObject The optimistic report object created when making a new chat, saved as optimistic data * @param {String} parentReportActionID The parent report action that a thread was created from (only passed for new threads) + * @param {Boolean} isFromDeepLink Whether or not this report is being opened from a deep link */ -function openReport(reportID, participantList = [], newReportObject = {}, parentReportActionID = '0') { +function openReport(reportID, participantList = [], newReportObject = {}, parentReportActionID = '0', isFromDeepLink = false) { const optimisticReportData = { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -368,6 +376,10 @@ function openReport(reportID, participantList = [], newReportObject = {}, parent parentReportActionID, }; + if (isFromDeepLink) { + params.shouldRetry = false; + } + // If we are creating a new report, we need to add the optimistic report data and a report action if (!_.isEmpty(newReportObject)) { // Change the method to set for new reports because it doesn't exist yet, is faster, @@ -415,7 +427,15 @@ function openReport(reportID, participantList = [], newReportObject = {}, parent } } - API.write('OpenReport', params, onyxData); + if (isFromDeepLink) { + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects('OpenReport', params, onyxData).finally(() => { + Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false); + }); + } else { + // eslint-disable-next-line rulesdir/no-multiple-api-calls + API.write('OpenReport', params, onyxData); + } } /** @@ -562,51 +582,6 @@ function readOldestAction(reportID, reportActionID) { ); } -/** - * Gets the IOUReport and the associated report actions. - * - * @param {String} chatReportID - * @param {Number} iouReportID - */ -function openPaymentDetailsPage(chatReportID, iouReportID) { - API.read( - 'OpenPaymentDetailsPage', - { - reportID: chatReportID, - iouReportID, - }, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IOU, - value: { - loading: true, - }, - }, - ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IOU, - value: { - loading: false, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IOU, - value: { - loading: false, - }, - }, - ], - }, - ); -} - /** * Gets transactions and data associated with the linked report (expense or IOU report) * @@ -711,16 +686,17 @@ function markCommentAsUnread(reportID, reportActionCreated) { /** * Toggles the pinned state of the report. * - * @param {Object} report + * @param {Object} reportID + * @param {Boolean} isPinnedChat */ -function togglePinnedState(report) { - const pinnedValue = !report.isPinned; +function togglePinnedState(reportID, isPinnedChat) { + const pinnedValue = !isPinnedChat; // Optimistically pin/unpin the report before we send out the command const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: {isPinned: pinnedValue}, }, ]; @@ -728,7 +704,7 @@ function togglePinnedState(report) { API.write( 'TogglePinnedChat', { - reportID: report.reportID, + reportID, pinnedValue, }, {optimisticData}, @@ -897,32 +873,6 @@ function deleteReportComment(reportID, reportAction) { API.write('DeleteComment', parameters, {optimisticData, successData, failureData}); } -/** - * @param {String} comment - * @returns {Array} - */ -const extractLinksInMarkdownComment = (comment) => { - const regex = /\[[^[\]]*\]\(([^()]*)\)/gm; - const matches = [...comment.matchAll(regex)]; - - // Element 1 from match is the regex group if it exists which contains the link URLs - const links = _.map(matches, (match) => Str.sanitizeURL(match[1])); - return links; -}; - -/** - * Compares two markdown comments and returns a list of the links removed in a new comment. - * - * @param {String} oldComment - * @param {String} newComment - * @returns {Array} - */ -const getRemovedMarkdownLinks = (oldComment, newComment) => { - const linksInOld = extractLinksInMarkdownComment(oldComment); - const linksInNew = extractLinksInMarkdownComment(newComment); - return _.difference(linksInOld, linksInNew); -}; - /** * Removes the links in html of a comment. * example: @@ -958,7 +908,7 @@ const handleUserDeletedLinksInHtml = (newCommentText, originalHtml) => { } const markdownOriginalComment = parser.htmlToMarkdown(originalHtml).trim(); const htmlForNewComment = parser.replace(newCommentText); - const removedLinks = getRemovedMarkdownLinks(markdownOriginalComment, newCommentText); + const removedLinks = parser.getRemovedMarkdownLinks(markdownOriginalComment, newCommentText); return removeLinksFromHtml(htmlForNewComment, removedLinks); }; @@ -1118,17 +1068,84 @@ function updateNotificationPreferenceAndNavigate(reportID, previousValue, newVal Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(reportID)); } +/** + * @param {String} reportID + * @param {String} previousValue + * @param {String} newValue + */ +function updateWelcomeMessage(reportID, previousValue, newValue) { + // No change needed, navigate back + if (previousValue === newValue) { + Navigation.goBack(); + return; + } + + const parsedWelcomeMessage = ReportUtils.getParsedComment(newValue); + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {welcomeMessage: newValue}, + }, + ]; + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {welcomeMessage: previousValue}, + }, + ]; + API.write('UpdateWelcomeMessage', {reportID, welcomeMessage: parsedWelcomeMessage}, {optimisticData, failureData}); + Navigation.goBack(); +} + +/** + * @param {Object} report + * @param {String} newValue + */ +function updateWriteCapabilityAndNavigate(report, newValue) { + if (report.writeCapability === newValue) { + Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(report.reportID)); + return; + } + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: {writeCapability: newValue}, + }, + ]; + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: {writeCapability: report.writeCapability}, + }, + ]; + API.write('UpdateReportWriteCapability', {reportID: report.reportID, writeCapability: newValue}, {optimisticData, failureData}); + // Return to the report settings page since this field utilizes push-to-page + Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(report.reportID)); +} + /** * Navigates to the 1:1 report with Concierge */ function navigateToConciergeChat() { - // If we don't have a chat with Concierge then create it if (!conciergeChatReportID) { - navigateToAndOpenReport([CONST.EMAIL.CONCIERGE]); - return; + // In order not to delay the report life cycle, we first navigate to the unknown report + if (_.isEmpty(Navigation.getReportIDFromRoute())) { + Navigation.navigate(ROUTES.REPORT); + } + // In order to avoid creating concierge repeatedly, + // we need to ensure that the server data has been successfully pulled + Welcome.serverDataIsReadyPromise().then(() => { + // If we don't have a chat with Concierge then create it + navigateToAndOpenReport([CONST.EMAIL.CONCIERGE]); + }); + } else { + Navigation.navigate(ROUTES.getReportRoute(conciergeChatReportID)); } - - Navigation.navigate(ROUTES.getReportRoute(conciergeChatReportID)); } /** @@ -1582,12 +1599,28 @@ function toggleEmojiReaction(reportID, reportAction, emoji, paramSkinTone = pref /** * @param {String|null} url + * @param {Boolean} isAuthenticated */ -function openReportFromDeepLink(url) { +function openReportFromDeepLink(url, isAuthenticated) { + const route = ReportUtils.getRouteFromLink(url); + const reportID = ReportUtils.getReportIDFromLink(url); + + if (reportID && !isAuthenticated) { + // Call the OpenReport command to check in the server if it's a public room. If so, we'll open it as an anonymous user + openReport(reportID, [], {}, '0', true); + + // Show the sign-in page if the app is offline + if (isNetworkOffline) { + Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false); + } + } else { + // If we're not opening a public room (no reportID) or the user is authenticated, we unblock the UI (hide splash screen) + Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false); + } + + // Navigate to the report after sign-in/sign-up. InteractionManager.runAfterInteractions(() => { Navigation.isReportScreenReady().then(() => { - const route = ReportUtils.getRouteFromLink(url); - const reportID = ReportUtils.getReportIDFromLink(url); if (reportID) { Navigation.navigate(ROUTES.getReportRoute(reportID)); } @@ -1635,10 +1668,98 @@ function leaveRoom(reportID) { navigateToConciergeChat(); } +/** + * @param {String} reportID + */ +function setLastOpenedPublicRoom(reportID) { + Onyx.set(ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID, reportID); +} + +/** + * Flag a comment as offensive + * + * @param {String} reportID + * @param {Object} reportAction + * @param {String} severity + */ +function flagComment(reportID, reportAction, severity) { + const message = reportAction.message[0]; + let updatedDecision; + if (severity === CONST.MODERATION.FLAG_SEVERITY_SPAM || severity === CONST.MODERATION.FLAG_SEVERITY_INCONSIDERATE) { + if (_.isEmpty(message.moderationDecisions) || message.moderationDecisions[message.moderationDecisions.length - 1].decision !== CONST.MODERATION.MODERATOR_DECISION_PENDING_HIDE) { + updatedDecision = [ + { + decision: CONST.MODERATION.MODERATOR_DECISION_PENDING, + }, + ]; + } + } else { + updatedDecision = [ + { + decision: CONST.MODERATION.MODERATOR_DECISION_PENDING_HIDE, + }, + ]; + } + + const reportActionID = reportAction.reportActionID; + + const updatedMessage = { + ...message, + moderationDecisions: updatedDecision, + }; + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportActionID]: { + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + message: [updatedMessage], + }, + }, + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportActionID]: { + ...reportAction, + pendingAction: null, + }, + }, + }, + ]; + + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: { + [reportActionID]: { + pendingAction: null, + }, + }, + }, + ]; + + const parameters = { + severity, + reportActionID, + }; + + API.write('FlagComment', parameters, {optimisticData, successData, failureData}); +} + export { addComment, addAttachment, reconnect, + updateWelcomeMessage, + updateWriteCapabilityAndNavigate, updateNotificationPreferenceAndNavigate, subscribeToReportTypingEvents, unsubscribeFromReportChannel, @@ -1664,7 +1785,6 @@ export { openReportFromDeepLink, navigateToAndOpenReport, navigateToAndOpenChildReport, - openPaymentDetailsPage, updatePolicyRoomNameAndNavigate, openMoneyRequestsReportPage, clearPolicyRoomNameErrors, @@ -1677,4 +1797,6 @@ export { hasAccountIDReacted, shouldShowReportActionNotification, leaveRoom, + setLastOpenedPublicRoom, + flagComment, }; diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index 348cafc13d8f..9cb3c127b287 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -1,6 +1,7 @@ import Onyx from 'react-native-onyx'; import _ from 'underscore'; import lodashGet from 'lodash/get'; +import {Linking} from 'react-native'; import ONYXKEYS from '../../../ONYXKEYS'; import redirectToSignIn from '../SignInRedirect'; import CONFIG from '../../../CONFIG'; @@ -15,11 +16,19 @@ import * as Authentication from '../../Authentication'; import * as Welcome from '../Welcome'; import * as API from '../../API'; import * as NetworkStore from '../../Network/NetworkStore'; -import DateUtils from '../../DateUtils'; import Navigation from '../../Navigation/Navigation'; import * as Device from '../Device'; import subscribeToReportCommentPushNotifications from '../../Notification/PushNotification/subscribeToReportCommentPushNotifications'; import ROUTES from '../../../ROUTES'; +import * as ErrorUtils from '../../ErrorUtils'; +import * as ReportUtils from '../../ReportUtils'; +import * as Report from '../Report'; + +let authTokenType = ''; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (session) => (authTokenType = lodashGet(session, 'authTokenType')), +}); let credentials = {}; Onyx.connect({ @@ -49,17 +58,6 @@ Onyx.connect({ }, }); -/** - * @private - * @returns {string} - */ -function getDeviceInfoForLogin() { - return JSON.stringify({ - ...Device.getDeviceInfo(), - parentLogin: credentials.login, - }); -} - /** * Clears the Onyx store and redirects user to the sign in page */ @@ -78,10 +76,38 @@ function signOut() { Timing.clearData(); } +/** + * Checks if the account is an anonymous account. + * + * @return {boolean} + */ +function isAnonymousUser() { + return authTokenType === 'anonymousAccount'; +} + function signOutAndRedirectToSignIn() { signOut(); redirectToSignIn(); Log.info('Redirecting to Sign In because signOut() was called'); + if (isAnonymousUser()) { + Linking.getInitialURL().then((url) => { + const reportID = ReportUtils.getReportIDFromLink(url); + if (reportID) { + Report.setLastOpenedPublicRoom(reportID); + } + }); + } +} + +/** + * @param {Function} callback The callback to execute if the action is allowed + * @returns {Function} same callback if the action is allowed, otherwise a function that signs out and redirects to sign in + */ +function checkIfActionIsAllowed(callback) { + if (isAnonymousUser()) { + return () => signOutAndRedirectToSignIn(); + } + return callback; } /** @@ -98,6 +124,7 @@ function resendValidationLink(login = credentials.login) { isLoading: true, errors: null, message: null, + loadingForm: CONST.FORMS.RESEND_VALIDATION_FORM, }, }, ]; @@ -108,6 +135,7 @@ function resendValidationLink(login = credentials.login) { value: { isLoading: false, message: 'resendValidationForm.linkHasBeenResent', + loadingForm: null, }, }, ]; @@ -118,6 +146,7 @@ function resendValidationLink(login = credentials.login) { value: { isLoading: false, message: null, + loadingForm: null, }, }, ]; @@ -139,6 +168,7 @@ function resendValidateCode(login = credentials.login) { isLoading: true, errors: null, message: null, + loadingForm: CONST.FORMS.VALIDATE_CODE_FORM, }, }, ]; @@ -149,6 +179,7 @@ function resendValidateCode(login = credentials.login) { value: { isLoading: false, message: 'validateCodeForm.codeSent', + loadingForm: null, }, }, ]; @@ -159,6 +190,7 @@ function resendValidateCode(login = credentials.login) { value: { isLoading: false, message: null, + loadingForm: null, }, }, ]; @@ -218,6 +250,7 @@ function beginSignIn(login) { ...CONST.DEFAULT_ACCOUNT_DATA, isLoading: true, message: null, + loadingForm: CONST.FORMS.LOGIN_FORM, }, }, ]; @@ -228,6 +261,7 @@ function beginSignIn(login) { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, { @@ -245,9 +279,8 @@ function beginSignIn(login) { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('loginForm.cannotGetAccountDetails'), - }, + loadingForm: null, + errors: ErrorUtils.getMicroSecondOnyxError('loginForm.cannotGetAccountDetails'), }, }, ]; @@ -335,6 +368,7 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON value: { ...CONST.DEFAULT_ACCOUNT_DATA, isLoading: true, + loadingForm: twoFactorAuthCode ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM, }, }, ]; @@ -345,6 +379,7 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, { @@ -362,6 +397,7 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, ]; @@ -370,7 +406,6 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON twoFactorAuthCode, email: credentials.login, preferredLocale, - deviceInfo: getDeviceInfoForLogin(), }; // Conditionally pass a password or validateCode to command since we temporarily allow both flows @@ -379,11 +414,12 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON } else { params.password = password; } - - API.write('SigninUser', params, {optimisticData, successData, failureData}); + Device.getDeviceInfoWithID().then((deviceInfo) => { + API.write('SigninUser', {...params, deviceInfo}, {optimisticData, successData, failureData}); + }); } -function signInWithValidateCode(accountID, code, twoFactorAuthCode) { +function signInWithValidateCode(accountID, code, preferredLocale = CONST.LOCALES.DEFAULT, twoFactorAuthCode = '') { // If this is called from the 2fa step, get the validateCode directly from onyx // instead of the one passed from the component state because the state is changing when this method is called. const validateCode = twoFactorAuthCode ? credentials.validateCode : code; @@ -395,6 +431,7 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode) { value: { ...CONST.DEFAULT_ACCOUNT_DATA, isLoading: true, + loadingForm: twoFactorAuthCode ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM, }, }, { @@ -408,7 +445,10 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode) { { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, - value: {isLoading: false}, + value: { + isLoading: false, + loadingForm: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -429,7 +469,10 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode) { { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, - value: {isLoading: false}, + value: { + isLoading: false, + loadingForm: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -437,21 +480,23 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode) { value: {autoAuthState: CONST.AUTO_AUTH_STATE.FAILED}, }, ]; - - API.write( - 'SigninUserWithLink', - { - accountID, - validateCode, - twoFactorAuthCode, - deviceInfo: getDeviceInfoForLogin(), - }, - {optimisticData, successData, failureData}, - ); + Device.getDeviceInfoWithID().then((deviceInfo) => { + API.write( + 'SigninUserWithLink', + { + accountID, + validateCode, + twoFactorAuthCode, + preferredLocale, + deviceInfo, + }, + {optimisticData, successData, failureData}, + ); + }); } -function signInWithValidateCodeAndNavigate(accountID, validateCode, twoFactorAuthCode) { - signInWithValidateCode(accountID, validateCode, twoFactorAuthCode); +function signInWithValidateCodeAndNavigate(accountID, validateCode, preferredLocale = CONST.LOCALES.DEFAULT, twoFactorAuthCode = '') { + signInWithValidateCode(accountID, validateCode, preferredLocale, twoFactorAuthCode); Navigation.navigate(ROUTES.HOME); } @@ -467,7 +512,9 @@ function signInWithValidateCodeAndNavigate(accountID, validateCode, twoFactorAut */ function initAutoAuthState(cachedAutoAuthState) { Onyx.merge(ONYXKEYS.SESSION, { - autoAuthState: cachedAutoAuthState === CONST.AUTO_AUTH_STATE.SIGNING_IN ? CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN : CONST.AUTO_AUTH_STATE.NOT_STARTED, + autoAuthState: _.contains([CONST.AUTO_AUTH_STATE.SIGNING_IN, CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN], cachedAutoAuthState) + ? CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN + : CONST.AUTO_AUTH_STATE.NOT_STARTED, }); } @@ -512,6 +559,7 @@ function resendResetPassword() { forgotPassword: true, message: null, errors: null, + loadingForm: CONST.FORMS.RESEND_VALIDATION_FORM, }, }, ], @@ -522,6 +570,7 @@ function resendResetPassword() { value: { isLoading: false, message: 'resendValidationForm.linkHasBeenResent', + loadingForm: null, }, }, ], @@ -531,6 +580,7 @@ function resendResetPassword() { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, ], @@ -708,6 +758,7 @@ function requestUnlinkValidationLink() { isLoading: true, errors: null, message: null, + loadingForm: CONST.FORMS.UNLINK_LOGIN_FORM, }, }, ]; @@ -718,6 +769,7 @@ function requestUnlinkValidationLink() { value: { isLoading: false, message: Localize.translateLocal('unlinkLoginForm.linkSent'), + loadingForm: null, }, }, ]; @@ -727,6 +779,7 @@ function requestUnlinkValidationLink() { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, ]; @@ -861,6 +914,7 @@ function validateTwoFactorAuth(twoFactorAuthCode) { export { beginSignIn, + checkIfActionIsAllowed, updatePasswordAndSignin, signIn, signInWithValidateCode, @@ -883,6 +937,7 @@ export { reauthenticatePusher, invalidateCredentials, invalidateAuthToken, + isAnonymousUser, toggleTwoFactorAuth, validateTwoFactorAuth, }; diff --git a/src/libs/actions/SignInRedirect.js b/src/libs/actions/SignInRedirect.js index 998d21a73894..a500635222d6 100644 --- a/src/libs/actions/SignInRedirect.js +++ b/src/libs/actions/SignInRedirect.js @@ -3,14 +3,13 @@ import lodashGet from 'lodash/get'; import _ from 'underscore'; import ONYXKEYS from '../../ONYXKEYS'; import * as MainQueue from '../Network/MainQueue'; -import DateUtils from '../DateUtils'; -import * as Localize from '../Localize'; import * as PersistedRequests from './PersistedRequests'; import NetworkConnection from '../NetworkConnection'; import HttpUtils from '../HttpUtils'; import navigationRef from '../Navigation/navigationRef'; import SCREENS from '../../SCREENS'; import Navigation from '../Navigation/Navigation'; +import * as ErrorUtils from '../ErrorUtils'; let currentIsOffline; let currentShouldForceOffline; @@ -49,7 +48,7 @@ function clearStorageAndRedirect(errorMessage) { } // `Onyx.clear` reinitializes the Onyx instance with initial values so use `Onyx.merge` instead of `Onyx.set` - Onyx.merge(ONYXKEYS.SESSION, {errors: {[DateUtils.getMicroseconds()]: Localize.translateLocal(errorMessage)}}); + Onyx.merge(ONYXKEYS.SESSION, {errors: ErrorUtils.getMicroSecondOnyxError(errorMessage)}); }); } @@ -67,6 +66,7 @@ function resetHomeRouteParams() { }); Navigation.setParams(emptyParams, lodashGet(homeRoute, 'key', '')); + Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false); }); } diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 0de8dd3bb30c..da751e4209a8 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -9,6 +9,7 @@ import Navigation from '../Navigation/Navigation'; import ROUTES from '../../ROUTES'; import CONST from '../../CONST'; import DateUtils from '../DateUtils'; +import * as UserUtils from '../UserUtils'; /** * Clears out the task info from the store @@ -61,7 +62,13 @@ function createTaskAndNavigate(currentUserEmail, parentReportID, title, descript { onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTaskReport.reportID}`, - value: optimisticTaskReport, + value: { + ...optimisticTaskReport, + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + isOptimisticReport: true, + }, }, { onyxMethod: Onyx.METHOD.SET, @@ -80,7 +87,18 @@ function createTaskAndNavigate(currentUserEmail, parentReportID, title, descript }, ]; - const successData = []; + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticTaskReport.reportID}`, + value: { + pendingFields: { + createChat: null, + }, + isOptimisticReport: false, + }, + }, + ]; const failureData = [ { @@ -286,7 +304,7 @@ function editTaskAndNavigate(report, ownerEmail, title, description, assignee) { const editTaskReportAction = ReportUtils.buildOptimisticEditedTaskReportAction(ownerEmail); // Sometimes title is undefined, so we need to check for that, and we provide it to multiple functions - const reportName = title || report.reportName; + const reportName = (title || report.reportName).trim(); // If we make a change to the assignee, we want to add a comment to the assignee's chat let optimisticAssigneeAddComment; @@ -307,7 +325,7 @@ function editTaskAndNavigate(report, ownerEmail, title, description, assignee) { key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, value: { reportName, - description: description || report.description, + description: (description || report.description).trim(), managerEmail: assignee || report.managerEmail, }, }, @@ -362,7 +380,7 @@ function editTaskAndNavigate(report, ownerEmail, title, description, assignee) { { taskReportID: report.reportID, title: reportName, - description: description || report.description, + description: (description || report.description).trim(), assignee: assignee || report.assignee, editedTaskReportActionID: editTaskReportAction.reportActionID, assigneeChatReportActionID: optimisticAssigneeAddComment ? optimisticAssigneeAddComment.reportAction.reportActionID : 0, @@ -390,7 +408,7 @@ function setTaskReport(report) { function setDetailsValue(title, description) { // This is only needed for creation of a new task and so it should only be stored locally - Onyx.merge(ONYXKEYS.TASK, {title, description}); + Onyx.merge(ONYXKEYS.TASK, {title: title.trim(), description: description.trim()}); } /** @@ -398,7 +416,7 @@ function setDetailsValue(title, description) { * @param {string} title */ function setTitleValue(title) { - Onyx.merge(ONYXKEYS.TASK, {title}); + Onyx.merge(ONYXKEYS.TASK, {title: title.trim()}); } /** @@ -406,7 +424,7 @@ function setTitleValue(title) { * @param {string} description */ function setDescriptionValue(description) { - Onyx.merge(ONYXKEYS.TASK, {description}); + Onyx.merge(ONYXKEYS.TASK, {description: description.trim()}); } /** @@ -478,7 +496,7 @@ function getAssignee(details) { subtitle: '', }; } - const source = ReportUtils.getAvatar(lodashGet(details, 'avatar', ''), lodashGet(details, 'login', '')); + const source = UserUtils.getAvatar(lodashGet(details, 'avatar', ''), lodashGet(details, 'login', '')); return { icons: [{source, type: 'avatar', name: details.login}], displayName: details.displayName, diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index ad1a18496b58..809ca4e2c2f6 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -15,7 +15,7 @@ import * as SequentialQueue from '../Network/SequentialQueue'; import PusherUtils from '../PusherUtils'; import * as Report from './Report'; import * as ReportActionsUtils from '../ReportActionsUtils'; -import DateUtils from '../DateUtils'; +import * as ErrorUtils from '../ErrorUtils'; import * as Session from './Session'; import * as PersonalDetails from './PersonalDetails'; @@ -165,9 +165,7 @@ function requestContactMethodValidateCode(contactMethod) { [contactMethod]: { validateCodeSent: false, errorFields: { - validateCodeSent: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.requestContactMethodValidateCode'), - }, + validateCodeSent: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.requestContactMethodValidateCode'), }, pendingFields: { validateCodeSent: null, @@ -266,9 +264,7 @@ function deleteContactMethod(contactMethod, loginList) { [contactMethod]: { ...oldLoginData, errorFields: { - deletedLogin: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.deleteContactMethod'), - }, + deletedLogin: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.deleteContactMethod'), }, pendingFields: { deletedLogin: null, @@ -352,9 +348,7 @@ function addNewContactMethodAndNavigate(contactMethod, password) { value: { [contactMethod]: { errorFields: { - addedLogin: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.addContactMethod'), - }, + addedLogin: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.addContactMethod'), }, pendingFields: { addedLogin: null, @@ -453,9 +447,7 @@ function validateSecondaryLogin(contactMethod, validateCode) { value: { [contactMethod]: { errorFields: { - validateLogin: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.validateSecondaryLogin'), - }, + validateLogin: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.validateSecondaryLogin'), }, pendingFields: { validateLogin: null, @@ -596,6 +588,10 @@ function subscribeToUserEventsUsingMultipleEventType() { // Handles Onyx updates coming from Pusher through the mega multipleEvents. PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON) => { SequentialQueue.getCurrentRequest().then(() => { + // If we don't have the currentUserAccountID (user is logged out) we don't want to update Onyx with data from Pusher + if (!currentUserAccountID) { + return; + } Onyx.update(pushJSON); triggerNotifications(pushJSON); }); @@ -612,6 +608,10 @@ function subscribeToUserDeprecatedEvents() { // Receive any relevant Onyx updates from the server PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.ONYX_API_UPDATE, currentUserAccountID, (pushJSON) => { SequentialQueue.getCurrentRequest().then(() => { + // If we don't have the currentUserAccountID (user is logged out) we don't want to update Onyx with data from Pusher + if (!currentUserAccountID) { + return; + } Onyx.update(pushJSON); triggerNotifications(pushJSON); }); @@ -835,9 +835,7 @@ function setContactMethodAsDefault(newDefaultContactMethod) { defaultLogin: null, }, errorFields: { - defaultLogin: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.setDefaultContactMethod'), - }, + defaultLogin: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.setDefaultContactMethod'), }, }, }, diff --git a/src/libs/actions/Welcome.js b/src/libs/actions/Welcome.js index c3e8599a0169..ddd0d872ce97 100644 --- a/src/libs/actions/Welcome.js +++ b/src/libs/actions/Welcome.js @@ -151,4 +151,8 @@ function resetReadyCheck() { }); } -export {show, resetReadyCheck}; +function serverDataIsReadyPromise() { + return isReadyPromise; +} + +export {show, serverDataIsReadyPromise, resetReadyCheck}; diff --git a/src/libs/focusTextInputAfterAnimation/index.js b/src/libs/focusTextInputAfterAnimation/index.js index 068e8da887b8..dba6e0fdcddc 100644 --- a/src/libs/focusTextInputAfterAnimation/index.js +++ b/src/libs/focusTextInputAfterAnimation/index.js @@ -1,6 +1,5 @@ /** - * This library is a no-op for all platforms except for Android and will immediately focus the given input without any delays. This is important for native iOS clients because - * text inputs can only be focused from user interactions and wrapping the focus() inside a setTimeout breaks that use case since it's no longer triggered from a user interaction. + * This library is a no-op for all platforms except for Android and iOS and will immediately focus the given input without any delays. * * @param {Object} inputRef */ diff --git a/src/libs/focusTextInputAfterAnimation/index.android.js b/src/libs/focusTextInputAfterAnimation/index.native.js similarity index 52% rename from src/libs/focusTextInputAfterAnimation/index.android.js rename to src/libs/focusTextInputAfterAnimation/index.native.js index 554c7a2f6b8e..8cd44d111642 100644 --- a/src/libs/focusTextInputAfterAnimation/index.android.js +++ b/src/libs/focusTextInputAfterAnimation/index.native.js @@ -1,14 +1,14 @@ /** - * For native Android devices, if an input is focused while an animation is happening, then the keyboard is not displayed. Delaying the focus until after the animation is done will ensure - * that the keyboard opens properly. + * Focus the text input with a slight delay to make sure modals are closed first. + * Since in react-native-modal `onModalHide` is called before the modal is actually hidden. + * It results in the keyboard being dismissed right away on both iOS and Android. + * See this discussion for more details: https://github.com/Expensify/App/issues/18300 * * @param {Object} inputRef * @param {Number} animationLength you must use your best guess as to what a good animationLength is. It can't be too short, or the animation won't be finished. It can't be too long or * the user will notice that it feels sluggish */ const focusTextInputAfterAnimation = (inputRef, animationLength = 0) => { - // This setTimeout is necessary because there are some animations that are just impossible to listen to in order to determine when they are finished (like when items are added to - // a FlatList). setTimeout(() => { inputRef.focus(); }, animationLength); diff --git a/src/libs/getSafeAreaPaddingTop/index.android.js b/src/libs/getSafeAreaPaddingTop/index.android.js deleted file mode 100644 index db66c1739ffb..000000000000 --- a/src/libs/getSafeAreaPaddingTop/index.android.js +++ /dev/null @@ -1,12 +0,0 @@ -import {StatusBar} from 'react-native'; - -/** - * Returns safe area padding top to use for a View - * - * @param {Object} insets - * @param {Boolean} statusBarTranslucent - * @returns {Number} - */ -export default function getSafeAreaPaddingTop(insets, statusBarTranslucent) { - return (statusBarTranslucent && StatusBar.currentHeight) || 0; -} diff --git a/src/libs/getSafeAreaPaddingTop/index.js b/src/libs/getSafeAreaPaddingTop/index.js deleted file mode 100644 index 89b3579587e7..000000000000 --- a/src/libs/getSafeAreaPaddingTop/index.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Takes safe area insets and returns padding top to use for a View - * - * @param {Object} insets - * @returns {Number} - */ -export default function getSafeAreaPaddingTop(insets) { - return insets.top; -} diff --git a/src/libs/getWindowHeightAdjustment/index.android.js b/src/libs/getWindowHeightAdjustment/index.android.js new file mode 100644 index 000000000000..b360467d31c1 --- /dev/null +++ b/src/libs/getWindowHeightAdjustment/index.android.js @@ -0,0 +1,4 @@ +// On Android the window height does not include the status bar height, so we need to add it manually. +export default function getWindowHeightAdjustment(insets) { + return insets.top; +} diff --git a/src/libs/getWindowHeightAdjustment/index.js b/src/libs/getWindowHeightAdjustment/index.js new file mode 100644 index 000000000000..9ddd1e7cefee --- /dev/null +++ b/src/libs/getWindowHeightAdjustment/index.js @@ -0,0 +1,4 @@ +// Some platforms need to adjust the window height. +export default function getWindowHeightAdjustment() { + return 0; +} diff --git a/src/libs/isInputAutoFilled.js b/src/libs/isInputAutoFilled.js new file mode 100644 index 000000000000..3a64ffd441ee --- /dev/null +++ b/src/libs/isInputAutoFilled.js @@ -0,0 +1,9 @@ +/** + * Check the input is auto filled or not + * @param {Object} input + * @return {Boolean} + */ +export default function isInputAutoFilled(input) { + if (!input.matches) return false; + return input.matches(':-webkit-autofill') || input.matches(':autofill'); +} diff --git a/src/libs/isReportMessageAttachment.js b/src/libs/isReportMessageAttachment.js index db7df0df6c89..b449c72eaa0d 100644 --- a/src/libs/isReportMessageAttachment.js +++ b/src/libs/isReportMessageAttachment.js @@ -13,5 +13,5 @@ export default function isReportMessageAttachment({text, html}) { } const regex = new RegExp(` ${CONST.ATTACHMENT_SOURCE_ATTRIBUTE}="(.*)"`, 'i'); - return text === CONST.ATTACHMENT_MESSAGE_TEXT && !!html.match(regex); + return text === CONST.ATTACHMENT_MESSAGE_TEXT && (!!html.match(regex) || html === CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML); } diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index baee3381673b..3c1f124aca1a 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -28,6 +28,7 @@ import * as Report from '../libs/actions/Report'; import OfflineWithFeedback from '../components/OfflineWithFeedback'; import AutoUpdateTime from '../components/AutoUpdateTime'; import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; +import * as UserUtils from '../libs/UserUtils'; const matchType = PropTypes.shape({ params: PropTypes.shape({ @@ -48,9 +49,13 @@ const propTypes = { /** Route params */ route: matchType.isRequired, - /** Session of currently logged in user */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, + /** Login list for the user that is signed in */ + loginList: PropTypes.shape({ + /** Date login was validated, used to show info indicator status */ + validatedDate: PropTypes.string, + + /** Field-specific server side errors keyed by microtime */ + errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), }), ...withLocalizePropTypes, @@ -59,9 +64,7 @@ const propTypes = { const defaultProps = { // When opening someone else's profile (via deep link) before login, this is empty personalDetails: {}, - session: { - email: null, - }, + loginList: {}, }; /** @@ -93,7 +96,7 @@ class DetailsPage extends React.PureComponent { details = { login, displayName: ReportUtils.getDisplayNameForParticipant(login), - avatar: ReportUtils.getAvatar(lodashGet(details, 'avatar', ''), login), + avatar: UserUtils.getAvatar(lodashGet(details, 'avatar', ''), login), }; } @@ -113,6 +116,8 @@ class DetailsPage extends React.PureComponent { const phoneNumber = getPhoneNumber(details); const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : details.login; + const isCurrentUser = _.keys(this.props.loginList).includes(details.login); + return ( @@ -131,7 +136,7 @@ class DetailsPage extends React.PureComponent { @@ -144,7 +149,7 @@ class DetailsPage extends React.PureComponent { @@ -187,7 +192,7 @@ class DetailsPage extends React.PureComponent { ) : null} {shouldShowLocalTime && } - {details.login !== this.props.session.email && ( + {!isCurrentUser && ( ( ); diff --git a/src/pages/FlagCommentPage.js b/src/pages/FlagCommentPage.js new file mode 100644 index 000000000000..5cbda92c814e --- /dev/null +++ b/src/pages/FlagCommentPage.js @@ -0,0 +1,182 @@ +import React from 'react'; +import _ from 'underscore'; +import {View, ScrollView} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import reportPropTypes from './reportPropTypes'; +import reportActionPropTypes from './home/report/reportActionPropTypes'; +import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; +import compose from '../libs/compose'; +import ONYXKEYS from '../ONYXKEYS'; +import ScreenWrapper from '../components/ScreenWrapper'; +import HeaderWithCloseButton from '../components/HeaderWithCloseButton'; +import styles from '../styles/styles'; +import Navigation from '../libs/Navigation/Navigation'; +import Text from '../components/Text'; +import * as Expensicons from '../components/Icon/Expensicons'; +import MenuItem from '../components/MenuItem'; +import * as Report from '../libs/actions/Report'; +import CONST from '../CONST'; +import * as ReportUtils from '../libs/ReportUtils'; +import * as ReportActionsUtils from '../libs/ReportActionsUtils'; +import * as Session from '../libs/actions/Session'; + +const propTypes = { + /** Array of report actions for this report */ + reportActions: PropTypes.shape(reportActionPropTypes), + + /** The active report */ + report: reportPropTypes, + + /** Route params */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** Report ID passed via route r/:reportID/:reportActionID */ + reportID: PropTypes.string, + + /** ReportActionID passed via route r/:reportID/:reportActionID */ + reportActionID: PropTypes.string, + }), + }).isRequired, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + reportActions: {}, + report: {}, +}; + +/** + * Get the reportID for the associated chatReport + * + * @param {Object} route + * @param {Object} route.params + * @param {String} route.params.reportID + * @returns {String} + */ +function getReportID(route) { + return route.params.reportID.toString(); +} + +function FlagCommentPage(props) { + let reportAction = props.reportActions[`${props.route.params.reportActionID.toString()}`]; + const severities = [ + { + severity: CONST.MODERATION.FLAG_SEVERITY_SPAM, + name: props.translate('moderation.spam'), + icon: Expensicons.FlagLevelOne, + description: props.translate('moderation.spamDescription'), + furtherDetails: props.translate('moderation.levelOneResult'), + furtherDetailsIcon: Expensicons.FlagLevelOne, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_INCONSIDERATE, + name: props.translate('moderation.inconsiderate'), + icon: Expensicons.FlagLevelOne, + description: props.translate('moderation.inconsiderateDescription'), + furtherDetails: props.translate('moderation.levelOneResult'), + furtherDetailsIcon: Expensicons.FlagLevelOne, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_INTIMIDATION, + name: props.translate('moderation.intimidation'), + icon: Expensicons.FlagLevelTwo, + description: props.translate('moderation.intimidationDescription'), + furtherDetails: props.translate('moderation.levelTwoResult'), + furtherDetailsIcon: Expensicons.FlagLevelTwo, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_BULLYING, + name: props.translate('moderation.bullying'), + icon: Expensicons.FlagLevelTwo, + description: props.translate('moderation.bullyingDescription'), + furtherDetails: props.translate('moderation.levelTwoResult'), + furtherDetailsIcon: Expensicons.FlagLevelTwo, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_HARASSMENT, + name: props.translate('moderation.harassment'), + icon: Expensicons.FlagLevelThree, + description: props.translate('moderation.harassmentDescription'), + furtherDetails: props.translate('moderation.levelThreeResult'), + furtherDetailsIcon: Expensicons.FlagLevelThree, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_ASSAULT, + name: props.translate('moderation.assault'), + icon: Expensicons.FlagLevelThree, + description: props.translate('moderation.assaultDescription'), + furtherDetails: props.translate('moderation.levelThreeResult'), + furtherDetailsIcon: Expensicons.FlagLevelThree, + }, + ]; + + const flagComment = (severity) => { + let reportID = getReportID(props.route); + + // Handle threads if needed + if (reportAction === undefined) { + reportID = ReportUtils.getParentReport(props.report).reportID; + reportAction = ReportActionsUtils.getParentReportAction(props.report); + } + Report.flagComment(reportID, reportAction, severity); + Navigation.dismissModal(); + }; + + const severityMenuItems = _.map(severities, (item, index) => ( + flagComment(item.severity))} + style={[styles.pt2, styles.pb4, styles.mh5, styles.ph0, styles.flexRow, styles.borderBottom]} + furtherDetails={item.furtherDetails} + furtherDetailsIcon={item.furtherDetailsIcon} + hoverAndPressStyle={[styles.mh0, styles.ph5]} + /> + )); + + return ( + + {({safeAreaPaddingBottomStyle}) => ( + <> + Navigation.dismissModal()} + /> + + + + {props.translate('moderation.flagDescription')} + + + {props.translate('moderation.chooseAReason')} + {severityMenuItems} + + + )} + + ); +} + +FlagCommentPage.propTypes = propTypes; +FlagCommentPage.defaultProps = defaultProps; +FlagCommentPage.displayName = 'FlagCommentPage'; + +export default compose( + withLocalize, + withOnyx({ + reportActions: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, + canEvict: false, + }, + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, + }, + }), +)(FlagCommentPage); diff --git a/src/pages/ReimbursementAccount/BankAccountManualStep.js b/src/pages/ReimbursementAccount/BankAccountManualStep.js index 0cb6a706abf8..6d12b5db81a6 100644 --- a/src/pages/ReimbursementAccount/BankAccountManualStep.js +++ b/src/pages/ReimbursementAccount/BankAccountManualStep.js @@ -69,7 +69,7 @@ class BankAccountManualStep extends React.Component { return ( { { )} {props.translate('common.privacy')} - Linking.openURL('https://community.expensify.com/discussion/5677/deep-dive-how-expensify-protects-your-information/')} + style={[styles.flexRow, styles.alignItemsCenter]} + accessibilityLabel={props.translate('bankAccount.yourDataIsSecure')} > - - - {props.translate('bankAccount.yourDataIsSecure')} - - - - + + {props.translate('bankAccount.yourDataIsSecure')} + + + - + diff --git a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js index 9d7506383219..5211a7ea86d5 100644 --- a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js +++ b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js @@ -50,7 +50,7 @@ const ContinueBankAccountSetup = (props) => { { includeSafeAreaPaddingBottom={false} > diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index f073abbb2616..f1483bfb102c 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -1,4 +1,4 @@ -import React, {Component} from 'react'; +import React, {useMemo} from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -17,6 +17,7 @@ import * as OptionsListUtils from '../libs/OptionsListUtils'; import * as ReportUtils from '../libs/ReportUtils'; import * as PolicyUtils from '../libs/PolicyUtils'; import * as Report from '../libs/actions/Report'; +import * as Session from '../libs/actions/Session'; import participantPropTypes from '../components/participantPropTypes'; import * as Expensicons from '../components/Icon/Expensicons'; import ROUTES from '../ROUTES'; @@ -56,146 +57,145 @@ const defaultProps = { personalDetails: {}, }; -class ReportDetailsPage extends Component { - getPolicy() { - return this.props.policies[`${ONYXKEYS.COLLECTION.POLICY}${this.props.report.policyID}`]; - } +const ReportDetailsPage = (props) => { + const policy = useMemo(() => props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`], [props.policies, props.report.policyID]); + const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]); + const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(props.report), [props.report]); + const isChatRoom = useMemo(() => ReportUtils.isChatRoom(props.report), [props.report]); + const isThread = useMemo(() => ReportUtils.isThread(props.report), [props.report]); + const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(props.report), [props.report]); + const isArchivedRoom = useMemo(() => ReportUtils.isArchivedRoom(props.report), [props.report]); - getMenuItems() { - const menuItems = [ + // eslint-disable-next-line react-hooks/exhaustive-deps -- policy is a dependency because `getChatRoomSubtitle` calls `getPolicyName` which in turn retrieves the value from the `policy` value stored in Onyx + const chatRoomSubtitle = useMemo(() => ReportUtils.getChatRoomSubtitle(props.report), [props.report, policy]); + const canLeaveRoom = useMemo(() => ReportUtils.canLeaveRoom(props.report, !_.isEmpty(policy)), [policy, props.report]); + const participants = useMemo(() => lodashGet(props.report, 'participants', []), [props.report]); + + const menuItems = useMemo(() => { + if (isArchivedRoom) { + return []; + } + + const items = [ { key: CONST.REPORT_DETAILS_MENU_ITEM.SHARE_CODE, translationKey: 'common.shareCode', icon: Expensicons.QrCode, - action: () => Navigation.navigate(ROUTES.getReportShareCodeRoute(this.props.report.reportID)), + action: () => Navigation.navigate(ROUTES.getReportShareCodeRoute(props.report.reportID)), }, ]; - if (ReportUtils.isArchivedRoom(this.props.report)) { - return []; - } - - if (lodashGet(this.props.report, 'participants', []).length) { - menuItems.push({ + if (participants.length) { + items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.MEMBERS, translationKey: 'common.members', icon: Expensicons.Users, - subtitle: lodashGet(this.props.report, 'participants', []).length, + subtitle: participants.length, action: () => { - Navigation.navigate(ROUTES.getReportParticipantsRoute(this.props.report.reportID)); + Navigation.navigate(ROUTES.getReportParticipantsRoute(props.report.reportID)); }, }); } - if (ReportUtils.isPolicyExpenseChat(this.props.report) || ReportUtils.isChatRoom(this.props.report) || ReportUtils.isThread(this.props.report)) { - menuItems.push({ + if (isPolicyExpenseChat || isChatRoom || isThread) { + items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS, translationKey: 'common.settings', icon: Expensicons.Gear, action: () => { - Navigation.navigate(ROUTES.getReportSettingsRoute(this.props.report.reportID)); + Navigation.navigate(ROUTES.getReportSettingsRoute(props.report.reportID)); }, }); } - const policy = this.getPolicy(); - const isThread = ReportUtils.isThread(this.props.report); - if (ReportUtils.isUserCreatedPolicyRoom(this.props.report) || ReportUtils.canLeaveRoom(this.props.report, !_.isEmpty(policy)) || isThread) { - menuItems.push({ + if (isUserCreatedPolicyRoom || canLeaveRoom || isThread) { + items.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM, translationKey: isThread ? 'common.leaveThread' : 'common.leaveRoom', icon: Expensicons.Exit, - action: () => Report.leaveRoom(this.props.report.reportID), + action: () => Report.leaveRoom(props.report.reportID), }); } - return menuItems; - } - - render() { - const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(this.props.report); - const isChatRoom = ReportUtils.isChatRoom(this.props.report); - const isThread = ReportUtils.isThread(this.props.report); - const chatRoomSubtitle = ReportUtils.getChatRoomSubtitle(this.props.report); - const participants = lodashGet(this.props.report, 'participants', []); - const isMultipleParticipant = participants.length > 1; - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips( - OptionsListUtils.getPersonalDetailsForLogins(participants, this.props.personalDetails), - isMultipleParticipant, - ); - const menuItems = this.getMenuItems(); - const isPolicyAdmin = PolicyUtils.isPolicyAdmin(this.getPolicy()); - const chatRoomSubtitleText = ( - - {chatRoomSubtitle} - - ); - return ( - - - Navigation.goBack()} - onCloseButtonPress={() => Navigation.dismissModal()} - /> - - - - - - - - - - - {isPolicyAdmin ? ( - { - Navigation.navigate(ROUTES.getWorkspaceInitialRoute(this.props.report.policyID)); - }} - > - {chatRoomSubtitleText} - - ) : ( - chatRoomSubtitleText - )} - - + return items; + }, [props.report.reportID, participants, isArchivedRoom, isPolicyExpenseChat, isChatRoom, isThread, isUserCreatedPolicyRoom, canLeaveRoom]); + + const displayNamesWithTooltips = useMemo(() => { + const hasMultipleParticipants = participants.length > 1; + return ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForLogins(participants, props.personalDetails), hasMultipleParticipants); + }, [participants, props.personalDetails]); + + const chatRoomSubtitleText = chatRoomSubtitle ? ( + + {chatRoomSubtitle} + + ) : null; + + return ( + + + Navigation.goBack()} + onCloseButtonPress={() => Navigation.dismissModal()} + /> + + + + - {_.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 ( - + + - ); - })} - - - - ); - } -} + + {isPolicyAdmin ? ( + { + Navigation.navigate(ROUTES.getWorkspaceInitialRoute(props.report.policyID)); + }} + > + {chatRoomSubtitleText} + + ) : ( + chatRoomSubtitleText + )} + + + {_.map(menuItems, (item) => { + const brickRoadIndicator = + ReportUtils.hasReportNameError(props.report) && item.key === CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; + return ( + + ); + })} + + + + ); +}; +ReportDetailsPage.displayName = 'ReportDetailsPage'; ReportDetailsPage.propTypes = propTypes; ReportDetailsPage.defaultProps = defaultProps; + export default compose( withLocalize, withReportOrNotFound, diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index b8f988f78eaf..81b4bb0ec2ab 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -20,6 +20,7 @@ import reportPropTypes from './reportPropTypes'; import withReportOrNotFound from './home/report/withReportOrNotFound'; import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; import CONST from '../CONST'; +import * as UserUtils from '../libs/UserUtils'; const propTypes = { /* Onyx Props */ @@ -65,7 +66,7 @@ const getAllParticipants = (report, personalDetails) => { displayName: userPersonalDetail.displayName, icons: [ { - source: ReportUtils.getAvatar(userPersonalDetail.avatar, login), + source: UserUtils.getAvatar(userPersonalDetail.avatar, login), name: login, type: CONST.ICON_TYPE_AVATAR, }, diff --git a/src/pages/ReportWelcomeMessagePage.js b/src/pages/ReportWelcomeMessagePage.js new file mode 100644 index 000000000000..60ffbebb3638 --- /dev/null +++ b/src/pages/ReportWelcomeMessagePage.js @@ -0,0 +1,94 @@ +import React, {useCallback, useRef, useState} from 'react'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import {View} from 'react-native'; +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import compose from '../libs/compose'; +import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; +import ScreenWrapper from '../components/ScreenWrapper'; +import Navigation from '../libs/Navigation/Navigation'; +import HeaderWithCloseButton from '../components/HeaderWithCloseButton'; +import styles from '../styles/styles'; +import reportPropTypes from './reportPropTypes'; +import withReportOrNotFound from './home/report/withReportOrNotFound'; +import Text from '../components/Text'; +import TextInput from '../components/TextInput'; +import * as Report from '../libs/actions/Report'; +import ONYXKEYS from '../ONYXKEYS'; +import CONST from '../CONST'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; +import Form from '../components/Form'; + +const propTypes = { + ...withLocalizePropTypes, + + /** The report currently being looked at */ + report: reportPropTypes.isRequired, + + /** Route params */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** Report ID passed via route r/:reportID/welcomeMessage */ + reportID: PropTypes.string, + }), + }).isRequired, +}; + +function ReportWelcomeMessagePage(props) { + const parser = new ExpensiMark(); + const [welcomeMessage, setWelcomeMessage] = useState(parser.htmlToMarkdown(props.report.welcomeMessage)); + const welcomeMessageInputRef = useRef(null); + + const handleWelcomeMessageChange = useCallback((value) => { + setWelcomeMessage(value); + }, []); + + const submitForm = useCallback(() => { + Report.updateWelcomeMessage(props.report.reportID, props.report.welcomeMessage, welcomeMessage); + }, [props.report.reportID, props.report.welcomeMessage, welcomeMessage]); + + return ( + { + if (!welcomeMessageInputRef.current) { + return; + } + welcomeMessageInputRef.current.focus(); + }} + > + + Navigation.goBack()} + onCloseButtonPress={() => Navigation.dismissModal()} + /> +
({})} + submitButtonText={props.translate('common.save')} + enabledWhenOffline + > + {props.translate('welcomeMessagePage.explainerText')} + + + +
+
+
+ ); +} + +ReportWelcomeMessagePage.propTypes = propTypes; +export default compose(withLocalize, withReportOrNotFound)(ReportWelcomeMessagePage); diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js index c67c42520767..d6c419b10882 100644 --- a/src/pages/ShareCodePage.js +++ b/src/pages/ShareCodePage.js @@ -17,6 +17,7 @@ import * as Expensicons from '../components/Icon/Expensicons'; import getPlatform from '../libs/getPlatform'; import CONST from '../CONST'; import ContextMenuItem from '../components/ContextMenuItem'; +import * as UserUtils from '../libs/UserUtils'; const propTypes = { /** The report currently being looked at */ @@ -39,7 +40,7 @@ class ShareCodePage extends React.Component { const isReport = this.props.report != null && this.props.report.reportID != null; const subtitle = ReportUtils.getChatRoomSubtitle(this.props.report); - const url = isReport ? `${CONST.NEW_EXPENSIFY_URL}r/${this.props.report.reportID}` : `${CONST.NEW_EXPENSIFY_URL}details?login=${this.props.session.email}`; + const url = isReport ? `${CONST.NEW_EXPENSIFY_URL}r/${this.props.report.reportID}` : `${CONST.NEW_EXPENSIFY_URL}details?login=${encodeURIComponent(this.props.session.email)}`; const platform = getPlatform(); const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; @@ -60,7 +61,7 @@ class ShareCodePage extends React.Component { url={url} title={isReport ? this.props.report.reportName : this.props.currentUserPersonalDetails.displayName} subtitle={isReport ? subtitle : this.props.session.email} - logo={isReport ? roomAvatar : this.props.currentUserPersonalDetails.avatar} + logo={isReport ? roomAvatar : UserUtils.getAvatarUrl(this.props.currentUserPersonalDetails.avatar, this.props.currentUserPersonalDetails.login)} /> diff --git a/src/pages/ValidateLoginPage/index.js b/src/pages/ValidateLoginPage/index.js index 25d7f16123ce..97461b42ab92 100644 --- a/src/pages/ValidateLoginPage/index.js +++ b/src/pages/ValidateLoginPage/index.js @@ -9,6 +9,9 @@ import ONYXKEYS from '../../ONYXKEYS'; import * as Session from '../../libs/actions/Session'; import Permissions from '../../libs/Permissions'; import Navigation from '../../libs/Navigation/Navigation'; +import withLocalize from '../../components/withLocalize'; +import CONST from '../../CONST'; +import compose from '../../libs/compose'; const propTypes = { /** The accountID and validateCode are passed via the URL */ @@ -22,6 +25,9 @@ const propTypes = { /** Currently logged in user authToken */ authToken: PropTypes.string, }), + + /** Indicates which locale the user currently has selected */ + preferredLocale: PropTypes.string, }; const defaultProps = { @@ -30,6 +36,7 @@ const defaultProps = { session: { authToken: null, }, + preferredLocale: CONST.LOCALES.DEFAULT, }; class ValidateLoginPage extends Component { @@ -42,7 +49,7 @@ class ValidateLoginPage extends Component { // because we don't want to block the user with the interstitial page. Navigation.goBack(false); } else { - Session.signInWithValidateCodeAndNavigate(accountID, validateCode); + Session.signInWithValidateCodeAndNavigate(accountID, validateCode, this.props.preferredLocale); } } else { User.validateLogin(accountID, validateCode); @@ -57,7 +64,10 @@ class ValidateLoginPage extends Component { ValidateLoginPage.propTypes = propTypes; ValidateLoginPage.defaultProps = defaultProps; -export default withOnyx({ - betas: {key: ONYXKEYS.BETAS}, - session: {key: ONYXKEYS.SESSION}, -})(ValidateLoginPage); +export default compose( + withLocalize, + 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 index 00b459a20a65..24a736086357 100644 --- a/src/pages/ValidateLoginPage/index.website.js +++ b/src/pages/ValidateLoginPage/index.website.js @@ -83,7 +83,7 @@ class ValidateLoginPage extends Component { } // The user has initiated the sign in process on the same browser, in another tab. - Session.signInWithValidateCode(this.getAccountID(), this.getValidateCode()); + Session.signInWithValidateCode(this.getAccountID(), this.getValidateCode(), this.props.preferredLocale); } componentDidUpdate() { diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index bf63309b52dd..73cc0fc4a535 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -9,7 +9,6 @@ import themeColors from '../../styles/themes/default'; import Icon from '../../components/Icon'; import * as Expensicons from '../../components/Icon/Expensicons'; import compose from '../../libs/compose'; -import * as Report from '../../libs/actions/Report'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; import MultipleAvatars from '../../components/MultipleAvatars'; import SubscriptAvatar from '../../components/SubscriptAvatar'; @@ -28,6 +27,7 @@ import ONYXKEYS from '../../ONYXKEYS'; import ThreeDotsMenu from '../../components/ThreeDotsMenu'; import * as Task from '../../libs/actions/Task'; import reportActionPropTypes from './report/reportActionPropTypes'; +import PinButton from '../../components/PinButton'; const propTypes = { /** Toggles the navigationMenu open and closed */ @@ -116,7 +116,6 @@ const HeaderView = (props) => { } const shouldShowThreeDotsButton = !!threeDotMenuItems.length; - const avatarTooltip = isChatRoom ? undefined : _.pluck(displayNamesWithTooltips, 'tooltip'); const shouldShowSubscript = isPolicyExpenseChat && !props.report.isOwnPolicyExpenseChat && !ReportUtils.isArchivedRoom(props.report) && !isTaskReport; const icons = ReportUtils.getIcons(reportHeaderData, props.personalDetails); const brickRoadIndicator = ReportUtils.hasReportNameError(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; @@ -136,7 +135,9 @@ const HeaderView = (props) => { text={props.translate('common.back')} shiftVertical={4} > - + + + )} @@ -157,7 +158,7 @@ const HeaderView = (props) => { ) : ( )} @@ -194,17 +195,7 @@ const HeaderView = (props) => { guideCalendarLink={guideCalendarLink} /> )} - - Report.togglePinnedState(props.report)} - style={[styles.touchableButtonImage]} - > - - - + {shouldShowThreeDotsButton && ( )} diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js index d307d06b7984..2d85a960f66d 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js @@ -1,5 +1,5 @@ import React from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import _ from 'underscore'; import PropTypes from 'prop-types'; import getReportActionContextMenuStyles from '../../../../styles/getReportActionContextMenuStyles'; @@ -10,6 +10,8 @@ import ContextMenuActions, {CONTEXT_MENU_TYPES} from './ContextMenuActions'; import compose from '../../../../libs/compose'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; import {withBetas} from '../../../../components/OnyxProvider'; +import * as Session from '../../../../libs/actions/Session'; +import {hideContextMenu} from './ReportActionContextMenu'; const propTypes = { /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ @@ -59,8 +61,27 @@ class BaseReportActionContextMenu extends React.Component { this.props.anchor, this.props.isChronosReport, this.props.reportID, + this.props.isPinnedChat, ); + /** + * Checks if user is anonymous. If true, hides the context menu and + * shows the sign in modal. Else, executes the callback. + * + * @param {Function} callback + */ + const interceptAnonymousUser = (callback) => { + if (Session.isAnonymousUser()) { + hideContextMenu(false); + + InteractionManager.runAfterInteractions(() => { + Session.signOutAndRedirectToSignIn(); + }); + } else { + callback(); + } + }; + return ( (this.props.isVisible || this.state.shouldKeepOpen) && ( this.setState({shouldKeepOpen: false}), openContextMenu: () => this.setState({shouldKeepOpen: true}), + interceptAnonymousUser, }; if (contextAction.renderContent) { @@ -95,7 +117,7 @@ class BaseReportActionContextMenu extends React.Component { successText={contextAction.successTextTranslateKey ? this.props.translate(contextAction.successTextTranslateKey) : undefined} isMini={this.props.isMini} key={contextAction.textTranslateKey} - onPress={() => contextAction.onPress(closePopup, payload)} + onPress={() => interceptAnonymousUser(() => contextAction.onPress(closePopup, payload))} description={contextAction.getDescription(this.props.selection, this.props.isSmallScreenWidth)} autoReset={contextAction.autoReset} /> diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 300d689aea52..b5c9d6412a02 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -19,6 +19,8 @@ import * as Environment from '../../../../libs/Environment/Environment'; import Permissions from '../../../../libs/Permissions'; import QuickEmojiReactions from '../../../../components/Reactions/QuickEmojiReactions'; import MiniQuickEmojiReactions from '../../../../components/Reactions/MiniQuickEmojiReactions'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import ROUTES from '../../../../ROUTES'; /** * Gets the HTML version of the message in an action. @@ -34,6 +36,7 @@ const CONTEXT_MENU_TYPES = { LINK: 'LINK', REPORT_ACTION: 'REPORT_ACTION', EMAIL: 'EMAIL', + REPORT: 'REPORT', }; // A list of all the context actions in this menu. @@ -88,7 +91,12 @@ export default [ shouldShow: (type, reportAction) => { const message = _.last(lodashGet(reportAction, 'message', [{}])); const isAttachment = _.has(reportAction, 'isAttachment') ? reportAction.isAttachment : ReportUtils.isReportMessageAttachment(message); - return isAttachment && reportAction.reportActionID; + return ( + isAttachment && + message.html !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && + reportAction.reportActionID && + !lodashGet(reportAction, 'originalMessage.isDeletedParentAction', false) + ); }, onPress: (closePopover, {reportAction}) => { const message = _.last(lodashGet(reportAction, 'message', [{}])); @@ -111,10 +119,7 @@ export default [ successTextTranslateKey: '', successIcon: null, shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID) => - Permissions.canUseThreads(betas) && - type === CONTEXT_MENU_TYPES.REPORT_ACTION && - reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && - !ReportUtils.isThreadFirstChat(reportAction, reportID), + type === CONTEXT_MENU_TYPES.REPORT_ACTION && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID), onPress: (closePopover, {reportAction, reportID}) => { Report.navigateToAndOpenChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID); if (closePopover) { @@ -145,7 +150,7 @@ export default [ Clipboard.setString(selection.replace('mailto:', '')); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, - getDescription: () => {}, + getDescription: (selection) => selection.replace('mailto:', ''), }, { textTranslateKey: 'reportActionContextMenu.copyToClipboard', @@ -264,6 +269,49 @@ export default [ }, getDescription: () => {}, }, + { + textTranslateKey: 'common.pin', + icon: Expensicons.Pin, + shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat) => type === CONTEXT_MENU_TYPES.REPORT && !isPinnedChat, + onPress: (closePopover, {reportID}) => { + Report.togglePinnedState(reportID, false); + if (closePopover) { + hideContextMenu(false); + } + }, + getDescription: () => {}, + }, + { + textTranslateKey: 'common.unPin', + icon: Expensicons.Pin, + shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat) => type === CONTEXT_MENU_TYPES.REPORT && isPinnedChat, + onPress: (closePopover, {reportID}) => { + Report.togglePinnedState(reportID, true); + if (closePopover) { + hideContextMenu(false); + } + }, + getDescription: () => {}, + }, + { + textTranslateKey: 'reportActionContextMenu.flagAsOffensive', + icon: Expensicons.Flag, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID) => + type === CONTEXT_MENU_TYPES.REPORT_ACTION && + ReportUtils.canFlagReportAction(reportAction, reportID) && + !isArchivedRoom && + !isChronosReport && + !ReportUtils.isConciergeChatReport(reportID) && + reportAction.actorEmail !== CONST.EMAIL.CONCIERGE, + onPress: (closePopover, {reportID, reportAction}) => { + if (closePopover) { + hideContextMenu(false, () => Navigation.navigate(ROUTES.getFlagCommentRoute(reportID, reportAction.reportActionID))); + } + + Navigation.navigate(ROUTES.getFlagCommentRoute(reportID, reportAction.reportActionID)); + }, + getDescription: () => {}, + }, ]; export {CONTEXT_MENU_TYPES}; diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 2d255868c539..770968567139 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -39,6 +39,7 @@ class PopoverReportActionContextMenu extends React.Component { }, isArchivedRoom: false, isChronosReport: false, + isPinnedChat: false, }; this.onPopoverShow = () => {}; this.onPopoverHide = () => {}; @@ -95,7 +96,7 @@ class PopoverReportActionContextMenu extends React.Component { getContextMenuMeasuredLocation() { return new Promise((resolve) => { if (this.contextMenuAnchor) { - this.contextMenuAnchor.measureInWindow((x, y) => resolve({x, y})); + (this.contextMenuAnchor.current || this.contextMenuAnchor).measureInWindow((x, y) => resolve({x, y})); } else { resolve({x: 0, y: 0}); } @@ -126,9 +127,22 @@ class PopoverReportActionContextMenu extends React.Component { * @param {Function} [onHide] - Run a callback when Menu is hidden * @param {Boolean} isArchivedRoom - Whether the provided report is an archived room * @param {Boolean} isChronosReport - Flag to check if the chat participant is Chronos - * @param {String} childReportID - ReportAction childReportID + * @param {Boolean} isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action */ - showContextMenu(type, event, selection, contextMenuAnchor, reportID, reportAction, draftMessage, onShow = () => {}, onHide = () => {}, isArchivedRoom, isChronosReport) { + showContextMenu( + type, + event, + selection, + contextMenuAnchor, + reportID, + reportAction, + draftMessage, + onShow = () => {}, + onHide = () => {}, + isArchivedRoom = false, + isChronosReport = false, + isPinnedChat = false, + ) { const nativeEvent = event.nativeEvent || {}; this.contextMenuAnchor = contextMenuAnchor; this.contextMenuTargetNode = nativeEvent.target; @@ -158,6 +172,7 @@ class PopoverReportActionContextMenu extends React.Component { reportActionDraftMessage: draftMessage, isArchivedRoom, isChronosReport, + isPinnedChat, }); }); } @@ -246,6 +261,7 @@ class PopoverReportActionContextMenu extends React.Component { shouldSetModalVisibilityForDeleteConfirmation: true, isArchivedRoom: false, isChronosReport: false, + isPinnedChat: false, }); } @@ -292,6 +308,7 @@ class PopoverReportActionContextMenu extends React.Component { selection={this.state.selection} isArchivedRoom={this.state.isArchivedRoom} isChronosReport={this.state.isChronosReport} + isPinnedChat={this.state.isPinnedChat} anchor={this.contextMenuTargetNode} contentRef={this.contentRef} /> diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.js b/src/pages/home/report/ContextMenu/ReportActionContextMenu.js index d3d0ce226794..ea68cae8c710 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.js @@ -5,7 +5,7 @@ const contextMenuRef = React.createRef(); /** * Show the ReportActionContextMenu modal popover. * - * @param {string} type - the context menu type to display [EMAIL, LINK, REPORT_ACTION] + * @param {string} type - the context menu type to display [EMAIL, LINK, REPORT_ACTION, REPORT] * @param {Object} [event] - A press event. * @param {String} [selection] - Copied content. * @param {Element} contextMenuAnchor - popoverAnchor @@ -16,7 +16,7 @@ const contextMenuRef = React.createRef(); * @param {Function} [onHide=() => {}] - Run a callback when Menu is hidden * @param {Boolean} isArchivedRoom - Whether the provided report is an archived room * @param {Boolean} isChronosReport - Flag to check if the chat participant is Chronos - * @param {String} childReportID - The child report (thread) of this action + * @param {Boolean} isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action */ function showContextMenu( type, @@ -30,12 +30,12 @@ function showContextMenu( onHide = () => {}, isArchivedRoom = false, isChronosReport = false, - childReportID = '0', + isPinnedChat = false, ) { if (!contextMenuRef.current) { return; } - contextMenuRef.current.showContextMenu(type, event, selection, contextMenuAnchor, reportID, reportAction, draftMessage, onShow, onHide, isArchivedRoom, isChronosReport, childReportID); + contextMenuRef.current.showContextMenu(type, event, selection, contextMenuAnchor, reportID, reportAction, draftMessage, onShow, onHide, isArchivedRoom, isChronosReport, isPinnedChat); } /** diff --git a/src/pages/home/report/ReactionList/BaseReactionList.js b/src/pages/home/report/ReactionList/BaseReactionList.js index 50cd4c0fa63a..eda4f4db0f48 100755 --- a/src/pages/home/report/ReactionList/BaseReactionList.js +++ b/src/pages/home/report/ReactionList/BaseReactionList.js @@ -5,13 +5,15 @@ import PropTypes from 'prop-types'; import Str from 'expensify-common/lib/str'; import styles from '../../../../styles/styles'; import HeaderReactionList from './HeaderReactionList'; -import * as ReportUtils from '../../../../libs/ReportUtils'; +import * as UserUtils from '../../../../libs/UserUtils'; import CONST from '../../../../CONST'; import participantPropTypes from '../../../../components/participantPropTypes'; import reactionPropTypes from './reactionPropTypes'; import OptionRow from '../../../../components/OptionRow'; import variables from '../../../../styles/variables'; import withWindowDimensions from '../../../../components/withWindowDimensions'; +import Navigation from '../../../../libs/Navigation/Navigation'; +import ROUTES from '../../../../ROUTES'; const propTypes = { /** @@ -31,37 +33,6 @@ const defaultProps = { hasUserReacted: false, }; -/** - * 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 - * so that the sticky headers function properly - * - * @param {Object} params - * @param {Object} params.item - * @return {React.Component} - */ -const renderItem = ({item}) => ( - -); - /** * Create a unique key for each action in the FlatList. * @param {Object} item @@ -90,6 +61,42 @@ const BaseReactionList = (props) => { if (!props.isVisible) { return null; } + + /** + * 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 + * so that the sticky headers function properly + * + * @param {Object} params + * @param {Object} params.item + * @return {React.Component} + */ + const renderItem = ({item}) => ( + { + props.onClose(); + Navigation.navigate(ROUTES.getDetailsRoute(item.login)); + }} + option={{ + text: Str.removeSMSDomain(item.displayName), + alternateText: Str.removeSMSDomain(item.login), + participantsList: [item], + icons: [ + { + source: UserUtils.getAvatar(item.avatar, item.login), + name: item.login, + type: CONST.ICON_TYPE_AVATAR, + }, + ], + keyForList: item.login, + }} + /> + ); + return ( <> ( {`:${props.emojiName}:`} - - {props.isSmallScreenWidth && ( - - - - )} ); diff --git a/src/pages/home/report/ReactionList/PopoverReactionList.js b/src/pages/home/report/ReactionList/PopoverReactionList.js index 48da70d363f8..ba36c1ce09ff 100644 --- a/src/pages/home/report/ReactionList/PopoverReactionList.js +++ b/src/pages/home/report/ReactionList/PopoverReactionList.js @@ -1,27 +1,20 @@ import React from 'react'; import {Dimensions} from 'react-native'; - +import _ from 'underscore'; import lodashGet from 'lodash/get'; -import lodashMap from 'lodash/map'; -import lodashFilter from 'lodash/filter'; -import lodashFind from 'lodash/find'; -import lodashEach from 'lodash/each'; -import lodashIsEqual from 'lodash/isEqual'; - import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import * as Report from '../../../../libs/actions/Report'; import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import PopoverWithMeasuredContent from '../../../../components/PopoverWithMeasuredContent'; - import BaseReactionList from './BaseReactionList'; import compose from '../../../../libs/compose'; import reportActionPropTypes from '../reportActionPropTypes'; import ONYXKEYS from '../../../../ONYXKEYS'; -import getPreferredEmojiCode from '../../../../components/Reactions/getPreferredEmojiCode'; import withCurrentUserPersonalDetails from '../../../../components/withCurrentUserPersonalDetails'; import * as PersonalDetailsUtils from '../../../../libs/PersonalDetailsUtils'; import emojis from '../../../../../assets/emojis'; +import * as EmojiUtils from '../../../../libs/EmojiUtils'; const propTypes = { /** Actions from the ChatReport */ @@ -34,26 +27,6 @@ const defaultProps = { reportActions: {}, }; -/** - * Given an emoji object and a list of senders it will return an - * array of emoji codes, that represents all used variations of the - * emoji. - * @param {{ name: string, code: string, types: string[] }} emoji - * @param {Array} users - * @return {string[]} - * */ -const getUniqueEmojiCodes = (emoji, users) => { - const emojiCodes = []; - lodashEach(users, (user) => { - const emojiCode = getPreferredEmojiCode(emoji, user.skinTone); - - if (emojiCode && !emojiCodes.includes(emojiCode)) { - emojiCodes.push(emojiCode); - } - }); - return emojiCodes; -}; - class PopoverReactionList extends React.Component { constructor(props) { super(props); @@ -105,13 +78,13 @@ class PopoverReactionList extends React.Component { this.state.popoverAnchorPosition !== nextState.popoverAnchorPosition || previousLocale !== nextLocale || (this.state.isPopoverVisible && - (!lodashIsEqual(prevSelectedReaction, selectedReaction) || + (!_.isEqual(prevSelectedReaction, selectedReaction) || this.state.emojiName !== nextState.emojiName || this.state.emojiCount !== nextState.emojiCount || this.state.hasUserReacted !== nextState.hasUserReacted || this.state.reportActionID !== nextState.reportActionID || - !lodashIsEqual(this.state.emojiCodes, nextState.emojiCodes) || - !lodashIsEqual(this.state.users, nextState.users))) + !_.isEqual(this.state.emojiCodes, nextState.emojiCodes) || + !_.isEqual(this.state.users, nextState.users))) ); } @@ -167,10 +140,10 @@ class PopoverReactionList extends React.Component { * @returns {Object} */ getSelectedReaction(reportActions, reportActionID, emojiName) { - const reportAction = lodashFind(reportActions, (action) => action.reportActionID === reportActionID); + const reportAction = _.find(reportActions, (action) => action.reportActionID === reportActionID); const reactions = lodashGet(reportAction, ['message', 0, 'reactions'], []); - const reactionsWithCount = lodashFilter(reactions, (reaction) => reaction.users.length > 0); - return lodashFind(reactionsWithCount, (reaction) => reaction.emoji === emojiName); + const reactionsWithCount = _.filter(reactions, (reaction) => reaction.users.length > 0); + return _.find(reactionsWithCount, (reaction) => reaction.emoji === emojiName); } /** @@ -189,9 +162,9 @@ class PopoverReactionList extends React.Component { }; } const emojiCount = selectedReaction.users.length; - const reactionUsers = lodashMap(selectedReaction.users, (sender) => sender.accountID.toString()); - const emoji = lodashFind(emojis, (e) => e.name === selectedReaction.emoji); - const emojiCodes = getUniqueEmojiCodes(emoji, selectedReaction.users); + const reactionUsers = _.map(selectedReaction.users, (sender) => sender.accountID.toString()); + const emoji = _.find(emojis, (e) => e.name === selectedReaction.emoji); + const emojiCodes = EmojiUtils.getUniqueEmojiCodes(emoji, selectedReaction.users); const hasUserReacted = Report.hasAccountIDReacted(this.props.currentUserPersonalDetails.accountID, reactionUsers); const users = PersonalDetailsUtils.getPersonalDetailsByIDs(reactionUsers); return { diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index b234348384f9..da855c59e258 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -53,7 +53,7 @@ import * as ComposerActions from '../../../libs/actions/Composer'; import * as Welcome from '../../../libs/actions/Welcome'; import Permissions from '../../../libs/Permissions'; import * as TaskUtils from '../../../libs/actions/Task'; -import * as OptionsListUtils from '../../../libs/OptionsListUtils'; +import * as Browser from '../../../libs/Browser'; const propTypes = { /** Beta features list */ @@ -116,9 +116,6 @@ const propTypes = { /** The type of action that's pending */ pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), - /** Collection of recent reports, used to calculate the mention suggestions */ - reports: PropTypes.objectOf(reportPropTypes), - ...windowDimensionsPropTypes, ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, @@ -137,7 +134,6 @@ const defaultProps = { preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, isComposerFullSize: false, pendingAction: null, - reports: {}, shouldShowComposeInput: true, ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -187,7 +183,6 @@ class ReportActionCompose extends React.Component { this.updateNumberOfLines = this.updateNumberOfLines.bind(this); this.showPopoverMenu = this.showPopoverMenu.bind(this); this.comment = props.comment; - this.setShouldBlockEmojiCalcToFalse = this.setShouldBlockEmojiCalcToFalse.bind(this); // React Native will retain focus on an input for native devices but web/mWeb behave differently so we have some focus management // code that will refocus the compose input after a user closes a modal or some other actions, see usage of ReportActionComposeFocusManager @@ -197,6 +192,17 @@ class ReportActionCompose extends React.Component { // prevent auto focus on existing chat for mobile device this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); + this.shouldAutoFocus = !props.modal.isVisible && (this.shouldFocusInputOnScreenFocus || this.isEmptyChat()) && props.shouldShowComposeInput; + + // These variables are used to decide whether to block the suggestions list from showing to prevent flickering + this.shouldBlockEmojiCalc = false; + this.shouldBlockMentionCalc = false; + + // For mobile Safari, updating the selection prop on an unfocused input will cause it to automatically gain focus + // and subsequent programmatic focus shifts (e.g., modal focus trap) to show the blue frame (:focus-visible style), + // so we need to ensure that it is only updated after focus. + const isMobileSafari = Browser.isMobileSafari(); + this.state = { isFocused: this.shouldFocusInputOnScreenFocus && !this.props.modal.isVisible && !this.props.modal.willAlertModalBecomeVisible && this.props.shouldShowComposeInput, isFullComposerAvailable: props.isComposerFullSize, @@ -205,8 +211,8 @@ class ReportActionCompose extends React.Component { isMenuVisible: false, isDraggingOver: false, selection: { - start: props.comment.length, - end: props.comment.length, + start: isMobileSafari && !this.shouldAutoFocus ? 0 : props.comment.length, + end: isMobileSafari && !this.shouldAutoFocus ? 0 : props.comment.length, }, maxLines: props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES, value: props.comment, @@ -280,6 +286,12 @@ class ReportActionCompose extends React.Component { onSelectionChange(e) { LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); this.setState({selection: e.nativeEvent.selection}); + if (!this.state.value || e.nativeEvent.selection.end < 1) { + this.resetSuggestions(); + this.shouldBlockEmojiCalc = false; + this.shouldBlockMentionCalc = false; + return; + } this.calculateEmojiSuggestion(); this.calculateMentionSuggestion(); } @@ -416,13 +428,6 @@ class ReportActionCompose extends React.Component { } } - // eslint-disable-next-line rulesdir/prefer-early-return - setShouldBlockEmojiCalcToFalse() { - if (this.state && this.state.shouldBlockEmojiCalc) { - this.setState({shouldBlockEmojiCalc: false}); - } - } - /** * Determines if we can show the task option * @param {Array} reportParticipants @@ -446,6 +451,53 @@ class ReportActionCompose extends React.Component { ]; } + /** + * Build the suggestions for mentions + * @param {Object} personalDetails + * @param {String} [searchValue] + * @returns {Object} + */ + getMentionOptions(personalDetails, searchValue = '') { + const suggestions = []; + + if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue)) { + suggestions.push({ + text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, + alternateText: this.props.translate('mentionSuggestions.hereAlternateText'), + icons: [ + { + source: Expensicons.Megaphone, + type: 'avatar', + }, + ], + }); + } + + const filteredPersonalDetails = _.filter(_.values(personalDetails), (detail) => { + if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) { + return false; + } + return true; + }); + + const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login); + _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_ITEMS - suggestions.length), (detail) => { + suggestions.push({ + text: detail.displayName, + alternateText: detail.login, + icons: [ + { + name: detail.login, + source: detail.avatar, + type: 'avatar', + }, + ], + }); + }); + + return suggestions; + } + /** * Clean data related to EmojiSuggestions and MentionSuggestions */ @@ -459,14 +511,11 @@ class ReportActionCompose extends React.Component { * Calculates and cares about the content of an Emoji Suggester */ calculateEmojiSuggestion() { - if (!this.state.value) { - this.resetSuggestions(); - return; - } - if (this.state.shouldBlockEmojiCalc) { - this.setState({shouldBlockEmojiCalc: false}); + if (this.shouldBlockEmojiCalc) { + this.shouldBlockEmojiCalc = false; return; } + const leftString = this.state.value.substring(0, this.state.selection.end); const colonIndex = leftString.lastIndexOf(':'); const isCurrentlyShowingEmojiSuggestion = this.isEmojiCode(this.state.value, this.state.selection.end); @@ -493,12 +542,13 @@ class ReportActionCompose extends React.Component { } calculateMentionSuggestion() { - if (this.state.selection.end < 1) { + if (this.shouldBlockMentionCalc) { + this.shouldBlockMentionCalc = false; return; } const valueAfterTheCursor = this.state.value.substring(this.state.selection.end); - const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); let indexOfLastNonWhitespaceCharAfterTheCursor; if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) { @@ -509,7 +559,7 @@ class ReportActionCompose extends React.Component { } const leftString = this.state.value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); - const words = leftString.split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); const lastWord = _.last(words); let atSignIndex; @@ -529,9 +579,7 @@ class ReportActionCompose extends React.Component { const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); if (!isCursorBeforeTheMention && this.isMentionCode(lastWord)) { - const options = OptionsListUtils.getNewChatOptions(this.props.reports, this.props.personalDetails, this.props.betas, prefix); - const suggestions = _.filter([...options.recentReports, options.userToInvite], (x) => !!x); - + const suggestions = this.getMentionOptions(this.props.personalDetails, prefix); nextState.suggestedMentions = suggestions; nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions); } @@ -546,7 +594,7 @@ class ReportActionCompose extends React.Component { * @returns {Boolean} */ isEmojiCode(str, pos) { - const leftWords = str.slice(0, pos).split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); const leftWord = _.last(leftWords); return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; @@ -561,6 +609,15 @@ class ReportActionCompose extends React.Component { return CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); } + /** + * Trims first character of the string if it is a space + * @param {String} str + * @returns {String} + */ + trimLeadingSpace(str) { + return str.slice(0, 1) === ' ' ? str.slice(1) : str; + } + /** * Replace the code of emoji and update selection * @param {Number} highlightedEmojiIndex @@ -571,7 +628,7 @@ class ReportActionCompose extends React.Component { const emojiCode = emojiObject.types && emojiObject.types[this.props.preferredSkinTone] ? emojiObject.types[this.props.preferredSkinTone] : emojiObject.code; const commentAfterColonWithEmojiNameRemoved = this.state.value.slice(this.state.selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); - this.updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); + this.updateComment(`${commentBeforeColon}${emojiCode} ${this.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); // In some Android phones keyboard, the text to search for the emoji is not cleared // will be added after the user starts typing again on the keyboard. This package is // a workaround to reset the keyboard natively. @@ -596,10 +653,10 @@ class ReportActionCompose extends React.Component { insertSelectedMention(highlightedMentionIndex) { const commentBeforeAtSign = this.state.value.slice(0, this.state.atSignIndex); const mentionObject = this.state.suggestedMentions[highlightedMentionIndex]; - const mentionCode = `@${mentionObject.alternateText}`; + const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`; const commentAfterAtSignWithMentionRemoved = this.state.value.slice(this.state.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); - this.updateComment(`${commentBeforeAtSign}${mentionCode} ${commentAfterAtSignWithMentionRemoved}`, true); + this.updateComment(`${commentBeforeAtSign}${mentionCode} ${this.trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); this.setState((prevState) => ({ selection: { start: prevState.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, @@ -853,7 +910,6 @@ class ReportActionCompose extends React.Component { const reportParticipants = _.without(lodashGet(this.props.report, 'participants', []), this.props.currentUserPersonalDetails.login); const participantsWithoutExpensifyEmails = _.difference(reportParticipants, CONST.EXPENSIFY_EMAILS); const reportRecipient = this.props.personalDetails[participantsWithoutExpensifyEmails[0]]; - const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(this.props.personalDetails, this.props.report) && !this.props.isComposerFullSize; // Prevents focusing and showing the keyboard while the drawer is covering the chat. @@ -863,6 +919,7 @@ class ReportActionCompose extends React.Component { const shouldUseFocusedColor = !isBlockedFromConcierge && !this.props.disabled && (this.state.isFocused || this.state.isDraggingOver); const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength; const isFullComposerAvailable = this.state.isFullComposerAvailable && !_.isEmpty(this.state.value); + const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); return ( - {shouldShowReportRecipientLocalTime && } + {shouldShowReportRecipientLocalTime && hasReportRecipient && } this.setState({isAttachmentPreviewActive: true})} onModalHide={() => { - this.setShouldBlockEmojiCalcToFalse(); + this.shouldBlockEmojiCalc = false; + this.shouldBlockMentionCalc = false; this.setState({isAttachmentPreviewActive: false}); }} > @@ -972,10 +1030,11 @@ class ReportActionCompose extends React.Component { icon: Expensicons.Paperclip, text: this.props.translate('reportActionCompose.addAttachment'), onSelected: () => { - // Set a flag to block emoji calculation until we're finished using the file picker, + // Set a flag to block suggestion calculation until we're finished using the file picker, // which will stop any flickering as the file picker opens on non-native devices. if (this.willBlurTextInputOnTapOutside) { - this.setState({shouldBlockEmojiCalc: true}); + this.shouldBlockEmojiCalc = true; + this.shouldBlockMentionCalc = true; } openPicker({ @@ -1014,7 +1073,7 @@ class ReportActionCompose extends React.Component { disabled={this.props.disabled} > this.updateComment(comment, true)} onKeyPress={this.triggerHotkeyActions} - style={[ - this.props.numberOfLines > 1 ? styles.textInputComposeMultiLines : styles.textInputCompose, - this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4, - ]} + style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} maxLines={this.state.maxLines} onFocus={() => this.setIsFocused(true)} onBlur={() => { this.setIsFocused(false); this.resetSuggestions(); }} - onClick={this.setShouldBlockEmojiCalcToFalse} + onClick={() => { + this.shouldBlockEmojiCalc = false; + this.shouldBlockMentionCalc = false; + }} onPasteFile={displayFileInModal} shouldClear={this.state.textInputShouldClear} onClear={() => this.setTextInputShouldClear(false)} @@ -1045,6 +1104,7 @@ class ReportActionCompose extends React.Component { value={this.state.value} numberOfLines={this.props.numberOfLines} onNumberOfLinesChange={this.updateNumberOfLines} + shouldCalculateCaretPosition onLayout={(e) => { const composerHeight = e.nativeEvent.layout.height; if (this.state.composerHeight === composerHeight) { @@ -1195,9 +1255,6 @@ export default compose( key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, selector: EmojiUtils.getPreferredSkinToneIndex, }, - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS, }, diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index a0511469fa4a..6a31d96bd23a 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -1,7 +1,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; -import React, {Component} from 'react'; -import {View} from 'react-native'; +import React, {useState, useRef, useEffect, memo, useCallback} from 'react'; +import {InteractionManager, View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import CONST from '../../../CONST'; @@ -10,6 +10,7 @@ import reportActionPropTypes from './reportActionPropTypes'; import * as StyleUtils from '../../../styles/StyleUtils'; import PressableWithSecondaryInteraction from '../../../components/PressableWithSecondaryInteraction'; import Hoverable from '../../../components/Hoverable'; +import Button from '../../../components/Button'; import ReportActionItemSingle from './ReportActionItemSingle'; import ReportActionItemGrouped from './ReportActionItemGrouped'; import MoneyRequestAction from '../../../components/ReportActionItem/MoneyRequestAction'; @@ -30,6 +31,7 @@ import RenameAction from '../../../components/ReportActionItem/RenameAction'; import InlineSystemMessage from '../../../components/InlineSystemMessage'; import styles from '../../../styles/styles'; import SelectionScraper from '../../../libs/SelectionScraper'; +import focusTextInputAfterAnimation from '../../../libs/focusTextInputAfterAnimation'; import * as User from '../../../libs/actions/User'; import * as ReportUtils from '../../../libs/ReportUtils'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; @@ -37,7 +39,6 @@ import * as ReportActions from '../../../libs/actions/ReportActions'; import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; import reportPropTypes from '../../reportPropTypes'; import {ShowContextMenuContext} from '../../../components/ShowContextMenuContext'; -import focusTextInputAfterAnimation from '../../../libs/focusTextInputAfterAnimation'; import ChronosOOOListActions from '../../../components/ReportActionItem/ChronosOOOListActions'; import ReportActionItemReactions from '../../../components/Reactions/ReportActionItemReactions'; import * as Report from '../../../libs/actions/Report'; @@ -51,9 +52,12 @@ import ReportPreview from '../../../components/ReportActionItem/ReportPreview'; import ReportActionItemDraft from './ReportActionItemDraft'; import TaskPreview from '../../../components/ReportActionItem/TaskPreview'; import TaskAction from '../../../components/ReportActionItem/TaskAction'; -import Permissions from '../../../libs/Permissions'; +import * as Session from '../../../libs/actions/Session'; +import {hideContextMenu} from './ContextMenu/ReportActionContextMenu'; const propTypes = { + ...windowDimensionsPropTypes, + /** Report for this action */ report: reportPropTypes.isRequired, @@ -66,9 +70,6 @@ const propTypes = { /** Is this the most recent IOU Action? */ isMostRecentIOUReportAction: PropTypes.bool.isRequired, - /** Whether there is an outstanding amount in IOU */ - hasOutstandingIOU: PropTypes.bool, - /** Should we display the new marker on top of the comment? */ shouldDisplayNewMarker: PropTypes.bool.isRequired, @@ -81,119 +82,118 @@ const propTypes = { /** Draft message - if this is set the comment is in 'edit' mode */ draftMessage: PropTypes.string, + /* Whether the option has an outstanding IOU */ + // eslint-disable-next-line react/no-unused-prop-types + hasOutstandingIOU: PropTypes.bool, + /** Stores user's preferred skin tone */ preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** All of the personalDetails */ personalDetails: PropTypes.objectOf(personalDetailsPropType), - - /** List of betas available to current user */ - betas: PropTypes.arrayOf(PropTypes.string), - - ...windowDimensionsPropTypes, }; const defaultProps = { draftMessage: '', - hasOutstandingIOU: false, preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, personalDetails: {}, shouldShowSubscriptAvatar: false, - betas: [], + hasOutstandingIOU: false, }; -class ReportActionItem extends Component { - constructor(props) { - super(props); - this.popoverAnchor = undefined; - this.state = { - isContextMenuActive: ReportActionContextMenu.isActiveReportAction(props.action.reportActionID), - }; - this.checkIfContextMenuActive = this.checkIfContextMenuActive.bind(this); - this.showPopover = this.showPopover.bind(this); - this.renderItemContent = this.renderItemContent.bind(this); - this.toggleReaction = this.toggleReaction.bind(this); - } +function ReportActionItem(props) { + const [isContextMenuActive, setIsContextMenuActive] = useState(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); + const [isHidden, setIsHidden] = useState(false); + const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); + const textInputRef = useRef(); + const popoverAnchorRef = useRef(); - shouldComponentUpdate(nextProps, nextState) { - return ( - this.props.displayAsGroup !== nextProps.displayAsGroup || - this.props.draftMessage !== nextProps.draftMessage || - this.props.isMostRecentIOUReportAction !== nextProps.isMostRecentIOUReportAction || - this.props.hasOutstandingIOU !== nextProps.hasOutstandingIOU || - this.props.shouldDisplayNewMarker !== nextProps.shouldDisplayNewMarker || - !_.isEqual(this.props.action, nextProps.action) || - this.state.isContextMenuActive !== nextState.isContextMenuActive || - lodashGet(this.props.report, 'statusNum') !== lodashGet(nextProps.report, 'statusNum') || - lodashGet(this.props.report, 'stateNum') !== lodashGet(nextProps.report, 'stateNum') || - this.props.translate !== nextProps.translate - ); - } + const isDraftEmpty = !props.draftMessage; + useEffect(() => { + if (isDraftEmpty) { + return; + } - componentDidUpdate(prevProps) { - if (prevProps.draftMessage || !this.props.draftMessage) { + focusTextInputAfterAnimation(textInputRef.current, 100); + }, [isDraftEmpty]); + + // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator + // Removed messages should not be shown anyway and should not need this flow + useEffect(() => { + if (!props.action.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT || _.isEmpty(props.action.message[0].moderationDecisions)) { return; } - // Only focus the input when user edits a message, skip it for existing drafts being edited of the report. - // There is an animation when the comment is hidden and the edit form is shown, and there can be bugs on different mobile platforms - // if the input is given focus in the middle of that animation which can prevent the keyboard from opening. - focusTextInputAfterAnimation(this.textInput, 100); - } + // Right now we are only sending the latest moderationDecision to the frontend even though it is an array + let decisions = props.action.message[0].moderationDecisions; + if (decisions.length > 1) { + decisions = decisions.slice(-1); + } + const latestDecision = decisions[0]; + if (latestDecision.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_HIDE || latestDecision.decision === CONST.MODERATION.MODERATOR_DECISION_HIDDEN) { + setIsHidden(true); + } + setModerationDecision(latestDecision.decision); + }, [props.action.message, props.action.actionName]); - checkIfContextMenuActive() { - this.setState({isContextMenuActive: ReportActionContextMenu.isActiveReportAction(this.props.action.reportActionID)}); - } + const toggleContextMenuFromActiveReportAction = useCallback(() => { + setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); + }, [props.action.reportActionID]); /** * Show the ReportActionContextMenu modal popover. * * @param {Object} [event] - A press event. */ - showPopover(event) { - // Block menu on the message being Edited or if the report action item has errors - if (this.props.draftMessage || !_.isEmpty(this.props.action.errors)) { - return; - } - - this.setState({isContextMenuActive: true}); - - const selection = SelectionScraper.getCurrentSelection(); - ReportActionContextMenu.showContextMenu( - ContextMenuActions.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - this.popoverAnchor, - this.props.report.reportID, - this.props.action, - this.props.draftMessage, - undefined, - this.checkIfContextMenuActive, - ReportUtils.isArchivedRoom(this.props.report), - ReportUtils.chatIncludesChronos(this.props.report), - this.props.action.childReportID, - ); - } + const showPopover = useCallback( + (event) => { + // Block menu on the message being Edited or if the report action item has errors + if (props.draftMessage || !_.isEmpty(props.action.errors)) { + return; + } + + setIsContextMenuActive(true); + + const selection = SelectionScraper.getCurrentSelection(); + ReportActionContextMenu.showContextMenu( + ContextMenuActions.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + selection, + popoverAnchorRef, + props.report.reportID, + props.action, + props.draftMessage, + () => {}, + toggleContextMenuFromActiveReportAction, + ReportUtils.isArchivedRoom(props.report), + ReportUtils.chatIncludesChronos(props.report), + ); + }, + [props.draftMessage, props.action, props.report, toggleContextMenuFromActiveReportAction], + ); - toggleReaction(emoji) { - Report.toggleEmojiReaction(this.props.report.reportID, this.props.action, emoji); - } + const toggleReaction = useCallback( + (emoji) => { + Report.toggleEmojiReaction(props.report.reportID, props.action, emoji); + }, + [props.report, props.action], + ); /** * Get the content of ReportActionItem * @param {Boolean} hovered whether the ReportActionItem is hovered * @returns {Object} child component(s) */ - renderItemContent(hovered = false) { + const renderItemContent = (hovered = false) => { let children; - const originalMessage = lodashGet(this.props.action, 'originalMessage', {}); + const originalMessage = lodashGet(props.action, 'originalMessage', {}); // IOUDetails only exists when we are sending money const isSendingMoney = originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails'); // Show the IOUPreview for when request was created, bill was split or money was sent if ( - this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && + props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && originalMessage && // For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || isSendingMoney) @@ -202,125 +202,162 @@ class ReportActionItem extends Component { const iouReportID = originalMessage.IOUReportID ? originalMessage.IOUReportID.toString() : '0'; children = ( ); - } else if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { + } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { children = ( ); } else if ( - this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || - this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELED || - this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED + props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || + props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELED || + props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED ) { children = ( ); - } else if (ReportActionsUtils.isCreatedTaskReportAction(this.props.action)) { + } else if (ReportActionsUtils.isCreatedTaskReportAction(props.action)) { children = ( ); } else { - const message = _.last(lodashGet(this.props.action, 'message', [{}])); - const isAttachment = _.has(this.props.action, 'isAttachment') ? this.props.action.isAttachment : ReportUtils.isReportMessageAttachment(message); + const message = _.last(lodashGet(props.action, 'message', [{}])); + const hasBeenFlagged = !_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], moderationDecision); + const isAttachment = _.has(props.action, 'isAttachment') ? props.action.isAttachment : ReportUtils.isReportMessageAttachment(message); children = ( - {!this.props.draftMessage ? ( - + {!props.draftMessage ? ( + + + {props.displayAsGroup && hasBeenFlagged && ( + + )} + ) : ( (this.textInput = el)} - report={this.props.report} + action={props.action} + draftMessage={props.draftMessage} + reportID={props.report.reportID} + index={props.index} + ref={textInputRef} + report={props.report} // Avoid defining within component due to an existing Onyx bug - preferredSkinTone={this.props.preferredSkinTone} + preferredSkinTone={props.preferredSkinTone} shouldDisableEmojiPicker={ - (ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge)) || - ReportUtils.isArchivedRoom(this.props.report) + (ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge)) || ReportUtils.isArchivedRoom(props.report) } /> )} + {!props.displayAsGroup && hasBeenFlagged && ( + + )} ); } - const reactions = _.get(this.props, ['action', 'message', 0, 'reactions'], []); + const reactions = _.get(props, ['action', 'message', 0, 'reactions'], []); const hasReactions = reactions.length > 0; - const numberOfThreadReplies = _.get(this.props, ['action', 'childVisibleActionCount'], 0); + const numberOfThreadReplies = _.get(props, ['action', 'childVisibleActionCount'], 0); const hasReplies = numberOfThreadReplies > 0; - const shouldDisplayThreadReplies = - hasReplies && - this.props.action.childCommenterCount && - Permissions.canUseThreads(this.props.betas) && - !ReportUtils.isThreadFirstChat(this.props.action, this.props.report.reportID); - const oldestFourEmails = lodashGet(this.props.action, 'childOldestFourEmails', '').split(','); + const shouldDisplayThreadReplies = hasReplies && props.action.childCommenterCount && !ReportUtils.isThreadFirstChat(props.action, props.report.reportID); + const oldestFourEmails = lodashGet(props.action, 'childOldestFourEmails', '').split(','); return ( <> {children} {hasReactions && ( - + { + if (Session.isAnonymousUser()) { + hideContextMenu(false); + + InteractionManager.runAfterInteractions(() => { + Session.signOutAndRedirectToSignIn(); + }); + } else { + toggleReaction(emoji); + } + }} /> )} {shouldDisplayThreadReplies && ( )} ); - } + }; /** * Get ReportActionItem with a proper wrapper @@ -328,21 +365,22 @@ class ReportActionItem extends Component { * @param {Boolean} isWhisper whether the ReportActionItem is a whisper * @returns {Object} report action item */ - renderReportActionItem(hovered, isWhisper) { - const content = this.renderItemContent(hovered || this.state.isContextMenuActive); + const renderReportActionItem = (hovered, isWhisper) => { + const content = renderItemContent(hovered || isContextMenuActive); - if (this.props.draftMessage) { + if (props.draftMessage) { return {content}; } - if (!this.props.displayAsGroup) { + if (!props.displayAsGroup) { return ( {content} @@ -350,102 +388,101 @@ class ReportActionItem extends Component { } return {content}; - } + }; - render() { - if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - return ; - } - if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { - return ; - } - if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CHRONOSOOOLIST) { - return ( - - ); - } - - const hasErrors = !_.isEmpty(this.props.action.errors); - const whisperedTo = lodashGet(this.props.action, 'whisperedTo', []); - const isWhisper = whisperedTo.length > 0; - const isMultipleParticipant = whisperedTo.length > 1; - const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedTo); - const whisperedToPersonalDetails = isWhisper ? _.filter(this.props.personalDetails, (details) => _.includes(whisperedTo, details.login)) : []; - const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; + if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return ; + } + if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { + return ; + } + if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CHRONOSOOOLIST) { return ( - (this.popoverAnchor = el)} - onPressIn={() => this.props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} - onPressOut={() => ControlSelection.unblock()} - onSecondaryInteraction={this.showPopover} - preventDefaultContextMenu={!this.props.draftMessage && !hasErrors} - withoutFocusOnSecondaryInteraction - > - - {(hovered) => ( - - {this.props.shouldDisplayNewMarker && } - - + ); + } + + const hasErrors = !_.isEmpty(props.action.errors); + const whisperedTo = lodashGet(props.action, 'whisperedTo', []); + const isWhisper = whisperedTo.length > 0; + const isMultipleParticipant = whisperedTo.length > 1; + const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedTo); + const whisperedToPersonalDetails = isWhisper ? _.filter(props.personalDetails, (details) => _.includes(whisperedTo, details.login)) : []; + const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; + return ( + props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onSecondaryInteraction={showPopover} + preventDefaultContextMenu={!props.draftMessage && !hasErrors} + withoutFocusOnSecondaryInteraction + > + + {(hovered) => ( + + {props.shouldDisplayNewMarker && } + + + ReportActions.clearReportActionErrors(props.report.reportID, props.action)} + pendingAction={props.draftMessage ? null : props.action.pendingAction} + errors={props.action.errors} + errorRowStyles={[styles.ml10, styles.mr2]} + needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(props.action)} > - ReportActions.clearReportActionErrors(this.props.report.reportID, this.props.action)} - pendingAction={this.props.draftMessage ? null : this.props.action.pendingAction} - errors={this.props.action.errors} - errorRowStyles={[styles.ml10, styles.mr2]} - needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(this.props.action)} - > - {isWhisper && ( - - - - - - {this.props.translate('reportActionContextMenu.onlyVisible')} -   - - + + - )} - {this.renderReportActionItem(hovered, isWhisper)} - - + + {props.translate('reportActionContextMenu.onlyVisible')} +   + + + + )} + {renderReportActionItem(hovered, isWhisper)} + - )} - - - - - - ); - } + + )} + + + + + + ); } + ReportActionItem.propTypes = propTypes; ReportActionItem.defaultProps = defaultProps; @@ -466,8 +503,19 @@ export default compose( preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, }, - betas: { - key: ONYXKEYS.BETAS, - }, }), -)(ReportActionItem); +)( + memo( + ReportActionItem, + (prevProps, nextProps) => + prevProps.displayAsGroup === nextProps.displayAsGroup && + prevProps.draftMessage === nextProps.draftMessage && + prevProps.isMostRecentIOUReportAction === nextProps.isMostRecentIOUReportAction && + prevProps.hasOutstandingIOU === nextProps.hasOutstandingIOU && + prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && + _.isEqual(prevProps.action, nextProps.action) && + lodashGet(prevProps.report, 'statusNum') === lodashGet(nextProps.report, 'statusNum') && + lodashGet(prevProps.report, 'stateNum') === lodashGet(nextProps.report, 'stateNum') && + prevProps.translate === nextProps.translate, + ), +); diff --git a/src/pages/home/report/ReportActionItemCreated.js b/src/pages/home/report/ReportActionItemCreated.js index 5fcda0903e75..a00e557ea613 100644 --- a/src/pages/home/report/ReportActionItemCreated.js +++ b/src/pages/home/report/ReportActionItemCreated.js @@ -1,5 +1,5 @@ import React from 'react'; -import {Pressable, View, Image} from 'react-native'; +import {View, Image} from 'react-native'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; @@ -17,6 +17,7 @@ import * as StyleUtils from '../../../styles/StyleUtils'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import compose from '../../../libs/compose'; import withLocalize from '../../../components/withLocalize'; +import PressableWithoutFeedback from '../../../components/Pressable/PressableWithoutFeedback'; const propTypes = { /** The id of the report */ @@ -59,12 +60,14 @@ const ReportActionItemCreated = (props) => { accessibilityLabel={props.translate('accessibilityHints.chatWelcomeMessage')} style={[styles.p5, StyleUtils.getReportWelcomeTopMarginStyle(props.isSmallScreenWidth)]} > - ReportUtils.navigateToDetailsPage(props.report)} style={[styles.ph5, styles.pb3, styles.alignSelfStart]} + accessibilityLabel={props.translate('common.details')} + accessibilityRole="button" > - + diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index e134c327df55..19fa6d6982e3 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -109,7 +109,7 @@ const ReportActionItemFragment = (props) => { // Only render HTML if we have html in the fragment if (!differByLineBreaksOnly) { const isPendingDelete = props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && props.network.isOffline; - const editedTag = props.fragment.isEdited ? '' : ''; + const editedTag = props.fragment.isEdited ? `` : ''; const htmlContent = applyStrikethrough(html + editedTag, isPendingDelete); return ${htmlContent}` : `${htmlContent}`} />; @@ -125,7 +125,7 @@ const ReportActionItemFragment = (props) => { - + {!props.shouldHideThreadDividerLine && } ); }; diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index f388faf1b5a4..96fd6be6ad26 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -21,6 +21,7 @@ import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; import CONST from '../../../CONST'; import SubscriptAvatar from '../../../components/SubscriptAvatar'; import reportPropTypes from '../../reportPropTypes'; +import * as UserUtils from '../../../libs/UserUtils'; const propTypes = { /** All the data of the action */ @@ -45,6 +46,9 @@ const propTypes = { /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */ shouldShowSubscriptAvatar: PropTypes.bool, + /** If the message has been flagged for moderation */ + hasBeenFlagged: PropTypes.bool, + ...withLocalizePropTypes, }; @@ -53,6 +57,7 @@ const defaultProps = { wrapperStyles: [styles.chatItem], showHeader: true, shouldShowSubscriptAvatar: false, + hasBeenFlagged: false, report: undefined, }; @@ -63,7 +68,7 @@ const showUserDetails = (email) => { const ReportActionItemSingle = (props) => { const actorEmail = props.action.actorEmail.replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const {avatar, displayName, pendingFields} = props.personalDetails[actorEmail] || {}; - const avatarSource = ReportUtils.getAvatar(avatar, actorEmail); + const avatarSource = UserUtils.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, @@ -96,10 +101,12 @@ const ReportActionItemSingle = (props) => { /> ) : ( - + + + )} @@ -127,7 +134,7 @@ const ReportActionItemSingle = (props) => { ) : null} - {props.children} + {props.children} ); diff --git a/src/pages/home/report/ReportActionItemThread.js b/src/pages/home/report/ReportActionItemThread.js index 63dfce693ef3..41cbf8c96d89 100644 --- a/src/pages/home/report/ReportActionItemThread.js +++ b/src/pages/home/report/ReportActionItemThread.js @@ -1,7 +1,6 @@ import React from 'react'; import {View, Pressable, Text} from 'react-native'; import PropTypes from 'prop-types'; -import _ from 'underscore'; import styles from '../../../styles/styles'; import * as Report from '../../../libs/actions/Report'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; @@ -49,7 +48,6 @@ const ReportActionItemThread = (props) => { size={CONST.AVATAR_SIZE.SMALL} icons={props.icons} shouldStackHorizontally - avatarTooltips={_.map(props.icons, (icon) => icon.name)} isHovered={props.isHovered} isInReportAction /> diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index 1a3b44c3f806..b10134b2ceb7 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -115,10 +115,14 @@ const ReportActionsList = (props) => { // When the new indicator should not be displayed we explicitly set it to null const shouldDisplayNewMarker = reportAction.reportActionID === newMarkerReportActionID; const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && ReportUtils.isThread(report); + const shouldHideThreadDividerLine = + shouldDisplayParentAction && sortedReportActions.length > 1 && sortedReportActions[sortedReportActions.length - 2].reportActionID === newMarkerReportActionID; return shouldDisplayParentAction ? ( ) : ( @@ -80,15 +83,19 @@ class ReportFooter extends React.Component { {!hideComposer && (this.props.shouldShowComposeInput || !this.props.isSmallScreenWidth) && ( - + {Session.isAnonymousUser() ? ( + + ) : ( + + )} )} diff --git a/src/pages/home/report/withReportOrNotFound.js b/src/pages/home/report/withReportOrNotFound.js index 204965e25bd8..ec561db20c81 100644 --- a/src/pages/home/report/withReportOrNotFound.js +++ b/src/pages/home/report/withReportOrNotFound.js @@ -6,6 +6,7 @@ import getComponentDisplayName from '../../../libs/getComponentDisplayName'; import NotFoundPage from '../../ErrorPage/NotFoundPage'; import ONYXKEYS from '../../../ONYXKEYS'; import reportPropTypes from '../../reportPropTypes'; +import FullscreenLoadingIndicator from '../../../components/FullscreenLoadingIndicator'; export default function (WrappedComponent) { const propTypes = { @@ -15,15 +16,22 @@ export default function (WrappedComponent) { /** The report currently being looked at */ report: reportPropTypes, + + /** Indicated whether the report data is loading */ + isLoadingReportData: PropTypes.bool, }; const defaultProps = { forwardedRef: () => {}, report: {}, + isLoadingReportData: true, }; class WithReportOrNotFound extends Component { render() { + if (this.props.isLoadingReportData && (_.isEmpty(this.props.report) || !this.props.report.reportID)) { + return ; + } if (_.isEmpty(this.props.report) || !this.props.report.reportID) { return ; } @@ -56,5 +64,8 @@ export default function (WrappedComponent) { report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, }, + isLoadingReportData: { + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + }, })(withReportOrNotFound); } diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index d48ca795a3e8..93f72968b2eb 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -21,7 +21,6 @@ import CONST from '../../../CONST'; import participantPropTypes from '../../../components/participantPropTypes'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import * as App from '../../../libs/actions/App'; -import * as ReportUtils from '../../../libs/ReportUtils'; import withCurrentUserPersonalDetails from '../../../components/withCurrentUserPersonalDetails'; import withWindowDimensions from '../../../components/withWindowDimensions'; import reportActionPropTypes from '../report/reportActionPropTypes'; @@ -34,6 +33,9 @@ import defaultTheme from '../../../styles/themes/default'; import OptionsListSkeletonView from '../../../components/OptionsListSkeletonView'; import variables from '../../../styles/variables'; import LogoComponent from '../../../../assets/images/expensify-wordmark.svg'; +import * as Session from '../../../libs/actions/Session'; +import Button from '../../../components/Button'; +import * as UserUtils from '../../../libs/UserUtils'; const propTypes = { /** Toggles the navigation menu open and closed */ @@ -107,6 +109,7 @@ class SidebarLinks extends React.Component { // Prevent opening Search page when click Search icon quickly after clicking FAB icon return; } + Navigation.navigate(ROUTES.SEARCH); } @@ -115,6 +118,7 @@ class SidebarLinks extends React.Component { // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon return; } + Navigation.navigate(ROUTES.SETTINGS); } @@ -166,7 +170,7 @@ class SidebarLinks extends React.Component { accessibilityLabel={this.props.translate('sidebarScreen.buttonSearch')} accessibilityRole="button" style={[styles.flexRow, styles.ph5]} - onPress={this.showSearchPage} + onPress={Session.checkIfActionIsAllowed(this.showSearchPage)} > @@ -174,14 +178,25 @@ class SidebarLinks extends React.Component { - - - + {Session.isAnonymousUser() ? ( + + + ) : ( + + + + )} { - if (ReportUtils.isIOUReport(report)) { - return null; - } - return ( - report && { - reportID: report.reportID, - participants: report.participants, - hasDraft: report.hasDraft, - isPinned: report.isPinned, - errorFields: { - addWorkspaceRoom: report.errorFields && report.errorFields.addWorkspaceRoom, - }, - lastReadTime: report.lastReadTime, - lastMentionedTime: report.lastMentionedTime, - lastMessageText: report.lastMessageText, - lastVisibleActionCreated: report.lastVisibleActionCreated, - iouReportID: report.iouReportID, - hasOutstandingIOU: report.hasOutstandingIOU, - statusNum: report.statusNum, - stateNum: report.stateNum, - chatType: report.chatType, - policyID: report.policyID, - reportName: report.reportName, - } - ); -}; +const chatReportSelector = (report) => + report && { + reportID: report.reportID, + participants: report.participants, + hasDraft: report.hasDraft, + isPinned: report.isPinned, + errorFields: { + addWorkspaceRoom: report.errorFields && report.errorFields.addWorkspaceRoom, + }, + lastReadTime: report.lastReadTime, + lastMentionedTime: report.lastMentionedTime, + lastMessageText: report.lastMessageText, + lastVisibleActionCreated: report.lastVisibleActionCreated, + iouReportID: report.iouReportID, + hasOutstandingIOU: report.hasOutstandingIOU, + statusNum: report.statusNum, + stateNum: report.stateNum, + chatType: report.chatType, + policyID: report.policyID, + reportName: report.reportName, + }; /** * @param {Object} [personalDetails] @@ -262,7 +271,7 @@ const personalDetailsSelector = (personalDetails) => login: personalData.login, displayName: personalData.displayName, firstName: personalData.firstName, - avatar: ReportUtils.getAvatar(personalData.avatar, personalData.login), + avatar: UserUtils.getAvatar(personalData.avatar, personalData.login), }; return finalPersonalDetails; }, diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 6c0dd2cf6cd5..10b40b26034d 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -22,6 +22,7 @@ import * as Welcome from '../../../../libs/actions/Welcome'; import withNavigationFocus from '../../../../components/withNavigationFocus'; import withDrawerState from '../../../../components/withDrawerState'; import * as TaskUtils from '../../../../libs/actions/Task'; +import * as Session from '../../../../libs/actions/Session'; /** * @param {Object} [policy] @@ -34,6 +35,8 @@ const policySelector = (policy) => }; const propTypes = { + ...withLocalizePropTypes, + /* Callback function when the menu is shown */ onShowCreateMenu: PropTypes.func, @@ -51,8 +54,6 @@ const propTypes = { /** Indicated whether the report data is loading */ isLoading: PropTypes.bool, - - ...withLocalizePropTypes, }; const defaultProps = { onHideCreateMenu: () => {}, @@ -72,9 +73,11 @@ class FloatingActionButtonAndPopover extends React.Component { this.showCreateMenu = this.showCreateMenu.bind(this); this.hideCreateMenu = this.hideCreateMenu.bind(this); + this.interceptAnonymousUser = this.interceptAnonymousUser.bind(this); this.state = { isCreateMenuActive: false, + isAnonymousUser: Session.isAnonymousUser(), }; } @@ -162,6 +165,20 @@ class FloatingActionButtonAndPopover extends React.Component { }); } + /** + * Checks if user is anonymous. If true, shows the sign in modal, else, + * executes the callback. + * + * @param {Function} callback + */ + interceptAnonymousUser(callback) { + if (this.state.isAnonymousUser) { + Session.signOutAndRedirectToSignIn(); + } else { + callback(); + } + } + render() { // Workspaces are policies with type === 'free' const workspaces = _.filter(this.props.allPolicies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE); @@ -178,19 +195,19 @@ class FloatingActionButtonAndPopover extends React.Component { { icon: Expensicons.ChatBubble, text: this.props.translate('sidebarScreen.newChat'), - onSelected: () => Navigation.navigate(ROUTES.NEW_CHAT), + onSelected: () => this.interceptAnonymousUser(() => Navigation.navigate(ROUTES.NEW_CHAT)), }, { icon: Expensicons.Users, text: this.props.translate('sidebarScreen.newGroup'), - onSelected: () => Navigation.navigate(ROUTES.NEW_GROUP), + onSelected: () => this.interceptAnonymousUser(() => Navigation.navigate(ROUTES.NEW_GROUP)), }, ...(Permissions.canUsePolicyRooms(this.props.betas) && workspaces.length ? [ { icon: Expensicons.Hashtag, text: this.props.translate('sidebarScreen.newRoom'), - onSelected: () => Navigation.navigate(ROUTES.WORKSPACE_NEW_ROOM), + onSelected: () => this.interceptAnonymousUser(() => Navigation.navigate(ROUTES.WORKSPACE_NEW_ROOM)), }, ] : []), @@ -199,7 +216,7 @@ class FloatingActionButtonAndPopover extends React.Component { { icon: Expensicons.Send, text: this.props.translate('iou.sendMoney'), - onSelected: () => Navigation.navigate(ROUTES.IOU_SEND), + onSelected: () => this.interceptAnonymousUser(() => Navigation.navigate(ROUTES.IOU_SEND)), }, ] : []), @@ -208,7 +225,7 @@ class FloatingActionButtonAndPopover extends React.Component { { icon: Expensicons.MoneyCircle, text: this.props.translate('iou.requestMoney'), - onSelected: () => Navigation.navigate(ROUTES.IOU_REQUEST), + onSelected: () => this.interceptAnonymousUser(() => Navigation.navigate(ROUTES.IOU_REQUEST)), }, ] : []), @@ -217,7 +234,7 @@ class FloatingActionButtonAndPopover extends React.Component { { icon: Expensicons.Receipt, text: this.props.translate('iou.splitBill'), - onSelected: () => Navigation.navigate(ROUTES.IOU_BILL), + onSelected: () => this.interceptAnonymousUser(() => Navigation.navigate(ROUTES.IOU_BILL)), }, ] : []), @@ -226,7 +243,7 @@ class FloatingActionButtonAndPopover extends React.Component { { icon: Expensicons.Task, text: this.props.translate('newTaskPage.assignTask'), - onSelected: () => TaskUtils.clearOutTaskInfoAndNavigate(), + onSelected: () => this.interceptAnonymousUser(() => TaskUtils.clearOutTaskInfoAndNavigate()), }, ] : []), @@ -238,7 +255,7 @@ class FloatingActionButtonAndPopover extends React.Component { iconHeight: 40, text: this.props.translate('workspace.new.newWorkspace'), description: this.props.translate('workspace.new.getTheExpensifyCardAndMore'), - onSelected: () => Policy.createWorkspace(), + onSelected: () => this.interceptAnonymousUser(() => Policy.createWorkspace()), }, ] : []), diff --git a/src/pages/iou/IOUDetailsModal.js b/src/pages/iou/IOUDetailsModal.js deleted file mode 100644 index 84de2a089242..000000000000 --- a/src/pages/iou/IOUDetailsModal.js +++ /dev/null @@ -1,222 +0,0 @@ -import React, {Component} from 'react'; -import {View, ScrollView} from 'react-native'; -import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; -import lodashGet from 'lodash/get'; -import _ from 'underscore'; -import styles from '../../styles/styles'; -import ONYXKEYS from '../../ONYXKEYS'; -import {withNetwork} from '../../components/OnyxProvider'; -import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; -import Navigation from '../../libs/Navigation/Navigation'; -import ScreenWrapper from '../../components/ScreenWrapper'; -import * as Report from '../../libs/actions/Report'; -import IOUPreview from '../../components/ReportActionItem/IOUPreview'; -import IOUTransactions from './IOUTransactions'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import compose from '../../libs/compose'; -import CONST from '../../CONST'; -import SettlementButton from '../../components/SettlementButton'; -import ROUTES from '../../ROUTES'; -import FixedFooter from '../../components/FixedFooter'; -import networkPropTypes from '../../components/networkPropTypes'; -import reportActionPropTypes from '../home/report/reportActionPropTypes'; -import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; -import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; -import * as IOU from '../../libs/actions/IOU'; - -const propTypes = { - /** URL Route params */ - route: PropTypes.shape({ - /** Params from the URL path */ - params: PropTypes.shape({ - /** chatReportID passed via route: /iou/details/:chatReportID/:iouReportID */ - chatReportID: PropTypes.string, - - /** iouReportID passed via route: /iou/details/:chatReportID/:iouReportID */ - iouReportID: PropTypes.string, - }), - }).isRequired, - - /* Onyx Props */ - /** Holds data related to IOU view state, rather than the underlying IOU data. */ - iou: PropTypes.shape({ - /** Is the IOU Report currently being loaded? */ - loading: PropTypes.bool, - - /** Error message, empty represents no error */ - error: PropTypes.bool, - }), - - /** IOU Report data object */ - iouReport: PropTypes.shape({ - /** ID for the chatReport that this IOU is linked to */ - chatReportID: PropTypes.string, - - /** Manager is the person who currently owes money */ - managerEmail: PropTypes.string, - - /** Owner is the person who is owed money */ - ownerEmail: PropTypes.string, - - /** Does the iouReport have an outstanding IOU? */ - hasOutstandingIOU: PropTypes.bool, - }), - - /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user email */ - email: PropTypes.string, - }), - - /** Actions from the ChatReport */ - reportActions: PropTypes.shape(reportActionPropTypes), - - /** Information about the network */ - network: networkPropTypes.isRequired, - - /** chatReport associated with iouReport */ - chatReport: PropTypes.shape({ - /** Report ID associated with the transaction */ - reportID: PropTypes.string, - - /** The participants of this report */ - participants: PropTypes.arrayOf(PropTypes.string), - - /** Whether the chat report has an outstanding IOU */ - hasOutstandingIOU: PropTypes.bool.isRequired, - }), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - iou: {}, - reportActions: {}, - iouReport: undefined, - session: { - email: null, - }, - chatReport: { - participants: [], - }, -}; - -class IOUDetailsModal extends Component { - componentDidMount() { - if (this.props.network.isOffline) { - return; - } - - this.fetchData(); - } - - componentDidUpdate(prevProps) { - if (!prevProps.network.isOffline || this.props.network.isOffline) { - return; - } - - this.fetchData(); - } - - fetchData() { - Report.openPaymentDetailsPage(this.props.route.params.chatReportID, this.props.route.params.iouReportID); - } - - // Finds if there is a reportAction pending for this IOU - findPendingAction() { - const reportActionWithPendingAction = _.find( - this.props.reportActions, - (reportAction) => - reportAction.originalMessage && Number(reportAction.originalMessage.IOUReportID) === Number(this.props.route.params.iouReportID) && !_.isEmpty(reportAction.pendingAction), - ); - return reportActionWithPendingAction ? reportActionWithPendingAction.pendingAction : undefined; - } - - render() { - const sessionEmail = lodashGet(this.props.session, 'email', null); - 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 ( - - {({safeAreaPaddingBottomStyle}) => ( - - - {this.props.iou.loading ? ( - - - - ) : ( - - - 1} - isIOUAction={false} - pendingAction={pendingAction} - /> - - - {hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail && ( - - IOU.payMoneyRequest(paymentMethodType, this.props.chatReport, this.props.iouReport)} - 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} - policyID={this.props.iouReport.policyID} - /> - - )} - - )} - - )} - - ); - } -} - -IOUDetailsModal.propTypes = propTypes; -IOUDetailsModal.defaultProps = defaultProps; - -export default compose( - withLocalize, - withNetwork(), - withOnyx({ - iou: { - key: ONYXKEYS.IOU, - }, - chatReport: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.chatReportID}`, - }, - iouReport: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.iouReportID}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, - reportActions: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.chatReportID}`, - canEvict: false, - }, - }), -)(IOUDetailsModal); diff --git a/src/pages/iou/IOUTransactions.js b/src/pages/iou/IOUTransactions.js deleted file mode 100644 index b8a0998e492d..000000000000 --- a/src/pages/iou/IOUTransactions.js +++ /dev/null @@ -1,116 +0,0 @@ -import React, {Component} from 'react'; -import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import PropTypes from 'prop-types'; -import lodashGet from 'lodash/get'; -import styles from '../../styles/styles'; -import ONYXKEYS from '../../ONYXKEYS'; -import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; -import reportActionPropTypes from '../home/report/reportActionPropTypes'; -import ReportTransaction from '../../components/ReportTransaction'; -import CONST from '../../CONST'; - -const propTypes = { - /** Actions from the ChatReport */ - reportActions: PropTypes.shape(reportActionPropTypes), - - /** ReportID for the associated chat report */ - chatReportID: PropTypes.string.isRequired, - - /** ReportID for the associated IOU report */ - iouReportID: PropTypes.string.isRequired, - - /** Email for the authenticated user */ - userEmail: PropTypes.string.isRequired, - - /** Is the associated IOU settled? */ - isIOUSettled: PropTypes.bool, -}; - -const defaultProps = { - reportActions: {}, - isIOUSettled: false, -}; - -class IOUTransactions extends Component { - constructor(props) { - super(props); - - this.getDeletableTransactions = this.getDeletableTransactions.bind(this); - } - - /** - * Builds and returns the deletableTransactionIDs array. A transaction must meet multiple requirements in order - * to be deletable. We must exclude transactions not associated with the iouReportID, actions which have already - * been deleted, and those which are not of type 'create'. - * - * @returns {Array} - */ - getDeletableTransactions() { - if (this.props.isIOUSettled) { - return []; - } - - // iouReportIDs should be strings, but we still have places that send them as ints so we convert them both to Numbers for comparison - const actionsForIOUReport = _.filter( - this.props.reportActions, - (action) => action.originalMessage && action.originalMessage.type && Number(action.originalMessage.IOUReportID) === Number(this.props.iouReportID), - ); - - const deletedTransactionIDs = _.chain(actionsForIOUReport) - .filter((action) => _.contains([CONST.IOU.REPORT_ACTION_TYPE.CANCEL, CONST.IOU.REPORT_ACTION_TYPE.DECLINE, CONST.IOU.REPORT_ACTION_TYPE.DELETE], action.originalMessage.type)) - .map((deletedAction) => lodashGet(deletedAction, 'originalMessage.IOUTransactionID', '')) - .compact() - .value(); - - return _.chain(actionsForIOUReport) - .filter((action) => action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE) - .filter((action) => !_.contains(deletedTransactionIDs, action.originalMessage.IOUTransactionID)) - .filter((action) => this.props.userEmail === action.actorEmail) - .map((action) => lodashGet(action, 'originalMessage.IOUTransactionID', '')) - .compact() - .value(); - } - - render() { - const sortedReportActions = ReportActionsUtils.getSortedReportActionsForDisplay(this.props.reportActions); - return ( - - {_.map(sortedReportActions, (reportAction) => { - // iouReportIDs should be strings, but we still have places that send them as ints so we convert them both to Numbers for comparison - if ( - !reportAction.originalMessage || - reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU || - Number(reportAction.originalMessage.IOUReportID) !== Number(this.props.iouReportID) - ) { - return; - } - - const deletableTransactions = this.getDeletableTransactions(); - const canBeDeleted = _.contains(deletableTransactions, reportAction.originalMessage.IOUTransactionID); - return ( - - ); - })} - - ); - } -} - -IOUTransactions.defaultProps = defaultProps; -IOUTransactions.propTypes = propTypes; -export default withOnyx({ - reportActions: { - key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, - canEvict: false, - }, -})(IOUTransactions); diff --git a/src/pages/iou/ModalHeader.js b/src/pages/iou/ModalHeader.js index dd5b3fdadf54..82b7aff7df83 100644 --- a/src/pages/iou/ModalHeader.js +++ b/src/pages/iou/ModalHeader.js @@ -1,5 +1,5 @@ import React from 'react'; -import {View, TouchableOpacity} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import styles from '../../styles/styles'; import Icon from '../../components/Icon'; @@ -8,6 +8,7 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize import * as Expensicons from '../../components/Icon/Expensicons'; import Tooltip from '../../components/Tooltip'; import Navigation from '../../libs/Navigation/Navigation'; +import PressableWithFeedback from '../../components/Pressable/PressableWithFeedback'; const propTypes = { /** Title of the header */ @@ -32,25 +33,33 @@ const ModalHeader = (props) => ( {props.shouldShowBackButton && ( - - + )}
- Navigation.dismissModal()} style={[styles.touchableButtonImage]} accessibilityRole="button" accessibilityLabel={props.translate('common.close')} + // disable hover dimming + hoverDimmingValue={1} + pressDimmingValue={0.2} > - +
diff --git a/src/pages/iou/MoneyRequestModal.js b/src/pages/iou/MoneyRequestModal.js index 6d8f82ea5b45..efd53b22ce4d 100644 --- a/src/pages/iou/MoneyRequestModal.js +++ b/src/pages/iou/MoneyRequestModal.js @@ -23,7 +23,7 @@ import withCurrentUserPersonalDetails from '../../components/withCurrentUserPers import reportPropTypes from '../reportPropTypes'; import * as ReportUtils from '../../libs/ReportUtils'; import * as ReportScrollManager from '../../libs/ReportScrollManager'; -import useOnNetworkReconnect from '../../components/hooks/useOnNetworkReconnect'; +import useOnNetworkReconnect from '../../hooks/useOnNetworkReconnect'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import * as CurrencyUtils from '../../libs/CurrencyUtils'; @@ -113,11 +113,18 @@ const MoneyRequestModal = (props) => { useEffect(() => { PersonalDetails.openMoneyRequestModalPage(); - IOU.setIOUSelectedCurrency(props.currentUserPersonalDetails.localCurrencyCode); IOU.setMoneyRequestDescription(''); - // eslint-disable-next-line react-hooks/exhaustive-deps -- props.currentUserPersonalDetails will always exist from Onyx and we don't want this effect to run again }, []); + // We update selected currency when PersonalDetails.openMoneyRequestModalPage finishes + // props.currentUserPersonalDetails might be stale data or might not exist if user is signing in + useEffect(() => { + if (_.isUndefined(props.currentUserPersonalDetails.localCurrencyCode)) { + return; + } + IOU.setIOUSelectedCurrency(props.currentUserPersonalDetails.localCurrencyCode); + }, [props.currentUserPersonalDetails.localCurrencyCode]); + // User came back online, so let's refetch the currency details based on location useOnNetworkReconnect(PersonalDetails.openMoneyRequestModalPage); @@ -292,6 +299,7 @@ const MoneyRequestModal = (props) => { ); const amountButtonText = isEditingAmountAfterConfirm ? props.translate('common.save') : props.translate('common.next'); const enableMaxHeight = DeviceCapabilities.canUseTouchScreen() && currentStep === Steps.MoneyRequestParticipants; + const bankAccountRoute = ReportUtils.getBankAccountRoute(props.report); return ( { canModifyParticipants={!_.isEmpty(reportID)} navigateToStep={navigateToStep} policyID={props.report.policyID} + bankAccountRoute={bankAccountRoute} /> )} diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js new file mode 100644 index 000000000000..19178582b6ba --- /dev/null +++ b/src/pages/iou/SplitBillDetailsPage.js @@ -0,0 +1,117 @@ +import React from 'react'; +import _ from 'underscore'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import styles from '../../styles/styles'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as OptionsListUtils from '../../libs/OptionsListUtils'; +import ModalHeader from './ModalHeader'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import MoneyRequestConfirmationList from '../../components/MoneyRequestConfirmationList'; +import personalDetailsPropType from '../personalDetailsPropType'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import reportActionPropTypes from '../home/report/reportActionPropTypes'; +import reportPropTypes from '../reportPropTypes'; +import withReportOrNotFound from '../home/report/withReportOrNotFound'; +import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; +import CONST from '../../CONST'; + +const propTypes = { + /* Onyx Props */ + + /** The personal details of the person who is logged in */ + personalDetails: personalDetailsPropType, + + /** The active report */ + report: reportPropTypes.isRequired, + + /** Array of report actions for this report */ + reportActions: PropTypes.shape(reportActionPropTypes), + + /** Route params */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** Report ID passed via route r/:reportID/split/details */ + reportID: PropTypes.string, + + /** ReportActionID passed via route r/split/:reportActionID */ + reportActionID: PropTypes.string, + }), + }).isRequired, + + ...withLocalizePropTypes, +}; + +const defaultProps = { + personalDetails: {}, + reportActions: {}, +}; + +/** + * Get the reportID for the associated chatReport + * + * @param {Object} route + * @param {Object} route.params + * @param {String} route.params.reportID + * @returns {String} + */ +function getReportID(route) { + return route.params.reportID.toString(); +} + +const SplitBillDetailsPage = (props) => { + const reportAction = props.reportActions[`${props.route.params.reportActionID.toString()}`]; + const personalDetails = OptionsListUtils.getPersonalDetailsForLogins(reportAction.originalMessage.participants, props.personalDetails); + const participants = OptionsListUtils.getParticipantsOptions(reportAction.originalMessage, personalDetails); + const payeePersonalDetails = _.filter(participants, (participant) => participant.login === reportAction.actorEmail)[0]; + const participantsExcludingPayee = _.filter(participants, (participant) => participant.login !== reportAction.actorEmail); + const splitAmount = parseInt(lodashGet(reportAction, 'originalMessage.amount', 0), 10); + + return ( + + + + + {Boolean(participants.length) && ( + + )} + + + + ); +}; + +SplitBillDetailsPage.propTypes = propTypes; +SplitBillDetailsPage.defaultProps = defaultProps; +SplitBillDetailsPage.displayName = 'SplitBillDetailsPage'; + +export default compose( + withLocalize, + withReportOrNotFound, + withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS, + }, + reportActions: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, + canEvict: false, + }, + }), +)(SplitBillDetailsPage); diff --git a/src/pages/iou/steps/MoneyRequestAmountPage.js b/src/pages/iou/steps/MoneyRequestAmountPage.js index 3732e638dfe0..39495c6a5f2c 100755 --- a/src/pages/iou/steps/MoneyRequestAmountPage.js +++ b/src/pages/iou/steps/MoneyRequestAmountPage.js @@ -44,9 +44,7 @@ const propTypes = { }; const defaultProps = { - iou: { - selectedCurrencyCode: CONST.CURRENCY.USD, - }, + iou: {}, }; class MoneyRequestAmountPage extends React.Component { constructor(props) { @@ -66,7 +64,7 @@ class MoneyRequestAmountPage extends React.Component { const selectedAmountAsString = props.selectedAmount ? props.selectedAmount.toString() : ''; this.state = { amount: selectedAmountAsString, - selectedCurrencyCode: props.iou.selectedCurrencyCode, + selectedCurrencyCode: _.isUndefined(props.iou.selectedCurrencyCode) ? CONST.CURRENCY.USD : props.iou.selectedCurrencyCode, shouldUpdateSelection: true, selection: { start: selectedAmountAsString.length, @@ -85,6 +83,14 @@ class MoneyRequestAmountPage extends React.Component { }); } + componentDidUpdate(prevProps) { + if (prevProps.iou.selectedCurrencyCode === this.props.iou.selectedCurrencyCode) { + return; + } + + this.setState({selectedCurrencyCode: this.props.iou.selectedCurrencyCode}); + } + componentWillUnmount() { this.unsubscribeNavFocus(); } @@ -232,7 +238,7 @@ class MoneyRequestAmountPage extends React.Component { * @param {String} key */ updateAmountNumberPad(key) { - if (!this.textInput.isFocused()) { + if (this.state.shouldUpdateSelection && !this.textInput.isFocused()) { this.textInput.focus(); } @@ -261,6 +267,9 @@ class MoneyRequestAmountPage extends React.Component { */ updateLongPressHandlerState(value) { this.setState({shouldUpdateSelection: !value}); + if (!value && !this.textInput.isFocused()) { + this.textInput.focus(); + } } /** diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index 025d22b33b37..e86edadf5223 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -30,12 +30,16 @@ const propTypes = { navigateToStep: PropTypes.func.isRequired, /** The policyID of the request */ - policyID: PropTypes.string.isRequired, + policyID: PropTypes.string, + + /** Depending on expense report or personal IOU report, respective bank account route */ + bankAccountRoute: PropTypes.string.isRequired, }; const defaultProps = { iouType: CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, canModifyParticipants: false, + policyID: '', }; const MoneyRequestConfirmPage = (props) => ( @@ -49,10 +53,11 @@ const MoneyRequestConfirmPage = (props) => ( canModifyParticipants={props.canModifyParticipants} navigateToStep={props.navigateToStep} policyID={props.policyID} + bankAccountRoute={props.bankAccountRoute} /> ); -MoneyRequestConfirmPage.displayName = 'IOUConfirmPage'; +MoneyRequestConfirmPage.displayName = 'MoneyRequestConfirmPage'; MoneyRequestConfirmPage.propTypes = propTypes; MoneyRequestConfirmPage.defaultProps = defaultProps; diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 74a7c7961a9d..90613ef78b86 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -82,8 +82,7 @@ class MoneyRequestParticipantsSelector extends Component { CONST.EXPENSIFY_EMAILS, // If we are using this component in the "Request money" flow then we pass the includeOwnedWorkspaceChats argument so that the current user - // sees the option to request money from their admin on their own Workspace Chat. These will always be shown in the "Recents" section of the selector - // along with any other recent chats. + // sees the option to request money from their admin on their own Workspace Chat. this.props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, ); } diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js index 671d4e74fbfc..28bdc2365622 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js @@ -11,7 +11,6 @@ import * as ReportUtils from '../../../../libs/ReportUtils'; import CONST from '../../../../CONST'; import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import compose from '../../../../libs/compose'; -import Text from '../../../../components/Text'; import personalDetailsPropType from '../../../personalDetailsPropType'; import reportPropTypes from '../../../reportPropTypes'; import avatarPropTypes from '../../../../components/avatarPropTypes'; @@ -214,7 +213,6 @@ class MoneyRequestParticipantsSplitSelector extends Component { return ( 0 ? this.props.safeAreaPaddingBottomStyle : {}]}> - {this.props.translate('common.to')} - - + + - - - + + )} diff --git a/src/pages/settings/Payments/TransferBalancePage.js b/src/pages/settings/Payments/TransferBalancePage.js index dc6bae210bea..eafa2de82b31 100644 --- a/src/pages/settings/Payments/TransferBalancePage.js +++ b/src/pages/settings/Payments/TransferBalancePage.js @@ -26,6 +26,7 @@ import FormAlertWithSubmitButton from '../../../components/FormAlertWithSubmitBu import {withNetwork} from '../../../components/OnyxProvider'; import ConfirmationPage from '../../../components/ConfirmationPage'; import * as CurrencyUtils from '../../../libs/CurrencyUtils'; +import FullPageNotFoundView from '../../../components/BlockingViews/FullPageNotFoundView'; const propTypes = { /** User's wallet information */ @@ -166,70 +167,82 @@ class TransferBalancePage extends React.Component { const isButtonDisabled = !isTransferable || !selectedAccount; const errorMessage = !_.isEmpty(this.props.walletTransfer.errors) ? _.chain(this.props.walletTransfer.errors).values().first().value() : ''; + const shouldShowTransferView = + PaymentUtils.hasExpensifyPaymentMethod(this.props.cardList, this.props.bankAccountList) && this.props.userWallet.tierName === CONST.WALLET.TIER_NAME.GOLD; + return ( - Navigation.goBack()} - onCloseButtonPress={() => Navigation.dismissModal(true)} - /> - - - - Navigation.navigate(ROUTES.SETTINGS_PAYMENTS)} > - - {_.map(this.paymentTypes, (paymentType) => ( + Navigation.goBack()} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + + + + + + {_.map(this.paymentTypes, (paymentType) => ( + this.navigateToChooseTransferAccount(paymentType.type)} + /> + ))} + + {this.props.translate('transferAmountPage.whichAccount')} + {Boolean(selectedAccount) && ( this.navigateToChooseTransferAccount(paymentType.type)} + title={selectedAccount.title} + description={selectedAccount.description} + shouldShowRightIcon + iconWidth={selectedAccount.iconSize} + iconHeight={selectedAccount.iconSize} + icon={selectedAccount.icon} + onPress={() => this.navigateToChooseTransferAccount(selectedAccount.accountType)} /> - ))} - - {this.props.translate('transferAmountPage.whichAccount')} - {Boolean(selectedAccount) && ( - this.navigateToChooseTransferAccount(selectedAccount.accountType)} + )} + + {this.props.translate('transferAmountPage.fee')} + {CurrencyUtils.convertToDisplayString(calculatedFee)} + + + + PaymentMethods.transferWalletBalance(selectedAccount)} + isDisabled={isButtonDisabled || this.props.network.isOffline} + message={errorMessage} + isAlertVisible={!_.isEmpty(errorMessage)} /> - )} - - {this.props.translate('transferAmountPage.fee')} - {CurrencyUtils.convertToDisplayString(calculatedFee)} - - - PaymentMethods.transferWalletBalance(selectedAccount)} - isDisabled={isButtonDisabled || this.props.network.isOffline} - message={errorMessage} - isAlertVisible={!_.isEmpty(errorMessage)} - /> - + ); } diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js index 221f4a0ff549..eae718349bd0 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js @@ -103,7 +103,7 @@ function NewContactMethodPage(props) { const addNewContactMethod = (values) => { const phoneLogin = getPhoneLogin(values.phoneOrEmail); const validateIfnumber = validateNumber(phoneLogin); - const submitDetail = (validateIfnumber || values.phoneOrEmail).trim(); + const submitDetail = (validateIfnumber || values.phoneOrEmail).trim().toLowerCase(); User.addNewContactMethodAndNavigate(submitDetail, values.password); }; diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js index 0dc19aa7d4e3..53049a73d959 100644 --- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js @@ -135,7 +135,7 @@ function BaseValidateCodeForm(props) { errorRowStyles={[styles.mt2]} onClose={() => User.clearContactMethodErrors(props.contactMethod, 'validateCodeSent')} > - + { onClose={PersonalDetails.clearAvatarErrors} > policy && policy.id === this.props.report.policyID); const shouldDisableRename = this.shouldDisableRename(linkedWorkspace) || ReportUtils.isThread(this.props.report); const notificationPreference = this.props.translate(`notificationPreferencesPage.notificationPreferences.${this.props.report.notificationPreference}`); + const shouldDisableWelcomeMessage = this.shouldDisableRename(linkedWorkspace); + const writeCapability = this.props.report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL; + const writeCapabilityText = this.props.translate(`writeCapabilityPage.writeCapability.${writeCapability}`); + const shouldAllowWriteCapabilityEditing = lodashGet(linkedWorkspace, 'role', '') === CONST.POLICY.ROLE.ADMIN; return ( @@ -131,6 +147,29 @@ class ReportSettingsPage extends Component { )} )} + {shouldAllowWriteCapabilityEditing ? ( + Navigation.navigate(ROUTES.getReportSettingsWriteCapabilityRoute(this.props.report.reportID))} + /> + ) : ( + + + {this.props.translate('writeCapabilityPage.label')} + + + {writeCapabilityText} + + + )} {Boolean(linkedWorkspace) && ( @@ -170,6 +209,14 @@ class ReportSettingsPage extends Component { )} + {!shouldDisableWelcomeMessage && ( + Navigation.navigate(ROUTES.getReportWelcomeMessageRoute(this.props.report.reportID))} + shouldShowRightIcon + /> + )} ); diff --git a/src/pages/settings/Report/WriteCapabilityPage.js b/src/pages/settings/Report/WriteCapabilityPage.js new file mode 100644 index 000000000000..40246f29aadc --- /dev/null +++ b/src/pages/settings/Report/WriteCapabilityPage.js @@ -0,0 +1,67 @@ +import React from 'react'; +import _ from 'underscore'; +import CONST from '../../../CONST'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import styles from '../../../styles/styles'; +import OptionsList from '../../../components/OptionsList'; +import Navigation from '../../../libs/Navigation/Navigation'; +import compose from '../../../libs/compose'; +import withReportOrNotFound from '../../home/report/withReportOrNotFound'; +import reportPropTypes from '../../reportPropTypes'; +import ROUTES from '../../../ROUTES'; +import * as Report from '../../../libs/actions/Report'; +import * as Expensicons from '../../../components/Icon/Expensicons'; +import themeColors from '../../../styles/themes/default'; + +const propTypes = { + ...withLocalizePropTypes, + + /** The report for which we are setting write capability */ + report: reportPropTypes.isRequired, +}; +const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; + +const WriteCapabilityPage = (props) => { + const writeCapabilityOptions = _.map(CONST.REPORT.WRITE_CAPABILITIES, (value) => ({ + value, + text: props.translate(`writeCapabilityPage.writeCapability.${value}`), + keyForList: value, + + // Include the green checkmark icon to indicate the currently selected value + customIcon: value === (props.report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL) ? greenCheckmark : null, + + // This property will make the currently selected value have bold text + boldStyle: value === (props.report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL), + })); + + return ( + + Navigation.navigate(ROUTES.getReportSettingsRoute(props.report.reportID))} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + Report.updateWriteCapabilityAndNavigate(props.report, option.value)} + hideSectionHeaders + optionHoveredStyle={{ + ...styles.hoveredComponentBG, + ...styles.mhn5, + ...styles.ph5, + }} + shouldHaveOptionSeparator + shouldDisableRowInnerPadding + contentContainerStyles={[styles.ph5]} + /> + + ); +}; + +WriteCapabilityPage.displayName = 'WriteCapabilityPage'; +WriteCapabilityPage.propTypes = propTypes; + +export default compose(withLocalize, withReportOrNotFound)(WriteCapabilityPage); diff --git a/src/pages/signin/ChangeExpensifyLoginLink.js b/src/pages/signin/ChangeExpensifyLoginLink.js index 4e033d947710..0ea56a176af2 100755 --- a/src/pages/signin/ChangeExpensifyLoginLink.js +++ b/src/pages/signin/ChangeExpensifyLoginLink.js @@ -1,5 +1,5 @@ import React from 'react'; -import {TouchableOpacity, View} from 'react-native'; +import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import PropTypes from 'prop-types'; @@ -8,6 +8,7 @@ import styles from '../../styles/styles'; import ONYXKEYS from '../../ONYXKEYS'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import compose from '../../libs/compose'; +import PressableWithFeedback from '../../components/Pressable/PressableWithFeedback'; const propTypes = { /** The credentials of the logged in person */ @@ -31,15 +32,17 @@ const defaultProps = { const ChangeExpensifyLoginLink = (props) => ( {!_.isEmpty(props.credentials.login) && {props.translate('loginForm.notYou', {user: props.formatPhoneNumber(props.credentials.login)})}} - {props.translate('common.goBack')} {'.'} - + ); diff --git a/src/pages/signin/LoginForm.js b/src/pages/signin/LoginForm.js index a544e391d9b9..154efb3b56e0 100755 --- a/src/pages/signin/LoginForm.js +++ b/src/pages/signin/LoginForm.js @@ -24,6 +24,7 @@ import * as ErrorUtils from '../../libs/ErrorUtils'; import DotIndicatorMessage from '../../components/DotIndicatorMessage'; import * as CloseAccount from '../../libs/actions/CloseAccount'; import CONST from '../../CONST'; +import isInputAutoFilled from '../../libs/isInputAutoFilled'; const propTypes = { /** Should we dismiss the keyboard when transitioning away from the page? */ @@ -108,12 +109,12 @@ class LoginForm extends React.Component { formError: null, }); - if (this.props.account.errors) { + if (this.props.account.errors || this.props.account.message) { Session.clearAccountMessages(); } // Clear the "Account successfully closed" message when the user starts typing - if (this.props.closeAccount.success) { + if (this.props.closeAccount.success && !isInputAutoFilled(this.input)) { CloseAccount.setDefaultData(); } } @@ -207,7 +208,7 @@ class LoginForm extends React.Component { - {this.props.translate('passwordForm.forgot')} - + @@ -226,7 +230,10 @@ class PasswordForm extends React.Component { success style={[styles.mv3]} text={this.props.translate('common.signIn')} - isLoading={this.props.account.isLoading} + isLoading={ + this.props.account.isLoading && + this.props.account.loadingForm === (this.props.account.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM) + } onPress={this.validateAndSubmitForm} /> diff --git a/src/pages/signin/ResendValidationForm.js b/src/pages/signin/ResendValidationForm.js index b732f35f3a08..c17e19099b7f 100755 --- a/src/pages/signin/ResendValidationForm.js +++ b/src/pages/signin/ResendValidationForm.js @@ -1,6 +1,6 @@ import React from 'react'; import _ from 'underscore'; -import {TouchableOpacity, View} from 'react-native'; +import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import Str from 'expensify-common/lib/str'; @@ -13,10 +13,12 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize import compose from '../../libs/compose'; import redirectToSignIn from '../../libs/actions/SignInRedirect'; import Avatar from '../../components/Avatar'; -import * as ReportUtils from '../../libs/ReportUtils'; +import * as UserUtils from '../../libs/UserUtils'; import networkPropTypes from '../../components/networkPropTypes'; import {withNetwork} from '../../components/OnyxProvider'; import DotIndicatorMessage from '../../components/DotIndicatorMessage'; +import PressableWithFeedback from '../../components/Pressable/PressableWithFeedback'; +import CONST from '../../CONST'; const propTypes = { /* Onyx Props */ @@ -59,7 +61,7 @@ const ResendValidationForm = (props) => { <> @@ -90,14 +92,21 @@ const ResendValidationForm = (props) => { /> )} - redirectToSignIn()}> + redirectToSignIn()} + accessibilityRole="button" + accessibilityLabel={props.translate('common.back')} + // disable hover dim for switch + hoverDimmingValue={1} + pressDimmingValue={0.2} + > {props.translate('common.back')} - + ); } } @@ -171,6 +176,7 @@ SignInPage.propTypes = propTypes; SignInPage.defaultProps = defaultProps; export default compose( + withSafeAreaInsets, withLocalize, withWindowDimensions, withOnyx({ diff --git a/src/pages/signin/SignInPageLayout/Footer.js b/src/pages/signin/SignInPageLayout/Footer.js index b17e0faee990..fb58f5292dd6 100644 --- a/src/pages/signin/SignInPageLayout/Footer.js +++ b/src/pages/signin/SignInPageLayout/Footer.js @@ -178,13 +178,15 @@ const Footer = (props) => { {_.map(column.rows, (row) => ( {(hovered) => ( - - {props.translate(row.translationPath)} - + + + {props.translate(row.translationPath)} + + )} ))} diff --git a/src/pages/signin/SignInPageLayout/SignInPageContent.js b/src/pages/signin/SignInPageLayout/SignInPageContent.js index 3f1dea8997d9..a1fc645fb19d 100755 --- a/src/pages/signin/SignInPageLayout/SignInPageContent.js +++ b/src/pages/signin/SignInPageLayout/SignInPageContent.js @@ -56,6 +56,7 @@ const SignInPageContent = (props) => ( ( {_.map(socialsList, (social) => ( {(hovered) => ( - - - + + + + + )} ))} diff --git a/src/pages/signin/UnlinkLoginForm.js b/src/pages/signin/UnlinkLoginForm.js index 31daae92be13..6066f5ef32fa 100644 --- a/src/pages/signin/UnlinkLoginForm.js +++ b/src/pages/signin/UnlinkLoginForm.js @@ -15,6 +15,7 @@ import redirectToSignIn from '../../libs/actions/SignInRedirect'; import networkPropTypes from '../../components/networkPropTypes'; import {withNetwork} from '../../components/OnyxProvider'; import DotIndicatorMessage from '../../components/DotIndicatorMessage'; +import CONST from '../../CONST'; const propTypes = { /* Onyx Props */ @@ -83,7 +84,7 @@ const UnlinkLoginForm = (props) => { medium success text={props.translate('unlinkLoginForm.unlink')} - isLoading={props.account.isLoading} + isLoading={props.account.isLoading && props.account.loadingForm === CONST.FORMS.UNLINK_LOGIN_FORM} onPress={() => Session.requestUnlinkValidationLink()} isDisabled={props.network.isOffline || !_.isEmpty(props.account.message)} /> diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 25b7deaf0b03..ab48b2bce207 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -1,5 +1,5 @@ import React from 'react'; -import {TouchableOpacity, View} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -24,6 +24,7 @@ import * as User from '../../../libs/actions/User'; import FormHelpMessage from '../../../components/FormHelpMessage'; import MagicCodeInput from '../../../components/MagicCodeInput'; import Terms from '../Terms'; +import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; const propTypes = { /* Onyx Props */ @@ -158,6 +159,10 @@ class BaseValidateCodeForm extends React.Component { const requiresTwoFactorAuth = this.props.account.requiresTwoFactorAuth; if (requiresTwoFactorAuth) { + if (this.input2FA) { + this.input2FA.blur(); + } + if (!this.state.twoFactorAuthCode.trim()) { this.setState({formError: {twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'}}); return; @@ -168,6 +173,10 @@ class BaseValidateCodeForm extends React.Component { return; } } else { + if (this.inputValidateCode) { + this.inputValidateCode.blur(); + } + if (!this.state.validateCode.trim()) { this.setState({formError: {validateCode: 'validateCodeForm.error.pleaseFillMagicCode'}}); return; @@ -184,7 +193,7 @@ class BaseValidateCodeForm extends React.Component { const accountID = lodashGet(this.props, 'credentials.accountID'); if (accountID) { - Session.signInWithValidateCode(accountID, this.state.validateCode, this.state.twoFactorAuthCode); + Session.signInWithValidateCode(accountID, this.state.validateCode, this.props.preferredLocale, this.state.twoFactorAuthCode); } else { Session.signIn('', this.state.validateCode, this.state.twoFactorAuthCode, this.props.preferredLocale); } @@ -229,13 +238,17 @@ class BaseValidateCodeForm extends React.Component { {this.state.linkSent ? ( {this.props.account.message ? this.props.translate(this.props.account.message) : ''} ) : ( - {this.props.translate('validateCodeForm.magicCodeNotReceived')} - + )} @@ -248,7 +261,10 @@ class BaseValidateCodeForm extends React.Component { success style={[styles.mv3]} text={this.props.translate('common.signIn')} - isLoading={this.props.account.isLoading} + isLoading={ + this.props.account.isLoading && + this.props.account.loadingForm === (this.props.account.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM) + } onPress={this.validateAndSubmitForm} /> diff --git a/src/pages/tasks/NewTaskDetailsPage.js b/src/pages/tasks/NewTaskDetailsPage.js index ea7ecf7cc312..6d69f4a0076d 100644 --- a/src/pages/tasks/NewTaskDetailsPage.js +++ b/src/pages/tasks/NewTaskDetailsPage.js @@ -85,6 +85,7 @@ const NewTaskPage = (props) => { { // If we have an assignee, we want to set the assignee data // If there's an issue with the assignee chosen, we want to notify the user if (props.task.assignee) { - const assigneeDetails = lodashGet(props.personalDetails, props.task.assignee); + const assigneeDetails = lodashGet(OptionsListUtils.getPersonalDetailsForLogins([props.task.assignee], props.personalDetails), props.task.assignee); if (!assigneeDetails) { setSubmitError(true); return setErrorMessage(props.translate('newTaskPage.assigneeError')); diff --git a/src/pages/tasks/TaskAssigneeSelectorModal.js b/src/pages/tasks/TaskAssigneeSelectorModal.js index 02d22d6b107c..4b1abb489093 100644 --- a/src/pages/tasks/TaskAssigneeSelectorModal.js +++ b/src/pages/tasks/TaskAssigneeSelectorModal.js @@ -156,10 +156,10 @@ const TaskAssigneeSelectorModal = (props) => { // Check to see if we're creating a new task // If there's no route params, we're creating a new task - if (!props.route.params && option.alternateText) { + if (!props.route.params && option.login) { // Clear out the state value, set the assignee and navigate back to the NewTaskPage setSearchValue(''); - TaskUtils.setAssigneeValue(option.alternateText, props.task.shareDestination); + TaskUtils.setAssigneeValue(option.login, props.task.shareDestination); return Navigation.goBack(); } @@ -167,9 +167,9 @@ const TaskAssigneeSelectorModal = (props) => { if (props.route.params.reportID && props.task.report.reportID === props.route.params.reportID) { // There was an issue where sometimes a new assignee didn't have a DM thread // This would cause the app to crash, so we need to make sure we have a DM thread - TaskUtils.setAssigneeValue(option.alternateText, props.task.shareDestination); + TaskUtils.setAssigneeValue(option.login, props.task.shareDestination); // Pass through the selected assignee - TaskUtils.editTaskAndNavigate(props.task.report, props.session.email, '', '', option.alternateText); + TaskUtils.editTaskAndNavigate(props.task.report, props.session.email, '', '', option.login); } }; diff --git a/src/pages/tasks/TaskTitlePage.js b/src/pages/tasks/TaskTitlePage.js index 9fb5e3b8a479..ee91b77002d5 100644 --- a/src/pages/tasks/TaskTitlePage.js +++ b/src/pages/tasks/TaskTitlePage.js @@ -47,7 +47,7 @@ function TaskTitlePage(props) { const errors = {}; if (_.isEmpty(values.title)) { - errors.title = props.translate('common.error.fieldRequired'); + errors.title = props.translate('newTaskPage.pleaseEnterTaskName'); } return errors; diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index 01a434fa5ae5..9cebeb235259 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -18,7 +18,8 @@ import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import compose from '../../libs/compose'; import Avatar from '../../components/Avatar'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; -import withPolicy, {policyPropTypes, policyDefaultProps} from './withPolicy'; +import {policyPropTypes, policyDefaultProps} from './withPolicy'; +import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import reportPropTypes from '../reportPropTypes'; import * as Policy from '../../libs/actions/Policy'; import * as PolicyUtils from '../../libs/PolicyUtils'; @@ -77,6 +78,18 @@ const WorkspaceInitialPage = (props) => { Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); }, [props.reports, policy]); + /** + * Navigates to workspace rooms + * @param {String} chatType + */ + const goToRoom = useCallback( + (type) => { + const room = _.find(props.reports, (report) => report && report.policyID === policy.id && report.chatType === type); + Navigation.navigate(ROUTES.getReportRoute(room.reportID)); + }, + [props.reports, policy], + ); + const policyName = lodashGet(policy, 'name', ''); const hasMembersError = PolicyUtils.hasPolicyMemberError(props.policyMemberList); const hasGeneralSettingsError = !_.isEmpty(lodashGet(policy, 'errorFields.generalSettings', {})) || !_.isEmpty(lodashGet(policy, 'errorFields.avatar', {})); @@ -128,6 +141,24 @@ const WorkspaceInitialPage = (props) => { }, ]; + const threeDotsMenuItems = [ + { + icon: Expensicons.Trashcan, + text: props.translate('workspace.common.delete'), + onSelected: () => setIsDeleteModalOpen(true), + }, + { + icon: Expensicons.Hashtag, + text: props.translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS}), + onSelected: () => goToRoom(CONST.REPORT.CHAT_TYPE.POLICY_ADMINS), + }, + { + icon: Expensicons.Hashtag, + text: props.translate('workspace.common.goToRoom', {roomName: CONST.REPORT.WORKSPACE_CHAT_ROOMS.ANNOUNCE}), + onSelected: () => goToRoom(CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE), + }, + ]; + return ( {({safeAreaPaddingBottomStyle}) => ( @@ -143,13 +174,7 @@ const WorkspaceInitialPage = (props) => { shouldShowThreeDotsButton shouldShowGetAssistanceButton guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_INITIAL} - threeDotsMenuItems={[ - { - icon: Expensicons.Trashcan, - text: props.translate('workspace.common.delete'), - onSelected: () => setIsDeleteModalOpen(true), - }, - ]} + threeDotsMenuItems={threeDotsMenuItems} threeDotsAnchorPosition={styles.threeDotsPopoverOffset(props.windowWidth)} /> @@ -162,12 +187,12 @@ const WorkspaceInitialPage = (props) => { - openEditor(policy.id)} - > - + + openEditor(policy.id)} + > { name={policyName} type={CONST.ICON_TYPE_WORKSPACE} /> - - + + {!_.isEmpty(policy.name) && ( - openEditor(policy.id)} - > - + + openEditor(policy.id)} + > {policy.name} - - + + )} @@ -235,7 +260,7 @@ WorkspaceInitialPage.displayName = 'WorkspaceInitialPage'; export default compose( withLocalize, - withPolicy, + withPolicyAndFullscreenLoading, withWindowDimensions, withOnyx({ reports: { diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.js b/src/pages/workspace/WorkspaceInviteMessagePage.js index 66ede8a556ed..1a3c146b366b 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.js +++ b/src/pages/workspace/WorkspaceInviteMessagePage.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import {Pressable, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import ScreenWrapper from '../../components/ScreenWrapper'; @@ -18,7 +17,8 @@ import MultipleAvatars from '../../components/MultipleAvatars'; import CONST from '../../CONST'; import * as Link from '../../libs/actions/Link'; import Text from '../../components/Text'; -import withPolicy, {policyPropTypes, policyDefaultProps} from './withPolicy'; +import {policyPropTypes, policyDefaultProps} from './withPolicy'; +import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; import ROUTES from '../../ROUTES'; import * as Localize from '../../libs/Localize'; @@ -114,11 +114,6 @@ class WorkspaceInviteMessagePage extends React.Component { }); } - getAvatarTooltips() { - const filteredPersonalDetails = _.pick(this.props.personalDetails, this.props.invitedMembersDraft); - return _.map(filteredPersonalDetails, (personalDetail) => Str.removeSMSDomain(personalDetail.login)); - } - sendInvitation() { Policy.addMembersToWorkspace(this.props.invitedMembersDraft, this.state.welcomeNote, this.props.route.params.policyID, this.props.betas); Policy.setWorkspaceInviteMembersDraft(this.props.route.params.policyID, []); @@ -186,7 +181,6 @@ class WorkspaceInviteMessagePage extends React.Component { icons={OptionsListUtils.getAvatarsForLogins(this.props.invitedMembersDraft, this.props.personalDetails)} shouldStackHorizontally secondAvatarStyle={[styles.secondAvatarInline]} - avatarTooltips={this.getAvatarTooltips()} /> @@ -219,7 +213,7 @@ WorkspaceInviteMessagePage.defaultProps = defaultProps; export default compose( withLocalize, - withPolicy, + withPolicyAndFullscreenLoading, withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS, diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js index 67f9a1bbb11c..a69c3fa7eeb5 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -18,7 +18,8 @@ import OptionsSelector from '../../components/OptionsSelector'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; import CONST from '../../CONST'; import * as Link from '../../libs/actions/Link'; -import withPolicy, {policyPropTypes, policyDefaultProps} from './withPolicy'; +import {policyPropTypes, policyDefaultProps} from './withPolicy'; +import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import {withNetwork} from '../../components/OnyxProvider'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; import networkPropTypes from '../../components/networkPropTypes'; @@ -256,57 +257,55 @@ class WorkspaceInvitePage extends React.Component { return ( - {({didScreenTransitionEnd}) => ( - Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)} + Navigation.navigate(ROUTES.SETTINGS_WORKSPACES)} + > + - - this.clearErrors(true)} - shouldShowGetAssistanceButton - guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS} - shouldShowBackButton - onBackButtonPress={() => Navigation.goBack()} + this.clearErrors(true)} + shouldShowGetAssistanceButton + guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS} + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} + /> + + - - - - - - - - - )} + + + + + + ); } @@ -317,7 +316,7 @@ WorkspaceInvitePage.defaultProps = defaultProps; export default compose( withLocalize, - withPolicy, + withPolicyAndFullscreenLoading, withNetwork(), withOnyx({ personalDetails: { diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 0e9739a74f8e..b03b08ef76e5 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -21,13 +21,14 @@ import ConfirmModal from '../../components/ConfirmModal'; import personalDetailsPropType from '../personalDetailsPropType'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; import OptionRow from '../../components/OptionRow'; -import withPolicy, {policyPropTypes, policyDefaultProps} from './withPolicy'; +import {policyPropTypes, policyDefaultProps} from './withPolicy'; +import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import CONST from '../../CONST'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; import {withNetwork} from '../../components/OnyxProvider'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; import networkPropTypes from '../../components/networkPropTypes'; -import * as ReportUtils from '../../libs/ReportUtils'; +import * as UserUtils from '../../libs/UserUtils'; import FormHelpMessage from '../../components/FormHelpMessage'; import TextInput from '../../components/TextInput'; import KeyboardDismissingFlatList from '../../components/KeyboardDismissingFlatList'; @@ -343,7 +344,7 @@ class WorkspaceMembersPage extends React.Component { participantsList: [item], icons: [ { - source: ReportUtils.getAvatar(item.avatar, item.login), + source: UserUtils.getAvatar(item.avatar, item.login), name: item.login, type: CONST.ICON_TYPE_AVATAR, }, @@ -501,7 +502,7 @@ WorkspaceMembersPage.defaultProps = defaultProps; export default compose( withLocalize, withWindowDimensions, - withPolicy, + withPolicyAndFullscreenLoading, withNetwork(), withOnyx({ personalDetails: { diff --git a/src/pages/workspace/WorkspacePageWithSections.js b/src/pages/workspace/WorkspacePageWithSections.js index 14cc6ac01403..e318ac54863f 100644 --- a/src/pages/workspace/WorkspacePageWithSections.js +++ b/src/pages/workspace/WorkspacePageWithSections.js @@ -16,7 +16,7 @@ import * as BankAccounts from '../../libs/actions/BankAccounts'; import BankAccount from '../../libs/models/BankAccount'; import * as ReimbursementAccountProps from '../ReimbursementAccount/reimbursementAccountPropTypes'; import userPropTypes from '../settings/userPropTypes'; -import withPolicy from './withPolicy'; +import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import {withNetwork} from '../../components/OnyxProvider'; import networkPropTypes from '../../components/networkPropTypes'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; @@ -56,6 +56,9 @@ const propTypes = { /** The guides call task ID to associate with the workspace page being shown */ guidesCallTaskID: PropTypes.string, + /** The route where we navigate when the user press the back button */ + backButtonRoute: PropTypes.string, + /** Policy values needed in the component */ policy: PropTypes.shape({ name: PropTypes.string, @@ -75,6 +78,7 @@ const defaultProps = { guidesCallTaskID: '', shouldUseScrollView: false, shouldSkipVBBACall: false, + backButtonRoute: '', }; class WorkspacePageWithSections extends React.Component { @@ -120,7 +124,7 @@ class WorkspacePageWithSections extends React.Component { shouldShowGetAssistanceButton guidesCallTaskID={this.props.guidesCallTaskID} shouldShowBackButton - onBackButtonPress={() => Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policyID))} + onBackButtonPress={() => Navigation.navigate(this.props.backButtonRoute || ROUTES.getWorkspaceInitialRoute(policyID))} onCloseButtonPress={() => Navigation.dismissModal()} /> {this.props.shouldUseScrollView ? ( @@ -153,6 +157,6 @@ export default compose( key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, }, }), - withPolicy, + withPolicyAndFullscreenLoading, withNetwork(), )(WorkspacePageWithSections); diff --git a/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js index 2c2321e5c84b..5022eb0c170c 100644 --- a/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js +++ b/src/pages/workspace/bills/WorkspaceBillsNoVBAView.js @@ -31,7 +31,7 @@ const WorkspaceBillsNoVBAView = (props) => ( {props.translate('workspace.bills.unlockNoVBACopy')} Policy.clearCustomUnitErrors(this.props.policy.id, this.state.unitID, this.state.unitRateID)} + pendingAction={lodashGet(distanceCustomUnit, 'pendingAction') || lodashGet(distanceCustomRate, 'pendingAction')} + shouldShowErrorMessages={false} > - - - this.setRate(value)} - value={this.state.unitRateValue} - autoCompleteType="off" - autoCorrect={false} - keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} - onKeyPress={this.debounceUpdateOnCursorMove} - maxLength={12} - /> - - - this.setUnit(value)} - backgroundColor={themeColors.cardBG} - /> - - + Navigation.navigate(ROUTES.getWorkspaceRateAndUnitRoute(this.props.policy.id))} + wrapperStyle={[styles.mhn5, styles.wAuto]} + brickRoadIndicator={(lodashGet(distanceCustomUnit, 'errors') || lodashGet(distanceCustomRate, 'errors')) && CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR} + /> + ( {props.translate('workspace.travel.noVBACopy')}