diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 308404b74bc0..ff888c135be9 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -125,6 +125,9 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Setup Node + uses: Expensify/App/.github/actions/composite/setupNode@main + - name: Make zip directory for everything to send to AWS Device Farm run: mkdir zip @@ -137,7 +140,7 @@ jobs: # The downloaded artifact will be a file named "app-e2e-release.apk" so we have to rename it - name: Rename baseline APK - run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-baseline.apk" + run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-main.apk" - name: Download delta APK uses: actions/download-artifact@e9ef242655d12993efdcda9058dee2db83a2cb9b @@ -147,7 +150,7 @@ jobs: path: zip - name: Rename delta APK - run: mv "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2eRelease-compare.apk" + run: mv "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2eRelease-delta.apk" - name: Copy e2e code into zip folder run: cp -r tests/e2e zip @@ -162,44 +165,72 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_REGION: us-west-2 - - name: Schedule AWS Device Farm test run + - name: Schedule AWS Device Farm test run on main branch uses: realm/aws-devicefarm/test-application@7b9a91236c456c97e28d384c9e476035d5ea686b + id: schedule-awsdf-main with: name: App E2E Performance Regression Tests project_arn: ${{ secrets.AWS_PROJECT_ARN }} device_pool_arn: ${{ secrets.AWS_DEVICE_POOL_ARN }} - app_file: zip/app-e2eRelease-baseline.apk + app_file: zip/app-e2eRelease-main.apk app_type: ANDROID_APP test_type: APPIUM_NODE test_package_file: App.zip test_package_type: APPIUM_NODE_TEST_PACKAGE - test_spec_file: tests/e2e/TestSpec.yml + test_spec_file: tests/e2e/TestSpecMain.yml test_spec_type: APPIUM_NODE_TEST_SPEC remote_src: false file_artifacts: Customer Artifacts.zip + log_artifacts: debug.log cleanup: true - - name: Unzip AWS Device Farm results - if: ${{ always() }} - run: unzip "Customer Artifacts.zip" - - - name: Print AWS Device Farm run results - if: ${{ always() }} - run: cat "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md" - - - name: Print AWS Device Farm verbose run results - if: ${{ always() && runner.debug != null && fromJSON(runner.debug) }} - run: cat "./Host_Machine_Files/\$WORKING_DIRECTORY/debug.log" - -# TODO: Once tests are more reliable we should uncomment this -# - name: Check if test failed, if so post the results and add the DeployBlocker label -# run: | -# if grep -q '🔴' ./Host_Machine_Files/\$WORKING_DIRECTORY/output.md; then -# gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash -# gh pr comment ${{ inputs.PR_NUMBER }} -F ./Host_Machine_Files/\$WORKING_DIRECTORY/output.md -# gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers 📣 Please look into this performance regression as it's a deploy blocker." -# else -# echo '✅ no performance regression detected' -# fi -# env: -# GITHUB_TOKEN: ${{ github.token }} + - name: Print logs if run failed + if: failure() + run: | + echo ${{ steps.schedule-awsdf-main.outputs.data }} + unzip "Customer Artifacts.zip" -d mainResults + cat ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/debug.log + + - name: Unzip AWS Device Farm main results + run: unzip "Customer Artifacts.zip" -d mainResults + + - name: Delete Customer Artifacts.zip + run: rm "Customer Artifacts.zip" + + - name: Schedule AWS Device Farm test run on delta branch + uses: realm/aws-devicefarm/test-application@7b9a91236c456c97e28d384c9e476035d5ea686b + with: + name: App E2E Performance Regression Tests + project_arn: ${{ secrets.AWS_PROJECT_ARN }} + device_pool_arn: ${{ secrets.AWS_DEVICE_POOL_ARN }} + app_file: zip/app-e2eRelease-delta.apk + app_type: ANDROID_APP + test_type: APPIUM_NODE + test_package_file: App.zip + test_package_type: APPIUM_NODE_TEST_PACKAGE + test_spec_file: tests/e2e/TestSpecDelta.yml + test_spec_type: APPIUM_NODE_TEST_SPEC + remote_src: false + file_artifacts: Customer Artifacts.zip + cleanup: true + + - name: Unzip AWS Device Farm delta results + run: unzip "Customer Artifacts.zip" -d deltaResults + + - name: Compare results + run: node tests/e2e/merge.js --mainPath ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/main.json --deltaPath ./deltaResults//Host_Machine_Files/\$WORKING_DIRECTORY/delta.json --outputPath ./output.md + + - name: Print results + run: cat "./output.md" + + - name: Check if test failed, if so post the results and add the DeployBlocker label + run: | + if grep -q '🔴' ./output.md; then + gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash + gh pr comment ${{ inputs.PR_NUMBER }} -F ./output.md + gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers 📣 Please look into this performance regression as it's a deploy blocker." + else + echo '✅ no performance regression detected' + fi + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/android/app/build.gradle b/android/app/build.gradle index 3c146d446acb..b801bd34b244 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001038800 - versionName "1.3.88-0" + versionCode 1001039001 + versionName "1.3.90-1" } flavorDimensions "default" diff --git a/assets/css/pdf.css b/assets/css/pdf.css index 9cbbf31b074c..26c80a5baf27 100644 --- a/assets/css/pdf.css +++ b/assets/css/pdf.css @@ -11,12 +11,7 @@ border-image: url(../images/shadow.png) 9 9 repeat; background-color: rgba(255, 255, 255, 1); } -.react-pdf__message { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); -} + .react-pdf__Page__annotations { height: 0; } diff --git a/assets/images/product-illustrations/simple-illustration__smartscan.svg b/assets/images/product-illustrations/simple-illustration__smartscan.svg new file mode 100644 index 000000000000..34d1fadfaa3b --- /dev/null +++ b/assets/images/product-illustrations/simple-illustration__smartscan.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 7dc851c95c9e..d8a24adefdc3 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -83,6 +83,8 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ {from: 'web/favicon-unread.png'}, {from: 'web/og-preview-image.png'}, {from: 'web/apple-touch-icon.png'}, + {from: 'assets/images/expensify-app-icon.svg'}, + {from: 'web/manifest.json'}, {from: 'assets/css', to: 'css'}, {from: 'assets/fonts/web', to: 'fonts'}, {from: 'node_modules/react-pdf/dist/esm/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'}, diff --git a/contributingGuides/OFFLINE_UX.md b/contributingGuides/OFFLINE_UX.md index a678a0b5b042..cca5c6286f73 100644 --- a/contributingGuides/OFFLINE_UX.md +++ b/contributingGuides/OFFLINE_UX.md @@ -104,7 +104,7 @@ This pattern greys out the submit button on a form and does not allow the form t **How to implement:** Use the `` component. This pattern should use the `API.write()` method. -**Example:** Inviting new memebers to a workspace. +**Example:** Inviting new members to a workspace. ### D - Full Page Blocking UI Pattern This pattern blocks the user from interacting with an entire page. diff --git a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md new file mode 100644 index 000000000000..5c9761b7ff1d --- /dev/null +++ b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md @@ -0,0 +1,247 @@ +--- +title: Expensify Card Perks +description: Get the most out of your Expensify Card with exclusive perks! +--- + + +# Overview +The Expensify Card is packed with perks, both native to our Card program and through exclusive discounts with partnering solutions. The Expensify Card’s primary perks include: +- Access to our premiere Expensify Lounge (with more locations coming soon) +- Swipe to Win, where every swipe has a chance to win fun personalized gifts for you and your closest friends and family members +- And unbeatable cash back incentive with each swipe +Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners. + +# Expensify Card Perks + +## Access to the Expensify Lounge +Our [world-class lounge](https://use.expensify.com/lounge) is now open for Expensify members and guests to enjoy! + +We invite you to visit our sleek San Francisco lounge, where sweeping city views provide the perfect backdrop for a morning coffee to start your day. + +Enjoy complimentary cocktails and snacks in a vibrant atmosphere with blazing-fast WiFi. Whether you want a place to focus on work, socialize with other members, or simply kick back and relax – our lounge is ready and waiting to welcome you. + +You can sign up for free [here](https://use.expensify.com) if you’re not an Expensify member. If you have any questions, reach out to concierge@expensify.com and [check this out](https://use.expensify.com/lounge) for more info. + +## Swipe to Win +Swipe to Win is a new [Expensify Card](https://use.expensify.com/company-credit-card) perk that gives cardholders the chance to send a gift to a friend, family member, or essential worker on the frontlines! + +Winners can choose to _Send a Smile_ or _Send a Laugh_. To start, we’re offering one gift per option: + +- **Send A Smile:** Champagne by Expensify +- **Send a Laugh:** Jenga Set + +**How to Participate** +It’s easy! Once you have an Expensify Card, you just need to start using it. With each swipe, you're automatically entered to win and have a 1 in 250 chance of getting a prize! + +**How will I know if I’ve won?** +Winners will be notified immediately via the Expensify app, and receive additional instructions on how to choose and send their desired gift. + +If you don't have Expensify notifications turned on yet, here are some helpful guides: +- [Apple Notification Preferences](https://support.apple.com/en-us/HT201925) +- [Android Notification Preferences](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fsupport.google.com%2Fandroid%2Fanswer%2F9079661%3Fhl%3Den) + +# Partner Specific Perks + +## Amazon AWS +Whether you are a two-person startup launching a new company or a venture-backed startup, we all could use a little relief in these difficult times. AWS Activate provides you with access to the resources you need to quickly get started on AWS - including free credits, technical support, and training. + +All Expensify customers that have adopted The Expensify Card qualify when they add their Expensify Card for billing with AWS! + +**Apply now by going [to this link](https://aws.amazon.com/startups/credits) and using the OrgID: 0qyIA (Case Sensitive)** + +The full details on the AWS Activate program can be found in AWS's [terms & conditions](https://aws.amazon.com/activate/terms/) and the [Activate FAQs](https://aws.amazon.com/startups/faq). + +## Stripe +Whether you’re creating a subscription service, an on-demand marketplace, or an e-commerce store, Stripe’s integrated payments platform helps you build and scale your business globally. + +**Receive waived Stripe fees, if you’re new to Stripe, for your first $5,000 in processed payments.** + +**How to redeem:** Sign up for Stripe using your Expensify Card. + +## Lamar Advertising +Lamar provides out-of-home advertising space for clients on billboards, digital, airport displays, transit, and highway logo signs. + +**Receive at minimum a 10% discount on your first campaign.** + +**How do redeem:** Contact Expensify’s dedicated account manager, Lisa Kane, and mention you’re an Expensify cardholder. + +Email: lkane@lamar.com + +## Carta +Simplify equity management with Carta. + +**Receive a 20% first-year discount and waived implementation fees for Carta.** + +**How to redeem:** Sign up using your Expensify Card + +## Pilot +Pilot specializes in bookkeeping and tax prep for startups and e-commerce providers. When you work with Pilot, you’re paired with a dedicated finance expert who takes the work off your plate and is on hand to answer your questions. + +**20% off the first 6-months of Pilot Core** + +**How to redeem:** Sign-up using your Expensify Card. + +## Spotlight Reporting +The integrated cloud reporting and forecasting tool that allows you to create insights for better business decisions. Designed by Accountants, for Accountants + +**20% discount off your subscription for the first 6 months, plus one free seat to Spotlight Certification.** + +**How to redeem:** Sign up using your Expensify Card. + +## Guideline +Guideline's full-service 401(k) plans make it easier and more affordable to offer your employees the retirement benefits they deserve. + +**Receive 3 months free.** + +**How to redeem:** Sign up using your Expensify Card. + +## Gusto +Gusto's people platform helps businesses like yours onboard, pay, insure, and support your hardworking team. Payroll, benefits, and more + +**3 months free service** + +**How to redeem:** Sign-up using your Expensify Card. + +## QuickBooks Online +QuickBooks accounting software helps keep your books accurate and up to date, automatically such as: invoicing, cashflow, expense tracking, and more. + +**Receive 30% off QuickBooks Online for the first 12 months.** + +**How to redeem:** Sign up using your Expensify Card. + +## Highfive +Highfive improves the ease and quality of intelligent in-room video conferencing. + +**Receive 50% off the Highfive Select starter package. 10% off the Highfive Premium Package.** + +**How to redeem:** Sign-up with your Expensify Card. + +## Zendesk +**$436 in credits for Zendesk Suite products per month for the first year** + +How to redeem: +1. Reach out to startups@zendesk.com with the following: "Expensify asked me to send an email regarding the Zendesk promotion”. You'll receive a code you use in step 5 below. +2. Start a Zendesk Trial (can be a suite trial or something different) in USD. If your trial is not in USD, contact Zendesk. If you already have a current trial, the code applies and can be used. +3. From inside your Zendesk trial, click the Buy Now button. +4. Select your chosen plan with monthly billing. The $436 monthly credit works for up to 4 licenses of the Suite, but the code can also apply $436 to any alternative monthly plan selection. +5. Enter the promo code that was provided to you in step 1 after emailing Zendesk. +6. Complete the checkout process and note that once your free credit runs out after 12 monthly billing periods, you will be charged for your next month with Zendesk. + +## Xero +Accounting Software With Everything You Need To Run Your Business Beautifully. Smart Online Accounting. Bank Connections + +**U.S. residents get 50% off Xero for six months.** + +Head to [this](https://apps.xero.com/us/app/expensify?xtid=x30expensify&utm_source=expensify&utm_medium=web&utm_campaign=cardoffer) page and sign-up for Xero using your Expensify Card! + +## Freshworks +Boost your startup journey with leading customer and employee engagement solutions from Freshworks including CRM, livechat, support, marketing automation, ITSM and HRMS. + +How to receive $4,000 in credits on Freshworks products: + +[Click here](https://www.freshworks.com/partners/startup-program/expensify-card/) and fill out the form and enter your details, Freshbooks will recognize your company as an Expensify Card customer automatically. + +## Slack +**Receive 25% off for the first year:** You’ll enjoy premium features like unlimited messaging and apps, Slack Connect channels, group video calls, priority support, and much more. It’s all just a click away. + +**How to redeem with your Expensify Card:** [Click here](https://slack.com/promo/partner?remote_promo=ead919f5) to redeem the offer by using your Expensify Card to manage the billing. + +## Deel.com +Deel makes onboarding international team members in 150 different countries painless. Quickly bring on contractors or hire employees in seconds with Deel as your employer of record (EOR). It’s one simple, powerful dashboard that houses everything you need. Finalize contracts, pay employees, and manage all your payroll data in one place seamlessly. + +**How to redeem 3 months free, then 30% off the rest of the year with Deel.com:** Click [here](https://www.deel.com/partners/expensify) and sign up using your Expensify Card. + +## Snap +**$1,000 in Snap credits** +Whether you're looking to increase online sales, drive app installs, or get more leads, Snapchat can connect you with a unique mobile audience primed to take action. For a limited time, spend $1000 in Snapchat's Ads Manager and receive $1000 in ad credit to use towards your next campaign! + +**How to redeem with your Expensify Card:** Click on `create ad` or `request a call` by clicking here. Enter your details to set up your account if you don't already have one.Add the Expensify Card as your payment option for your Snap Business account.Credits will be automatically placed in your account once you've reached $1,000 in spend. + +## Aircall +Aircall is the cloud-based phone system of choice for modern brands. Aircall allows sales and support teams to have meaningful and efficient phone conversations, and integrates with the most popular CRMs, Help desks, and business tools. Pricing is dependent on the number of users within the account. Discount could range from $270-$9,000+ + +**2 Months Free** + +**How to redeem with your Expensify Card:** +1. Click [here])(http://pages.aircall.io/Expensify-RewardsPartnerReferral.html) +2. Sign up for a demo +3. Let our team know you're an Expensify customer + +## NetSuite +NetSuite helps companies manage core business processes with a cloud-based ERP and accounting software. Expensify has a direct integration with NetSuite so that expenses are coded to your exact preference and data is always synchronized across the two systems. + +**10% OFF for the First Year** + +**How to redeem:** +1. Fill out this [Google form](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fdocs.google.com%2Fforms%2Fd%2Fe%2F1FAIpQLSeiOzLrdO-MgqeEMwEqgdQoh4SJBi42MZME9ycHp4SQjlk3bQ%2Fviewform%3Fusp%3Dsf_link). +2. An Expensify rep will make an introduction to a NetSuite sales rep to get the process started. This offer is only for prospective NetSuite customers. If you are currently a NetSuite customer, this promotion does not apply. +3. Once you are set up and pay for your first year with NetSuite, we will send you a payment equal to 10% of your first year contract within three months of paying your first NetSuite invoice. + +## PagerDuty +PagerDuty's Platform for Real-Time Operations integrates machine data & human intelligence to improve visibility & agility across organizations. + +**25% OFF** + +**How to redeem:** +1. Sign-up using your Expensify Card +2. Use the discount code EXPENSIFYPDTEAM for a 25% discount on the Team plan or EXPENSIFYPDBUSINESS for a 25% discount on the Business plan within the Cost Summary section upon checkout. + +## Typeform +Typeform makes collecting and sharing information comfortable and conversational. It's a web-based platform you can use to create anything from surveys to apps, without needing to write a single line of code. + +**30% off annual premium and professional plans** + +**How to redeem with your Expensify Card:** +1. Click on the 'Get Typeform` by [clicking here](https://try.typeform.com/expensify/?utm_source=expensify&utm_medium=referral&utm_campaign=expensify_integration&utm_content=directory) +2. Enter your details and setup your free account +3. Verify your email by clicking on the link that Typeform sends you +4. Go through the on boarding flow within Tyepform +5. Click on the 'Upgrade' button from within your workspace +6. Select your plan +7. Enter the coupon 'EXPENSIFY30' on the checkout page +8. Click on 'Upgrade now' once you've filled out all of your payment details with your Expensify Card + +## Intercom +Intercom builds a suite of messaging-first products for businesses to accelerate growth across the customer lifecycle. + +**3-months free service** + +**How to redeem:** Sign-up using your Expensify Card. + +## Talkspace +Prescription management and personalized treatment from a network of licensed prescribers trained in mental healthcare. Therapists are licensed, verified and background-checked. Working with a Talkspace therapist will give you an unbiased, trained perspective and provide you with the guidance and tools to help you feel better. When it comes to your mental health, the right therapist makes all the difference. + +**$125 OFF Talkspace purchases** + +**How to redeem with your Expensify Card:** Use the code at EXPENSIFY at the time of checkout. + +## Stripe Atlas +Stripe Atlas helps removes obstacles typically associated with starting a business so you can build your startup from anywhere in the world. + +**Receive $100 off Stripe Atlas and get access to a startup toolkit and special offers on additional Strip Atlas services.** + +**How to redeem:** Sign up with your Expensify Card. + +# FAQ + +## Where is the Expensify Lounge? +The Expensify Lounge is located on the 16th floor of 88 Kearny Street in San Francisco, California, 94108. This is currently our only lounge location, but keep an eye out for more work lounges popping up soon! + +## When is the Expensify Lounge open? +The lounge is open 8 a.m. to 6 p.m. from Monday through Friday, except for national holidays. Capacity is limited, and we are admitting loungers on a first-come, first-served basis, so make sure to get there early! + +## Who can use the lounge workplace? +Customers with an Expensify subscription can use Expensify’s lounge workplace, and any partner who has completed [ExpensifyApproved! University!](https://university.expensify.com/users/sign_in?next=%2Fdashboard) + + + + +# FAQ +This section covers the useful but not as vital information, it should capture commonly queried elements which do not organically form part of the About or How-to sections. + +- What's idiosyncratic or potentially confusing about this feature? +- Is there anything unique about how this feature relates to billing/activity? +- If this feature is released, are there any common confusions that can't be solved by improvements to the product itself? +- Similarly, if this feature hasn't been released, can you predict and pre-empt any potential confusion? +- Is there any general troubleshooting for this feature? + - Note: troubleshooting should generally go in the FAQ, but if there is extensive troubleshooting, such as with integrations, that will be housed in a separate page, stored with and linked from the main page for that feature. diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md b/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Kayak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_01.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_01.png new file mode 100644 index 000000000000..a9bc57525a1a Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_01.png differ diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_02.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_02.png new file mode 100644 index 000000000000..4bd2c5af455b Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_02.png differ diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_03.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_03.png new file mode 100644 index 000000000000..f5318cd5272a Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_03.png differ diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_04.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_04.png new file mode 100644 index 000000000000..8913771747aa Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_04.png differ diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_05.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_05.png new file mode 100644 index 000000000000..f1f43ae16f03 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_05.png differ diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_06.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_06.png new file mode 100644 index 000000000000..51854b6e2690 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_06.png differ diff --git a/docs/assets/images/ExpensifyHelp_ApprovingReports_07.png b/docs/assets/images/ExpensifyHelp_ApprovingReports_07.png new file mode 100644 index 000000000000..b750ffdc486f Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_ApprovingReports_07.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 7ce5e62128d4..c79b98b0b771 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.88 + 1.3.90 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.88.0 + 1.3.90.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index a3e9a8469eca..9dc48ca35174 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.88 + 1.3.90 CFBundleSignature ???? CFBundleVersion - 1.3.88.0 + 1.3.90.1 diff --git a/ios/expensify_chat_adhoc.mobileprovision.gpg b/ios/expensify_chat_adhoc.mobileprovision.gpg index 994136a07b6c..1dae451f168c 100644 Binary files a/ios/expensify_chat_adhoc.mobileprovision.gpg and b/ios/expensify_chat_adhoc.mobileprovision.gpg differ diff --git a/metro.config.js b/metro.config.js index bf2ff904df70..62ca2a25c6b2 100644 --- a/metro.config.js +++ b/metro.config.js @@ -7,9 +7,10 @@ require('dotenv').config(); const defaultConfig = getDefaultConfig(__dirname); const isUsingMockAPI = process.env.E2E_TESTING === 'true'; + if (isUsingMockAPI) { // eslint-disable-next-line no-console - console.warn('⚠️ Using mock API'); + console.log('⚠️⚠️⚠️⚠️ Using mock API ⚠️⚠️⚠️⚠️'); } /** @@ -25,10 +26,14 @@ const config = { resolveRequest: (context, moduleName, platform) => { const resolution = context.resolveRequest(context, moduleName, platform); if (isUsingMockAPI && moduleName.includes('/API')) { + const originalPath = resolution.filePath; + const mockPath = originalPath.replace('src/libs/API.ts', 'src/libs/E2E/API.mock.js').replace('/src/libs/API.js/', 'src/libs/E2E/API.mock.js'); + // eslint-disable-next-line no-console + console.log('⚠️⚠️⚠️⚠️ Replacing resolution path', originalPath, ' => ', mockPath); + return { ...resolution, - // TODO: Change API.mock.js extension once it is migrated to TypeScript - filePath: resolution.filePath.replace(/src\/libs\/API.js/, 'src/libs/E2E/API.mock.js'), + filePath: mockPath, }; } return resolution; diff --git a/package-lock.json b/package-lock.json index 2d904d4e0205..6d579a2736a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.88-0", + "version": "1.3.90-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.88-0", + "version": "1.3.90-1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9025a2508636..0bf706c73edd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.88-0", + "version": "1.3.90-1", "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.", @@ -49,7 +49,9 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "test:e2e": "node tests/e2e/testRunner.js --development", + "test:e2e:main": "node tests/e2e/testRunner.js --development --branch main --skipCheckout", + "test:e2e:delta": "node tests/e2e/testRunner.js --development --branch main --label delta --skipCheckout", + "test:e2e:compare": "node tests/e2e/merge.js", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", "workflow-test:generate": "node workflow_tests/utils/preGenerateTest.js" diff --git a/src/CONST.ts b/src/CONST.ts index 048c2dee5bab..a6106b88a532 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -124,7 +124,16 @@ const CONST = { VIEW_HEIGHT: 275, }, MONEY_REPORT: { - MIN_HEIGHT: 280, + SMALL_SCREEN: { + IMAGE_HEIGHT: 300, + CONTAINER_MINHEIGHT: 280, + VIEW_HEIGHT: 220, + }, + WIDE_SCREEN: { + IMAGE_HEIGHT: 450, + CONTAINER_MINHEIGHT: 280, + VIEW_HEIGHT: 275, + }, }, }, @@ -1254,7 +1263,7 @@ const CONST = { BANK: 'Expensify Card', FRAUD_TYPES: { DOMAIN: 'domain', - INDIVIDUAL: 'individal', + INDIVIDUAL: 'individual', NONE: 'none', }, STATE: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a2308cb56ef1..bcc4685368cb 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -7,12 +7,13 @@ import CONST from './CONST'; /** * This is a file containing constants for all of the routes we want to be able to go to - * Returns an encoded URI component for the backTo param which can be added to the end of URLs + * Returns the URL with an encoded URI component for the backTo param which can be added to the end of URLs * @param backTo * @returns */ -function getBackToParam(backTo?: string): string { - return backTo ? `?backTo=${encodeURIComponent(backTo)}` : ''; +function getUrlWithBackToParam(url: string, backTo?: string): string { + const backToParam = backTo ? `${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` : ''; + return url + backToParam; } export default { @@ -30,7 +31,7 @@ export default { }, PROFILE: { route: 'a/:accountID', - getRoute: (accountID: string | number, backTo?: string) => `a/${accountID}${getBackToParam(backTo)}`, + getRoute: (accountID: string | number, backTo?: string) => getUrlWithBackToParam(`a/${accountID}`, backTo), }, TRANSITION_BETWEEN_APPS: 'transition', @@ -56,7 +57,7 @@ export default { BANK_ACCOUNT_PERSONAL: 'bank-account/personal', BANK_ACCOUNT_WITH_STEP_TO_OPEN: { route: 'bank-account/:stepToOpen?', - getRoute: (stepToOpen = '', policyID = '', backTo?: string): string => `bank-account/${stepToOpen}?policyID=${policyID}${getBackToParam(backTo)}`, + getRoute: (stepToOpen = '', policyID = '', backTo?: string): string => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo), }, SETTINGS: 'settings', @@ -108,7 +109,7 @@ export default { SETTINGS_PERSONAL_DETAILS_ADDRESS: 'settings/profile/personal-details/address', SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY: { route: 'settings/profile/personal-details/address/country', - getRoute: (country: string, backTo?: string) => `settings/profile/personal-details/address/country?country=${country}${getBackToParam(backTo)}`, + getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/personal-details/address/country?country=${country}`, backTo), }, SETTINGS_CONTACT_METHODS: 'settings/profile/contact-methods', SETTINGS_CONTACT_METHOD_DETAILS: { diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 61b138747950..3f85ba8b5ccb 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -376,7 +376,7 @@ function AttachmentModal(props) { text: props.translate('common.download'), onSelected: () => downloadAttachment(source), }); - if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && !isSettled) { + if (TransactionUtils.hasReceipt(props.transaction) && !TransactionUtils.isReceiptBeingScanned(props.transaction) && canEdit) { menuItems.push({ icon: Expensicons.Trashcan, text: props.translate('receipt.deleteReceipt'), @@ -447,6 +447,7 @@ function AttachmentModal(props) { onToggleKeyboard={updateConfirmButtonVisibility} isWorkspaceAvatar={props.isWorkspaceAvatar} fallbackSource={props.fallbackSource} + isUsedInAttachmentModal /> ) )} diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js index fc17f79a0aaa..1d1de83951ee 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.js @@ -1,19 +1,19 @@ import React, {memo} from 'react'; -import styles from '../../../../styles/styles'; import {attachmentViewPdfPropTypes, attachmentViewPdfDefaultProps} from './propTypes'; import PDFView from '../../../PDFView'; -function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, onPress, onScaleChanged, onToggleKeyboard, onLoadComplete}) { +function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, onPress, onScaleChanged, onToggleKeyboard, onLoadComplete, errorLabelStyles, style}) { return ( ); } diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js index fdf151c4d5d0..fea72a3fe37a 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js @@ -1,10 +1,9 @@ import React, {memo, useCallback, useContext, useEffect} from 'react'; -import styles from '../../../../styles/styles'; import {attachmentViewPdfPropTypes, attachmentViewPdfDefaultProps} from './propTypes'; import PDFView from '../../../PDFView'; import AttachmentCarouselPagerContext from '../../AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; -function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarousel, onPress, onScaleChanged: onScaleChangedProp, onToggleKeyboard, onLoadComplete}) { +function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarousel, onPress, onScaleChanged: onScaleChangedProp, onToggleKeyboard, onLoadComplete, errorLabelStyles, style}) { const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); useEffect(() => { @@ -41,10 +40,11 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse isFocused={isFocused} sourceURL={encryptedSourceUrl} fileName={file.name} - style={styles.imageModalPDF} + style={style} onToggleKeyboard={onToggleKeyboard} onScaleChanged={onScaleChanged} onLoadComplete={onLoadComplete} + errorLabelStyles={errorLabelStyles} /> ); } diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/propTypes.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/propTypes.js index ea17cd9490b3..07203cc2fe74 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/propTypes.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/propTypes.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import * as AttachmentsPropTypes from '../../propTypes'; +import stylePropTypes from '../../../../styles/stylePropTypes'; const attachmentViewPdfPropTypes = { /** File object maybe be instance of File or Object */ @@ -8,12 +9,20 @@ const attachmentViewPdfPropTypes = { encryptedSourceUrl: PropTypes.string.isRequired, onToggleKeyboard: PropTypes.func.isRequired, onLoadComplete: PropTypes.func.isRequired, + + /** Additional style props */ + style: stylePropTypes, + + /** Styles for the error label */ + errorLabelStyles: stylePropTypes, }; const attachmentViewPdfDefaultProps = { file: { name: '', }, + style: [], + errorLabelStyles: [], }; export {attachmentViewPdfPropTypes, attachmentViewPdfDefaultProps}; diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js index 34ff45160ce9..66d7b2fa89d6 100755 --- a/src/components/Attachments/AttachmentView/index.js +++ b/src/components/Attachments/AttachmentView/index.js @@ -23,6 +23,7 @@ import DistanceEReceipt from '../../DistanceEReceipt'; import useNetwork from '../../../hooks/useNetwork'; import ONYXKEYS from '../../../ONYXKEYS'; import EReceipt from '../../EReceipt'; +import cursor from '../../../styles/utilities/cursor'; const propTypes = { ...attachmentViewPropTypes, @@ -75,6 +76,7 @@ function AttachmentView({ isWorkspaceAvatar, fallbackSource, transaction, + isUsedInAttachmentModal, }) { const [loadComplete, setLoadComplete] = useState(false); const [imageError, setImageError] = useState(false); @@ -132,6 +134,8 @@ function AttachmentView({ onScaleChanged={onScaleChanged} onToggleKeyboard={onToggleKeyboard} onLoadComplete={() => !loadComplete && setLoadComplete(true)} + errorLabelStyles={isUsedInAttachmentModal ? [styles.textLabel, styles.textLarge] : [cursor.cursorAuto]} + style={isUsedInAttachmentModal ? styles.imageModalPDF : styles.flex1} /> ); } diff --git a/src/components/Attachments/AttachmentView/propTypes.js b/src/components/Attachments/AttachmentView/propTypes.js index 2d4acdda0c1f..71ae3639b61c 100644 --- a/src/components/Attachments/AttachmentView/propTypes.js +++ b/src/components/Attachments/AttachmentView/propTypes.js @@ -22,6 +22,9 @@ const attachmentViewPropTypes = { /** Handles scale changed event */ onScaleChanged: PropTypes.func, + + /** Whether this AttachmentView is shown as part of an AttachmentModal */ + isUsedInAttachmentModal: PropTypes.bool, }; const attachmentViewDefaultProps = { @@ -33,6 +36,7 @@ const attachmentViewDefaultProps = { isUsedInCarousel: false, onPress: undefined, onScaleChanged: () => {}, + isUsedInAttachmentModal: false, }; export {attachmentViewPropTypes, attachmentViewDefaultProps}; diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index a44d1841bbb6..3dd23d9051eb 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -16,7 +16,7 @@ import withLocalize, {withLocalizePropTypes} from './withLocalize'; import variables from '../styles/variables'; import CONST from '../CONST'; import SpinningIndicatorAnimation from '../styles/animation/SpinningIndicatorAnimation'; -import Tooltip from './Tooltip'; +import Tooltip from './Tooltip/PopoverAnchorTooltip'; import stylePropTypes from '../styles/stylePropTypes'; import * as FileUtils from '../libs/fileDownload/FileUtils'; import getImageResolution from '../libs/fileDownload/getImageResolution'; @@ -24,7 +24,7 @@ import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import AttachmentModal from './AttachmentModal'; import DotIndicatorMessage from './DotIndicatorMessage'; import * as Browser from '../libs/Browser'; -import withNavigationFocus, {withNavigationFocusPropTypes} from './withNavigationFocus'; +import withNavigationFocus from './withNavigationFocus'; import compose from '../libs/compose'; const propTypes = { @@ -91,8 +91,10 @@ const propTypes = { /** File name of the avatar */ originalFileName: PropTypes.string, + /** Whether navigation is focused */ + isFocused: PropTypes.bool.isRequired, + ...withLocalizePropTypes, - ...withNavigationFocusPropTypes, }; const defaultProps = { diff --git a/src/components/BaseMiniContextMenuItem.js b/src/components/BaseMiniContextMenuItem.js index 3c423ffc80ea..ffbc5ee76d98 100644 --- a/src/components/BaseMiniContextMenuItem.js +++ b/src/components/BaseMiniContextMenuItem.js @@ -6,7 +6,7 @@ import styles from '../styles/styles'; import * as StyleUtils from '../styles/StyleUtils'; import getButtonState from '../libs/getButtonState'; import variables from '../styles/variables'; -import Tooltip from './Tooltip'; +import Tooltip from './Tooltip/PopoverAnchorTooltip'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import ReportActionComposeFocusManager from '../libs/ReportActionComposeFocusManager'; import DomUtils from '../libs/DomUtils'; diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js index 5bdda580d357..4c034038305d 100644 --- a/src/components/DatePicker/index.android.js +++ b/src/components/DatePicker/index.android.js @@ -1,97 +1,79 @@ -import React from 'react'; +import React, {forwardRef, useCallback, useImperativeHandle, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; import RNDatePicker from '@react-native-community/datetimepicker'; import moment from 'moment'; -import _ from 'underscore'; import TextInput from '../TextInput'; import CONST from '../../CONST'; import {propTypes, defaultProps} from './datepickerPropTypes'; import styles from '../../styles/styles'; -class DatePicker extends React.Component { - constructor(props) { - super(props); +function DatePicker({value, defaultValue, label, placeholder, errorText, containerStyles, disabled, onBlur, onInputChange, maxDate, minDate}, outerRef) { + const ref = useRef(); - this.state = { - isPickerVisible: false, - }; - - this.showPicker = this.showPicker.bind(this); - this.setDate = this.setDate.bind(this); - } + const [isPickerVisible, setIsPickerVisible] = useState(false); /** * @param {Event} event * @param {Date} selectedDate */ - setDate(event, selectedDate) { - this.setState({isPickerVisible: false}); + const setDate = (event, selectedDate) => { + setIsPickerVisible(false); if (event.type === 'set') { const asMoment = moment(selectedDate, true); - this.props.onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING)); + onInputChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING)); } - } + }; - showPicker() { + const showPicker = useCallback(() => { Keyboard.dismiss(); - this.setState({isPickerVisible: true}); - } + setIsPickerVisible(true); + }, []); - render() { - const dateAsText = this.props.value || this.props.defaultValue ? moment(this.props.value || this.props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; + useImperativeHandle( + outerRef, + () => ({ + ...ref.current, + focus: showPicker, + }), + [showPicker], + ); - return ( - <> - { - if (!_.isFunction(this.props.innerRef)) { - return; - } - if (el && el.focus && typeof el.focus === 'function') { - let inputRef = {...el}; - inputRef = {...inputRef, focus: this.showPicker}; - this.props.innerRef(inputRef); - return; - } + const dateAsText = value || defaultValue ? moment(value || defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; - this.props.innerRef(el); - }} + return ( + <> + + {isPickerVisible && ( + - {this.state.isPickerVisible && ( - - )} - - ); - } + )} + + ); } DatePicker.propTypes = propTypes; DatePicker.defaultProps = defaultProps; +DatePicker.displayName = 'DatePicker'; -export default React.forwardRef((props, ref) => ( - -)); +export default forwardRef(DatePicker); diff --git a/src/components/DotIndicatorMessage.js b/src/components/DotIndicatorMessage.js index b3528b43dc75..fc4d74339d6e 100644 --- a/src/components/DotIndicatorMessage.js +++ b/src/components/DotIndicatorMessage.js @@ -3,6 +3,7 @@ import _ from 'underscore'; import PropTypes from 'prop-types'; import {View} from 'react-native'; import styles from '../styles/styles'; +import stylePropTypes from '../styles/stylePropTypes'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import themeColors from '../styles/themes/default'; @@ -25,11 +26,15 @@ const propTypes = { // Additional styles to apply to the container */ // eslint-disable-next-line react/forbid-prop-types style: PropTypes.arrayOf(PropTypes.object), + + // Additional styles to apply to the text + textStyles: stylePropTypes, }; const defaultProps = { messages: {}, style: [], + textStyles: [], }; function DotIndicatorMessage(props) { @@ -64,7 +69,7 @@ function DotIndicatorMessage(props) { {_.map(sortedMessages, (message, i) => ( {message} diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index 8b32234fdbdf..5c2f65e24b01 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -6,11 +6,10 @@ import EmojiPickerMenu from './EmojiPickerMenu'; import CONST from '../../CONST'; import styles from '../../styles/styles'; import PopoverWithMeasuredContent from '../PopoverWithMeasuredContent'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import withViewportOffsetTop from '../withViewportOffsetTop'; -import compose from '../../libs/compose'; import * as StyleUtils from '../../styles/StyleUtils'; import calculateAnchorPosition from '../../libs/calculateAnchorPosition'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; const DEFAULT_ANCHOR_ORIGIN = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, @@ -18,7 +17,6 @@ const DEFAULT_ANCHOR_ORIGIN = { }; const propTypes = { - ...windowDimensionsPropTypes, viewportOffsetTop: PropTypes.number.isRequired, }; @@ -34,6 +32,7 @@ const EmojiPicker = forwardRef((props, ref) => { const onModalHide = useRef(() => {}); const onEmojiSelected = useRef(() => {}); const emojiSearchInput = useRef(); + const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); /** * Show the emoji picker menu. @@ -125,7 +124,7 @@ const EmojiPicker = forwardRef((props, ref) => { const emojiPopoverDimensionListener = Dimensions.addEventListener('change', () => { if (!emojiPopoverAnchor.current) { // In small screen width, the window size change might be due to keyboard open/hide, we should avoid hide EmojiPicker in those cases - if (isEmojiPickerVisible && !props.isSmallScreenWidth) { + if (isEmojiPickerVisible && !isSmallScreenWidth) { hideEmojiPicker(); } return; @@ -137,7 +136,7 @@ const EmojiPicker = forwardRef((props, ref) => { return () => { emojiPopoverDimensionListener.remove(); }; - }, [isEmojiPickerVisible, props.isSmallScreenWidth, emojiPopoverAnchorOrigin]); + }, [isEmojiPickerVisible, isSmallScreenWidth, emojiPopoverAnchorOrigin]); // There is no way to disable animations, and they are really laggy, because there are so many // emojis. The best alternative is to set it to 1ms so it just "pops" in and out @@ -162,7 +161,7 @@ const EmojiPicker = forwardRef((props, ref) => { height: CONST.EMOJI_PICKER_SIZE.HEIGHT, }} anchorAlignment={emojiPopoverAnchorOrigin} - outerStyle={StyleUtils.getOuterModalStyle(props.windowHeight, props.viewportOffsetTop)} + outerStyle={StyleUtils.getOuterModalStyle(windowHeight, props.viewportOffsetTop)} innerContainerStyle={styles.popoverInnerContainer} avoidKeyboard > @@ -176,4 +175,4 @@ const EmojiPicker = forwardRef((props, ref) => { EmojiPicker.propTypes = propTypes; EmojiPicker.displayName = 'EmojiPicker'; -export default compose(withViewportOffsetTop, withWindowDimensions)(EmojiPicker); +export default withViewportOffsetTop(EmojiPicker); diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index cbfc3517117c..0d1426cbf987 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -4,7 +4,7 @@ import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; import getButtonState from '../../libs/getButtonState'; import * as Expensicons from '../Icon/Expensicons'; -import Tooltip from '../Tooltip'; +import Tooltip from '../Tooltip/PopoverAnchorTooltip'; import Icon from '../Icon'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import * as EmojiPickerAction from '../../libs/actions/EmojiPickerAction'; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index df47d3494928..aea3b0f5b984 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -11,7 +11,6 @@ import * as StyleUtils from '../../../styles/StyleUtils'; import emojiAssets from '../../../../assets/emojis'; import EmojiPickerMenuItem from '../EmojiPickerMenuItem'; import Text from '../../Text'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../../withLocalize'; import compose from '../../../libs/compose'; import getOperatingSystem from '../../../libs/getOperatingSystem'; @@ -23,6 +22,7 @@ import CategoryShortcutBar from '../CategoryShortcutBar'; import TextInput from '../../TextInput'; import isEnterWhileComposition from '../../../libs/KeyboardShortcut/isEnterWhileComposition'; import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus'; +import useWindowDimensions from '../../../hooks/useWindowDimensions'; const propTypes = { /** Function to add the selected emoji to the main compose text input */ @@ -37,9 +37,6 @@ const propTypes = { // eslint-disable-next-line react/forbid-prop-types frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.object), - /** Props related to the dimensions of the window */ - ...windowDimensionsPropTypes, - ...withLocalizePropTypes, }; @@ -52,7 +49,9 @@ const defaultProps = { const throttleTime = Browser.isMobile() ? 200 : 50; function EmojiPickerMenu(props) { - const {forwardedRef, frequentlyUsedEmojis, preferredSkinTone, onEmojiSelected, preferredLocale, isSmallScreenWidth, windowHeight, translate} = props; + const {forwardedRef, frequentlyUsedEmojis, preferredSkinTone, onEmojiSelected, preferredLocale, translate} = props; + + const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); // Ref for the emoji search input const searchInputRef = useRef(null); @@ -528,7 +527,6 @@ EmojiPickerMenu.propTypes = propTypes; EmojiPickerMenu.defaultProps = defaultProps; export default compose( - withWindowDimensions, withLocalize, withOnyx({ preferredSkinTone: { diff --git a/src/components/FixedFooter.js b/src/components/FixedFooter.js deleted file mode 100644 index bad2639ae7e8..000000000000 --- a/src/components/FixedFooter.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import PropTypes from 'prop-types'; -import styles from '../styles/styles'; - -const propTypes = { - /** Children to wrap in FixedFooter. */ - children: PropTypes.node.isRequired, - - /** Styles to be assigned to Container */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - style: [], -}; - -function FixedFooter(props) { - return {props.children}; -} - -FixedFooter.propTypes = propTypes; -FixedFooter.defaultProps = defaultProps; -FixedFooter.displayName = 'FixedFooter'; -export default FixedFooter; diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx new file mode 100644 index 000000000000..c44b9bf3d0e0 --- /dev/null +++ b/src/components/FixedFooter.tsx @@ -0,0 +1,19 @@ +import React, {ReactNode} from 'react'; +import {StyleProp, View, ViewStyle} from 'react-native'; +import styles from '../styles/styles'; + +type FixedFooterProps = { + /** Children to wrap in FixedFooter. */ + children: ReactNode; + + /** Styles to be assigned to Container */ + style: Array>; +}; + +function FixedFooter({style = [], children}: FixedFooterProps) { + return {children}; +} + +FixedFooter.displayName = 'FixedFooter'; + +export default FixedFooter; diff --git a/src/components/FloatingActionButton.js b/src/components/FloatingActionButton.js index d6f5b907ace0..ef97cd1822a2 100644 --- a/src/components/FloatingActionButton.js +++ b/src/components/FloatingActionButton.js @@ -6,7 +6,7 @@ import * as Expensicons from './Icon/Expensicons'; import styles from '../styles/styles'; import * as StyleUtils from '../styles/StyleUtils'; import themeColors from '../styles/themes/default'; -import Tooltip from './Tooltip'; +import Tooltip from './Tooltip/PopoverAnchorTooltip'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; import variables from '../styles/variables'; diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index ada40c24ed89..add58dbef18c 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -100,7 +100,7 @@ function getInitialValueByType(valueType) { } function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) { - const inputRefs = useRef(null); + const inputRefs = useRef({}); const touchedInputs = useRef({}); const [inputValues, setInputValues] = useState({}); const [errors, setErrors] = useState({}); @@ -204,8 +204,10 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC const registerInput = useCallback( (inputID, propsToParse = {}) => { - const newRef = propsToParse.ref || createRef(); - inputRefs[inputID] = newRef; + const newRef = inputRefs.current[inputID] || propsToParse.ref || createRef(); + if (inputRefs.current[inputID] !== newRef) { + inputRefs.current[inputID] = newRef; + } if (!_.isUndefined(propsToParse.value)) { inputValues[inputID] = propsToParse.value; diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js index 3d9fd37d6f22..82e70b68b3f0 100644 --- a/src/components/Form/FormWrapper.js +++ b/src/components/Form/FormWrapper.js @@ -105,8 +105,8 @@ function FormWrapper(props) { footerContent={footerContent} onFixTheErrorsLinkPressed={() => { const errorFields = !_.isEmpty(errors) ? errors : formState.errorFields; - const focusKey = _.find(_.keys(inputRefs), (key) => _.keys(errorFields).includes(key)); - const focusInput = inputRefs[focusKey].current; + const focusKey = _.find(_.keys(inputRefs.current), (key) => _.keys(errorFields).includes(key)); + const focusInput = inputRefs.current[focusKey].current; // Dismiss the keyboard for non-text fields by checking if the component has the isFocused method, as only TextInput has this method. if (typeof focusInput.isFocused !== 'function') { diff --git a/src/components/Icon/Illustrations.js b/src/components/Icon/Illustrations.js index 0e39872a3da6..c9a86cf8f10c 100644 --- a/src/components/Icon/Illustrations.js +++ b/src/components/Icon/Illustrations.js @@ -46,6 +46,7 @@ import TreasureChest from '../../../assets/images/simple-illustrations/simple-il import ThumbsUpStars from '../../../assets/images/simple-illustrations/simple-illustration__thumbsupstars.svg'; import Hands from '../../../assets/images/product-illustrations/home-illustration-hands.svg'; import HandEarth from '../../../assets/images/simple-illustrations/simple-illustration__handearth.svg'; +import SmartScan from '../../../assets/images/product-illustrations/simple-illustration__smartscan.svg'; export { Abracadabra, @@ -96,4 +97,5 @@ export { ThumbsUpStars, Hands, HandEarth, + SmartScan, }; diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index ba035c8b3baf..2b992e462e34 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -154,6 +154,10 @@ function OptionRowLHN(props) { const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText; const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(optionItem); + const isGroupChat = + optionItem.type === CONST.REPORT.TYPE.CHAT && _.isEmpty(optionItem.chatType) && !optionItem.isThread && lodashGet(optionItem, 'displayNamesWithTooltips.length', 0) > 2; + const fullTitle = isGroupChat ? ReportUtils.getDisplayNamesStringFromTooltips(optionItem.displayNamesWithTooltips) : optionItem.text; + return ( - {optionItem.isLastMessageDeletedParentAction ? translate('parentReportAction.deletedMessage') : optionItem.alternateText} + {optionItem.alternateText} ) : null} diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index 3386dbe8c8cd..e93e3690138e 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -4,7 +4,6 @@ import _ from 'underscore'; import PropTypes from 'prop-types'; import React, {useEffect, useRef, useMemo} from 'react'; import {deepEqual} from 'fast-equals'; -import {withReportCommentDrafts} from '../OnyxProvider'; import SidebarUtils from '../../libs/SidebarUtils'; import compose from '../../libs/compose'; import ONYXKEYS from '../../ONYXKEYS'; @@ -164,14 +163,10 @@ const personalDetailsSelector = (personalDetails) => */ export default React.memo( compose( - withReportCommentDrafts({ - propName: 'comment', - transformValue: (drafts, props) => { - const draftKey = `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${props.reportID}`; - return lodashGet(drafts, draftKey, ''); - }, - }), withOnyx({ + comment: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, + }, fullReport: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, }, diff --git a/src/components/LocationErrorMessage/BaseLocationErrorMessage.js b/src/components/LocationErrorMessage/BaseLocationErrorMessage.js index bdcd6bed3638..3a638f3e999e 100644 --- a/src/components/LocationErrorMessage/BaseLocationErrorMessage.js +++ b/src/components/LocationErrorMessage/BaseLocationErrorMessage.js @@ -59,6 +59,7 @@ function BaseLocationErrorMessage({onClose, onAllowLocationLinkPress, locationEr e.preventDefault()} style={[styles.touchableButtonImage]} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={translate('common.close')} diff --git a/src/components/LottieAnimations.js b/src/components/LottieAnimations.ts similarity index 100% rename from src/components/LottieAnimations.js rename to src/components/LottieAnimations.ts diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js index 8ae4672e758e..ab0b77c21653 100644 --- a/src/components/MoneyReportHeader.js +++ b/src/components/MoneyReportHeader.js @@ -121,10 +121,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt shouldShowPaymentOptions style={[styles.pv2]} formattedAmount={formattedAmount} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, - }} /> )} diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 5ca08bf82f89..0b266351a60c 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -6,6 +6,7 @@ import _ from 'underscore'; import {View} from 'react-native'; import lodashGet from 'lodash/get'; import {useIsFocused} from '@react-navigation/native'; +import {isEmpty} from 'lodash'; import Text from './Text'; import styles from '../styles/styles'; import * as ReportUtils from '../libs/ReportUtils'; @@ -42,6 +43,7 @@ import * as IOU from '../libs/actions/IOU'; import * as TransactionUtils from '../libs/TransactionUtils'; import * as PolicyUtils from '../libs/PolicyUtils'; import * as MoneyRequestUtils from '../libs/MoneyRequestUtils'; +import {iouDefaultProps, iouPropTypes} from '../pages/iou/propTypes'; const propTypes = { /** Callback to inform parent modal of success */ @@ -165,6 +167,9 @@ const propTypes = { /** Collection of tags attached to a policy */ policyTags: tagPropTypes, + + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ + iou: iouPropTypes, }; const defaultProps = { @@ -199,6 +204,7 @@ const defaultProps = { isScanRequest: false, shouldShowSmartScanFields: true, isPolicyExpenseChat: false, + iou: iouDefaultProps, }; function MoneyRequestConfirmationList(props) { @@ -506,7 +512,11 @@ function MoneyRequestConfirmationList(props) { policyID={props.policyID} shouldShowPaymentOptions buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} - anchorAlignment={{ + kycWallAnchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + paymentMethodDropdownAnchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} @@ -538,7 +548,6 @@ function MoneyRequestConfirmationList(props) { const {image: receiptImage, thumbnail: receiptThumbnail} = props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, props.receiptPath, props.receiptFilename) : {}; - return ( @@ -753,5 +762,8 @@ export default compose( policy: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, }, + iou: { + key: ONYXKEYS.IOU, + }, }), )(MoneyRequestConfirmationList); diff --git a/src/components/OnyxProvider.tsx b/src/components/OnyxProvider.tsx index 3bd4ca52c3be..8682e832debc 100644 --- a/src/components/OnyxProvider.tsx +++ b/src/components/OnyxProvider.tsx @@ -6,7 +6,7 @@ import ComposeProviders from './ComposeProviders'; // Set up any providers for individual keys. This should only be used in cases where many components will subscribe to // the same key (e.g. FlatList renderItem components) const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK); -const [withPersonalDetails, PersonalDetailsProvider] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST); +const [withPersonalDetails, PersonalDetailsProvider, , usePersonalDetails] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST); const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE); const [withReportActionsDrafts, ReportActionsDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS); const [withBlockedFromConcierge, BlockedFromConciergeProvider] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); @@ -45,6 +45,7 @@ export default OnyxProvider; export { withNetwork, withPersonalDetails, + usePersonalDetails, withReportActionsDrafts, withCurrentDate, withBlockedFromConcierge, diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 4ffddd700359..0125fc8e178e 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -9,7 +9,7 @@ import OptionsList from '../OptionsList'; import CONST from '../../CONST'; import styles from '../../styles/styles'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; -import withNavigationFocus, {withNavigationFocusPropTypes} from '../withNavigationFocus'; +import withNavigationFocus from '../withNavigationFocus'; import TextInput from '../TextInput'; import ArrowKeyFocusManager from '../ArrowKeyFocusManager'; import KeyboardShortcut from '../../libs/KeyboardShortcut'; @@ -32,9 +32,11 @@ const propTypes = { /** List styles for OptionsList */ listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + /** Whether navigation is focused */ + isFocused: PropTypes.bool.isRequired, + ...optionsSelectorPropTypes, ...withLocalizePropTypes, - ...withNavigationFocusPropTypes, }; const defaultProps = { diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js index bd5fe8162d2e..66e9df30b5c3 100644 --- a/src/components/PDFView/index.js +++ b/src/components/PDFView/index.js @@ -15,11 +15,11 @@ import PDFPasswordForm from './PDFPasswordForm'; import * as pdfViewPropTypes from './pdfViewPropTypes'; import withWindowDimensions from '../withWindowDimensions'; import withLocalize from '../withLocalize'; -import Text from '../Text'; import compose from '../../libs/compose'; import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback'; import Log from '../../libs/Log'; import ONYXKEYS from '../../ONYXKEYS'; +import Text from '../Text'; /** * Each page has a default border. The app should take this size into account @@ -283,7 +283,7 @@ class PDFView extends Component { }) => this.setState({containerWidth: width, containerHeight: height})} > {this.props.translate('attachmentView.failedToLoadPDF')}} + error={{this.props.translate('attachmentView.failedToLoadPDF')}} loading={} file={this.props.sourceURL} options={{ diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js index 0bd9936c628b..fc1a204b3324 100644 --- a/src/components/PDFView/index.native.js +++ b/src/components/PDFView/index.native.js @@ -143,7 +143,7 @@ class PDFView extends Component { {this.state.failedToLoadPDF && ( - {this.props.translate('attachmentView.failedToLoadPDF')} + {this.props.translate('attachmentView.failedToLoadPDF')} )} {this.state.shouldAttemptPDFLoad && ( diff --git a/src/components/PDFView/pdfViewPropTypes.js b/src/components/PDFView/pdfViewPropTypes.js index 21ebc880301e..4568ed527983 100644 --- a/src/components/PDFView/pdfViewPropTypes.js +++ b/src/components/PDFView/pdfViewPropTypes.js @@ -27,6 +27,9 @@ const propTypes = { /** Should focus to the password input */ isFocused: PropTypes.bool, + /** Styles for the error label */ + errorLabelStyles: stylePropTypes, + ...windowDimensionsPropTypes, }; @@ -39,6 +42,7 @@ const defaultProps = { onScaleChanged: () => {}, onLoadComplete: () => {}, isFocused: false, + errorLabelStyles: [], }; export {propTypes, defaultProps}; diff --git a/src/components/Reactions/AddReactionBubble.js b/src/components/Reactions/AddReactionBubble.js index 656188559334..8a862c7e1b96 100644 --- a/src/components/Reactions/AddReactionBubble.js +++ b/src/components/Reactions/AddReactionBubble.js @@ -1,7 +1,7 @@ import React, {useRef, useEffect} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; -import Tooltip from '../Tooltip'; +import Tooltip from '../Tooltip/PopoverAnchorTooltip'; import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; import Icon from '../Icon'; diff --git a/src/components/ReportActionItem/MoneyReportView.js b/src/components/ReportActionItem/MoneyReportView.js index 2ffd0359d9d6..3f9b8bf53837 100644 --- a/src/components/ReportActionItem/MoneyReportView.js +++ b/src/components/ReportActionItem/MoneyReportView.js @@ -7,7 +7,6 @@ import styles from '../../styles/styles'; import themeColors from '../../styles/themes/default'; import * as ReportUtils from '../../libs/ReportUtils'; import * as StyleUtils from '../../styles/StyleUtils'; -import CONST from '../../CONST'; import Text from '../Text'; import Icon from '../Icon'; import * as Expensicons from '../Icon/Expensicons'; @@ -41,9 +40,9 @@ function MoneyReportView(props) { const subAmountTextStyles = [styles.taskTitleMenuItem, styles.alignSelfCenter, StyleUtils.getFontSizeStyle(variables.fontSizeh1), StyleUtils.getColorStyle(themeColors.textSupporting)]; return ( - + - + - - {shouldShowBreakdown ? ( - <> - - - - {translate('cardTransactions.outOfPocket')} - - - - - {formattedOutOfPocketAmount} - - - - - - - {translate('cardTransactions.companySpend')} - + {shouldShowBreakdown ? ( + <> + + + + {translate('cardTransactions.outOfPocket')} + + + + + {formattedOutOfPocketAmount} + + - - - {formattedCompanySpendAmount} - + + + + {translate('cardTransactions.companySpend')} + + + + + {formattedCompanySpendAmount} + + - - - ) : undefined} - + + ) : undefined} + + ); } diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index ab95fb749ac1..19f4a5b8e103 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -106,6 +106,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should const isExpensifyCardTransaction = TransactionUtils.isExpensifyCardTransaction(transaction); const cardProgramName = isExpensifyCardTransaction ? CardUtils.getCardDescription(transactionCardID) : ''; + // Flags for allowing or disallowing editing a money request const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction) && !isExpensifyCardTransaction; @@ -181,8 +182,8 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should titleIcon={Expensicons.Checkmark} description={amountDescription} titleStyle={styles.newKansasLarge} - interactive={canEdit} - shouldShowRightIcon={canEdit} + interactive={canEdit && !isSettled} + shouldShowRightIcon={canEdit && !isSettled} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} brickRoadIndicator={hasErrors && transactionAmount === 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} @@ -206,8 +207,8 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DISTANCE))} /> @@ -230,8 +231,8 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} brickRoadIndicator={hasErrors && transactionDate === '' ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index ef70502f30f7..bdeec2640cdc 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -245,9 +245,13 @@ function ReportPreview(props) { onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)} enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} - style={[styles.mt3]} shouldShowPaymentOptions - anchorAlignment={{ + style={[styles.mt3]} + kycWallAnchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + paymentMethodDropdownAnchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} diff --git a/src/components/RoomNameInput/roomNameInputPropTypes.js b/src/components/RoomNameInput/roomNameInputPropTypes.js index ab1ac37d32c8..7f8292f0123e 100644 --- a/src/components/RoomNameInput/roomNameInputPropTypes.js +++ b/src/components/RoomNameInput/roomNameInputPropTypes.js @@ -1,5 +1,4 @@ import PropTypes from 'prop-types'; -import {withNavigationFocusPropTypes} from '../withNavigationFocus'; const propTypes = { /** Callback to execute when the text input is modified correctly */ @@ -29,7 +28,8 @@ const propTypes = { /** Whether we should wait before focusing the TextInput, useful when using transitions on Android */ shouldDelayFocus: PropTypes.bool, - ...withNavigationFocusPropTypes, + /** Whether navigation is focused */ + isFocused: PropTypes.bool.isRequired, }; const defaultProps = { diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index 287f3210b14d..2989fd103850 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -70,8 +70,14 @@ const propTypes = { /** Whether we should show a loading state for the main button */ isLoading: PropTypes.bool, - /** The anchor alignment of the popover menu */ - anchorAlignment: PropTypes.shape({ + /** The anchor alignment of the popover menu for payment method dropdown */ + paymentMethodDropdownAnchorAlignment: PropTypes.shape({ + horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), + vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), + }), + + /** The anchor alignment of the popover menu for KYC wall popover */ + kycWallAnchorAlignment: PropTypes.shape({ horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), }), @@ -96,8 +102,12 @@ const defaultProps = { policyID: '', formattedAmount: '', buttonSize: CONST.DROPDOWN_BUTTON_SIZE.MEDIUM, - anchorAlignment: { - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + kycWallAnchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, // button is at left, so horizontal anchor is at LEFT + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP + }, + paymentMethodDropdownAnchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, // caret for dropdown is at right, so horizontal anchor is at RIGHT vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP }, }; @@ -105,7 +115,8 @@ const defaultProps = { function SettlementButton({ addDebitCardRoute, addBankAccountRoute, - anchorAlignment, + kycWallAnchorAlignment, + paymentMethodDropdownAnchorAlignment, betas, buttonSize, chatReportID, @@ -210,7 +221,7 @@ function SettlementButton({ source={CONST.KYC_WALL_SOURCE.REPORT} chatReportID={chatReportID} iouReport={iouReport} - anchorAlignment={anchorAlignment} + anchorAlignment={kycWallAnchorAlignment} > {(triggerKYCFlow, buttonRef) => ( )} diff --git a/src/components/SwipeableView/index.js b/src/components/SwipeableView/index.js deleted file mode 100644 index 96640b107608..000000000000 --- a/src/components/SwipeableView/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export default ({children}) => children; - -// Swipeable View is available just on Android/iOS for now. diff --git a/src/components/SwipeableView/index.native.js b/src/components/SwipeableView/index.native.tsx similarity index 65% rename from src/components/SwipeableView/index.native.js rename to src/components/SwipeableView/index.native.tsx index 2f1148721af1..ac500f025016 100644 --- a/src/components/SwipeableView/index.native.js +++ b/src/components/SwipeableView/index.native.tsx @@ -1,41 +1,34 @@ import React, {useRef} from 'react'; import {PanResponder, View} from 'react-native'; -import PropTypes from 'prop-types'; import CONST from '../../CONST'; +import SwipeableViewProps from './types'; -const propTypes = { - children: PropTypes.element.isRequired, - - /** Callback to fire when the user swipes down on the child content */ - onSwipeDown: PropTypes.func.isRequired, -}; - -function SwipeableView(props) { +function SwipeableView({children, onSwipeDown}: SwipeableViewProps) { const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT; const oldYRef = useRef(0); const panResponder = useRef( PanResponder.create({ - // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance - // & swipe direction is downwards + // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards + // eslint-disable-next-line @typescript-eslint/naming-convention onMoveShouldSetPanResponderCapture: (_event, gestureState) => { if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) { return true; } oldYRef.current = gestureState.dy; + return false; }, // Calls the callback when the swipe down is released; after the completion of the gesture - onPanResponderRelease: props.onSwipeDown, + onPanResponderRelease: onSwipeDown, }), ).current; return ( // eslint-disable-next-line react/jsx-props-no-spreading - {props.children} + {children} ); } -SwipeableView.propTypes = propTypes; SwipeableView.displayName = 'SwipeableView'; export default SwipeableView; diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx new file mode 100644 index 000000000000..335c3e7dcf03 --- /dev/null +++ b/src/components/SwipeableView/index.tsx @@ -0,0 +1,4 @@ +import SwipeableViewProps from './types'; + +// Swipeable View is available just on Android/iOS for now. +export default ({children}: SwipeableViewProps) => children; diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts new file mode 100644 index 000000000000..560df7ef5a45 --- /dev/null +++ b/src/components/SwipeableView/types.ts @@ -0,0 +1,11 @@ +import {ReactNode} from 'react'; + +type SwipeableViewProps = { + /** The content to be rendered within the SwipeableView */ + children: ReactNode; + + /** Callback to fire when the user swipes down on the child content */ + onSwipeDown: () => void; +}; + +export default SwipeableViewProps; diff --git a/src/components/TestToolRow.js b/src/components/TestToolRow.tsx similarity index 67% rename from src/components/TestToolRow.js rename to src/components/TestToolRow.tsx index 8dcd1ba35f43..540c9dbc5068 100644 --- a/src/components/TestToolRow.js +++ b/src/components/TestToolRow.tsx @@ -1,29 +1,27 @@ import React from 'react'; -import PropTypes from 'prop-types'; import {View} from 'react-native'; import styles from '../styles/styles'; import Text from './Text'; -const propTypes = { +type TestToolRowProps = { /** Title of control */ - title: PropTypes.string.isRequired, + title: string; /** Control component jsx */ - children: PropTypes.node.isRequired, + children: React.ReactNode; }; -function TestToolRow(props) { +function TestToolRow({title, children}: TestToolRowProps) { return ( - {props.title} + {title} - {props.children} + {children} ); } -TestToolRow.propTypes = propTypes; TestToolRow.displayName = 'TestToolRow'; export default TestToolRow; diff --git a/src/components/Text.js b/src/components/Text.tsx similarity index 61% rename from src/components/Text.js rename to src/components/Text.tsx index 83b6be8fffb0..60a59aae1520 100644 --- a/src/components/Text.js +++ b/src/components/Text.tsx @@ -1,54 +1,46 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import _ from 'underscore'; +import React, {ForwardedRef} from 'react'; // eslint-disable-next-line no-restricted-imports import {Text as RNText} from 'react-native'; +import type {TextStyle} from 'react-native'; import fontFamily from '../styles/fontFamily'; import themeColors from '../styles/themes/default'; import variables from '../styles/variables'; -const propTypes = { +type TextProps = { /** The color of the text */ - color: PropTypes.string, + color?: string; /** The size of the text */ - fontSize: PropTypes.number, + fontSize?: number; /** The alignment of the text */ - textAlign: PropTypes.string, + textAlign?: 'left' | 'right' | 'auto' | 'center' | 'justify'; /** Any children to display */ - children: PropTypes.node, + children: React.ReactNode; /** The family of the font to use */ - family: PropTypes.string, + family?: keyof typeof fontFamily; /** Any additional styles to apply */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, -}; -const defaultProps = { - color: themeColors.text, - fontSize: variables.fontSizeNormal, - family: 'EXP_NEUE', - textAlign: 'left', - children: null, - style: {}, + style?: TextStyle | TextStyle[]; }; -const Text = React.forwardRef(({color, fontSize, textAlign, children, family, style, ...props}, ref) => { +function Text( + {color = themeColors.text, fontSize = variables.fontSizeNormal, textAlign = 'left', children = null, family = 'EXP_NEUE', style = {}, ...props}: TextProps, + ref: ForwardedRef, +) { // If the style prop is an array of styles, we need to mix them all together - const mergedStyles = !_.isArray(style) + const mergedStyles = !Array.isArray(style) ? style - : _.reduce( - style, + : style.reduce( (finalStyles, s) => ({ ...finalStyles, ...s, }), {}, ); - const componentStyle = { + const componentStyle: TextStyle = { color, fontSize, textAlign, @@ -71,10 +63,8 @@ const Text = React.forwardRef(({color, fontSize, textAlign, children, family, st {children} ); -}); +} -Text.propTypes = propTypes; -Text.defaultProps = defaultProps; Text.displayName = 'Text'; -export default Text; +export default React.forwardRef(Text); diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js index c07a3fc8ee44..3aac98fa1275 100644 --- a/src/components/ThreeDotsMenu/index.js +++ b/src/components/ThreeDotsMenu/index.js @@ -6,7 +6,7 @@ import Icon from '../Icon'; import PopoverMenu from '../PopoverMenu'; import styles from '../../styles/styles'; import useLocalize from '../../hooks/useLocalize'; -import Tooltip from '../Tooltip'; +import Tooltip from '../Tooltip/PopoverAnchorTooltip'; import * as Expensicons from '../Icon/Expensicons'; import ThreeDotsMenuItemPropTypes from './ThreeDotsMenuItemPropTypes'; import CONST from '../../CONST'; diff --git a/src/components/Tooltip/BaseTooltip.js b/src/components/Tooltip/BaseTooltip.js index 1f60560be5ff..7ef80c552980 100644 --- a/src/components/Tooltip/BaseTooltip.js +++ b/src/components/Tooltip/BaseTooltip.js @@ -52,7 +52,7 @@ function chooseBoundingBox(target, clientX, clientY) { return target.getBoundingClientRect(); } -function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey, shouldHandleScroll, shiftHorizontal, shiftVertical}) { +function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey, shouldHandleScroll, shiftHorizontal, shiftVertical, tooltipRef}) { const {preferredLocale} = useLocalize(); const {windowWidth} = useWindowDimensions(); @@ -197,6 +197,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, { + // eslint-disable-next-line + const tooltipNode = tooltipRef.current ? tooltipRef.current._childNode : null; + if ( + isOpen && + popover && + popover.anchorRef && + popover.anchorRef.current && + tooltipNode && + (tooltipNode.contains(popover.anchorRef.current) || tooltipNode === popover.anchorRef.current) + ) { + return true; + } + + return false; + }, [isOpen, popover]); + + if (!shouldRender || isPopoverRelatedToTooltipOpen) { + return children; + } + + return ( + + {children} + + ); +} + +PopoverAnchorTooltip.displayName = 'PopoverAnchorTooltip'; +PopoverAnchorTooltip.propTypes = propTypes; +PopoverAnchorTooltip.defaultProps = defaultProps; + +export default PopoverAnchorTooltip; diff --git a/src/components/Tooltip/tooltipPropTypes.js b/src/components/Tooltip/tooltipPropTypes.js index 2ddf8120d58c..684a102e0339 100644 --- a/src/components/Tooltip/tooltipPropTypes.js +++ b/src/components/Tooltip/tooltipPropTypes.js @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import refPropTypes from '../refPropTypes'; import variables from '../../styles/variables'; import CONST from '../../CONST'; @@ -31,6 +32,9 @@ const propTypes = { /** passes this down to Hoverable component to decide whether to handle the scroll behaviour to show hover once the scroll ends */ shouldHandleScroll: PropTypes.bool, + + /** Reference to the tooltip container */ + tooltipRef: refPropTypes, }; const defaultProps = { @@ -42,6 +46,7 @@ const defaultProps = { renderTooltipContent: undefined, renderTooltipContentKey: [], shouldHandleScroll: false, + tooltipRef: () => {}, }; export {propTypes, defaultProps}; diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js index d89c9bc7a953..ed1b71c8fb0f 100755 --- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js +++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js @@ -14,7 +14,7 @@ import themeColors from '../../styles/themes/default'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import compose from '../../libs/compose'; -import Tooltip from '../Tooltip'; +import Tooltip from '../Tooltip/PopoverAnchorTooltip'; import {propTypes as videoChatButtonAndMenuPropTypes, defaultProps} from './videoChatButtonAndMenuPropTypes'; import * as Session from '../../libs/actions/Session'; import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback'; diff --git a/src/components/createOnyxContext.tsx b/src/components/createOnyxContext.tsx index d142e551012f..a0ac5942b098 100644 --- a/src/components/createOnyxContext.tsx +++ b/src/components/createOnyxContext.tsx @@ -1,4 +1,4 @@ -import React, {ComponentType, ForwardRefExoticComponent, ForwardedRef, PropsWithoutRef, ReactNode, RefAttributes, createContext, forwardRef} from 'react'; +import React, {ComponentType, ForwardRefExoticComponent, ForwardedRef, PropsWithoutRef, ReactNode, RefAttributes, createContext, forwardRef, useContext} from 'react'; import {withOnyx} from 'react-native-onyx'; import Str from 'expensify-common/lib/str'; import getComponentDisplayName from '../libs/getComponentDisplayName'; @@ -29,7 +29,12 @@ type WithOnyxKey = WrapComponentWithConsumer; // createOnyxContext return type -type CreateOnyxContext = [WithOnyxKey, ComponentType, TOnyxKey>>, React.Context>]; +type CreateOnyxContext = [ + WithOnyxKey, + ComponentType, TOnyxKey>>, + React.Context>, + () => OnyxValues[TOnyxKey], +]; export default (onyxKeyName: TOnyxKey): CreateOnyxContext => { const Context = createContext>(null); @@ -77,5 +82,13 @@ export default (onyxKeyName: TOnyxKey): CreateOnyxCon }; } - return [withOnyxKey, ProviderWithOnyx, Context]; + const useOnyxContext = () => { + const value = useContext(Context); + if (value === null) { + throw new Error(`useOnyxContext must be used within a OnyxProvider [key: ${onyxKeyName}]`); + } + return value; + }; + + return [withOnyxKey, ProviderWithOnyx, Context, useOnyxContext]; }; diff --git a/src/components/withNavigationFocus.js b/src/components/withNavigationFocus.js deleted file mode 100644 index f934f038e311..000000000000 --- a/src/components/withNavigationFocus.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {useIsFocused} from '@react-navigation/native'; -import getComponentDisplayName from '../libs/getComponentDisplayName'; -import refPropTypes from './refPropTypes'; - -const withNavigationFocusPropTypes = { - isFocused: PropTypes.bool.isRequired, -}; - -export default function withNavigationFocus(WrappedComponent) { - function WithNavigationFocus(props) { - const isFocused = useIsFocused(); - return ( - - ); - } - - WithNavigationFocus.displayName = `withNavigationFocus(${getComponentDisplayName(WrappedComponent)})`; - WithNavigationFocus.propTypes = { - forwardedRef: refPropTypes, - }; - WithNavigationFocus.defaultProps = { - forwardedRef: undefined, - }; - return React.forwardRef((props, ref) => ( - - )); -} - -export {withNavigationFocusPropTypes}; diff --git a/src/components/withNavigationFocus.tsx b/src/components/withNavigationFocus.tsx new file mode 100644 index 000000000000..f3f1d3561d9c --- /dev/null +++ b/src/components/withNavigationFocus.tsx @@ -0,0 +1,26 @@ +import React, {ComponentType, ForwardedRef, RefAttributes} from 'react'; +import {useIsFocused} from '@react-navigation/native'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; + +type WithNavigationFocusProps = { + isFocused: boolean; +}; + +export default function withNavigationFocus( + WrappedComponent: ComponentType>, +): (props: Omit & React.RefAttributes) => React.ReactElement | null { + function WithNavigationFocus(props: Omit, ref: ForwardedRef) { + const isFocused = useIsFocused(); + return ( + + ); + } + + WithNavigationFocus.displayName = `withNavigationFocus(${getComponentDisplayName(WrappedComponent)})`; + return React.forwardRef(WithNavigationFocus); +} diff --git a/src/hooks/usePermissions.js b/src/hooks/usePermissions.js deleted file mode 100644 index 1c31ffc8bb64..000000000000 --- a/src/hooks/usePermissions.js +++ /dev/null @@ -1,15 +0,0 @@ -import _ from 'underscore'; -import {useContext, useMemo} from 'react'; -import Permissions from '../libs/Permissions'; -import {BetasContext} from '../components/OnyxProvider'; - -export default function usePermissions() { - const betas = useContext(BetasContext); - return useMemo(() => { - const permissions = {}; - _.each(Permissions, (checkerFunction, beta) => { - permissions[beta] = checkerFunction(betas); - }); - return permissions; - }, [betas]); -} diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts new file mode 100644 index 000000000000..09e87554b5c3 --- /dev/null +++ b/src/hooks/usePermissions.ts @@ -0,0 +1,24 @@ +import {useContext, useMemo} from 'react'; +import Permissions from '../libs/Permissions'; +import {BetasContext} from '../components/OnyxProvider'; + +type PermissionKey = keyof typeof Permissions; +type UsePermissions = Partial>; +let permissionKey: PermissionKey; + +export default function usePermissions(): UsePermissions { + const betas = useContext(BetasContext); + return useMemo(() => { + const permissions: UsePermissions = {}; + + for (permissionKey in Permissions) { + if (betas) { + const checkerFunction = Permissions[permissionKey]; + + permissions[permissionKey] = checkerFunction(betas); + } + } + + return permissions; + }, [betas]); +} diff --git a/src/languages/en.ts b/src/languages/en.ts index bc6bba610475..1e8989e3e2a6 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -550,7 +550,7 @@ export default { deleteConfirmation: 'Are you sure that you want to delete this request?', settledExpensify: 'Paid', settledElsewhere: 'Paid elsewhere', - settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => `Pay ${formattedAmount} with Expensify`, + settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with Expensify` : `Pay with Expensify`), payElsewhere: 'Pay elsewhere', nextSteps: 'Next Steps', requestAmount: ({amount}: RequestAmountParams) => `request ${amount}`, @@ -869,6 +869,8 @@ export default { assignedCards: 'Assigned cards', assignedCardsDescription: 'These are cards assigned by a Workspace admin to manage company spend.', expensifyCard: 'Expensify Card', + walletActivationPending: "We're reviewing your information, please check back in a few minutes!", + walletActivationFailed: 'Unfortunately your wallet cannot be enabled at this time. Please chat with Concierge for further assistance.', }, cardPage: { expensifyCard: 'Expensify Card', @@ -876,6 +878,10 @@ export default { virtualCardNumber: 'Virtual card number', physicalCardNumber: 'Physical card number', reportFraud: 'Report virtual card fraud', + reviewTransaction: 'Review transaction', + suspiciousBannerTitle: 'Suspicious transaction', + suspiciousBannerDescription: 'We noticed suspicious transaction on your card. Tap below to review.', + cardLocked: "Your card is temporarily locked while our team reviews your company's account.", cardDetails: { cardNumber: 'Virtual card number', expiration: 'Expiration', diff --git a/src/languages/es.ts b/src/languages/es.ts index df34eb0b1759..5f916711d221 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -542,7 +542,7 @@ export default { deleteConfirmation: '¿Estás seguro de que quieres eliminar este pedido?', settledExpensify: 'Pagado', settledElsewhere: 'Pagado de otra forma', - settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => `Pagar ${formattedAmount} con Expensify`, + settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con Expensify` : `Pagar con Expensify`), payElsewhere: 'Pagar de otra forma', nextSteps: 'Pasos Siguientes', requestAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, @@ -865,6 +865,8 @@ export default { assignedCards: 'Tarjetas asignadas', assignedCardsDescription: 'Son tarjetas asignadas por un administrador del Espacio de Trabajo para gestionar los gastos de la empresa.', expensifyCard: 'Tarjeta Expensify', + walletActivationPending: 'Estamos revisando su información, por favor vuelve en unos minutos.', + walletActivationFailed: 'Lamentablemente, no podemos activar tu billetera en este momento. Chatea con Concierge para obtener más ayuda.', }, cardPage: { expensifyCard: 'Tarjeta Expensify', @@ -872,6 +874,10 @@ export default { virtualCardNumber: 'Número de la tarjeta virtual', physicalCardNumber: 'Número de la tarjeta física', reportFraud: 'Reportar fraude con la tarjeta virtual', + reviewTransaction: 'Revisar transacción', + suspiciousBannerTitle: 'Transacción sospechosa', + suspiciousBannerDescription: 'Hemos detectado una transacción sospechosa en la tarjeta. Haga click abajo para revisarla.', + cardLocked: 'La tarjeta está temporalmente bloqueada mientras nuestro equipo revisa la cuenta de tu empresa.', cardDetails: { cardNumber: 'Número de tarjeta virtual', expiration: 'Expiración', diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js index 890db2b45ad4..42caad5b3969 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.js @@ -12,11 +12,11 @@ const isAtLeastOneCentralPaneNavigatorInState = (state) => _.find(state.routes, /** * @param {Object} state - react-navigation state - * @returns {String|undefined} + * @returns {String} */ const getTopMostReportIDFromRHP = (state) => { if (!state) { - return; + return ''; } const topmostRightPane = lodashFindLast(state.routes, (route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR); @@ -33,6 +33,8 @@ const getTopMostReportIDFromRHP = (state) => { if (topmostRoute.params && topmostRoute.params.reportID) { return topmostRoute.params.reportID; } + + return ''; }; /** * Adds report route without any specific reportID to the state. diff --git a/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts b/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts new file mode 100644 index 000000000000..8819cc8aa47c --- /dev/null +++ b/src/libs/OnyxSelectors/reportWithoutHasDraftSelector.ts @@ -0,0 +1,9 @@ +import {OnyxKeyValue} from '../../ONYXKEYS'; + +export default function reportWithoutHasDraftSelector(report: OnyxKeyValue<'report_'>) { + if (!report) { + return report; + } + const {hasDraft, ...reportWithoutHasDraft} = report; + return reportWithoutHasDraft; +} diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 75806077daca..e909f0d86453 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -398,7 +398,9 @@ function getLastMessageTextForReport(report) { reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && ReportActionUtils.isMoneyRequestAction(reportAction), ); - lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastIOUMoneyReport, true); + lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastIOUMoneyReport, true, ReportUtils.isChatReport(report)); + } else if (ReportActionUtils.isReimbursementQueuedAction(lastReportAction)) { + lastMessageTextFromReport = ReportUtils.getReimbursementQueuedActionMessage(lastReportAction, report); } else if (ReportActionUtils.isDeletedParentAction(lastReportAction) && ReportUtils.isChatReport(report)) { lastMessageTextFromReport = ReportUtils.getDeletedParentActionMessageForChatReport(lastReportAction); } else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) { diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index d016e220e147..98a029bde5de 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -91,6 +91,10 @@ function isWhisperAction(reportAction: OnyxEntry): boolean { return (reportAction?.whisperedToAccountIDs ?? []).length > 0; } +function isReimbursementQueuedAction(reportAction: OnyxEntry) { + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED; +} + /** * Returns whether the comment is a thread parent message/the first message in a thread */ @@ -448,6 +452,19 @@ function getLastClosedReportAction(reportActions: ReportActions | null): OnyxEnt return lodashFindLast(sortedReportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) ?? null; } +/** + * The first visible action is the second last action in sortedReportActions which satisfy following conditions: + * 1. That is not pending deletion as pending deletion actions are kept in sortedReportActions in memory. + * 2. That has at least one visible child action. + * 3. While offline all actions in `sortedReportActions` are visible. + * 4. We will get the second last action from filtered actions because the last + * action is always the created action + */ +function getFirstVisibleReportActionID(sortedReportActions: ReportAction[], isOffline: boolean): string { + const sortedFilterReportActions = sortedReportActions.filter((action) => !isDeletedAction(action) || (action?.childVisibleActionCount ?? 0) > 0 || isOffline); + return sortedFilterReportActions.length > 1 ? sortedFilterReportActions[sortedFilterReportActions.length - 2].reportActionID : ''; +} + /** * @returns The latest report action in the `onyxData` or `null` if one couldn't be found */ @@ -636,6 +653,8 @@ export { isThreadParentMessage, isTransactionThread, isWhisperAction, + isReimbursementQueuedAction, shouldReportActionBeVisible, shouldReportActionBeVisibleAsLastAction, + getFirstVisibleReportActionID, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index efd4557aea8b..7a67d28816d7 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -283,6 +283,12 @@ function isSettled(reportID) { return false; } + // In case the payment is scheduled and we are waiting for the payee to set up their wallet, + // consider the report as paid as well. + if (report.isWaitingOnBankAccount && report.statusNum === CONST.REPORT.STATUS.APPROVED) { + return true; + } + return report.statusNum === CONST.REPORT.STATUS.REIMBURSED; } @@ -1202,25 +1208,50 @@ function getDisplayNameForParticipant(accountID, shouldUseShortForm = false) { * @returns {Array} */ function getDisplayNamesWithTooltips(personalDetailsList, isMultipleParticipantReport) { - return _.map(personalDetailsList, (user) => { - const accountID = Number(user.accountID); - const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport) || user.login || ''; - const avatar = UserUtils.getDefaultAvatar(accountID); - - let pronouns = user.pronouns; - if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) { - const pronounTranslationKey = pronouns.replace(CONST.PRONOUNS.PREFIX, ''); - pronouns = Localize.translateLocal(`pronouns.${pronounTranslationKey}`); - } + return _.chain(personalDetailsList) + .map((user) => { + const accountID = Number(user.accountID); + const displayName = getDisplayNameForParticipant(accountID, isMultipleParticipantReport) || user.login || ''; + const avatar = UserUtils.getDefaultAvatar(accountID); + + let pronouns = user.pronouns; + if (pronouns && pronouns.startsWith(CONST.PRONOUNS.PREFIX)) { + const pronounTranslationKey = pronouns.replace(CONST.PRONOUNS.PREFIX, ''); + pronouns = Localize.translateLocal(`pronouns.${pronounTranslationKey}`); + } - return { - displayName, - avatar, - login: user.login || '', - accountID, - pronouns, - }; - }); + return { + displayName, + avatar, + login: user.login || '', + accountID, + pronouns, + }; + }) + .sort((first, second) => { + // First sort by displayName/login + const displayNameLoginOrder = first.displayName.localeCompare(second.displayName); + if (displayNameLoginOrder !== 0) { + return displayNameLoginOrder; + } + + // Then fallback on accountID as the final sorting criteria. + return first.accountID > second.accountID; + }) + .value(); +} + +/** + * Gets a joined string of display names from the list of display name with tooltip objects. + * + * @param {Object} displayNamesWithTooltips + * @returns {String} + */ +function getDisplayNamesStringFromTooltips(displayNamesWithTooltips) { + return _.filter( + _.map(displayNamesWithTooltips, ({displayName}) => displayName), + (displayName) => !_.isEmpty(displayName), + ).join(', '); } /** @@ -1240,6 +1271,25 @@ function getDeletedParentActionMessageForChatReport(reportAction) { return deletedMessageText; } +/** + * Returns the preview message for `REIMBURSEMENTQUEUED` action + * + * @param {Object} reportAction + * @param {Object} report + * @returns {String} + */ +function getReimbursementQueuedActionMessage(reportAction, report) { + const submitterDisplayName = getDisplayNameForParticipant(report.ownerAccountID, true); + let messageKey; + if (lodashGet(reportAction, 'originalMessage.paymentType', '') === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + messageKey = 'iou.waitingOnEnabledWallet'; + } else { + messageKey = 'iou.waitingOnBankAccount'; + } + + return Localize.translateLocal(messageKey, {submitterDisplayName}); +} + /** * Returns the last visible message for a given report after considering the given optimistic actions * @@ -1295,7 +1345,8 @@ function isWaitingForIOUActionFromCurrentUser(report) { } // Money request waiting for current user to add their credit bank account - if (report.hasOutstandingIOU && report.ownerAccountID === currentUserAccountID && report.isWaitingOnBankAccount) { + // hasOutstandingIOU will be false if the user paid, but isWaitingOnBankAccount will be true if user don't have a wallet or bank account setup + if (!report.hasOutstandingIOU && report.isWaitingOnBankAccount && report.ownerAccountID === currentUserAccountID) { return true; } @@ -1487,28 +1538,70 @@ function getTransactionDetails(transaction, createdDateFormat = CONST.DATE.FNS_F * Can only edit if: * * - in case of IOU report - * - the current user is the requestor + * - the current user is the requestor and is not settled yet * - in case of expense report - * - the current user is the requestor + * - the current user is the requestor and is not settled yet * - or the user is an admin on the policy the expense report is tied to * * @param {Object} reportAction * @returns {Boolean} */ function canEditMoneyRequest(reportAction) { - // If the report action i snot IOU type, return true early + const isDeleted = ReportActionsUtils.isDeletedAction(reportAction); + + if (isDeleted) { + return false; + } + + // If the report action is not IOU type, return true early if (reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { return true; } + const moneyRequestReportID = lodashGet(reportAction, 'originalMessage.IOUReportID', 0); + if (!moneyRequestReportID) { return false; } + const moneyRequestReport = getReport(moneyRequestReportID); const isReportSettled = isSettled(moneyRequestReport.reportID); const isAdmin = isExpenseReport(moneyRequestReport) && lodashGet(getPolicy(moneyRequestReport.policyID), 'role', '') === CONST.POLICY.ROLE.ADMIN; const isRequestor = currentUserAccountID === reportAction.actorAccountID; - return !isReportSettled && (isAdmin || isRequestor); + + if (isAdmin) { + return true; + } + + return !isReportSettled && isRequestor; +} + +/** + * Checks if the current user can edit the provided property of a money request + * + * @param {Object} reportAction + * @param {String} reportID + * @param {String} fieldToEdit + * @returns {Boolean} + */ +function canEditFieldOfMoneyRequest(reportAction, reportID, fieldToEdit) { + // A list of fields that cannot be edited by anyone, once a money request has been settled + const nonEditableFieldsWhenSettled = [ + CONST.EDIT_REQUEST_FIELD.AMOUNT, + CONST.EDIT_REQUEST_FIELD.CURRENCY, + CONST.EDIT_REQUEST_FIELD.DATE, + CONST.EDIT_REQUEST_FIELD.RECEIPT, + CONST.EDIT_REQUEST_FIELD.DISTANCE, + ]; + + // Checks if this user has permissions to edit this money request + if (!canEditMoneyRequest(reportAction)) { + return false; // User doesn't have permission to edit + } + + // Checks if the report is settled + // Checks if the provided property is a restricted one + return !isSettled(reportID) || !nonEditableFieldsWhenSettled.includes(fieldToEdit); } /** @@ -1615,9 +1708,10 @@ function getTransactionReportName(reportAction) { * @param {Object} report * @param {Object} [reportAction={}] This can be either a report preview action or the IOU action * @param {Boolean} [shouldConsiderReceiptBeingScanned=false] + * @param {Boolean} isPreviewMessageForParentChatReport * @returns {String} */ -function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceiptBeingScanned = false) { +function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceiptBeingScanned = false, isPreviewMessageForParentChatReport = false) { const reportActionMessage = lodashGet(reportAction, 'message[0].html', ''); if (_.isEmpty(report) || !report.reportID) { @@ -1656,12 +1750,14 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip } } - if (isSettled(report.reportID)) { + // Show Paid preview message if it's settled or if the amount is paid & stuck at receivers end for only chat reports. + if (isSettled(report.reportID) || (report.isWaitingOnBankAccount && isPreviewMessageForParentChatReport)) { // A settled report preview message can come in three formats "paid ... elsewhere" or "paid ... with Expensify" let translatePhraseKey = 'iou.paidElsewhereWithAmount'; if ( _.contains([CONST.IOU.PAYMENT_TYPE.VBBA, CONST.IOU.PAYMENT_TYPE.EXPENSIFY], lodashGet(reportAction, 'originalMessage.paymentType')) || - reportActionMessage.match(/ (with Expensify|using Expensify)$/) + reportActionMessage.match(/ (with Expensify|using Expensify)$/) || + report.isWaitingOnBankAccount ) { translatePhraseKey = 'iou.paidWithExpensifyWithAmount'; } @@ -2446,7 +2542,6 @@ function buildOptimisticIOUReportAction( shouldShow: true, created: DateUtils.getDBTime(), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - receipt, whisperedToAccountIDs: _.contains([CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING], receipt.state) ? [currentUserAccountID] : [], }; } @@ -3289,7 +3384,8 @@ function chatIncludesChronos(report) { /** * Can only flag if: * - * - It was written by someone else + * - It was written by someone else and isn't a whisper + * - It's a welcome message whisper * - It's an ADDCOMMENT that is not an attachment * * @param {Object} reportAction @@ -3297,12 +3393,25 @@ function chatIncludesChronos(report) { * @returns {Boolean} */ function canFlagReportAction(reportAction, reportID) { + const report = getReport(reportID); + const isCurrentUserAction = reportAction.actorAccountID === currentUserAccountID; + + if (ReportActionsUtils.isWhisperAction(reportAction)) { + // Allow flagging welcome message whispers as they can be set by any room creator + if (report.welcomeMessage && !isCurrentUserAction && lodashGet(reportAction, 'originalMessage.html') === report.welcomeMessage) { + return true; + } + + // Disallow flagging the rest of whisper as they are sent by us + return false; + } + return ( - reportAction.actorAccountID !== currentUserAccountID && + !isCurrentUserAction && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportActionsUtils.isDeletedAction(reportAction) && !ReportActionsUtils.isCreatedTaskReportAction(reportAction) && - isAllowedToComment(getReport(reportID)) + isAllowedToComment(report) ); } @@ -3403,8 +3512,16 @@ function parseReportRouteParams(route) { } const pathSegments = parsingRoute.split('/'); + + const reportIDSegment = pathSegments[1]; + + // Check for "undefined" or any other unwanted string values + if (!reportIDSegment || reportIDSegment === 'undefined') { + return {reportID: '', isSubReportPageRoute: false}; + } + return { - reportID: pathSegments[1], + reportID: reportIDSegment, isSubReportPageRoute: pathSegments.length > 2, }; } @@ -3946,6 +4063,32 @@ function getIOUReportActionDisplayMessage(reportAction) { return displayMessage; } +/** + * Checks if a report is a group chat. + * + * A report is a group chat if it meets the following conditions: + * - Not a chat thread. + * - Not a task report. + * - Not a money request / IOU report. + * - Not an archived room. + * - Not a public / admin / announce chat room (chat type doesn't match any of the specified types). + * - More than 2 participants. + * + * @param {Object} report + * @returns {Boolean} + */ +function isGroupChat(report) { + return ( + report && + !isChatThread(report) && + !isTaskReport(report) && + !isMoneyRequestReport(report) && + !isArchivedRoom(report) && + !Object.values(CONST.REPORT.CHAT_TYPE).includes(getChatType(report)) && + lodashGet(report, 'participantAccountIDs.length', 0) > 2 + ); +} + /** * @param {Object} report * @returns {Boolean} @@ -4006,6 +4149,7 @@ export { getIcons, getRoomWelcomeMessage, getDisplayNamesWithTooltips, + getDisplayNamesStringFromTooltips, getReportName, getReport, getReportIDFromLink, @@ -4103,6 +4247,7 @@ export { getTaskAssigneeChatOnyxData, getParticipantsIDs, canEditMoneyRequest, + canEditFieldOfMoneyRequest, buildTransactionThread, areAllRequestsBeingSmartScanned, getTransactionsWithReceipts, @@ -4111,7 +4256,9 @@ export { hasMissingSmartscanFields, getIOUReportActionDisplayMessage, isWaitingForTaskCompleteFromAssignee, + isGroupChat, isReportDraft, shouldUseFullTitleToDisplay, parseReportRouteParams, + getReimbursementQueuedActionMessage, }; diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index caa8fb384e56..bd3d730edc0c 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -267,7 +267,6 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, isMoneyRequestReport: false, isExpenseRequest: false, isWaitingOnBankAccount: false, - isLastMessageDeletedParentAction: false, isAllowedToComment: true, }; @@ -429,7 +428,6 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, result.icons = ReportUtils.getIcons(report, personalDetails, UserUtils.getAvatar(personalDetail.avatar, personalDetail.accountID), '', -1, policy); result.searchText = OptionsListUtils.getSearchText(report, reportName, participantPersonalDetailList, result.isChatRoom || result.isPolicyExpenseChat, result.isThread); result.displayNamesWithTooltips = displayNamesWithTooltips; - result.isLastMessageDeletedParentAction = report.isLastMessageDeletedParentAction; if (status) { result.status = status; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 07e814f92884..94711f098152 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1054,7 +1054,8 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(participant); // In case the participant is a workspace, email & accountID should remain undefined and won't be used in the rest of this code - const email = isOwnPolicyExpenseChat || isPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login).toLowerCase(); + // participant.login is undefined when the request is initiated from a group DM with an unknown user, so we need to add a default + const email = isOwnPolicyExpenseChat || isPolicyExpenseChat ? '' : OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login || '').toLowerCase(); const accountID = isOwnPolicyExpenseChat || isPolicyExpenseChat ? 0 : Number(participant.accountID); if (email === currentUserEmailForIOUSplit) { return; @@ -1893,17 +1894,17 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC }, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, value: transaction, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, value: iouReport, }, { - onyxMethod: Onyx.METHOD.MERGE, + onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.chatReportID}`, value: chatReport, }, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index dc881252e4d8..be9e93c4c867 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -326,7 +326,6 @@ function addActions(reportID, text = '', file) { lastMessageHtml: lastCommentText, lastActorAccountID: currentUserAccountID, lastReadTime: currentTime, - isLastMessageDeletedParentAction: null, }; // Optimistically add the new actions to the store before waiting to save them to the server @@ -1047,25 +1046,17 @@ function deleteReportComment(reportID, reportAction) { lastMessageText: '', lastVisibleActionCreated: '', }; - if (reportAction.childVisibleActionCount === 0) { + const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportUtils.getLastVisibleMessage(originalReportID, optimisticReportActions); + if (lastMessageText || lastMessageTranslationKey) { + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, optimisticReportActions); + const lastVisibleActionCreated = lodashGet(lastVisibleAction, 'created'); + const lastActorAccountID = lodashGet(lastVisibleAction, 'actorAccountID'); optimisticReport = { - lastMessageTranslationKey: '', - lastMessageText: '', - isLastMessageDeletedParentAction: true, + lastMessageTranslationKey, + lastMessageText, + lastVisibleActionCreated, + lastActorAccountID, }; - } else { - const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportUtils.getLastVisibleMessage(originalReportID, optimisticReportActions); - if (lastMessageText || lastMessageTranslationKey) { - const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, optimisticReportActions); - const lastVisibleActionCreated = lodashGet(lastVisibleAction, 'created'); - const lastActorAccountID = lodashGet(lastVisibleAction, 'actorAccountID'); - optimisticReport = { - lastMessageTranslationKey, - lastMessageText, - lastVisibleActionCreated, - lastActorAccountID, - }; - } } // If the API call fails we must show the original message again, so we revert the message content back to how it was diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.ts similarity index 68% rename from src/libs/actions/Session/index.js rename to src/libs/actions/Session/index.ts index 3b623a42689d..c03335959e71 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.ts @@ -1,7 +1,9 @@ -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; -import lodashGet from 'lodash/get'; +import Onyx, {OnyxUpdate} from 'react-native-onyx'; import {Linking} from 'react-native'; +import {ValueOf} from 'type-fest'; +import throttle from 'lodash/throttle'; +import {ChannelAuthorizationCallback} from 'pusher-js/with-encryption'; +import {ChannelAuthorizationData} from 'pusher-js/types/src/core/auth/options'; import clearCache from './clearCache'; import ONYXKEYS from '../../../ONYXKEYS'; import redirectToSignIn from '../SignInRedirect'; @@ -21,16 +23,18 @@ import ROUTES from '../../../ROUTES'; import * as ErrorUtils from '../../ErrorUtils'; import * as ReportUtils from '../../ReportUtils'; import {hideContextMenu} from '../../../pages/home/report/ContextMenu/ReportActionContextMenu'; +import Credentials from '../../../types/onyx/Credentials'; +import {AutoAuthState} from '../../../types/onyx/Session'; -let sessionAuthTokenType = ''; -let sessionAuthToken = null; -let authPromiseResolver = null; +let sessionAuthTokenType: string | null = ''; +let sessionAuthToken: string | null = null; +let authPromiseResolver: ((value: boolean) => void) | null = null; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (session) => { - sessionAuthTokenType = lodashGet(session, 'authTokenType'); - sessionAuthToken = lodashGet(session, 'authToken'); + sessionAuthTokenType = session?.authTokenType ?? null; + sessionAuthToken = session?.authToken ?? null; if (sessionAuthToken && authPromiseResolver) { authPromiseResolver(true); @@ -39,13 +43,13 @@ Onyx.connect({ }, }); -let credentials = {}; +let credentials: Credentials = {}; Onyx.connect({ key: ONYXKEYS.CREDENTIALS, - callback: (val) => (credentials = val || {}), + callback: (value) => (credentials = value ?? {}), }); -let preferredLocale; +let preferredLocale: ValueOf | null = null; Onyx.connect({ key: ONYXKEYS.NVP_PREFERRED_LOCALE, callback: (val) => (preferredLocale = val), @@ -57,26 +61,34 @@ Onyx.connect({ function signOut() { Log.info('Flushing logs before signing out', true, {}, true); - API.write('LogOut', { + type LogOutParams = { + authToken: string | null; + partnerUserID: string; + partnerName: string; + partnerPassword: string; + shouldRetry: boolean; + }; + + const params: LogOutParams = { // Send current authToken because we will immediately clear it once triggering this command authToken: NetworkStore.getAuthToken(), - partnerUserID: lodashGet(credentials, 'autoGeneratedLogin', ''), + partnerUserID: credentials?.autoGeneratedLogin ?? '', partnerName: CONFIG.EXPENSIFY.PARTNER_NAME, partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, shouldRetry: false, - }); + }; + + API.write('LogOut', params); clearCache().then(() => { - Log.info('Cleared all chache data', true, {}, true); + Log.info('Cleared all cache data', true, {}, true); }); Timing.clearData(); } /** * Checks if the account is an anonymous account. - * - * @return {boolean} */ -function isAnonymousUser() { +function isAnonymousUser(): boolean { return sessionAuthTokenType === 'anonymousAccount'; } @@ -98,11 +110,11 @@ function signOutAndRedirectToSignIn() { } /** - * @param {Function} callback The callback to execute if the action is allowed - * @param {Boolean} isAnonymousAction The action is allowed for anonymous or not - * @returns {Function} same callback if the action is allowed, otherwise a function that signs out and redirects to sign in + * @param callback The callback to execute if the action is allowed + * @param isAnonymousAction The action is allowed for anonymous or not + * @returns same callback if the action is allowed, otherwise a function that signs out and redirects to sign in */ -function checkIfActionIsAllowed(callback, isAnonymousAction = false) { +function checkIfActionIsAllowed unknown>(callback: TCallback, isAnonymousAction = false): TCallback | (() => void) { if (isAnonymousUser() && !isAnonymousAction) { return () => signOutAndRedirectToSignIn(); } @@ -111,11 +123,9 @@ function checkIfActionIsAllowed(callback, isAnonymousAction = false) { /** * Resend the validation link to the user that is validating their account - * - * @param {String} [login] */ function resendValidationLink(login = credentials.login) { - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -127,7 +137,7 @@ function resendValidationLink(login = credentials.login) { }, }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -138,7 +148,7 @@ function resendValidationLink(login = credentials.login) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -150,16 +160,20 @@ function resendValidationLink(login = credentials.login) { }, ]; - API.write('RequestAccountValidationLink', {email: login}, {optimisticData, successData, failureData}); + type ResendValidationLinkParams = { + email?: string; + }; + + const params: ResendValidationLinkParams = {email: login}; + + API.write('RequestAccountValidationLink', params, {optimisticData, successData, failureData}); } /** * Request a new validate / magic code for user to sign in via passwordless flow - * - * @param {String} [login] */ function resendValidateCode(login = credentials.login) { - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -169,7 +183,7 @@ function resendValidateCode(login = credentials.login) { }, }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -178,7 +192,7 @@ function resendValidateCode(login = credentials.login) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -187,16 +201,26 @@ function resendValidateCode(login = credentials.login) { }, }, ]; - API.write('RequestNewValidateCode', {email: login}, {optimisticData, successData, failureData}); + + type RequestNewValidateCodeParams = { + email?: string; + }; + + const params: RequestNewValidateCodeParams = {email: login}; + + API.write('RequestNewValidateCode', params, {optimisticData, successData, failureData}); } -/** +type OnyxData = { + optimisticData: OnyxUpdate[]; + successData: OnyxUpdate[]; + failureData: OnyxUpdate[]; +}; /** * Constructs the state object for the BeginSignIn && BeginAppleSignIn API calls. - * @returns {Object} */ -function signInAttemptState() { +function signInAttemptState(): OnyxData { return { optimisticData: [ { @@ -234,7 +258,6 @@ function signInAttemptState() { value: { isLoading: false, loadingForm: null, - // eslint-disable-next-line rulesdir/prefer-localization errors: ErrorUtils.getMicroSecondOnyxError('loginForm.cannotGetAccountDetails'), }, }, @@ -244,45 +267,59 @@ function signInAttemptState() { /** * Checks the API to see if an account exists for the given login. - * - * @param {String} login */ -function beginSignIn(login) { +function beginSignIn(email: string) { const {optimisticData, successData, failureData} = signInAttemptState(); - API.read('BeginSignIn', {email: login}, {optimisticData, successData, failureData}); + + type BeginSignInParams = { + email: string; + }; + + const params: BeginSignInParams = {email}; + + API.read('BeginSignIn', params, {optimisticData, successData, failureData}); } /** * Given an idToken from Sign in with Apple, checks the API to see if an account * exists for that email address and signs the user in if so. - * - * @param {String} idToken */ -function beginAppleSignIn(idToken) { +function beginAppleSignIn(idToken: string) { const {optimisticData, successData, failureData} = signInAttemptState(); - API.write('SignInWithApple', {idToken, preferredLocale}, {optimisticData, successData, failureData}); + + type BeginAppleSignInParams = { + idToken: string; + preferredLocale: ValueOf | null; + }; + + const params: BeginAppleSignInParams = {idToken, preferredLocale}; + + API.write('SignInWithApple', params, {optimisticData, successData, failureData}); } /** * Shows Google sign-in process, and if an auth token is successfully obtained, * passes the token on to the Expensify API to sign in with - * - * @param {String} token */ -function beginGoogleSignIn(token) { +function beginGoogleSignIn(token: string) { const {optimisticData, successData, failureData} = signInAttemptState(); - API.write('SignInWithGoogle', {token, preferredLocale}, {optimisticData, successData, failureData}); + + type BeginGoogleSignInParams = { + token: string; + preferredLocale: ValueOf | null; + }; + + const params: BeginGoogleSignInParams = {token, preferredLocale}; + + API.write('SignInWithGoogle', params, {optimisticData, successData, failureData}); } /** * Will create a temporary login for the user in the passed authenticate response which is used when * re-authenticating after an authToken expires. - * - * @param {String} email - * @param {String} authToken */ -function signInWithShortLivedAuthToken(email, authToken) { - const optimisticData = [ +function signInWithShortLivedAuthToken(email: string, authToken: string) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -293,7 +330,7 @@ function signInWithShortLivedAuthToken(email, authToken) { }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -303,7 +340,7 @@ function signInWithShortLivedAuthToken(email, authToken) { }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -317,7 +354,16 @@ function signInWithShortLivedAuthToken(email, authToken) { // scene 1: the user is transitioning to newDot from a different account on oldDot. // scene 2: the user is transitioning to desktop app from a different account on web app. const oldPartnerUserID = credentials.login === email && credentials.autoGeneratedLogin ? credentials.autoGeneratedLogin : ''; - API.read('SignInWithShortLivedAuthToken', {authToken, oldPartnerUserID, skipReauthentication: true}, {optimisticData, successData, failureData}); + + type SignInWithShortLivedAuthTokenParams = { + authToken: string; + oldPartnerUserID: string; + skipReauthentication: boolean; + }; + + const params: SignInWithShortLivedAuthTokenParams = {authToken, oldPartnerUserID, skipReauthentication: true}; + + API.read('SignInWithShortLivedAuthToken', params, {optimisticData, successData, failureData}); } /** @@ -325,11 +371,10 @@ function signInWithShortLivedAuthToken(email, authToken) { * then it will create a temporary login for them which is used when re-authenticating * after an authToken expires. * - * @param {String} validateCode 6 digit code required for login - * @param {String} [twoFactorAuthCode] + * @param validateCode - 6 digit code required for login */ -function signIn(validateCode, twoFactorAuthCode) { - const optimisticData = [ +function signIn(validateCode: string, twoFactorAuthCode?: string) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -341,7 +386,7 @@ function signIn(validateCode, twoFactorAuthCode) { }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -359,7 +404,7 @@ function signIn(validateCode, twoFactorAuthCode) { }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -370,27 +415,37 @@ function signIn(validateCode, twoFactorAuthCode) { }, ]; - const params = { - twoFactorAuthCode, - email: credentials.login, - preferredLocale, - }; - - // Conditionally pass a password or validateCode to command since we temporarily allow both flows - if (validateCode || twoFactorAuthCode) { - params.validateCode = validateCode || credentials.validateCode; - } Device.getDeviceInfoWithID().then((deviceInfo) => { - API.write('SigninUser', {...params, deviceInfo}, {optimisticData, successData, failureData}); + type SignInUserParams = { + twoFactorAuthCode?: string; + email?: string; + preferredLocale: ValueOf | null; + validateCode?: string; + deviceInfo: string; + }; + + const params: SignInUserParams = { + twoFactorAuthCode, + email: credentials.login, + preferredLocale, + deviceInfo, + }; + + // Conditionally pass a password or validateCode to command since we temporarily allow both flows + if (validateCode || twoFactorAuthCode) { + params.validateCode = validateCode || credentials.validateCode; + } + + API.write('SigninUser', params, {optimisticData, successData, failureData}); }); } -function signInWithValidateCode(accountID, code, twoFactorAuthCode = '') { +function signInWithValidateCode(accountID: number, code: string, 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; - const optimisticData = [ + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -407,7 +462,7 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode = '') { }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -431,7 +486,7 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode = '') { }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -447,21 +502,27 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode = '') { }, ]; Device.getDeviceInfoWithID().then((deviceInfo) => { - API.write( - 'SigninUserWithLink', - { - accountID, - validateCode, - twoFactorAuthCode, - preferredLocale, - deviceInfo, - }, - {optimisticData, successData, failureData}, - ); + type SignInUserWithLinkParams = { + accountID: number; + validateCode?: string; + twoFactorAuthCode?: string; + preferredLocale: ValueOf | null; + deviceInfo: string; + }; + + const params: SignInUserWithLinkParams = { + accountID, + validateCode, + twoFactorAuthCode, + preferredLocale, + deviceInfo, + }; + + API.write('SigninUserWithLink', params, {optimisticData, successData, failureData}); }); } -function signInWithValidateCodeAndNavigate(accountID, validateCode, twoFactorAuthCode = '') { +function signInWithValidateCodeAndNavigate(accountID: number, validateCode: string, twoFactorAuthCode = '') { signInWithValidateCode(accountID, validateCode, twoFactorAuthCode); Navigation.navigate(ROUTES.HOME); } @@ -473,14 +534,12 @@ function signInWithValidateCodeAndNavigate(accountID, validateCode, twoFactorAut * When the user gets authenticated, the component is unmounted and then remounted * when AppNavigator switches from PublicScreens to AuthScreens. * That's the reason why autoAuthState initialization is skipped while the last state is SIGNING_IN. - * - * @param {string} cachedAutoAuthState */ -function initAutoAuthState(cachedAutoAuthState) { +function initAutoAuthState(cachedAutoAuthState: AutoAuthState) { + const signedInStates: AutoAuthState[] = [CONST.AUTO_AUTH_STATE.SIGNING_IN, CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN]; + Onyx.merge(ONYXKEYS.SESSION, { - 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, + autoAuthState: signedInStates.includes(cachedAutoAuthState) ? CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN : CONST.AUTO_AUTH_STATE.NOT_STARTED, }); } @@ -495,22 +554,19 @@ function invalidateAuthToken() { /** * Sets the SupportToken - * @param {String} supportToken - * @param {String} email - * @param {Number} accountID */ -function setSupportAuthToken(supportToken, email, accountID) { - if (supportToken) { +function setSupportAuthToken(supportAuthToken: string, email: string, accountID: number) { + if (supportAuthToken) { Onyx.merge(ONYXKEYS.SESSION, { authToken: '1', - supportAuthToken: supportToken, + supportAuthToken, email, accountID, }); } else { Onyx.set(ONYXKEYS.SESSION, {}); } - NetworkStore.setSupportAuthToken(supportToken); + NetworkStore.setSupportAuthToken(supportAuthToken); } /** @@ -541,14 +597,14 @@ function clearAccountMessages() { }); } -function setAccountError(error) { +function setAccountError(error: string) { Onyx.merge(ONYXKEYS.ACCOUNT, {errors: ErrorUtils.getMicroSecondOnyxError(error)}); } // It's necessary to throttle requests to reauthenticate since calling this multiple times will cause Pusher to // reconnect each time when we only need to reconnect once. This way, if an authToken is expired and we try to // subscribe to a bunch of channels at once we will only reauthenticate and force reconnect Pusher once. -const reauthenticatePusher = _.throttle( +const reauthenticatePusher = throttle( () => { Log.info('[Pusher] Re-authenticating and then reconnecting'); Authentication.reauthenticate('AuthenticatePusher') @@ -561,24 +617,32 @@ const reauthenticatePusher = _.throttle( {trailing: false}, ); -/** - * @param {String} socketID - * @param {String} channelName - * @param {Function} callback - */ -function authenticatePusher(socketID, channelName, callback) { +function authenticatePusher(socketID: string, channelName: string, callback: ChannelAuthorizationCallback) { Log.info('[PusherAuthorizer] Attempting to authorize Pusher', false, {channelName}); - // We use makeRequestWithSideEffects here because we need to authorize to Pusher (an external service) each time a user connects to any channel. - // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects('AuthenticatePusher', { + type AuthenticatePusherParams = { + // eslint-disable-next-line @typescript-eslint/naming-convention + socket_id: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + channel_name: string; + shouldRetry: boolean; + forceNetworkRequest: boolean; + }; + + const params: AuthenticatePusherParams = { + // eslint-disable-next-line @typescript-eslint/naming-convention socket_id: socketID, + // eslint-disable-next-line @typescript-eslint/naming-convention channel_name: channelName, shouldRetry: false, forceNetworkRequest: true, - }) + }; + + // We use makeRequestWithSideEffects here because we need to authorize to Pusher (an external service) each time a user connects to any channel. + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects('AuthenticatePusher', params) .then((response) => { - if (response.jsonCode === CONST.JSON_CODE.NOT_AUTHENTICATED) { + if (response?.jsonCode === CONST.JSON_CODE.NOT_AUTHENTICATED) { Log.hmmm('[PusherAuthorizer] Unable to authenticate Pusher because authToken is expired'); callback(new Error('Pusher failed to authenticate because authToken is expired'), {auth: ''}); @@ -587,14 +651,14 @@ function authenticatePusher(socketID, channelName, callback) { return; } - if (response.jsonCode !== CONST.JSON_CODE.SUCCESS) { + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { Log.hmmm('[PusherAuthorizer] Unable to authenticate Pusher for reason other than expired session'); - callback(new Error(`Pusher failed to authenticate because code: ${response.jsonCode} message: ${response.message}`), {auth: ''}); + callback(new Error(`Pusher failed to authenticate because code: ${response?.jsonCode} message: ${response?.message}`), {auth: ''}); return; } Log.info('[PusherAuthorizer] Pusher authenticated successfully', false, {channelName}); - callback(null, response); + callback(null, response as ChannelAuthorizationData); }) .catch((error) => { Log.hmmm('[PusherAuthorizer] Unhandled error: ', {channelName, error}); @@ -640,11 +704,17 @@ function requestUnlinkValidationLink() { }, ]; - API.write('RequestUnlinkValidationLink', {email: credentials.login}, {optimisticData, successData, failureData}); + type RequestUnlinkValidationLinkParams = { + email?: string; + }; + + const params: RequestUnlinkValidationLinkParams = {email: credentials.login}; + + API.write('RequestUnlinkValidationLink', params, {optimisticData, successData, failureData}); } -function unlinkLogin(accountID, validateCode) { - const optimisticData = [ +function unlinkLogin(accountID: number, validateCode: string) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -654,7 +724,7 @@ function unlinkLogin(accountID, validateCode) { }, }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -671,7 +741,7 @@ function unlinkLogin(accountID, validateCode) { }, }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -681,27 +751,28 @@ function unlinkLogin(accountID, validateCode) { }, ]; - API.write( - 'UnlinkLogin', - { - accountID, - validateCode, - }, - { - optimisticData, - successData, - failureData, - }, - ); + type UnlinkLoginParams = { + accountID: number; + validateCode: string; + }; + + const params: UnlinkLoginParams = { + accountID, + validateCode, + }; + + API.write('UnlinkLogin', params, { + optimisticData, + successData, + failureData, + }); } /** * Toggles two-factor authentication based on the `enable` parameter - * - * @param {Boolean} enable */ -function toggleTwoFactorAuth(enable) { - const optimisticData = [ +function toggleTwoFactorAuth(enable: boolean) { + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -711,7 +782,7 @@ function toggleTwoFactorAuth(enable) { }, ]; - const successData = [ + const successData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -721,7 +792,7 @@ function toggleTwoFactorAuth(enable) { }, ]; - const failureData = [ + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -734,7 +805,7 @@ function toggleTwoFactorAuth(enable) { API.write(enable ? 'EnableTwoFactorAuth' : 'DisableTwoFactorAuth', {}, {optimisticData, successData, failureData}); } -function validateTwoFactorAuth(twoFactorAuthCode) { +function validateTwoFactorAuth(twoFactorAuthCode: string) { const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -765,7 +836,13 @@ function validateTwoFactorAuth(twoFactorAuthCode) { }, ]; - API.write('TwoFactorAuth_Validate', {twoFactorAuthCode}, {optimisticData, successData, failureData}); + type ValidateTwoFactorAuthParams = { + twoFactorAuthCode: string; + }; + + const params: ValidateTwoFactorAuthParams = {twoFactorAuthCode}; + + API.write('TwoFactorAuth_Validate', params, {optimisticData, successData, failureData}); } /** @@ -775,14 +852,14 @@ function validateTwoFactorAuth(twoFactorAuthCode) { * Otherwise, the promise will resolve when the `authToken` in `ONYXKEYS.SESSION` becomes truthy via the Onyx callback. * The promise will not reject on failed login attempt. * - * @returns {Promise} A promise that resolves to `true` once the user is signed in. + * @returns A promise that resolves to `true` once the user is signed in. * @example * waitForUserSignIn().then(() => { * console.log('User is signed in!'); * }); */ -function waitForUserSignIn() { - return new Promise((resolve) => { +function waitForUserSignIn(): Promise { + return new Promise((resolve) => { if (sessionAuthToken) { resolve(true); } else { diff --git a/src/libs/actions/Session/updateSessionAuthTokens.js b/src/libs/actions/Session/updateSessionAuthTokens.js deleted file mode 100644 index e88b3b993c7a..000000000000 --- a/src/libs/actions/Session/updateSessionAuthTokens.js +++ /dev/null @@ -1,10 +0,0 @@ -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '../../../ONYXKEYS'; - -/** - * @param {String | undefined} authToken - * @param {String | undefined} encryptedAuthToken - */ -export default function updateSessionAuthTokens(authToken, encryptedAuthToken) { - Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken}); -} diff --git a/src/libs/actions/Session/updateSessionAuthTokens.ts b/src/libs/actions/Session/updateSessionAuthTokens.ts new file mode 100644 index 000000000000..9614face2070 --- /dev/null +++ b/src/libs/actions/Session/updateSessionAuthTokens.ts @@ -0,0 +1,6 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../../ONYXKEYS'; + +export default function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string) { + Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken}); +} diff --git a/src/libs/actions/Wallet.js b/src/libs/actions/Wallet.js index 183920eccf21..9ef4b547d4b4 100644 --- a/src/libs/actions/Wallet.js +++ b/src/libs/actions/Wallet.js @@ -214,9 +214,9 @@ function acceptWalletTerms(parameters) { const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.USER_WALLET, + key: ONYXKEYS.WALLET_TERMS, value: { - shouldShowWalletActivationSuccess: true, + isLoading: true, }, }, ]; @@ -227,6 +227,7 @@ function acceptWalletTerms(parameters) { key: ONYXKEYS.WALLET_TERMS, value: { errors: null, + isLoading: false, }, }, ]; @@ -236,10 +237,17 @@ function acceptWalletTerms(parameters) { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.USER_WALLET, value: { - shouldShowWalletActivationSuccess: null, + isPendingOnfidoResult: null, shouldShowFailedKYC: true, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.WALLET_TERMS, + value: { + isLoading: false, + }, + }, ]; API.write('AcceptWalletTerms', {hasAcceptedTerms: parameters.hasAcceptedTerms, reportID: parameters.chatReportID}, {optimisticData, successData, failureData}); diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js index 1eda16ad841a..cb53623caa8c 100644 --- a/src/pages/AddPersonalBankAccountPage.js +++ b/src/pages/AddPersonalBankAccountPage.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; @@ -7,21 +7,18 @@ import HeaderWithBackButton from '../components/HeaderWithBackButton'; import ScreenWrapper from '../components/ScreenWrapper'; import Navigation from '../libs/Navigation/Navigation'; import * as BankAccounts from '../libs/actions/BankAccounts'; -import * as PaymentMethods from '../libs/actions/PaymentMethods'; -import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; import AddPlaidBankAccount from '../components/AddPlaidBankAccount'; import getPlaidOAuthReceivedRedirectURI from '../libs/getPlaidOAuthReceivedRedirectURI'; -import compose from '../libs/compose'; import ONYXKEYS from '../ONYXKEYS'; import styles from '../styles/styles'; import Form from '../components/Form'; import ROUTES from '../ROUTES'; import * as PlaidDataProps from './ReimbursementAccount/plaidDataPropTypes'; import ConfirmationPage from '../components/ConfirmationPage'; +import * as PaymentMethods from '../libs/actions/PaymentMethods'; +import useLocalize from '../hooks/useLocalize'; const propTypes = { - ...withLocalizePropTypes, - /** Contains plaid data */ plaidData: PlaidDataProps.plaidDataPropTypes, @@ -59,112 +56,92 @@ const defaultProps = { }, }; -class AddPersonalBankAccountPage extends React.Component { - constructor(props) { - super(props); - - this.validate = this.validate.bind(this); - this.submit = this.submit.bind(this); - this.exitFlow = this.exitFlow.bind(this); - - this.state = { - selectedPlaidAccountID: '', - }; - } - - componentWillUnmount() { - BankAccounts.clearPersonalBankAccount(); - } +function AddPersonalBankAccountPage({personalBankAccount, plaidData}) { + const {translate} = useLocalize(); + const [selectedPlaidAccountId, setSelectedPlaidAccountId] = useState(''); + const shouldShowSuccess = lodashGet(personalBankAccount, 'shouldShowSuccess', false); /** * @returns {Object} */ - validate() { - return {}; - } + const validateBankAccountForm = () => ({}); - submit() { - const selectedPlaidBankAccount = _.findWhere(lodashGet(this.props.plaidData, 'bankAccounts', []), { - plaidAccountID: this.state.selectedPlaidAccountID, + const submitBankAccountForm = useCallback(() => { + const selectedPlaidBankAccount = _.findWhere(lodashGet(plaidData, 'bankAccounts', []), { + plaidAccountID: selectedPlaidAccountId, }); BankAccounts.addPersonalBankAccount(selectedPlaidBankAccount); - } - - exitFlow(shouldContinue = false) { - const exitReportID = lodashGet(this.props, 'personalBankAccount.exitReportID'); - const onSuccessFallbackRoute = lodashGet(this.props, 'personalBankAccount.onSuccessFallbackRoute', ''); - - if (exitReportID) { - Navigation.dismissModal(exitReportID); - } else if (shouldContinue && onSuccessFallbackRoute) { - PaymentMethods.continueSetup(onSuccessFallbackRoute); - } else { - Navigation.goBack(ROUTES.SETTINGS_WALLET); - } - } - - render() { - const shouldShowSuccess = lodashGet(this.props, 'personalBankAccount.shouldShowSuccess', false); - - return ( - - { + const exitReportID = lodashGet(personalBankAccount, 'exitReportID'); + const onSuccessFallbackRoute = lodashGet(personalBankAccount, 'onSuccessFallbackRoute', ''); + + if (exitReportID) { + Navigation.dismissModal(exitReportID); + } else if (shouldContinue && onSuccessFallbackRoute) { + PaymentMethods.continueSetup(onSuccessFallbackRoute); + } else { + Navigation.goBack(ROUTES.SETTINGS_WALLET); + } + }, + [personalBankAccount], + ); + + useEffect(() => BankAccounts.clearPersonalBankAccount, []); + + return ( + + + {shouldShowSuccess ? ( + exitFlow(true)} /> - {shouldShowSuccess ? ( - this.exitFlow(true)} + ) : ( +
+ Navigation.goBack(ROUTES.HOME)} + receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()} + selectedPlaidAccountID={selectedPlaidAccountId} /> - ) : ( - - <> - { - this.setState({selectedPlaidAccountID}); - }} - plaidData={this.props.plaidData} - onExitPlaid={() => Navigation.goBack(ROUTES.HOME)} - receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()} - selectedPlaidAccountID={this.state.selectedPlaidAccountID} - /> - - - )} -
- ); - } + + )} +
+ ); } - +AddPersonalBankAccountPage.displayName = 'AddPersonalBankAccountPage'; AddPersonalBankAccountPage.propTypes = propTypes; AddPersonalBankAccountPage.defaultProps = defaultProps; -export default compose( - withLocalize, - withOnyx({ - personalBankAccount: { - key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, - }, - plaidData: { - key: ONYXKEYS.PLAID_DATA, - }, - }), -)(AddPersonalBankAccountPage); +export default withOnyx({ + personalBankAccount: { + key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, + }, + plaidData: { + key: ONYXKEYS.PLAID_DATA, + }, +})(AddPersonalBankAccountPage); diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index a85f490bbb42..f039afeb085f 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -8,16 +8,13 @@ import ONYXKEYS from '../ONYXKEYS'; import ROUTES from '../ROUTES'; import compose from '../libs/compose'; import Navigation from '../libs/Navigation/Navigation'; -import * as ReportActionsUtils from '../libs/ReportActionsUtils'; import * as ReportUtils from '../libs/ReportUtils'; import * as PolicyUtils from '../libs/PolicyUtils'; import * as TransactionUtils from '../libs/TransactionUtils'; -import * as Policy from '../libs/actions/Policy'; import * as IOU from '../libs/actions/IOU'; import * as CurrencyUtils from '../libs/CurrencyUtils'; import * as OptionsListUtils from '../libs/OptionsListUtils'; import Permissions from '../libs/Permissions'; -import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../components/withCurrentUserPersonalDetails'; import tagPropTypes from '../components/tagPropTypes'; import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; import EditRequestDescriptionPage from './EditRequestDescriptionPage'; @@ -31,6 +28,7 @@ import EditRequestCategoryPage from './EditRequestCategoryPage'; import EditRequestTagPage from './EditRequestTagPage'; import categoryPropTypes from '../components/categoryPropTypes'; import ScreenWrapper from '../components/ScreenWrapper'; +import reportActionPropTypes from './home/report/reportActionPropTypes'; import transactionPropTypes from '../components/transactionPropTypes'; const propTypes = { @@ -56,49 +54,32 @@ const propTypes = { /** The parent report object for the thread report */ parentReport: reportPropTypes, - /** The policy object for the current route */ - policy: PropTypes.shape({ - /** The name of the policy */ - name: PropTypes.string, - - /** The URL for the policy avatar */ - avatar: PropTypes.string, - }), - - /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user email */ - email: PropTypes.string, - }), - /** Collection of categories attached to a policy */ policyCategories: PropTypes.objectOf(categoryPropTypes), /** Collection of tags attached to a policy */ policyTags: tagPropTypes, - /** The original transaction that is being edited */ - transaction: transactionPropTypes, + /** The actions from the parent report */ + parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), - ...withCurrentUserPersonalDetailsPropTypes, + /** Transaction that stores the request data */ + transaction: transactionPropTypes, }; const defaultProps = { betas: [], report: {}, parentReport: {}, - policy: null, - session: { - email: null, - }, policyCategories: {}, policyTags: {}, + parentReportActions: {}, transaction: {}, }; -function EditRequestPage({betas, report, route, parentReport, policy, session, policyCategories, policyTags, parentReportActions, transaction}) { +function EditRequestPage({betas, report, route, parentReport, policyCategories, policyTags, parentReportActions, transaction}) { const parentReportActionID = lodashGet(report, 'parentReportActionID', '0'); - const parentReportAction = lodashGet(parentReportActions, parentReportActionID); + const parentReportAction = lodashGet(parentReportActions, parentReportActionID, {}); const { amount: transactionAmount, currency: transactionCurrency, @@ -114,13 +95,6 @@ function EditRequestPage({betas, report, route, parentReport, policy, session, p const transactionCreated = TransactionUtils.getCreated(transaction); const fieldToEdit = lodashGet(route, ['params', 'field'], ''); - const isDeleted = ReportActionsUtils.isDeletedAction(parentReportAction); - const isSettled = ReportUtils.isSettled(parentReport.reportID); - - const isAdmin = Policy.isAdminOfFreePolicy([policy]) && ReportUtils.isExpenseReport(parentReport); - const isRequestor = ReportUtils.isMoneyRequestReport(parentReport) && lodashGet(session, 'accountID', null) === parentReportAction.actorAccountID; - const canEdit = !isSettled && !isDeleted && (isAdmin || isRequestor); - // For now, it always defaults to the first tag of the policy const policyTag = PolicyUtils.getTag(policyTags); const policyTagList = lodashGet(policyTag, 'tags', {}); @@ -135,15 +109,18 @@ function EditRequestPage({betas, report, route, parentReport, policy, session, p // A flag for showing the tags page const shouldShowTags = isPolicyExpenseChat && Permissions.canUseTags(betas) && (transactionTag || OptionsListUtils.hasEnabledOptions(lodashValues(policyTagList))); - // Dismiss the modal when the request is paid or deleted + // Decides whether to allow or disallow editing a money request useEffect(() => { - if (canEdit) { + // Do not dismiss the modal, when a current user can edit this property of the money request. + if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, parentReport.reportID, fieldToEdit)) { return; } + + // Dismiss the modal when a current user cannot edit a money request. Navigation.isNavigationReady().then(() => { Navigation.dismissModal(); }); - }, [canEdit]); + }, [parentReportAction, parentReport.reportID, fieldToEdit]); // Update the transaction object and close the modal function editMoneyRequest(transactionChanges) { @@ -300,7 +277,6 @@ EditRequestPage.displayName = 'EditRequestPage'; EditRequestPage.propTypes = propTypes; EditRequestPage.defaultProps = defaultProps; export default compose( - withCurrentUserPersonalDetails, withOnyx({ betas: { key: ONYXKEYS.BETAS, @@ -311,9 +287,6 @@ export default compose( }), // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ - policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, - }, policyCategories: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`, }, diff --git a/src/pages/EnablePayments/EnablePaymentsPage.js b/src/pages/EnablePayments/EnablePaymentsPage.js index 3f179e309a98..5f1577c3b31b 100644 --- a/src/pages/EnablePayments/EnablePaymentsPage.js +++ b/src/pages/EnablePayments/EnablePaymentsPage.js @@ -33,13 +33,20 @@ function EnablePaymentsPage({userWallet}) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const {isPendingOnfidoResult, hasFailedOnfido} = userWallet; + useEffect(() => { if (isOffline) { return; } + if (isPendingOnfidoResult || hasFailedOnfido) { + Navigation.navigate(ROUTES.SETTINGS_WALLET, CONST.NAVIGATION.TYPE.UP); + return; + } + Wallet.openEnablePaymentsPage(); - }, [isOffline]); + }, [isOffline, isPendingOnfidoResult, hasFailedOnfido]); if (_.isEmpty(userWallet)) { return ; @@ -64,10 +71,6 @@ function EnablePaymentsPage({userWallet}) { ); } - if (userWallet.shouldShowWalletActivationSuccess) { - return ; - } - const currentStep = userWallet.currentStep || CONST.WALLET.STEP.ADDITIONAL_DETAILS; switch (currentStep) { diff --git a/src/pages/EnablePayments/TermsStep.js b/src/pages/EnablePayments/TermsStep.js index 39f4826ec0b2..c11d8c39bda6 100644 --- a/src/pages/EnablePayments/TermsStep.js +++ b/src/pages/EnablePayments/TermsStep.js @@ -108,6 +108,7 @@ function TermsStep(props) { }} message={errorMessage} isAlertVisible={error || Boolean(errorMessage)} + isLoading={!!props.walletTerms.isLoading} containerStyles={[styles.mh0, styles.mv4]} /> diff --git a/src/pages/EnablePayments/userWalletPropTypes.js b/src/pages/EnablePayments/userWalletPropTypes.js index e6b4206be751..53332479d4ec 100644 --- a/src/pages/EnablePayments/userWalletPropTypes.js +++ b/src/pages/EnablePayments/userWalletPropTypes.js @@ -20,9 +20,12 @@ export default PropTypes.shape({ /** Status of wallet - e.g. SILVER or GOLD */ tierName: PropTypes.string, - /** Whether we should show the ActivateStep success view after the user finished the KYC flow */ - shouldShowWalletActivationSuccess: PropTypes.bool, + /** Whether the kyc is pending and is yet to be confirmed */ + isPendingOnfidoResult: PropTypes.bool, /** The wallet's programID, used to show the correct terms. */ walletProgramID: PropTypes.string, + + /** Whether the user has failed Onfido completely */ + hasFailedOnfido: PropTypes.bool, }); diff --git a/src/pages/EnablePayments/walletTermsPropTypes.js b/src/pages/EnablePayments/walletTermsPropTypes.js index 4dadd9946149..44d153f3b6ff 100644 --- a/src/pages/EnablePayments/walletTermsPropTypes.js +++ b/src/pages/EnablePayments/walletTermsPropTypes.js @@ -12,4 +12,7 @@ export default PropTypes.shape({ /** When the user accepts the Wallet's terms in order to pay an IOU, this is the ID of the chatReport the IOU is linked to */ chatReportID: PropTypes.string, + + /** Boolean to indicate whether the submission of wallet terms is being processed */ + isLoading: PropTypes.bool, }); diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 9ee5f838aafd..381564b82600 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -21,6 +21,7 @@ import personalDetailsPropType from './personalDetailsPropType'; import reportPropTypes from './reportPropTypes'; import variables from '../styles/variables'; import useNetwork from '../hooks/useNetwork'; +import useDelayedInputFocus from '../hooks/useDelayedInputFocus'; const propTypes = { /** Beta features list */ @@ -50,6 +51,7 @@ const defaultProps = { const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE); function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, isSearchingForReports}) { + const optionSelectorRef = React.createRef(null); const [searchTerm, setSearchTerm] = useState(''); const [filteredRecentReports, setFilteredRecentReports] = useState([]); const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); @@ -210,6 +212,9 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i } setSearchTerm(text); }, []); + + useDelayedInputFocus(optionSelectorRef, 600); + return ( 0 ? safeAreaPaddingBottomStyle : {}]}> { return errors; }; -function RequestorStep({reimbursementAccount, shouldShowOnfido, reimbursementAccountDraft, onBackButtonPress, getDefaultStateForField}) { +function RequestorStep({reimbursementAccount, shouldShowOnfido, onBackButtonPress, getDefaultStateForField}) { const {translate} = useLocalize(); const defaultValues = useMemo( @@ -111,9 +109,7 @@ function RequestorStep({reimbursementAccount, shouldShowOnfido, reimbursementAcc return ( ); } diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 8ddbf066a774..e88f6cd0b756 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -34,6 +34,7 @@ import * as Session from '../../libs/actions/Session'; import styles from '../../styles/styles'; import themeColors from '../../styles/themes/default'; import reportPropTypes from '../reportPropTypes'; +import reportWithoutHasDraftSelector from '../../libs/OnyxSelectors/reportWithoutHasDraftSelector'; const propTypes = { /** Toggles the navigationMenu open and closed */ @@ -80,7 +81,8 @@ function HeaderView(props) { const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.report); const isTaskReport = ReportUtils.isTaskReport(props.report); const reportHeaderData = !isTaskReport && !isChatThread && props.report.parentReportID ? props.parentReport : props.report; - const title = ReportUtils.getReportName(reportHeaderData); + // Use sorted display names for the title for group chats on native small screen widths + const title = ReportUtils.isGroupChat(props.report) ? ReportUtils.getDisplayNamesStringFromTooltips(displayNamesWithTooltips) : ReportUtils.getReportName(reportHeaderData); const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData); const isConcierge = ReportUtils.hasSingleParticipant(props.report) && _.contains(participants, CONST.ACCOUNT_ID.CONCIERGE); @@ -280,6 +282,7 @@ export default compose( }, parentReport: { key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || report.reportID}`, + selector: reportWithoutHasDraftSelector, }, session: { key: ONYXKEYS.SESSION, diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 81000c2dab92..32a14303e9a7 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -39,6 +39,7 @@ import DragAndDropProvider from '../../components/DragAndDrop/Provider'; import usePrevious from '../../hooks/usePrevious'; import CONST from '../../CONST'; import withCurrentReportID, {withCurrentReportIDPropTypes, withCurrentReportIDDefaultProps} from '../../components/withCurrentReportID'; +import reportWithoutHasDraftSelector from '../../libs/OnyxSelectors/reportWithoutHasDraftSelector'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -482,6 +483,7 @@ export default compose( report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, allowStaleData: true, + selector: reportWithoutHasDraftSelector, }, reportMetadata: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${getReportID(route)}`, diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 6522bedc825a..36cd9428b738 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -10,7 +10,7 @@ import AttachmentPicker from '../../../../components/AttachmentPicker'; import * as Report from '../../../../libs/actions/Report'; import PopoverMenu from '../../../../components/PopoverMenu'; import CONST from '../../../../CONST'; -import Tooltip from '../../../../components/Tooltip'; +import Tooltip from '../../../../components/Tooltip/PopoverAnchorTooltip'; import * as Browser from '../../../../libs/Browser'; import PressableWithFeedback from '../../../../components/Pressable/PressableWithFeedback'; import useLocalize from '../../../../hooks/useLocalize'; diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index d897df4e12ec..59221f57fd4b 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -9,6 +9,7 @@ import * as UserUtils from '../../../../libs/UserUtils'; import * as Expensicons from '../../../../components/Icon/Expensicons'; import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; import useLocalize from '../../../../hooks/useLocalize'; +import usePrevious from '../../../../hooks/usePrevious'; import ONYXKEYS from '../../../../ONYXKEYS'; import personalDetailsPropType from '../../../personalDetailsPropType'; import * as SuggestionProps from './suggestionProps'; @@ -57,6 +58,7 @@ function SuggestionMention({ isComposerFocused, }) { const {translate} = useLocalize(); + const previousValue = usePrevious(value); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu; @@ -234,8 +236,15 @@ function SuggestionMention({ ); useEffect(() => { + if (value.length < previousValue.length) { + // A workaround to not show the suggestions list when the user deletes a character before the mention. + // It is caused by a buggy behavior of the TextInput on iOS. Should be fixed after migration to Fabric. + // See: https://github.com/facebook/react-native/pull/36930#issuecomment-1593028467 + return; + } + calculateMentionSuggestion(selection.end); - }, [selection, calculateMentionSuggestion]); + }, [selection, value, previousValue, calculateMentionSuggestion]); const updateShouldShowSuggestionMenuToFalse = useCallback(() => { setSuggestionValues((prevState) => { diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 42bcfd49f207..2ae8c5c4ccdc 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -28,7 +28,7 @@ import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMe import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import * as ContextMenuActions from './ContextMenu/ContextMenuActions'; import * as EmojiPickerAction from '../../../libs/actions/EmojiPickerAction'; -import {withBlockedFromConcierge, withNetwork, withPersonalDetails, withReportActionsDrafts} from '../../../components/OnyxProvider'; +import {usePersonalDetails, withBlockedFromConcierge, withNetwork, withReportActionsDrafts} from '../../../components/OnyxProvider'; import RenameAction from '../../../components/ReportActionItem/RenameAction'; import InlineSystemMessage from '../../../components/InlineSystemMessage'; import styles from '../../../styles/styles'; @@ -49,7 +49,6 @@ import Icon from '../../../components/Icon'; import * as Expensicons from '../../../components/Icon/Expensicons'; import Text from '../../../components/Text'; import DisplayNames from '../../../components/DisplayNames'; -import personalDetailsPropType from '../../personalDetailsPropType'; import ReportPreview from '../../../components/ReportActionItem/ReportPreview'; import ReportActionItemDraft from './ReportActionItemDraft'; import TaskPreview from '../../../components/ReportActionItem/TaskPreview'; @@ -111,7 +110,6 @@ const propTypes = { ...windowDimensionsPropTypes, emojiReactions: EmojiReactionsPropTypes, - personalDetailsList: PropTypes.objectOf(personalDetailsPropType), /** IOU report for this action, if any */ iouReport: reportPropTypes, @@ -127,7 +125,6 @@ const defaultProps = { draftMessage: '', preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, emojiReactions: {}, - personalDetailsList: {}, shouldShowSubscriptAvatar: false, hasOutstandingIOU: false, iouReport: undefined, @@ -136,6 +133,7 @@ const defaultProps = { }; function ReportActionItem(props) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const [isContextMenuActive, setIsContextMenuActive] = useState(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); const [isHidden, setIsHidden] = useState(false); const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); @@ -150,6 +148,10 @@ function ReportActionItem(props) { const isReportActionLinked = props.linkedReportActionID === props.action.reportActionID; const highlightedBackgroundColorIfNeeded = useMemo(() => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(themeColors.highlightBG) : {}), [isReportActionLinked]); + 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'); // When active action changes, we need to update the `isContextMenuActive` state const isActiveReportActionForMenu = ReportActionContextMenu.isActiveReportAction(props.action.reportActionID); @@ -301,10 +303,6 @@ function ReportActionItem(props) { */ const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false) => { let children; - 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 MoneyRequestPreview for when request was created, bill was split or money was sent if ( @@ -362,7 +360,7 @@ function ReportActionItem(props) { /> ); } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED) { - const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(props.personalDetailsList, [props.report.ownerAccountID, 'displayName'], props.report.ownerEmail); + const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [props.report.ownerAccountID, 'displayName'], props.report.ownerEmail); const paymentType = lodashGet(props.action, 'originalMessage.paymentType', ''); const isSubmitterOfUnsettledReport = ReportUtils.isCurrentUserSubmitter(props.report.reportID) && !ReportUtils.isSettled(props.report.reportID); @@ -508,7 +506,7 @@ function ReportActionItem(props) { numberOfReplies={numberOfThreadReplies} mostRecentReply={`${props.action.childLastVisibleActionCreated}`} isHovered={hovered} - icons={ReportUtils.getIconsForParticipants(oldestFourAccountIDs, props.personalDetailsList)} + icons={ReportUtils.getIconsForParticipants(oldestFourAccountIDs, personalDetails)} onSecondaryInteraction={showPopover} /> @@ -623,12 +621,24 @@ function ReportActionItem(props) { ); } + // For the `pay` IOU action on non-send money flow, we don't want to render anything if `isWaitingOnBankAccount` is true + // Otherwise, we will see two system messages informing the payee needs to add a bank account or wallet + if ( + props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && + lodashGet(props.report, 'isWaitingOnBankAccount', false) && + originalMessage && + originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && + !isSendingMoney + ) { + return null; + } + const hasErrors = !_.isEmpty(props.action.errors); const whisperedToAccountIDs = props.action.whisperedToAccountIDs || []; const isWhisper = whisperedToAccountIDs.length > 0; const isMultipleParticipant = whisperedToAccountIDs.length > 1; const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedToAccountIDs); - const whisperedToPersonalDetails = isWhisper ? _.filter(props.personalDetailsList, (details) => _.includes(whisperedToAccountIDs, details.accountID)) : []; + const whisperedToPersonalDetails = isWhisper ? _.filter(personalDetails, (details) => _.includes(whisperedToAccountIDs, details.accountID)) : []; const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; return ( `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + selector: reportWithoutHasDraftSelector, }, personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index 60de11bdf218..fc189a3aef36 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -9,12 +9,11 @@ import ReportActionItemFragment from './ReportActionItemFragment'; import styles from '../../../styles/styles'; import ReportActionItemDate from './ReportActionItemDate'; import Avatar from '../../../components/Avatar'; -import personalDetailsPropType from '../../personalDetailsPropType'; import compose from '../../../libs/compose'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import Navigation from '../../../libs/Navigation/Navigation'; import ROUTES from '../../../ROUTES'; -import {withPersonalDetails} from '../../../components/OnyxProvider'; +import {usePersonalDetails} from '../../../components/OnyxProvider'; import ControlSelection from '../../../libs/ControlSelection'; import * as ReportUtils from '../../../libs/ReportUtils'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; @@ -37,9 +36,6 @@ const propTypes = { /** All the data of the action */ action: PropTypes.shape(reportActionPropTypes).isRequired, - /** All of the personalDetails */ - personalDetailsList: PropTypes.objectOf(personalDetailsPropType), - /** Styles for the outermost View */ // eslint-disable-next-line react/forbid-prop-types wrapperStyles: PropTypes.arrayOf(PropTypes.object), @@ -69,7 +65,6 @@ const propTypes = { }; const defaultProps = { - personalDetailsList: {}, wrapperStyles: [styles.chatItem], showHeader: true, shouldShowSubscriptAvatar: false, @@ -88,9 +83,10 @@ const showWorkspaceDetails = (reportID) => { }; function ReportActionItemSingle(props) { + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const actorAccountID = props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport ? props.iouReport.managerID : props.action.actorAccountID; - let {displayName} = props.personalDetailsList[actorAccountID] || {}; - const {avatar, login, pendingFields, status, fallbackIcon} = props.personalDetailsList[actorAccountID] || {}; + let {displayName} = personalDetails[actorAccountID] || {}; + const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID] || {}; let actorHint = (login || displayName || '').replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const displayAllActors = useMemo(() => props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport, [props.action.actionName, props.iouReport]); const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(props.report) && (!actorAccountID || displayAllActors); @@ -100,10 +96,10 @@ function ReportActionItemSingle(props) { displayName = ReportUtils.getPolicyName(props.report); actorHint = displayName; avatarSource = ReportUtils.getWorkspaceAvatar(props.report); - } else if (props.action.delegateAccountID && props.personalDetailsList[props.action.delegateAccountID]) { + } else if (props.action.delegateAccountID && personalDetails[props.action.delegateAccountID]) { // We replace the actor's email, name, and avatar with the Copilot manually for now. And only if we have their // details. This will be improved upon when the Copilot feature is implemented. - const delegateDetails = props.personalDetailsList[props.action.delegateAccountID]; + const delegateDetails = personalDetails[props.action.delegateAccountID]; const delegateDisplayName = delegateDetails.displayName; actorHint = `${delegateDisplayName} (${props.translate('reportAction.asCopilot')} ${displayName})`; displayName = actorHint; @@ -116,7 +112,7 @@ function ReportActionItemSingle(props) { if (displayAllActors) { // The ownerAccountID and actorAccountID can be the same if the a user requests money back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice const secondaryAccountId = props.iouReport.ownerAccountID === actorAccountID ? props.iouReport.managerID : props.iouReport.ownerAccountID; - const secondaryUserDetails = props.personalDetailsList[secondaryAccountId] || {}; + const secondaryUserDetails = personalDetails[secondaryAccountId] || {}; const secondaryDisplayName = lodashGet(secondaryUserDetails, 'displayName', ''); displayName = `${primaryDisplayName} & ${secondaryDisplayName}`; secondaryAvatar = { @@ -270,7 +266,6 @@ ReportActionItemSingle.displayName = 'ReportActionItemSingle'; export default compose( withLocalize, - withPersonalDetails(), withOnyx({ betas: { key: ONYXKEYS.BETAS, diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index 04ded0823e39..3cdd8ece876f 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -23,6 +23,7 @@ import reportPropTypes from '../../reportPropTypes'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import reportActionPropTypes from './reportActionPropTypes'; +import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; const propTypes = { /** The report currently being looked at */ @@ -97,6 +98,10 @@ function keyExtractor(item) { } function isMessageUnread(message, lastReadTime) { + if (!lastReadTime) { + return Boolean(!ReportActionsUtils.isCreatedAction(message)); + } + return Boolean(message && lastReadTime && message.created && lastReadTime < message.created); } @@ -273,8 +278,8 @@ function ReportActionsList({ * This is so that it will not be conflicting with header's separator line. */ const shouldHideThreadDividerLine = useMemo( - () => sortedReportActions.length > 1 && sortedReportActions[sortedReportActions.length - 2].reportActionID === currentUnreadMarker, - [sortedReportActions, currentUnreadMarker], + () => ReportActionsUtils.getFirstVisibleReportActionID(sortedReportActions, isOffline) === currentUnreadMarker, + [sortedReportActions, isOffline, currentUnreadMarker], ); /** @@ -288,7 +293,7 @@ function ReportActionsList({ if (!currentUnreadMarker) { const nextMessage = sortedReportActions[index + 1]; const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime); - shouldDisplay = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime); + shouldDisplay = isCurrentMessageUnread && (!nextMessage || !isMessageUnread(nextMessage, report.lastReadTime)); if (!messageManuallyMarkedUnread) { shouldDisplay = shouldDisplay && reportAction.actorAccountID !== Report.getCurrentUserAccountID(); } diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index 038c0ac33606..1f52f85e83a3 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -1,4 +1,4 @@ -import React, {useState, useMemo, useCallback, useRef} from 'react'; +import React, {useState, useMemo, useCallback, useRef, useEffect} from 'react'; import {Keyboard} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; @@ -14,6 +14,8 @@ import compose from '../../libs/compose'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import {withNetwork} from '../../components/OnyxProvider'; import * as CurrencyUtils from '../../libs/CurrencyUtils'; +import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; +import * as ReportUtils from '../../libs/ReportUtils'; import ROUTES from '../../ROUTES'; import {iouPropTypes, iouDefaultProps} from './propTypes'; import SelectionList from '../../components/SelectionList'; @@ -71,6 +73,28 @@ function IOUCurrencySelection(props) { const selectedCurrencyCode = (lodashGet(props.route, 'params.currency', props.iou.currency) || CONST.CURRENCY.USD).toUpperCase(); const iouType = lodashGet(props.route, 'params.iouType', CONST.IOU.TYPE.REQUEST); const reportID = lodashGet(props.route, 'params.reportID', ''); + const threadReportID = lodashGet(props.route, 'params.threadReportID', ''); + + // Decides whether to allow or disallow editing a money request + useEffect(() => { + // Do not dismiss the modal, when it is not the edit flow. + if (!threadReportID) { + return; + } + + const report = ReportUtils.getReport(threadReportID); + const parentReportAction = ReportActionsUtils.getReportAction(report.parentReportID, report.parentReportActionID); + + // Do not dismiss the modal, when a current user can edit this currency of this money request. + if (ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, report.parentReportID, CONST.EDIT_REQUEST_FIELD.CURRENCY)) { + return; + } + + // Dismiss the modal when a current user cannot edit a money request. + Navigation.isNavigationReady().then(() => { + Navigation.dismissModal(); + }); + }, [threadReportID]); const confirmCurrencySelection = useCallback( (option) => { diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js index 26388ea06ac0..979be64f68e9 100644 --- a/src/pages/iou/MoneyRequestSelectorPage.js +++ b/src/pages/iou/MoneyRequestSelectorPage.js @@ -157,7 +157,7 @@ export default compose( withReportOrNotFound(false), withOnyx({ selectedTab: { - key: `${ONYXKEYS.SELECTED_TAB}_${CONST.TAB.RECEIPT_TAB_ID}`, + key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, }, }), )(MoneyRequestSelectorPage); diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js index 9fb420791539..8faec1cbbe37 100644 --- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js +++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera.native.js @@ -3,6 +3,7 @@ import {Camera} from 'react-native-vision-camera'; import {useTabAnimation} from '@react-navigation/material-top-tabs'; import {useNavigation} from '@react-navigation/native'; import PropTypes from 'prop-types'; +import CONST from '../../../CONST'; const propTypes = { /* The index of the tab that contains this camera */ @@ -10,10 +11,13 @@ const propTypes = { /* Whether we're in a tab navigator */ isInTabNavigator: PropTypes.bool.isRequired, + + /** Name of the selected receipt tab */ + selectedTab: PropTypes.string.isRequired, }; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigator, ...props}, ref) => { +const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigator, selectedTab, ...props}, ref) => { // Get navigation to get initial isFocused value (only needed once during init!) const navigation = useNavigation(); const [isCameraActive, setIsCameraActive] = useState(navigation.isFocused()); @@ -31,6 +35,9 @@ const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigato } const listenerId = tabPositionAnimation.addListener(({value}) => { + if (selectedTab !== CONST.TAB.SCAN) { + return; + } // Activate camera as soon the index is animating towards the `cameraTabIndex` setIsCameraActive(value > cameraTabIndex - 1 && value < cameraTabIndex + 1); }); @@ -38,7 +45,7 @@ const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigato return () => { tabPositionAnimation.removeListener(listenerId); }; - }, [cameraTabIndex, tabPositionAnimation, isInTabNavigator]); + }, [cameraTabIndex, tabPositionAnimation, isInTabNavigator, selectedTab]); // Note: The useEffect can be removed once VisionCamera V3 is used. // Its only needed for android, because there is a native cameraX android bug. With out this flow would break the camera: diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js index 8bf13422f70c..649b6ea521f3 100644 --- a/src/pages/iou/ReceiptSelector/index.native.js +++ b/src/pages/iou/ReceiptSelector/index.native.js @@ -53,6 +53,9 @@ const propTypes = { /** Whether or not the receipt selector is in a tab navigator for tab animations */ isInTabNavigator: PropTypes.bool, + + /** Name of the selected receipt tab */ + selectedTab: PropTypes.string, }; const defaultProps = { @@ -60,9 +63,10 @@ const defaultProps = { iou: iouDefaultProps, transactionID: '', isInTabNavigator: true, + selectedTab: '', }; -function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator}) { +function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, selectedTab}) { const devices = useCameraDevices('wide-angle-camera'); const device = devices.back; @@ -195,6 +199,7 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator}) photo cameraTabIndex={pageIndex} isInTabNavigator={isInTabNavigator} + selectedTab={selectedTab} /> )} diff --git a/src/pages/settings/Security/CloseAccountPage.js b/src/pages/settings/Security/CloseAccountPage.js index 3151380dc1f5..763f6c77d774 100644 --- a/src/pages/settings/Security/CloseAccountPage.js +++ b/src/pages/settings/Security/CloseAccountPage.js @@ -16,10 +16,11 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import * as CloseAccount from '../../../libs/actions/CloseAccount'; import ONYXKEYS from '../../../ONYXKEYS'; -import Form from '../../../components/Form'; import CONST from '../../../CONST'; import ConfirmModal from '../../../components/ConfirmModal'; import * as ValidationUtils from '../../../libs/ValidationUtils'; +import FormProvider from '../../../components/Form/FormProvider'; +import InputWrapper from '../../../components/Form/InputWrapper'; const propTypes = { /** Session of currently logged in user */ @@ -91,7 +92,7 @@ function CloseAccountPage(props) { title={props.translate('closeAccountPage.closeAccount')} onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_SECURITY)} /> -
{props.translate('closeAccountPage.reasonForLeavingPrompt')} - {props.translate('closeAccountPage.enterDefaultContactToConfirm')} {userEmailOrPhone} - -
+
); } diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js index 7340a1f64511..ebad8d8bc5d0 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js @@ -32,11 +32,12 @@ function CodesStep({account = defaultAccount}) { const {setStep} = useTwoFactorAuthContext(); useEffect(() => { - if (account.recoveryCodes) { + if (account.requiresTwoFactorAuth || account.recoveryCodes) { return; } Session.toggleTwoFactorAuth(true); - }, [account.recoveryCodes]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- We want to run this when component mounts + }, []); return ( + + + {title} + {description} + + + + + +
+ ); +} + +DangerCardSection.propTypes = propTypes; +DangerCardSection.displayName = 'DangerCardSection'; + +export default DangerCardSection; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js index c7a178134139..e198d449d57d 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage.js +++ b/src/pages/settings/Wallet/ExpensifyCardPage.js @@ -21,6 +21,10 @@ import CardDetails from './WalletPage/CardDetails'; import MenuItem from '../../../components/MenuItem'; import CONST from '../../../CONST'; import assignedCardPropTypes from './assignedCardPropTypes'; +import theme from '../../../styles/themes/default'; +import DotIndicatorMessage from '../../../components/DotIndicatorMessage'; +import * as Link from '../../../libs/actions/Link'; +import DangerCardSection from './DangerCardSection'; const propTypes = { /* Onyx Props */ @@ -63,6 +67,9 @@ function ExpensifyCardPage({ setShouldShowCardDetails(true); }; + const hasDetectedDomainFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN); + const hasDetectedIndividualFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL); + return ( Navigation.goBack(ROUTES.SETTINGS_WALLET)} /> - + - - {!_.isEmpty(virtualCard) && ( + {hasDetectedDomainFraud ? ( + + ) : null} + + {hasDetectedIndividualFraud && !hasDetectedDomainFraud ? ( <> - {shouldShowCardDetails ? ( - - ) : ( - - } - /> - )} + Navigation.navigate(ROUTES.SETTINGS_REPORT_FRAUD.getRoute(domain))} + brickRoadIndicator="error" + onPress={() => Link.openOldDotLink('inbox')} /> - )} - {!_.isEmpty(physicalCard) && ( + ) : null} + + {!hasDetectedDomainFraud ? ( <> - Navigation.navigate(ROUTES.SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED.getRoute(domain))} + titleStyle={styles.newKansasLarge} /> + {!_.isEmpty(virtualCard) && ( + <> + {shouldShowCardDetails ? ( + + ) : ( + + } + /> + )} + Navigation.navigate(ROUTES.SETTINGS_REPORT_FRAUD.getRoute(domain))} + /> + + )} + {!_.isEmpty(physicalCard) && ( + <> + + Navigation.navigate(ROUTES.SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED.getRoute(domain))} + /> + + )} - )} + ) : null} {physicalCard.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED && (