diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index ca7345ef9462..4a53e75354c6 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -1,20 +1,23 @@ -# Deploying the ExpensifyHelp Jekyll site by dynamically generating routes file name: Deploy ExpensifyHelp on: - # Runs on pushes targeting the default branch + # Run on any push to main that has changes to the docs directory push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab + branches: + - main + paths: + - 'docs/**' + + # Run on any pull request (except PRs against staging or production) that has changes to the docs directory + pull_request: + types: [opened, synchronize] + branches-ignore: [staging, production] + paths: + - 'docs/**' + + # Run on any manual trigger workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: @@ -22,7 +25,6 @@ concurrency: cancel-in-progress: false jobs: - # Build job build: runs-on: ubuntu-latest steps: @@ -32,9 +34,6 @@ jobs: - name: Setup NodeJS uses: Expensify/App/.github/actions/composite/setupNode@main - - name: Setup Pages - uses: actions/configure-pages@f156874f8191504dae5b037505266ed5dda6c382 - - name: Create docs routes file run: ./.github/scripts/createDocsRoutes.sh @@ -44,19 +43,18 @@ jobs: source: ./docs/ destination: ./docs/_site - - name: Upload artifact - uses: actions/upload-pages-artifact@64bcae551a7b18bcb9a09042ddf1960979799187 + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@f0a1cd58cd66095dee69bfa18fa5efd1dde93bca + id: deploy with: - path: ./docs/_site - - # Deployment job - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@af48cf94a42f2c634308b1c9dc0151830b6f190a + apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: helpdot + directory: ./docs/_site + + - name: Leave a comment on the PR + uses: actions-cool/maintain-one-comment@de04bd2a3750d86b324829a3ff34d47e48e16f4b + if: ${{ github.event_name == 'pull_request' }} + with: + token: ${{ secrets.OS_BOTIFY_TOKEN }} + body: ${{ format('A preview of your ExpensifyHelp changes have been deployed to {0} ⚡️', steps.deploy.outputs.alias) }} 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 fa2bd3865ca2..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 1001038708 - versionName "1.3.87-8" + 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/HR-integrations/QuickBooks-Time.md b/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md index 3ee1c8656b4b..5bbd2c4b583c 100644 --- a/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/QuickBooks-Time.md @@ -1,5 +1,41 @@ --- -title: Coming Soon -description: Coming Soon +title: Expensify and TSheets/QuickBooks Time Integration Guide +description: This help document explains how to connect TSheets/QuickBooks Time to your Expensify policy --- -## Resource Coming Soon! +# Overview + +Connecting Expensify with TSheets/QuickBooks Time can streamline your expense tracking and time management processes. This integration allows you to automatically sync time entries from TSheets/QuickBooks Time with expenses in Expensify, ensuring accurate and efficient expense reporting. + +# How to set up the Expensify and TSheets/QuickBooks Time integration + +Before you begin, make sure you have the following: + +- **Expensify account:** You must have an active Expensify account. +- **TSheets account:** You must have a TSheets account and admin privileges to set up the integration. + +Now, follow these steps to set up the integration: + +1. Log into your Expensify account on your web browser + +2. Go to **Settings > Workspaces > Group > Workspace Name > Connections > TSheets** + +3. Click **Connect to TSheets** + +4. Follow the on-screen instructions to sign in to your TSheets account and grant Expensify access. + +5. Once the integration is authorized, you may need to configure some preferences. +- Specify how you want TSheets time entries to be imported into Expensify. You can typically customize settings like the date range, project/task mapping, and expense categories. + +6. Now, we’d recommend testing the integration. +- Create a sample time entry in TSheets and check if it’s automatically reflected in your Expensify account. + +7. If the test is successful, save your integration settings. + +8. You may also want to schedule regular syncs or specify how often Expensify should pull data from TSheets. + +With the integration set up, your TSheets time entries will now appear in Expensify as expenses. You can review, categorize, and submit these expenses as needed. + +Congratulations! You've successfully integrated Expensify with TSheets, simplifying your expense tracking and reporting process. + +For questions, don't hesitate to reach out to concierge@expensify.com or chat directly with your account manager + diff --git a/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md b/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/integrations/travel-integrations/Hotel-Tonight.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! 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 95a9a26df7f6..c79b98b0b771 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.87 + 1.3.90 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.87.8 + 1.3.90.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index d41b75440036..9dc48ca35174 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.87 + 1.3.90 CFBundleSignature ???? CFBundleVersion - 1.3.87.8 + 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 65c2be0529e3..6d579a2736a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.87-8", + "version": "1.3.90-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.87-8", + "version": "1.3.90-1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index aca3dc508c41..0bf706c73edd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.87-8", + "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 b5ceb8fc557d..bcc4685368cb 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -5,6 +5,17 @@ import CONST from './CONST'; * This is a file containing constants for all of the routes we want to be able to go to */ +/** + * This is a file containing constants for all of the routes we want to be able to go to + * 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 getUrlWithBackToParam(url: string, backTo?: string): string { + const backToParam = backTo ? `${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` : ''; + return url + backToParam; +} + export default { HOME: '', /** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */ @@ -20,10 +31,7 @@ export default { }, PROFILE: { route: 'a/:accountID', - getRoute: (accountID: string | number, backTo = '') => { - const backToParam = backTo ? `?backTo=${encodeURIComponent(backTo)}` : ''; - return `a/${accountID}${backToParam}`; - }, + getRoute: (accountID: string | number, backTo?: string) => getUrlWithBackToParam(`a/${accountID}`, backTo), }, TRANSITION_BETWEEN_APPS: 'transition', @@ -49,10 +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 => { - const backToParam = backTo ? `&backTo=${encodeURIComponent(backTo)}` : ''; - return `bank-account/${stepToOpen}?policyID=${policyID}${backToParam}`; - }, + getRoute: (stepToOpen = '', policyID = '', backTo?: string): string => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo), }, SETTINGS: 'settings', @@ -104,13 +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) => { - let route = `settings/profile/personal-details/address/country?country=${country}`; - if (backTo) { - route += `&backTo=${encodeURIComponent(backTo)}`; - } - return route; - }, + 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 a12b089ddf97..5c2f65e24b01 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -1,15 +1,15 @@ import React, {useState, useEffect, useRef, forwardRef, useImperativeHandle} from 'react'; import {Dimensions} from 'react-native'; import _ from 'underscore'; +import PropTypes from 'prop-types'; import EmojiPickerMenu from './EmojiPickerMenu'; import CONST from '../../CONST'; import styles from '../../styles/styles'; import PopoverWithMeasuredContent from '../PopoverWithMeasuredContent'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; -import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../withViewportOffsetTop'; -import compose from '../../libs/compose'; +import withViewportOffsetTop from '../withViewportOffsetTop'; 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, @@ -17,8 +17,7 @@ const DEFAULT_ANCHOR_ORIGIN = { }; const propTypes = { - ...windowDimensionsPropTypes, - ...viewportOffsetTopPropTypes, + viewportOffsetTop: PropTypes.number.isRequired, }; const EmojiPicker = forwardRef((props, ref) => { @@ -33,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. @@ -124,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; @@ -136,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 @@ -161,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 > @@ -175,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 0d7826ff3783..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); @@ -108,7 +107,6 @@ function EmojiPickerMenu(props) { const [selection, setSelection] = useState({start: 0, end: 0}); const [isFocused, setIsFocused] = useState(false); const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false); - const [selectTextOnFocus, setSelectTextOnFocus] = useState(false); useEffect(() => { const emojisAndHeaderRowIndices = getEmojisAndHeaderRowIndices(); @@ -162,8 +160,6 @@ function EmojiPickerMenu(props) { if (!searchInputRef.current) { return; } - - setSelectTextOnFocus(true); searchInputRef.current.focus(); } @@ -323,11 +319,7 @@ function EmojiPickerMenu(props) { // We allow typing in the search box if any key is pressed apart from Arrow keys. if (searchInputRef.current && !searchInputRef.current.isFocused()) { - setSelectTextOnFocus(false); searchInputRef.current.focus(); - - // Re-enable selection on the searchInput - setSelectTextOnFocus(true); } }, [filteredEmojis, highlightAdjacentEmoji, highlightedIndex, isFocused, onEmojiSelected, preferredSkinTone], @@ -371,13 +363,17 @@ function EmojiPickerMenu(props) { } setupEventHandlers(); - updateFirstNonHeaderIndex(emojis.current); return () => { cleanupEventHandlers(); }; }, [forwardedRef, shouldFocusInputOnScreenFocus, cleanupEventHandlers, setupEventHandlers]); + useEffect(() => { + // Find and store index of the first emoji item on mount + updateFirstNonHeaderIndex(emojis.current); + }, []); + const scrollToHeader = useCallback((headerIndex) => { if (!emojiListRef.current) { return; @@ -481,7 +477,6 @@ function EmojiPickerMenu(props) { defaultValue="" ref={searchInputRef} autoFocus={shouldFocusInputOnScreenFocus} - selectTextOnFocus={selectTextOnFocus} onSelectionChange={onSelectionChange} onFocus={() => { setHighlightedIndex(-1); @@ -532,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/PDFPasswordForm.js b/src/components/PDFView/PDFPasswordForm.js index 58a4e64a28a5..6b6163992589 100644 --- a/src/components/PDFView/PDFPasswordForm.js +++ b/src/components/PDFView/PDFPasswordForm.js @@ -131,7 +131,7 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat autoCorrect={false} textContentType="password" onChangeText={updatePassword} - returnKeyType="done" + returnKeyType="go" onSubmitEditing={submitPassword} errorText={errorText} onFocus={() => onPasswordFieldFocused(true)} 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/QRCode/index.js b/src/components/QRCode/index.js deleted file mode 100644 index f27cf28066ef..000000000000 --- a/src/components/QRCode/index.js +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import QRCodeLibrary from 'react-native-qrcode-svg'; -import PropTypes from 'prop-types'; -import defaultTheme from '../../styles/themes/default'; -import CONST from '../../CONST'; - -const propTypes = { - /** - * The QR code URL - */ - url: PropTypes.string.isRequired, - /** - * The logo which will be displayed in the middle of the QR code. - * Follows ImageProps href from react-native-svg that is used by react-native-qrcode-svg. - */ - logo: PropTypes.oneOfType([PropTypes.shape({uri: PropTypes.string}), PropTypes.number, PropTypes.string]), - /** - * The size ratio of logo to QR code - */ - logoRatio: PropTypes.number, - /** - * The size ratio of margin around logo to QR code - */ - logoMarginRatio: PropTypes.number, - /** - * The QRCode size - */ - size: PropTypes.number, - /** - * The QRCode color - */ - color: PropTypes.string, - /** - * The QRCode background color - */ - backgroundColor: PropTypes.string, - /** - * Function to retrieve the internal component ref and be able to call it's - * methods - */ - getRef: PropTypes.func, -}; - -const defaultProps = { - logo: undefined, - size: 120, - color: defaultTheme.text, - backgroundColor: defaultTheme.highlightBG, - getRef: undefined, - logoRatio: CONST.QR.DEFAULT_LOGO_SIZE_RATIO, - logoMarginRatio: CONST.QR.DEFAULT_LOGO_MARGIN_RATIO, -}; - -function QRCode(props) { - return ( - - ); -} - -QRCode.displayName = 'QRCode'; -QRCode.propTypes = propTypes; -QRCode.defaultProps = defaultProps; - -export default QRCode; diff --git a/src/components/QRCode/index.tsx b/src/components/QRCode/index.tsx new file mode 100644 index 000000000000..bca45c02fffa --- /dev/null +++ b/src/components/QRCode/index.tsx @@ -0,0 +1,71 @@ +import React, {Ref} from 'react'; +import QRCodeLibrary from 'react-native-qrcode-svg'; +import {ImageSourcePropType} from 'react-native'; +import defaultTheme from '../../styles/themes/default'; +import CONST from '../../CONST'; + +type LogoRatio = typeof CONST.QR.DEFAULT_LOGO_SIZE_RATIO | typeof CONST.QR.EXPENSIFY_LOGO_SIZE_RATIO; + +type LogoMarginRatio = typeof CONST.QR.DEFAULT_LOGO_MARGIN_RATIO | typeof CONST.QR.EXPENSIFY_LOGO_MARGIN_RATIO; + +type QRCodeProps = { + /** The QR code URL */ + url: string; + + /** + * The logo which will be displayed in the middle of the QR code. + * Follows ImageProps href from react-native-svg that is used by react-native-qrcode-svg. + */ + logo?: ImageSourcePropType; + + /** The size ratio of logo to QR code */ + logoRatio?: LogoRatio; + + /** The size ratio of margin around logo to QR code */ + logoMarginRatio?: LogoMarginRatio; + + /** The QRCode size */ + size?: number; + + /** The QRCode color */ + color?: string; + + /** The QRCode background color */ + backgroundColor?: string; + + /** + * Function to retrieve the internal component ref and be able to call it's + * methods + */ + getRef?: (ref: Ref) => Ref; +}; + +function QRCode({ + url, + logo, + getRef, + size = 120, + color = defaultTheme.text, + backgroundColor = defaultTheme.highlightBG, + logoRatio = CONST.QR.DEFAULT_LOGO_SIZE_RATIO, + logoMarginRatio = CONST.QR.DEFAULT_LOGO_MARGIN_RATIO, +}: QRCodeProps) { + return ( + + ); +} + +QRCode.displayName = 'QRCode'; + +export default QRCode; 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 9bc7a35f9fba..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'; @@ -34,16 +33,16 @@ function MoneyReportView(props) { const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(props.report); const shouldShowBreakdown = nonReimbursableSpend && reimbursableSpend; - const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, props.report.currency); + const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, props.report.currency, ReportUtils.hasOnlyDistanceRequestTransactions(props.report.reportID)); const formattedOutOfPocketAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, props.report.currency); const formattedCompanySpendAmount = CurrencyUtils.convertToDisplayString(nonReimbursableSpend, props.report.currency); 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 985d88e53d88..bdeec2640cdc 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -123,6 +123,7 @@ function ReportPreview(props) { const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(props.iouReportID); const numberOfScanningReceipts = _.filter(transactionsWithReceipts, (transaction) => TransactionUtils.isReceiptBeingScanned(transaction)).length; const hasReceipts = transactionsWithReceipts.length > 0; + const hasOnlyDistanceRequests = ReportUtils.hasOnlyDistanceRequestTransactions(props.iouReportID); const isScanning = hasReceipts && ReportUtils.areAllRequestsBeingSmartScanned(props.iouReportID, props.action); const hasErrors = hasReceipts && ReportUtils.hasMissingSmartscanFields(props.iouReportID); const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); @@ -145,6 +146,9 @@ function ReportPreview(props) { if (isScanning) { return props.translate('iou.receiptScanning'); } + if (hasOnlyDistanceRequests) { + return props.translate('common.tbd'); + } // If iouReport is not available, get amount from the action message (Ex: "Domain20821's Workspace owes $33.00" or "paid ₫60" or "paid -₫60 elsewhere") let displayAmount = ''; @@ -241,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/ZeroWidthView/index.js b/src/components/ZeroWidthView/index.js new file mode 100644 index 000000000000..6c3809a40a04 --- /dev/null +++ b/src/components/ZeroWidthView/index.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import * as EmojiUtils from '../../libs/EmojiUtils'; +import * as Browser from '../../libs/Browser'; +import Text from '../Text'; + +const propTypes = { + /** If this is the Concierge chat, we'll open the modal for requesting a setup call instead of showing popover menu */ + text: PropTypes.string, + + /** URL to the assigned guide's appointment booking calendar */ + displayAsGroup: PropTypes.bool, +}; + +const defaultProps = { + text: '', + displayAsGroup: false, +}; + +function ZeroWidthView({text, displayAsGroup}) { + const firstLetterIsEmoji = EmojiUtils.isFirstLetterEmoji(text); + if (firstLetterIsEmoji && !displayAsGroup && !Browser.isMobile()) { + return ; + } + return null; +} + +ZeroWidthView.propTypes = propTypes; +ZeroWidthView.defaultProps = defaultProps; +ZeroWidthView.displayName = 'ZeroWidthView'; + +export default ZeroWidthView; diff --git a/src/components/ZeroWidthView/index.native.js b/src/components/ZeroWidthView/index.native.js new file mode 100644 index 000000000000..59c3cc74ab72 --- /dev/null +++ b/src/components/ZeroWidthView/index.native.js @@ -0,0 +1,5 @@ +function ZeroWidthView() { + return null; +} + +export default ZeroWidthView; 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/withCurrentUserPersonalDetails.js b/src/components/withCurrentUserPersonalDetails.js deleted file mode 100644 index 7a47ea7cc712..000000000000 --- a/src/components/withCurrentUserPersonalDetails.js +++ /dev/null @@ -1,74 +0,0 @@ -import React, {useMemo} from 'react'; -import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; -import getComponentDisplayName from '../libs/getComponentDisplayName'; -import ONYXKEYS from '../ONYXKEYS'; -import personalDetailsPropType from '../pages/personalDetailsPropType'; -import refPropTypes from './refPropTypes'; - -const withCurrentUserPersonalDetailsPropTypes = { - currentUserPersonalDetails: personalDetailsPropType, -}; - -const withCurrentUserPersonalDetailsDefaultProps = { - currentUserPersonalDetails: {}, -}; - -export default function (WrappedComponent) { - const propTypes = { - forwardedRef: refPropTypes, - - /** Personal details of all the users, including current user */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - - /** Session of the current user */ - session: PropTypes.shape({ - accountID: PropTypes.number, - }), - }; - const defaultProps = { - forwardedRef: undefined, - personalDetails: {}, - session: { - accountID: 0, - }, - }; - - function WithCurrentUserPersonalDetails(props) { - const accountID = props.session.accountID; - const accountPersonalDetails = props.personalDetails[accountID]; - const currentUserPersonalDetails = useMemo(() => ({...accountPersonalDetails, accountID}), [accountPersonalDetails, accountID]); - return ( - - ); - } - - WithCurrentUserPersonalDetails.displayName = `WithCurrentUserPersonalDetails(${getComponentDisplayName(WrappedComponent)})`; - WithCurrentUserPersonalDetails.propTypes = propTypes; - - WithCurrentUserPersonalDetails.defaultProps = defaultProps; - - const withCurrentUserPersonalDetails = React.forwardRef((props, ref) => ( - - )); - - return withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - })(withCurrentUserPersonalDetails); -} - -export {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps}; diff --git a/src/components/withCurrentUserPersonalDetails.tsx b/src/components/withCurrentUserPersonalDetails.tsx new file mode 100644 index 000000000000..e1472f280f17 --- /dev/null +++ b/src/components/withCurrentUserPersonalDetails.tsx @@ -0,0 +1,67 @@ +import React, {ComponentType, RefAttributes, ForwardedRef, useMemo} from 'react'; +import {OnyxEntry, withOnyx} from 'react-native-onyx'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; +import ONYXKEYS from '../ONYXKEYS'; +import personalDetailsPropType from '../pages/personalDetailsPropType'; +import type {PersonalDetails, Session} from '../types/onyx'; + +type CurrentUserPersonalDetails = PersonalDetails | Record; + +type OnyxProps = { + /** Personal details of all the users, including current user */ + personalDetails: OnyxEntry>; + + /** Session of the current user */ + session: OnyxEntry; +}; + +type HOCProps = { + currentUserPersonalDetails: CurrentUserPersonalDetails; +}; + +type ComponentProps = OnyxProps & HOCProps; + +// TODO: remove when all components that use it will be migrated to TS +const withCurrentUserPersonalDetailsPropTypes = { + currentUserPersonalDetails: personalDetailsPropType, +}; + +const withCurrentUserPersonalDetailsDefaultProps: HOCProps = { + currentUserPersonalDetails: {}, +}; + +export default function ( + WrappedComponent: ComponentType>, +): ComponentType & RefAttributes, keyof OnyxProps>> { + function WithCurrentUserPersonalDetails(props: Omit, ref: ForwardedRef) { + const accountID = props.session?.accountID ?? 0; + const accountPersonalDetails = props.personalDetails?.[accountID]; + const currentUserPersonalDetails: CurrentUserPersonalDetails = useMemo( + () => (accountPersonalDetails ? {...accountPersonalDetails, accountID} : {}), + [accountPersonalDetails, accountID], + ); + return ( + + ); + } + + WithCurrentUserPersonalDetails.displayName = `WithCurrentUserPersonalDetails(${getComponentDisplayName(WrappedComponent)})`; + + const withCurrentUserPersonalDetails = React.forwardRef(WithCurrentUserPersonalDetails); + + return withOnyx & RefAttributes, OnyxProps>({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + session: { + key: ONYXKEYS.SESSION, + }, + })(withCurrentUserPersonalDetails); +} + +export {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps}; 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/components/withViewportOffsetTop.js b/src/components/withViewportOffsetTop.js deleted file mode 100644 index ccf928b3bd13..000000000000 --- a/src/components/withViewportOffsetTop.js +++ /dev/null @@ -1,61 +0,0 @@ -import React, {useEffect, forwardRef, useState} from 'react'; -import PropTypes from 'prop-types'; -import lodashGet from 'lodash/get'; -import getComponentDisplayName from '../libs/getComponentDisplayName'; -import addViewportResizeListener from '../libs/VisualViewport'; -import refPropTypes from './refPropTypes'; - -const viewportOffsetTopPropTypes = { - // viewportOffsetTop returns the offset of the top edge of the visual viewport from the - // top edge of the layout viewport in CSS pixels, when the visual viewport is resized. - - viewportOffsetTop: PropTypes.number.isRequired, -}; - -export default function (WrappedComponent) { - function WithViewportOffsetTop(props) { - const [viewportOffsetTop, setViewportOffsetTop] = useState(0); - - useEffect(() => { - /** - * @param {SyntheticEvent} e - */ - const updateDimensions = (e) => { - const targetOffsetTop = lodashGet(e, 'target.offsetTop', 0); - setViewportOffsetTop(targetOffsetTop); - }; - - const removeViewportResizeListener = addViewportResizeListener(updateDimensions); - - return () => { - removeViewportResizeListener(); - }; - }, []); - - return ( - - ); - } - - WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`; - WithViewportOffsetTop.propTypes = { - forwardedRef: refPropTypes, - }; - WithViewportOffsetTop.defaultProps = { - forwardedRef: undefined, - }; - return forwardRef((props, ref) => ( - - )); -} - -export {viewportOffsetTopPropTypes}; diff --git a/src/components/withViewportOffsetTop.tsx b/src/components/withViewportOffsetTop.tsx new file mode 100644 index 000000000000..e2e1dc2d3484 --- /dev/null +++ b/src/components/withViewportOffsetTop.tsx @@ -0,0 +1,41 @@ +import React, {useEffect, forwardRef, useState, ComponentType, RefAttributes, ForwardedRef} from 'react'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; +import addViewportResizeListener from '../libs/VisualViewport'; + +type ViewportOffsetTopProps = { + // viewportOffsetTop returns the offset of the top edge of the visual viewport from the + // top edge of the layout viewport in CSS pixels, when the visual viewport is resized. + viewportOffsetTop: number; +}; + +export default function withViewportOffsetTop(WrappedComponent: ComponentType>) { + function WithViewportOffsetTop(props: Omit, ref: ForwardedRef) { + const [viewportOffsetTop, setViewportOffsetTop] = useState(0); + + useEffect(() => { + const updateDimensions = (event: Event) => { + const targetOffsetTop = (event.target instanceof VisualViewport && event.target.offsetTop) || 0; + setViewportOffsetTop(targetOffsetTop); + }; + + const removeViewportResizeListener = addViewportResizeListener(updateDimensions); + + return () => { + removeViewportResizeListener(); + }; + }, []); + + return ( + + ); + } + + WithViewportOffsetTop.displayName = `WithViewportOffsetTop(${getComponentDisplayName(WrappedComponent)})`; + + return forwardRef(WithViewportOffsetTop); +} diff --git a/src/hooks/useCopySelectionHelper.js b/src/hooks/useCopySelectionHelper.ts similarity index 89% rename from src/hooks/useCopySelectionHelper.js rename to src/hooks/useCopySelectionHelper.ts index 42871981e29c..b41bfb3c4aee 100644 --- a/src/hooks/useCopySelectionHelper.js +++ b/src/hooks/useCopySelectionHelper.ts @@ -25,10 +25,12 @@ export default function useCopySelectionHelper() { copyShortcutConfig.shortcutKey, copySelectionToClipboard, copyShortcutConfig.descriptionKey, - copyShortcutConfig.modifiers, + [...copyShortcutConfig.modifiers], false, ); - return unsubscribeCopyShortcut; + return () => { + unsubscribeCopyShortcut(); + }; }, []); } 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 11637846130a..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}`, @@ -584,6 +584,7 @@ export default { threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} request${comment ? ` for ${comment}` : ''}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money`, + categorySelection: 'Select a category to add additional organization to your money', error: { invalidAmount: 'Please enter a valid amount before continuing.', invalidSplit: 'Split amounts do not equal total amount', @@ -868,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', @@ -875,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 e4a5c37241f2..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}`, @@ -578,6 +578,7 @@ export default { threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Solicitud de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero`, + categorySelection: 'Seleccione una categoría para organizar mejor tu dinero', error: { invalidAmount: 'Por favor ingresa un monto válido antes de continuar.', invalidSplit: 'La suma de las partes no equivale al monto total', @@ -864,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', @@ -871,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/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 21784d450a07..85ba8340c13e 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -2,6 +2,7 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS, {OnyxValues} from '../ONYXKEYS'; import CONST from '../CONST'; import BaseLocaleListener from './Localize/LocaleListener/BaseLocaleListener'; +import * as Localize from './Localize'; import * as NumberFormatUtils from './NumberFormatUtils'; let currencyList: OnyxValues[typeof ONYXKEYS.CURRENCY_LIST] = {}; @@ -96,8 +97,13 @@ function convertToFrontendAmount(amountAsInt: number): number { * * @param amountInCents – should be an integer. Anything after a decimal place will be dropped. * @param currency - IOU currency + * @param shouldFallbackToTbd - whether to return 'TBD' instead of a falsy value (e.g. 0.00) */ -function convertToDisplayString(amountInCents: number, currency: string = CONST.CURRENCY.USD): string { +function convertToDisplayString(amountInCents: number, currency: string = CONST.CURRENCY.USD, shouldFallbackToTbd = false): string { + if (shouldFallbackToTbd && !amountInCents) { + return Localize.translateLocal('common.tbd'); + } + const convertedAmount = convertToFrontendAmount(amountInCents); return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', 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/Navigation/OnyxTabNavigator.js b/src/libs/Navigation/OnyxTabNavigator.js index 2782054497b0..158160e9aa13 100644 --- a/src/libs/Navigation/OnyxTabNavigator.js +++ b/src/libs/Navigation/OnyxTabNavigator.js @@ -6,13 +6,13 @@ import Tab from '../actions/Tab'; import ONYXKEYS from '../../ONYXKEYS'; const propTypes = { - /* ID of the tab component to be saved in onyx */ + /** ID of the tab component to be saved in onyx */ id: PropTypes.string.isRequired, - /* Name of the selected tab */ + /** Name of the selected tab */ selectedTab: PropTypes.string, - /* Children nodes */ + /** Children nodes */ children: PropTypes.node.isRequired, }; 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/PolicyUtils.js b/src/libs/PolicyUtils.js index de902b53a7a4..2ecc818ebd23 100644 --- a/src/libs/PolicyUtils.js +++ b/src/libs/PolicyUtils.js @@ -161,7 +161,7 @@ const isPolicyAdmin = (policy) => lodashGet(policy, 'role') === CONST.POLICY.ROL * @param {Object} policies * @returns {Boolean} */ -const isPolicyMember = (policyID, policies) => _.some(policies, (policy) => policy.id === policyID); +const isPolicyMember = (policyID, policies) => _.some(policies, (policy) => lodashGet(policy, 'id') === policyID); /** * @param {Object} policyMembers diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index c795e5d1c3b1..98a029bde5de 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1,4 +1,3 @@ -import {isEqual, max} from 'date-fns'; import _ from 'lodash'; import lodashFindLast from 'lodash/findLast'; import Onyx, {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; @@ -92,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 */ @@ -370,24 +373,19 @@ function replaceBaseURL(reportAction: ReportAction): ReportAction { /** */ function getLastVisibleAction(reportID: string, actionsToMerge: ReportActions = {}): OnyxEntry { - const updatedActionsToMerge: ReportActions = {}; + let reportActions: ReportActions; if (actionsToMerge && Object.keys(actionsToMerge).length !== 0) { - Object.keys(actionsToMerge).forEach( - (actionToMergeID) => (updatedActionsToMerge[actionToMergeID] = {...allReportActions?.[reportID]?.[actionToMergeID], ...actionsToMerge[actionToMergeID]}), - ); - } - const actions = Object.values({ - ...allReportActions?.[reportID], - ...updatedActionsToMerge, - }); - const visibleActions = actions.filter((action) => shouldReportActionBeVisibleAsLastAction(action)); - - if (visibleActions.length === 0) { + reportActions = {...allReportActions?.[reportID]}; + Object.keys(actionsToMerge).forEach((actionToMergeID) => (reportActions[actionToMergeID] = {...allReportActions?.[reportID]?.[actionToMergeID], ...actionsToMerge[actionToMergeID]})); + } else { + reportActions = allReportActions?.[reportID] ?? {}; + } + const visibleReportActions = Object.values(reportActions ?? {}).filter((action) => shouldReportActionBeVisibleAsLastAction(action)); + const sortedReportActions = getSortedReportActions(visibleReportActions, true); + if (sortedReportActions.length === 0) { return null; } - const maxDate = max(visibleActions.map((action) => new Date(action.created))); - const maxAction = visibleActions.find((action) => isEqual(new Date(action.created), maxDate)); - return maxAction ?? null; + return sortedReportActions[0]; } function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = {}): LastVisibleMessage { @@ -454,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 */ @@ -642,6 +653,8 @@ export { isThreadParentMessage, isTransactionThread, isWhisperAction, + isReimbursementQueuedAction, shouldReportActionBeVisible, shouldReportActionBeVisibleAsLastAction, + getFirstVisibleReportActionID, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 5cf7396c6669..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; } @@ -660,6 +666,17 @@ function hasSingleParticipant(report) { return report && report.participantAccountIDs && report.participantAccountIDs.length === 1; } +/** + * Checks whether all the transactions linked to the IOU report are of the Distance Request type + * + * @param {string|null} iouReportID + * @returns {boolean} + */ +function hasOnlyDistanceRequestTransactions(iouReportID) { + const allTransactions = TransactionUtils.getAllReportTransactions(iouReportID); + return _.all(allTransactions, (transaction) => TransactionUtils.isDistanceRequest(transaction)); +} + /** * If the report is a thread and has a chat type set, it is a workspace chat. * @@ -1191,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(', '); } /** @@ -1229,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 * @@ -1284,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; } @@ -1415,7 +1477,7 @@ function getPolicyExpenseChatName(report, policy = undefined) { } /** - * Get the title for a IOU or expense chat which will be showing the payer and the amount + * Get the title for an IOU or expense chat which will be showing the payer and the amount * * @param {Object} report * @param {Object} [policy] @@ -1423,15 +1485,15 @@ function getPolicyExpenseChatName(report, policy = undefined) { */ function getMoneyRequestReportName(report, policy = undefined) { const moneyRequestTotal = getMoneyRequestReimbursableTotal(report); - const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report.currency); + const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report.currency, hasOnlyDistanceRequestTransactions(report.reportID)); const payerName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report.managerID); - const payerPaidAmountMesssage = Localize.translateLocal('iou.payerPaidAmount', { + const payerPaidAmountMessage = Localize.translateLocal('iou.payerPaidAmount', { payer: payerName, amount: formattedAmount, }); if (report.isWaitingOnBankAccount) { - return `${payerPaidAmountMesssage} • ${Localize.translateLocal('iou.pending')}`; + return `${payerPaidAmountMessage} • ${Localize.translateLocal('iou.pending')}`; } if (hasNonReimbursableTransactions(report.reportID)) { @@ -1442,7 +1504,7 @@ function getMoneyRequestReportName(report, policy = undefined) { return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName, amount: formattedAmount}); } - return payerPaidAmountMesssage; + return payerPaidAmountMessage; } /** @@ -1476,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); } /** @@ -1527,7 +1631,7 @@ function canEditReportAction(reportAction) { /** * Gets all transactions on an IOU report with a receipt * - * @param {Object|null} iouReportID + * @param {string|null} iouReportID * @returns {[Object]} */ function getTransactionsWithReceipts(iouReportID) { @@ -1593,7 +1697,7 @@ function getTransactionReportName(reportAction) { const {amount, currency, comment} = getTransactionDetails(transaction); return Localize.translateLocal(ReportActionsUtils.isSentMoneyReportAction(reportAction) ? 'iou.threadSentMoneyReportName' : 'iou.threadRequestReportName', { - formattedAmount: CurrencyUtils.convertToDisplayString(amount, currency), + formattedAmount: CurrencyUtils.convertToDisplayString(amount, currency, TransactionUtils.isDistanceRequest(transaction)), comment, }); } @@ -1604,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) { @@ -1645,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'; } @@ -2435,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] : [], }; } @@ -3278,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 @@ -3286,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) ); } @@ -3392,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, }; } @@ -3710,6 +3838,21 @@ function getPolicyExpenseChatReportIDByOwner(policyOwner) { return expenseChat.reportID; } +/** + * Check if the report can create the request with type is iouType + * @param {Object} report + * @param {Array} betas + * @param {String} iouType + * @returns {Boolean} + */ +function canCreateRequest(report, betas, iouType) { + const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []); + if (shouldDisableWriteActions(report)) { + return false; + } + return getMoneyRequestOptions(report, participantAccountIDs, betas).includes(iouType); +} + /** * @param {String} policyID * @param {Array} accountIDs @@ -3920,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} @@ -3980,6 +4149,7 @@ export { getIcons, getRoomWelcomeMessage, getDisplayNamesWithTooltips, + getDisplayNamesStringFromTooltips, getReportName, getReport, getReportIDFromLink, @@ -4039,6 +4209,7 @@ export { getCommentLength, getParsedComment, getMoneyRequestOptions, + canCreateRequest, hasIOUWaitingOnCurrentUserBankAccount, canRequestMoney, getWhisperDisplayNames, @@ -4076,14 +4247,18 @@ export { getTaskAssigneeChatOnyxData, getParticipantsIDs, canEditMoneyRequest, + canEditFieldOfMoneyRequest, buildTransactionThread, areAllRequestsBeingSmartScanned, getTransactionsWithReceipts, + hasOnlyDistanceRequestTransactions, hasNonReimbursableTransactions, 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/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 5f1114d4b03e..44f8094ca13d 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -414,7 +414,9 @@ function getWaypointIndex(key: string): number { * Filters the waypoints which are valid and returns those */ function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = false): WaypointCollection { - const sortedIndexes = Object.keys(waypoints).map(getWaypointIndex).sort(); + const sortedIndexes = Object.keys(waypoints) + .map(getWaypointIndex) + .sort((a, b) => a - b); const waypointValues = sortedIndexes.map((index) => waypoints[`waypoint${index}`]); // Ensure the number of waypoints is between 2 and 25 if (waypointValues.length < 2 || waypointValues.length > 25) { diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index bf4f170f1ba7..cc755f10dfc6 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -145,7 +145,7 @@ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAcc /** * Adds a bank account via Plaid * - * @TODO offline pattern for this command will have to be added later once the pattern B design doc is complete + * TODO: offline pattern for this command will have to be added later once the pattern B design doc is complete */ function addPersonalBankAccount(account: PlaidBankAccount) { const commandName = 'AddPersonalBankAccount'; 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/DetailsPage.js b/src/pages/DetailsPage.js index e4a1b763cd62..090e6205e925 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -89,8 +89,6 @@ function DetailsPage(props) { let details = _.find(props.personalDetails, (detail) => detail.login === login.toLowerCase()); if (!details) { - // TODO: these personal details aren't in my local test account but are in - // my staging account, i wonder why! if (login === CONST.EMAIL.CONCIERGE) { details = { accountID: CONST.ACCOUNT_ID.CONCIERGE, diff --git a/src/pages/EditRequestCategoryPage.js b/src/pages/EditRequestCategoryPage.js index e47935dd9df1..c0db5a16b140 100644 --- a/src/pages/EditRequestCategoryPage.js +++ b/src/pages/EditRequestCategoryPage.js @@ -4,6 +4,8 @@ import ScreenWrapper from '../components/ScreenWrapper'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; import Navigation from '../libs/Navigation/Navigation'; import useLocalize from '../hooks/useLocalize'; +import styles from '../styles/styles'; +import Text from '../components/Text'; import CategoryPicker from '../components/CategoryPicker'; const propTypes = { @@ -36,7 +38,7 @@ function EditRequestCategoryPage({defaultCategory, policyID, onSubmit}) { title={translate('common.category')} onBackButtonPress={Navigation.goBack} /> - + {translate('iou.categorySelection')} { - 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/EditRequestTagPage.js b/src/pages/EditRequestTagPage.js index a3525f2df23f..c5ae18f1376c 100644 --- a/src/pages/EditRequestTagPage.js +++ b/src/pages/EditRequestTagPage.js @@ -3,7 +3,9 @@ import PropTypes from 'prop-types'; import Navigation from '../libs/Navigation/Navigation'; import useLocalize from '../hooks/useLocalize'; import ScreenWrapper from '../components/ScreenWrapper'; +import Text from '../components/Text'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; +import styles from '../styles/styles'; import TagPicker from '../components/TagPicker'; const propTypes = { @@ -37,7 +39,7 @@ function EditRequestTagPage({defaultTag, policyID, tagName, onSubmit}) { title={tagName || translate('common.tag')} onBackButtonPress={Navigation.goBack} /> - + {translate('iou.tagSelection', {tagName: tagName || translate('common.tag')})} { 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/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index c6338159f65e..71eff57a246d 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -263,7 +263,7 @@ ReportDetailsPage.defaultProps = defaultProps; export default compose( withLocalize, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index 67933ebfe3e4..db56a8006e76 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -145,7 +145,7 @@ ReportParticipantsPage.displayName = 'ReportParticipantsPage'; export default compose( withLocalize, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, diff --git a/src/pages/ReportWelcomeMessagePage.js b/src/pages/ReportWelcomeMessagePage.js index 4602035f45f4..48dd07090ef4 100644 --- a/src/pages/ReportWelcomeMessagePage.js +++ b/src/pages/ReportWelcomeMessagePage.js @@ -123,7 +123,7 @@ ReportWelcomeMessagePage.defaultProps = defaultProps; export default compose( withLocalize, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, diff --git a/src/pages/RoomInvitePage.js b/src/pages/RoomInvitePage.js index c923a8d96d70..5021ccdc42d7 100644 --- a/src/pages/RoomInvitePage.js +++ b/src/pages/RoomInvitePage.js @@ -250,7 +250,7 @@ RoomInvitePage.defaultProps = defaultProps; RoomInvitePage.displayName = 'RoomInvitePage'; export default compose( - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, diff --git a/src/pages/RoomMembersPage.js b/src/pages/RoomMembersPage.js index 87e1afab8ae9..3a6e3b6fd90f 100644 --- a/src/pages/RoomMembersPage.js +++ b/src/pages/RoomMembersPage.js @@ -316,7 +316,7 @@ RoomMembersPage.displayName = 'RoomMembersPage'; export default compose( withLocalize, withWindowDimensions, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, 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 4eaf1c1ce15c..32a14303e9a7 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -26,7 +26,7 @@ import Banner from '../../components/Banner'; import reportPropTypes from '../reportPropTypes'; import reportMetadataPropTypes from '../reportMetadataPropTypes'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; -import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop'; +import withViewportOffsetTop from '../../components/withViewportOffsetTop'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; import personalDetailsPropType from '../personalDetailsPropType'; import getIsReportFullyVisible from '../../libs/getIsReportFullyVisible'; @@ -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 */ @@ -94,7 +95,7 @@ const propTypes = { /** Whether user is leaving the current report */ userLeavingStatus: PropTypes.bool, - ...viewportOffsetTopPropTypes, + viewportOffsetTop: PropTypes.number.isRequired, ...withCurrentReportIDPropTypes, }; @@ -317,7 +318,7 @@ function ReportScreen({ prevOnyxReportID === routeReportID && !onyxReportID && prevReport.statusNum === CONST.REPORT.STATUS.OPEN && - (report.statusNum === CONST.REPORT.STATUS.CLOSED || !report.statusNum)) + (report.statusNum === CONST.REPORT.STATUS.CLOSED || (!report.statusNum && !prevReport.parentReportID))) ) { Navigation.dismissModal(); if (Navigation.getTopmostReportId() === prevOnyxReportID) { @@ -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/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index ffd7f65185ce..e194d0870885 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -566,6 +566,7 @@ function ComposerWithSuggestions({ { - if (shouldBlockCalc.current || selectionEnd < 1) { + if (shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) { shouldBlockCalc.current = false; resetSuggestions(); return; @@ -229,12 +232,19 @@ function SuggestionMention({ })); setHighlightedMentionIndex(0); }, - [getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value], + [getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value, isComposerFocused], ); 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/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 60c31efb1446..74e9e79471e7 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -40,6 +40,7 @@ function Suggestions({ resetKeyboardInput, measureParentContainer, isAutoSuggestionPickerLarge, + isComposerFocused, }) { const suggestionEmojiRef = useRef(null); const suggestionMentionRef = useRef(null); @@ -103,6 +104,7 @@ function Suggestions({ composerHeight, isAutoSuggestionPickerLarge, measureParentContainer, + isComposerFocused, }; return ( diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js index 815a1c5619f5..62c29f3d418e 100644 --- a/src/pages/home/report/ReportActionCompose/suggestionProps.js +++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js @@ -24,6 +24,9 @@ const baseProps = { /** Meaures the parent container's position and dimensions. */ measureParentContainer: PropTypes.func.isRequired, + + /** Report composer focus state */ + isComposerFocused: PropTypes.bool, }; const implementationBaseProps = { 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/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index 0b6333e31ef8..57b51ef50519 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -18,7 +18,7 @@ import CONST from '../../../CONST'; import editedLabelStyles from '../../../styles/editedLabelStyles'; import UserDetailsTooltip from '../../../components/UserDetailsTooltip'; import avatarPropTypes from '../../../components/avatarPropTypes'; -import * as Browser from '../../../libs/Browser'; +import ZeroWidthView from '../../../components/ZeroWidthView'; const propTypes = { /** Users accountID */ @@ -90,24 +90,6 @@ const defaultProps = { }; function ReportActionItemFragment(props) { - /** - * Checks text element for presence of emoji as first character - * and insert Zero-Width character to avoid selection issue - * mentioned here https://github.com/Expensify/App/issues/29021 - * - * @param {String} text - * @param {Boolean} displayAsGroup - * @returns {ReactNode | null} Text component with zero width character - */ - - const checkForEmojiForSelection = (text, displayAsGroup) => { - const firstLetterIsEmoji = EmojiUtils.isFirstLetterEmoji(text); - if (firstLetterIsEmoji && !displayAsGroup && !Browser.isMobile()) { - return ; - } - return null; - }; - switch (props.fragment.type) { case 'COMMENT': { const {html, text} = props.fragment; @@ -139,7 +121,10 @@ function ReportActionItemFragment(props) { return ( - {checkForEmojiForSelection(text, props.displayAsGroup)} + { }; 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 c673c06470f8..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(); } @@ -391,6 +396,7 @@ function ReportActionsList({ onLayout={onLayoutInner} onScroll={trackVerticalScrolling} extraData={extraData} + testID="report-actions-list" /> diff --git a/src/pages/home/report/ReportDetailsShareCodePage.js b/src/pages/home/report/ReportDetailsShareCodePage.js index 62030f004bc6..7c22726ac82b 100644 --- a/src/pages/home/report/ReportDetailsShareCodePage.js +++ b/src/pages/home/report/ReportDetailsShareCodePage.js @@ -28,4 +28,4 @@ function ReportDetailsShareCodePage(props) { ReportDetailsShareCodePage.propTypes = propTypes; ReportDetailsShareCodePage.defaultProps = defaultProps; -export default withReportOrNotFound(ReportDetailsShareCodePage); +export default withReportOrNotFound()(ReportDetailsShareCodePage); diff --git a/src/pages/home/report/withReportOrNotFound.js b/src/pages/home/report/withReportOrNotFound.js index 5829ac7a6015..891e2e2418c6 100644 --- a/src/pages/home/report/withReportOrNotFound.js +++ b/src/pages/home/report/withReportOrNotFound.js @@ -9,7 +9,7 @@ import reportPropTypes from '../../reportPropTypes'; import FullscreenLoadingIndicator from '../../../components/FullscreenLoadingIndicator'; import * as ReportUtils from '../../../libs/ReportUtils'; -export default function (WrappedComponent) { +export default function (shouldRequireReportID = true) { const propTypes = { /** The HOC takes an optional ref as a prop and passes it as a ref to the wrapped component. * That way, if a ref is passed to a component wrapped in the HOC, the ref is a reference to the wrapped component, not the HOC. */ @@ -29,6 +29,14 @@ export default function (WrappedComponent) { }), ), + /** Route params */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** Report ID passed via route */ + reportID: PropTypes.string, + }), + }).isRequired, + /** Beta features list */ betas: PropTypes.arrayOf(PropTypes.string), @@ -44,67 +52,74 @@ export default function (WrappedComponent) { isLoadingReportData: true, }; - // eslint-disable-next-line rulesdir/no-negated-variables - function WithReportOrNotFound(props) { - const contentShown = React.useRef(false); - - const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID); + return (WrappedComponent) => { // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = _.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas); - - // If the content was shown but it's not anymore that means the report was deleted and we are probably navigating out of this screen. - // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition. - if (shouldShowNotFoundPage && contentShown.current) { - return null; + function WithReportOrNotFound(props) { + const contentShown = React.useRef(false); + + const isReportIdInRoute = !_.isUndefined(props.route.params.reportID); + + // If we should require reportID or we have a reportID in the route, we will check the reportID is valid or not + if (shouldRequireReportID || isReportIdInRoute) { + const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID); + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = _.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas); + + // If the content was shown but it's not anymore that means the report was deleted and we are probably navigating out of this screen. + // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition. + if (shouldShowNotFoundPage && contentShown.current) { + return null; + } + + if (shouldShowFullScreenLoadingIndicator) { + return ; + } + + if (shouldShowNotFoundPage) { + return ; + } + } + + if (!contentShown.current) { + contentShown.current = true; + } + + const rest = _.omit(props, ['forwardedRef']); + return ( + + ); } - if (shouldShowFullScreenLoadingIndicator) { - return ; - } + WithReportOrNotFound.propTypes = propTypes; + WithReportOrNotFound.defaultProps = defaultProps; + WithReportOrNotFound.displayName = `withReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - if (shouldShowNotFoundPage) { - return ; - } - - if (!contentShown.current) { - contentShown.current = true; - } - - const rest = _.omit(props, ['forwardedRef']); - return ( - ( + - ); - } - - WithReportOrNotFound.propTypes = propTypes; - WithReportOrNotFound.defaultProps = defaultProps; - WithReportOrNotFound.displayName = `withReportOrNotFound(${getComponentDisplayName(WrappedComponent)})`; - - // eslint-disable-next-line rulesdir/no-negated-variables - const withReportOrNotFound = React.forwardRef((props, ref) => ( - - )); - - return withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, - }, - isLoadingReportData: { - key: ONYXKEYS.IS_LOADING_REPORT_DATA, - }, - betas: { - key: ONYXKEYS.BETAS, - }, - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - })(withReportOrNotFound); + )); + + return withOnyx({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, + }, + isLoadingReportData: { + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + })(withReportOrNotFound); + }; } 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/MoneyRequestCategoryPage.js b/src/pages/iou/MoneyRequestCategoryPage.js index 5055c9f90f8a..7431a5deed77 100644 --- a/src/pages/iou/MoneyRequestCategoryPage.js +++ b/src/pages/iou/MoneyRequestCategoryPage.js @@ -12,6 +12,8 @@ import CategoryPicker from '../../components/CategoryPicker'; import ONYXKEYS from '../../ONYXKEYS'; import reportPropTypes from '../reportPropTypes'; import * as IOU from '../../libs/actions/IOU'; +import styles from '../../styles/styles'; +import Text from '../../components/Text'; import {iouPropTypes, iouDefaultProps} from './propTypes'; const propTypes = { @@ -70,7 +72,7 @@ function MoneyRequestCategoryPage({route, report, iou}) { title={translate('common.category')} onBackButtonPress={navigateBack} /> - + {translate('iou.categorySelection')} { @@ -89,7 +98,7 @@ function MoneyRequestSelectorPage(props) { testID={MoneyRequestSelectorPage.displayName} > {({safeAreaPaddingBottomStyle}) => ( - + `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, - }, - selectedTab: { - key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, - }, -})(MoneyRequestSelectorPage); +export default compose( + withReportOrNotFound(false), + withOnyx({ + selectedTab: { + 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/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js index a58f41e5e693..1c48a4f1a44a 100644 --- a/src/pages/iou/SplitBillDetailsPage.js +++ b/src/pages/iou/SplitBillDetailsPage.js @@ -145,6 +145,7 @@ function SplitBillDetailsPage(props) { reportActionID={reportAction.reportActionID} transactionID={props.transaction.transactionID} onConfirm={onConfirm} + isPolicyExpenseChat={ReportUtils.isPolicyExpenseChat(props.report)} /> )} diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index df10c5b4d609..8b697cde4880 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -127,14 +127,14 @@ function MoneyRequestConfirmPage(props) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (_.isEmpty(props.iou.participants) || (props.iou.amount === 0 && !props.iou.receiptPath && !isDistanceRequest) || shouldReset) { + if (_.isEmpty(props.iou.participants) || (props.iou.amount === 0 && !props.iou.receiptPath && !isDistanceRequest) || shouldReset || ReportUtils.isArchivedRoom(props.report)) { Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType.current, reportID.current), true); } return () => { prevMoneyRequestId.current = props.iou.id; }; - }, [props.iou.participants, props.iou.amount, props.iou.id, props.iou.receiptPath, isDistanceRequest]); + }, [props.iou.participants, props.iou.amount, props.iou.id, props.iou.receiptPath, isDistanceRequest, props.report]); const navigateBack = () => { let fallback; diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js index ae319f5a73bb..15a2c74d8a95 100644 --- a/src/pages/iou/steps/NewRequestAmountPage.js +++ b/src/pages/iou/steps/NewRequestAmountPage.js @@ -8,7 +8,6 @@ import _ from 'underscore'; import ONYXKEYS from '../../../ONYXKEYS'; import Navigation from '../../../libs/Navigation/Navigation'; import ROUTES from '../../../ROUTES'; -import * as ReportUtils from '../../../libs/ReportUtils'; import * as CurrencyUtils from '../../../libs/CurrencyUtils'; import reportPropTypes from '../../reportPropTypes'; import * as IOU from '../../../libs/actions/IOU'; @@ -83,14 +82,6 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { }, []), ); - // Check and dismiss modal - useEffect(() => { - if (!ReportUtils.shouldDisableWriteActions(report)) { - return; - } - Navigation.dismissModal(reportID); - }, [report, reportID]); - // Because we use Onyx to store IOU info, when we try to make two different money requests from different tabs, // it can result in an IOU sent with improper values. In such cases we want to reset the flow and redirect the user to the first step of the IOU. useEffect(() => { diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js index cce43117d4f2..480c425a9094 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js @@ -122,7 +122,7 @@ function NewContactMethodPage(props) { ref={(el) => (loginInputRef.current = el)} inputID="phoneOrEmail" autoCapitalize="none" - returnKeyType="done" + returnKeyType="go" maxLength={CONST.LOGIN_CHARACTER_LIMIT} />
diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js index 64e6bdfb4b5b..43e346150ca8 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.js +++ b/src/pages/settings/Report/NotificationPreferencePage.js @@ -56,4 +56,4 @@ function NotificationPreferencePage(props) { NotificationPreferencePage.displayName = 'NotificationPreferencePage'; NotificationPreferencePage.propTypes = propTypes; -export default compose(withLocalize, withReportOrNotFound)(NotificationPreferencePage); +export default compose(withLocalize, withReportOrNotFound())(NotificationPreferencePage); diff --git a/src/pages/settings/Report/ReportSettingsPage.js b/src/pages/settings/Report/ReportSettingsPage.js index 9ec6eb071de2..fb88cbd59f25 100644 --- a/src/pages/settings/Report/ReportSettingsPage.js +++ b/src/pages/settings/Report/ReportSettingsPage.js @@ -209,7 +209,7 @@ ReportSettingsPage.propTypes = propTypes; ReportSettingsPage.defaultProps = defaultProps; ReportSettingsPage.displayName = 'ReportSettingsPage'; export default compose( - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ policies: { key: ONYXKEYS.COLLECTION.POLICY, diff --git a/src/pages/settings/Report/RoomNamePage.js b/src/pages/settings/Report/RoomNamePage.js index 985d83e7fd95..4ce997533378 100644 --- a/src/pages/settings/Report/RoomNamePage.js +++ b/src/pages/settings/Report/RoomNamePage.js @@ -118,7 +118,7 @@ RoomNamePage.displayName = 'RoomNamePage'; export default compose( withLocalize, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ reports: { key: ONYXKEYS.COLLECTION.REPORT, diff --git a/src/pages/settings/Report/WriteCapabilityPage.js b/src/pages/settings/Report/WriteCapabilityPage.js index 1558d98a830a..174cc57d8d18 100644 --- a/src/pages/settings/Report/WriteCapabilityPage.js +++ b/src/pages/settings/Report/WriteCapabilityPage.js @@ -67,7 +67,7 @@ WriteCapabilityPage.defaultProps = defaultProps; export default compose( withLocalize, - withReportOrNotFound, + withReportOrNotFound(), withOnyx({ policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 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 && (