diff --git a/.eslintrc.js b/.eslintrc.js index 6194ccd39d3f..f852c970f85c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,6 +14,11 @@ const restrictedImportPaths = [ importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight'], message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from 'src/components/Pressable' instead.", }, + { + name: 'awesome-phonenumber', + importNames: ['parsePhoneNumber'], + message: "Please use '@libs/PhoneNumber' instead.", + }, { name: 'react-native-safe-area-context', importNames: ['useSafeAreaInsets', 'SafeAreaConsumer', 'SafeAreaInsetsContext'], diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 0951b194430b..156b9764bcca 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -30,9 +30,9 @@ jobs: # - git diff is used to see the files that were added on this branch # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main # - wc counts the words in the result of the intersection - count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) + count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) if [ "$count_new_js" -gt "0" ]; then - echo "ERROR: Found new JavaScript files in the /src/libs, /src/hooks, /src/styles, or /src/languages directories; use TypeScript instead." + echo "ERROR: Found new JavaScript files in the project; use TypeScript instead." exit 1 fi env: diff --git a/android/app/build.gradle b/android/app/build.gradle index bf11a970145d..cac6e9295d9d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -96,8 +96,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001042302 - versionName "1.4.23-2" + versionCode 1001042402 + versionName "1.4.24-2" } flavorDimensions "default" diff --git a/contributingGuides/TS_STYLE.md b/contributingGuides/TS_STYLE.md index b60c28147a45..b96f24a7c949 100644 --- a/contributingGuides/TS_STYLE.md +++ b/contributingGuides/TS_STYLE.md @@ -671,7 +671,7 @@ declare module "external-library-name" { > This section contains instructions that are applicable during the migration. -- 🚨 DO NOT write new code in TypeScript yet. The only time you write TypeScript code is when the file you're editing has already been migrated to TypeScript by the migration team, or when you need to add new files under `src/libs`, `src/hooks`, `src/styles`, and `src/languages` directories. This guideline will be updated once it's time for new code to be written in TypeScript. If you're doing a major overhaul or refactoring of particular features or utilities of App and you believe it might be beneficial to migrate relevant code to TypeScript as part of the refactoring, please ask in the #expensify-open-source channel about it (and prefix your message with `TS ATTENTION:`). +- 🚨 Any new files under `src/` directory MUST be created in TypeScript now! New files in other directories (e.g. `tests/`, `desktop/`) can be created in TypeScript, if desired. - If you're migrating a module that doesn't have a default implementation (i.e. `index.ts`, e.g. `getPlatform`), convert `index.website.js` to `index.ts`. Without `index.ts`, TypeScript cannot get type information where the module is imported. diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 6f62e6a5ba00..268c706bedef 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,7 +10,7 @@ "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", "electron-serve": "^1.2.0", - "electron-updater": "^6.1.6", + "electron-updater": "^6.1.7", "node-machine-id": "^1.1.12" } }, @@ -50,9 +50,9 @@ } }, "node_modules/builder-util-runtime": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.2.tgz", - "integrity": "sha512-Or2/ycVYRGQ876hKMfiz2Ghgzh3WllgPW75jqt1Ta2a5wprpnziFrHpQ9eUq6/ScsVXMnG4PmQqlMsE9NFg8DQ==", + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.3.tgz", + "integrity": "sha512-FGhkqXdFFZ5dNC4C+yuQB9ak311rpGAw+/ASz8ZdxwODCv1GGMWgLDeofRkdi0F3VCHQEWy/aXcJQozx2nOPiw==", "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -156,11 +156,11 @@ } }, "node_modules/electron-updater": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.6.tgz", - "integrity": "sha512-G2bO72i7kv+bVdBAjq6lQn8zkZ3wMRRjxBD4TGBjB77UiuMDUBeP45YAs4y08udPzttGW2qzpnbOUJY5RYWZfw==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.7.tgz", + "integrity": "sha512-SNOhYizjkm4ET+Y8ilJyUzcVsFJDtINzVN1TyHnZeMidZEG3YoBebMyXc/J6WSiXdUaOjC7ngekN6rNp6ardHA==", "dependencies": { - "builder-util-runtime": "9.2.2", + "builder-util-runtime": "9.2.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -467,9 +467,9 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, "builder-util-runtime": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.2.tgz", - "integrity": "sha512-Or2/ycVYRGQ876hKMfiz2Ghgzh3WllgPW75jqt1Ta2a5wprpnziFrHpQ9eUq6/ScsVXMnG4PmQqlMsE9NFg8DQ==", + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.3.tgz", + "integrity": "sha512-FGhkqXdFFZ5dNC4C+yuQB9ak311rpGAw+/ASz8ZdxwODCv1GGMWgLDeofRkdi0F3VCHQEWy/aXcJQozx2nOPiw==", "requires": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -541,11 +541,11 @@ "integrity": "sha512-zJG3wisMrDn2G/gnjrhyB074COvly1FnS0U7Edm8bfXLB8MYX7UtwR9/y2LkFreYjzQHm9nEbAfgCmF+9M9LHQ==" }, "electron-updater": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.6.tgz", - "integrity": "sha512-G2bO72i7kv+bVdBAjq6lQn8zkZ3wMRRjxBD4TGBjB77UiuMDUBeP45YAs4y08udPzttGW2qzpnbOUJY5RYWZfw==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.7.tgz", + "integrity": "sha512-SNOhYizjkm4ET+Y8ilJyUzcVsFJDtINzVN1TyHnZeMidZEG3YoBebMyXc/J6WSiXdUaOjC7ngekN6rNp6ardHA==", "requires": { - "builder-util-runtime": "9.2.2", + "builder-util-runtime": "9.2.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", diff --git a/desktop/package.json b/desktop/package.json index 7545e4b57dba..563a45851eb2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", "electron-serve": "^1.2.0", - "electron-updater": "^6.1.6", + "electron-updater": "^6.1.7", "node-machine-id": "^1.1.12" }, "author": "Expensify, Inc.", diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md deleted file mode 100644 index 85b534338b53..000000000000 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Export to GL Accounts -description: Export to GL Accounts ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md deleted file mode 100644 index 868ade604451..000000000000 --- a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md +++ /dev/null @@ -1,193 +0,0 @@ ---- -title: Expensify Card Perks -description: Get the most out of your Expensify Card with exclusive perks! ---- - - -# Overview -The Expensify Visa® Commercial 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: -- Unbeatable cash back incentive with each USD purchase - -Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners. - -# 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. diff --git a/docs/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.md b/docs/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.md deleted file mode 100644 index e14fadbec915..000000000000 --- a/docs/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: ExpensifyApproved! Partner Program -description: How to Join the ExpensifyApproved! Partner Program ---- - -# Overview - -As trusted accountants and financial advisors, you strive to offer your clients the best tools available. Expensify is recognized as a leading, all-in-one expense and corporate card management platform suitable for clients of every size. By becoming an ExpensifyApproved! Partner, you unlock exclusive benefits for both you and your clientele. -## Key Benefits -Dedicated Partner Manager: Enjoy personalized assistance with an assigned Partner Manager post-course completion. -Client Onboarding Support: A dedicated Client Onboarding Manager will aid in smooth transitions. -Free Expensify Account: Complimentary access to our platform for your convenience. -Revenue share (US-only): All partners receive 0.5% revenue share on client Expensify Card transactions. Keep this as a bonus or offer it to your clients as cash back. -Exclusive CPA Card (US-only): Automated expense reconciliation from swipe to journal entry with the CPA Card. -Special Pricing Offers (US-only): Avail partner-specific discounts for your clients and a revenue share from client Expensify Card transactions. -Professional Growth (US-only): Earn 3 CPE credits after completing the ExpensifyApproved! University. -Cobranded Marketing - Collaborate with your Partner Manager to craft custom marketing materials, case studies, and more. - -# How to join the ExpensifyApproved! Partner Program - -1. Enroll in ExpensifyApproved! University (EA!U) -Visit university.expensify.com and enroll in the “Getting Started with Expensify” course. -This course imparts the essentials of Expensify, ensuring you follow the best practices for client setups. - -2. Complete the course -Grasp the core features and functionalities of Expensify. -Ensure you're equipped to serve your clients using Expensify to its fullest. -Once completed, you’ll be prompted to schedule a call with your Partner Manager. **This call is required to earn your certification.** - -3. Once you successfully complete the course, you'll unlock: -- A dedicated Partner Manager - assigned to you after you have completed the course! -- A dedicated Client Setup Specialist -- Membership to the ExpensifyApproved! Partner Program. -- A complimentary free Expensify account -- Access to the exclusive CPA Card (US-only). -- Partner-specific discounts to extend to your clients. -- A 0.5% revenue share on client Expensify Card expenses (US-only) -- 3 CPE credits (US-only). diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ce787768bb02..b763c055e531 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.23 + 1.4.24 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.23.2 + 1.4.24.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 608b9ecbe60f..a0505cb8c169 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.23 + 1.4.24 CFBundleSignature ???? CFBundleVersion - 1.4.23.2 + 1.4.24.2 diff --git a/package-lock.json b/package-lock.json index 3caa3329fec5..55628f2bf0fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.23-2", + "version": "1.4.24-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.23-2", + "version": "1.4.24-2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8f510f6ec9d9..5572c12ef18d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.23-2", + "version": "1.4.24-2", "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.", diff --git a/patches/@react-native+virtualized-lists+0.72.8.patch b/patches/@react-native+virtualized-lists+0.72.8.patch new file mode 100644 index 000000000000..a3bef95f1618 --- /dev/null +++ b/patches/@react-native+virtualized-lists+0.72.8.patch @@ -0,0 +1,34 @@ +diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js +index ef5a3f0..2590edd 100644 +--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js ++++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js +@@ -125,19 +125,6 @@ function windowSizeOrDefault(windowSize: ?number) { + return windowSize ?? 21; + } + +-function findLastWhere( +- arr: $ReadOnlyArray, +- predicate: (element: T) => boolean, +-): T | null { +- for (let i = arr.length - 1; i >= 0; i--) { +- if (predicate(arr[i])) { +- return arr[i]; +- } +- } +- +- return null; +-} +- + /** + * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) + * and [``](https://reactnative.dev/docs/sectionlist) components, which are also better +@@ -1019,7 +1006,8 @@ class VirtualizedList extends StateSafePureComponent { + const spacerKey = this._getSpacerKey(!horizontal); + + const renderRegions = this.state.renderMask.enumerateRegions(); +- const lastSpacer = findLastWhere(renderRegions, r => r.isSpacer); ++ const lastRegion = renderRegions[renderRegions.length - 1]; ++ const lastSpacer = lastRegion?.isSpacer ? lastRegion : null; + + for (const section of renderRegions) { + if (section.isSpacer) { diff --git a/patches/react-native-web+0.19.9+004+fixLastSpacer.patch b/patches/react-native-web+0.19.9+004+fixLastSpacer.patch new file mode 100644 index 000000000000..fc48c00094dc --- /dev/null +++ b/patches/react-native-web+0.19.9+004+fixLastSpacer.patch @@ -0,0 +1,29 @@ +diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +index faeb323..68d740a 100644 +--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js ++++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +@@ -78,14 +78,6 @@ function scrollEventThrottleOrDefault(scrollEventThrottle) { + function windowSizeOrDefault(windowSize) { + return windowSize !== null && windowSize !== void 0 ? windowSize : 21; + } +-function findLastWhere(arr, predicate) { +- for (var i = arr.length - 1; i >= 0; i--) { +- if (predicate(arr[i])) { +- return arr[i]; +- } +- } +- return null; +-} + + /** + * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) +@@ -1119,7 +1111,8 @@ class VirtualizedList extends StateSafePureComponent { + _keylessItemComponentName = ''; + var spacerKey = this._getSpacerKey(!horizontal); + var renderRegions = this.state.renderMask.enumerateRegions(); +- var lastSpacer = findLastWhere(renderRegions, r => r.isSpacer); ++ var lastRegion = renderRegions[renderRegions.length - 1]; ++ var lastSpacer = lastRegion?.isSpacer ? lastRegion : null; + for (var _iterator = _createForOfIteratorHelperLoose(renderRegions), _step; !(_step = _iterator()).done;) { + var section = _step.value; + if (section.isSpacer) { diff --git a/src/CONST.ts b/src/CONST.ts index c6849db630f2..b1a6b6895de7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1180,6 +1180,10 @@ const CONST = { EXPENSIFY: 'Expensify', VBBA: 'ACH', }, + ACTION: { + EDIT: 'edit', + CREATE: 'create', + }, DEFAULT_AMOUNT: 0, TYPE: { SEND: 'send', @@ -3063,6 +3067,11 @@ const CONST = { CAROUSEL: 3, }, + BRICK_ROAD: { + GBR: 'GBR', + RBR: 'RBR', + }, + VIOLATIONS: { ALL_TAG_LEVELS_REQUIRED: 'allTagLevelsRequired', AUTO_REPORTED_REJECTED_EXPENSE: 'autoReportedRejectedExpense', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e8a860582bb1..7538a16d1a2c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -368,9 +368,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/participants/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_SCAN: { - route: 'create/:iouType/scan/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/scan/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/scan/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/scan/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_TAG: { route: 'create/:iouType/tag/:transactionID/:reportID', diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 1b4d350f7d4f..149dd7039151 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -383,7 +383,15 @@ function AttachmentModal(props) { text: props.translate('common.replace'), onSelected: () => { closeModal(); - Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT)); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.REQUEST, + props.transaction.transactionID, + props.report.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ); }, }); } diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index fc3bf4659bd7..5da9c6981603 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -107,7 +107,7 @@ function BaseAutoCompleteSuggestions( keyExtractor={keyExtractor} removeClippedSubviews={false} showsVerticalScrollIndicator={innerHeight > rowHeight.value} - extraData={highlightedSuggestionIndex} + extraData={[highlightedSuggestionIndex, renderSuggestionMenuItem]} /> diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index fb72f0cc845f..f8b820d559b7 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -1,6 +1,6 @@ -import {useIsFocused} from '@react-navigation/native'; +import {useFocusEffect} from '@react-navigation/native'; import type {ForwardedRef} from 'react'; -import React, {useCallback} from 'react'; +import React, {memo, useCallback, useMemo, useRef} from 'react'; import type {GestureResponderEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {ActivityIndicator, View} from 'react-native'; import Icon from '@components/Icon'; @@ -8,7 +8,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Text from '@components/Text'; import withNavigationFallback from '@components/withNavigationFallback'; -import useActiveElement from '@hooks/useActiveElement'; +import useActiveElementRole from '@hooks/useActiveElementRole'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -118,6 +118,57 @@ type ButtonProps = (ButtonWithText | ChildrenProps) & { accessibilityLabel?: string; }; +type KeyboardShortcutComponentProps = Pick; + +const accessibilityRoles: string[] = Object.values(CONST.ACCESSIBILITY_ROLE); + +const KeyboardShortcutComponent = memo( + ({isDisabled = false, isLoading = false, onPress = () => {}, pressOnEnter, allowBubble, enterKeyEventListenerPriority}: KeyboardShortcutComponentProps) => { + const isFocused = useRef(false); + const activeElementRole = useActiveElementRole(); + + const shouldDisableEnterShortcut = useMemo(() => accessibilityRoles.includes(activeElementRole ?? '') && activeElementRole !== CONST.ACCESSIBILITY_ROLE.TEXT, [activeElementRole]); + + useFocusEffect( + useCallback(() => { + isFocused.current = true; + + return () => { + isFocused.current = false; + }; + }, []), + ); + + const keyboardShortcutCallback = useCallback( + (event?: GestureResponderEvent | KeyboardEvent) => { + if (!validateSubmitShortcut(isDisabled, isLoading, event)) { + return; + } + onPress(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isDisabled, isLoading], + ); + + const config = useMemo( + () => ({ + isActive: pressOnEnter && !shouldDisableEnterShortcut && isFocused.current, + shouldBubble: allowBubble, + priority: enterKeyEventListenerPriority, + shouldPreventDefault: false, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [shouldDisableEnterShortcut], + ); + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, config); + + return null; + }, +); + +KeyboardShortcutComponent.displayName = 'KeyboardShortcutComponent'; + function Button( { allowBubble = false, @@ -164,27 +215,6 @@ function Button( ) { const theme = useTheme(); const styles = useThemeStyles(); - const isFocused = useIsFocused(); - const activeElement = useActiveElement(); - const accessibilityRoles: string[] = Object.values(CONST.ACCESSIBILITY_ROLE); - const shouldDisableEnterShortcut = accessibilityRoles.includes(activeElement?.role ?? '') && activeElement?.role !== CONST.ACCESSIBILITY_ROLE.TEXT; - - const keyboardShortcutCallback = useCallback( - (event?: GestureResponderEvent | KeyboardEvent) => { - if (!validateSubmitShortcut(isDisabled, isLoading, event)) { - return; - } - onPress(); - }, - [isDisabled, isLoading, onPress], - ); - - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, { - isActive: pressOnEnter && !shouldDisableEnterShortcut && isFocused, - shouldBubble: allowBubble, - priority: enterKeyEventListenerPriority, - shouldPreventDefault: false, - }); const renderContent = () => { if ('children' in rest) { @@ -247,72 +277,82 @@ function Button( }; return ( - { - if (event?.type === 'click') { - const currentTarget = event?.currentTarget as HTMLElement; - currentTarget?.blur(); - } - - if (shouldEnableHapticFeedback) { - HapticFeedback.press(); - } - return onPress(event); - }} - onLongPress={(event) => { - if (isLongPressDisabled) { - return; - } - if (shouldEnableHapticFeedback) { - HapticFeedback.longPress(); - } - onLongPress(event); - }} - onPressIn={onPressIn} - onPressOut={onPressOut} - onMouseDown={onMouseDown} - disabled={isLoading || isDisabled} - wrapperStyle={[ - isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {}, - styles.buttonContainer, - shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, - shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, - style, - ]} - style={[ - styles.button, - small ? styles.buttonSmall : undefined, - medium ? styles.buttonMedium : undefined, - large ? styles.buttonLarge : undefined, - success ? styles.buttonSuccess : undefined, - danger ? styles.buttonDanger : undefined, - isDisabled && (success || danger) ? styles.buttonOpacityDisabled : undefined, - isDisabled && !danger && !success ? styles.buttonDisabled : undefined, - shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, - shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined, - innerStyles, - ]} - hoverStyle={[ - shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined, - success && !isDisabled ? styles.buttonSuccessHovered : undefined, - danger && !isDisabled ? styles.buttonDangerHovered : undefined, - ]} - id={id} - accessibilityLabel={accessibilityLabel} - role={CONST.ROLE.BUTTON} - hoverDimmingValue={1} - > - {renderContent()} - {isLoading && ( - - )} - + <> + + { + if (event?.type === 'click') { + const currentTarget = event?.currentTarget as HTMLElement; + currentTarget?.blur(); + } + + if (shouldEnableHapticFeedback) { + HapticFeedback.press(); + } + return onPress(event); + }} + onLongPress={(event) => { + if (isLongPressDisabled) { + return; + } + if (shouldEnableHapticFeedback) { + HapticFeedback.longPress(); + } + onLongPress(event); + }} + onPressIn={onPressIn} + onPressOut={onPressOut} + onMouseDown={onMouseDown} + disabled={isLoading || isDisabled} + wrapperStyle={[ + isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {}, + styles.buttonContainer, + shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, + shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, + style, + ]} + style={[ + styles.button, + small ? styles.buttonSmall : undefined, + medium ? styles.buttonMedium : undefined, + large ? styles.buttonLarge : undefined, + success ? styles.buttonSuccess : undefined, + danger ? styles.buttonDanger : undefined, + isDisabled && (success || danger) ? styles.buttonOpacityDisabled : undefined, + isDisabled && !danger && !success ? styles.buttonDisabled : undefined, + shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, + shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined, + innerStyles, + ]} + hoverStyle={[ + shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined, + success && !isDisabled ? styles.buttonSuccessHovered : undefined, + danger && !isDisabled ? styles.buttonDangerHovered : undefined, + ]} + id={id} + accessibilityLabel={accessibilityLabel} + role={CONST.ROLE.BUTTON} + hoverDimmingValue={1} + > + {renderContent()} + {isLoading && ( + + )} + + ); } diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index bfdcb6715d40..d8d88970ea78 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -6,6 +6,12 @@ type TextSelection = { }; type ComposerProps = { + /** identify id in the text input */ + id?: string; + + /** Indicate whether input is multiline */ + multiline?: boolean; + /** Maximum number of lines in the text input */ maxLines?: number; @@ -18,6 +24,9 @@ type ComposerProps = { /** Number of lines for the comment */ numberOfLines?: number; + /** Callback method handle when the input is changed */ + onChangeText?: (numberOfLines: string) => void; + /** Callback method to update number of lines for the comment */ onNumberOfLinesChange?: (numberOfLines: number) => void; @@ -69,6 +78,8 @@ type ComposerProps = { onFocus?: (event: NativeSyntheticEvent) => void; + onBlur?: (event: NativeSyntheticEvent) => void; + /** Should make the input only scroll inside the element avoid scroll out to parent */ shouldContainScroll?: boolean; }; diff --git a/src/components/ConfirmedRoute.js b/src/components/ConfirmedRoute.tsx similarity index 61% rename from src/components/ConfirmedRoute.js rename to src/components/ConfirmedRoute.tsx index 466666dd9ef6..05d6557a72e3 100644 --- a/src/components/ConfirmedRoute.js +++ b/src/components/ConfirmedRoute.tsx @@ -1,9 +1,7 @@ -import lodashGet from 'lodash/get'; -import lodashIsNil from 'lodash/isNil'; -import PropTypes from 'prop-types'; import React, {useCallback, useEffect} from 'react'; +import type {ReactNode} from 'react'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx/lib/types'; import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -11,54 +9,51 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import * as MapboxToken from '@userActions/MapboxToken'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {MapboxAccessToken, Transaction} from '@src/types/onyx'; +import type {WaypointCollection} from '@src/types/onyx/Transaction'; +import type IconAsset from '@src/types/utils/IconAsset'; import DistanceMapView from './DistanceMapView'; import * as Expensicons from './Icon/Expensicons'; import ImageSVG from './ImageSVG'; import PendingMapView from './MapView/PendingMapView'; -import transactionPropTypes from './transactionPropTypes'; -const propTypes = { - /** Transaction that stores the distance request data */ - transaction: transactionPropTypes, +type WayPoint = { + id: string; + coordinate: [number, number]; + markerComponent: () => ReactNode; +}; +type ConfirmedRoutePropsOnyxProps = { /** Data about Mapbox token for calling Mapbox API */ - mapboxAccessToken: PropTypes.shape({ - /** Temporary token for Mapbox API */ - token: PropTypes.string, - - /** Time when the token will expire in ISO 8601 */ - expiration: PropTypes.string, - }), + mapboxAccessToken: OnyxEntry; }; -const defaultProps = { - transaction: {}, - mapboxAccessToken: { - token: '', - }, +type ConfirmedRouteProps = ConfirmedRoutePropsOnyxProps & { + /** Transaction that stores the distance request data */ + transaction: Transaction; }; -function ConfirmedRoute({mapboxAccessToken, transaction}) { +function ConfirmedRoute({mapboxAccessToken, transaction}: ConfirmedRouteProps) { const {isOffline} = useNetwork(); - const {route0: route} = transaction.routes || {}; - const waypoints = lodashGet(transaction, 'comment.waypoints', {}); - const coordinates = lodashGet(route, 'geometry.coordinates', []); + const {route0: route} = transaction.routes ?? {}; + const waypoints = transaction.comment?.waypoints ?? {}; + const coordinates = route.geometry?.coordinates ?? []; const theme = useTheme(); const styles = useThemeStyles(); const getWaypointMarkers = useCallback( - (waypointsData) => { - const numberOfWaypoints = _.size(waypointsData); + (waypointsData: WaypointCollection): WayPoint[] => { + const numberOfWaypoints = Object.keys(waypointsData).length; const lastWaypointIndex = numberOfWaypoints - 1; - return _.filter( - _.map(waypointsData, (waypoint, key) => { - if (!waypoint || lodashIsNil(waypoint.lat) || lodashIsNil(waypoint.lng)) { + return Object.entries(waypointsData) + .map(([key, waypoint]) => { + if (!waypoint?.lat || !waypoint?.lng) { return; } const index = TransactionUtils.getWaypointIndex(key); - let MarkerComponent; + let MarkerComponent: IconAsset; if (index === 0) { MarkerComponent = Expensicons.DotIndicatorUnfilled; } else if (index === lastWaypointIndex) { @@ -69,8 +64,8 @@ function ConfirmedRoute({mapboxAccessToken, transaction}) { return { id: `${waypoint.lng},${waypoint.lat},${index}`, - coordinate: [waypoint.lng, waypoint.lat], - markerComponent: () => ( + coordinate: [waypoint.lng, waypoint.lat] as const, + markerComponent: (): ReactNode => ( ), }; - }), - (waypoint) => waypoint, - ); + }) + .filter((waypoint): waypoint is WayPoint => !!waypoint); }, [theme], ); @@ -95,16 +89,16 @@ function ConfirmedRoute({mapboxAccessToken, transaction}) { return ( <> - {!isOffline && Boolean(mapboxAccessToken.token) ? ( + {!isOffline && Boolean(mapboxAccessToken?.token) ? ( } style={[styles.mapView, styles.br4]} waypoints={waypointMarkers} styleURL={CONST.MAPBOX.STYLE_URL} @@ -116,12 +110,10 @@ function ConfirmedRoute({mapboxAccessToken, transaction}) { ); } -export default withOnyx({ +export default withOnyx({ mapboxAccessToken: { key: ONYXKEYS.MAPBOX_ACCESS_TOKEN, }, })(ConfirmedRoute); ConfirmedRoute.displayName = 'ConfirmedRoute'; -ConfirmedRoute.propTypes = propTypes; -ConfirmedRoute.defaultProps = defaultProps; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index 0791e5113a1d..a723eed446a4 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -36,7 +36,7 @@ const throttleTime = Browser.isMobile() ? 200 : 50; function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {isSmallScreenWidth} = useWindowDimensions(); + const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); const {translate} = useLocalize(); const {singleExecution} = useSingleExecution(); const { @@ -335,7 +335,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { if (item.header) { return ( - + {translate(`emojiPicker.headers.${code}`)} ); @@ -368,18 +368,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { /> ); }, - [ - preferredSkinTone, - highlightedIndex, - isUsingKeyboardMovement, - highlightFirstEmoji, - singleExecution, - styles.emojiHeaderContainer, - styles.mh4, - styles.textLabelSupporting, - translate, - onEmojiSelected, - ], + [preferredSkinTone, highlightedIndex, isUsingKeyboardMovement, highlightFirstEmoji, singleExecution, translate, onEmojiSelected, isSmallScreenWidth, windowWidth, styles], ); return ( diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 209803f2a5d1..6cbfde0645de 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -61,7 +61,6 @@ function HeaderWithBackButton({ const StyleUtils = useStyleUtils(); const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); - // @ts-expect-error TODO: Remove this once useKeyboardState (https://github.com/Expensify/App/issues/24941) is migrated to TypeScript. const {isKeyboardShown} = useKeyboardState(); const waitForNavigate = useWaitForNavigation(); diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx index 4a4ba5560e60..e28400505280 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx +++ b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx @@ -4,6 +4,7 @@ import type {FlatListProps} from 'react-native'; import FlatList from '@components/FlatList'; const WINDOW_SIZE = 15; +const AUTOSCROLL_TO_TOP_THRESHOLD = 128; function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef) { return ( @@ -14,6 +15,7 @@ function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js index d1bf02b08191..71b14b6fadcd 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.js +++ b/src/components/LHNOptionsList/LHNOptionsList.js @@ -1,7 +1,7 @@ import {FlashList} from '@shopify/flash-list'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {memo, useCallback} from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -190,4 +190,4 @@ export default compose( key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, }, }), -)(memo(LHNOptionsList)); +)(LHNOptionsList); diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index 488630dd0590..be42abc797dd 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -100,7 +100,16 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, threeDotsMenuItems.push({ icon: Expensicons.Receipt, text: translate('receipt.addReceipt'), - onSelected: () => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT)), + onSelected: () => + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.REQUEST, + transaction.transactionID, + report.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ), }); } threeDotsMenuItems.push({ diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 2fee67a3d632..36d424ea28f2 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -726,26 +726,6 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ )} {shouldShowAllFields && ( <> - {shouldShowDate && ( - { - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); - }} - disabled={didConfirm} - interactive={!isReadOnly} - brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} - /> - )} {isDistanceRequest && ( )} + {shouldShowDate && ( + { + if (isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + }} + disabled={didConfirm} + interactive={!isReadOnly} + brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''} + /> + )} {shouldShowCategories && ( { + if (pinchEnabled) { + return; + } + setPinchEnabled(true); + }, [pinchEnabled]); + const pinchGesture = Gesture.Pinch() + .enabled(pinchEnabled) .onTouchesDown((evt, state) => { // we don't want to activate pinch gesture when we are scrolling pager if (!isScrolling.value) { @@ -466,6 +476,11 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr origin.y.value = adjustFocal.y; }) .onChange((evt) => { + if (evt.numberOfPointers !== 2) { + runOnJS(setPinchEnabled)(false); + return; + } + const newZoomScale = pinchScaleOffset.value * evt.scale; if (zoomScale.value >= zoomRange.min * zoomScaleBounceFactors.min && zoomScale.value <= zoomRange.max * zoomScaleBounceFactors.max) { diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js index 8b24066af969..bd3695eb7aa9 100644 --- a/src/components/OptionsList/BaseOptionsList.js +++ b/src/components/OptionsList/BaseOptionsList.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {forwardRef, memo, useEffect, useMemo, useRef} from 'react'; +import React, {forwardRef, memo, useEffect, useRef} from 'react'; import {View} from 'react-native'; import _ from 'underscore'; import OptionRow from '@components/OptionRow'; @@ -36,278 +36,268 @@ const defaultProps = { ...optionsListDefaultProps, }; -const viewabilityConfig = {viewAreaCoveragePercentThreshold: 95}; - -const BaseOptionsList = forwardRef( - ( - { - keyboardDismissMode, - onScrollBeginDrag, - onScroll, - listStyles, - focusedIndex, - selectedOptions, - headerMessage, - isLoading, - sections, - onLayout, - hideSectionHeaders, - shouldHaveOptionSeparator, - showTitleTooltip, - optionHoveredStyle, - sectionHeaderStyle, - showScrollIndicator, - contentContainerStyles: contentContainerStylesProp, - listContainerStyles: listContainerStylesProp, - shouldDisableRowInnerPadding, - shouldPreventDefaultFocusOnSelectRow, - disableFocusOptions, - canSelectMultipleOptions, - shouldShowMultipleOptionSelectorAsButton, - multipleOptionSelectorButtonText, - onAddToSelection, - highlightSelectedOptions, - onSelectRow, - boldStyle, - isDisabled, - isRowMultilineSupported, - isLoadingNewOptions, - nestedScrollEnabled, - bounces, - renderFooterContent, - safeAreaPaddingBottomStyle, - }, - innerRef, - ) => { - const styles = useThemeStyles(); - const flattenedData = useRef(); - const previousSections = usePrevious(sections); - const didLayout = useRef(false); - - const listContainerStyles = useMemo(() => listContainerStylesProp || [styles.flex1], [listContainerStylesProp, styles.flex1]); - const contentContainerStyles = useMemo(() => [safeAreaPaddingBottomStyle, ...contentContainerStylesProp], [contentContainerStylesProp, safeAreaPaddingBottomStyle]); - - /** - * This helper function is used to memoize the computation needed for getItemLayout. It is run whenever section data changes. - * - * @returns {Array} - */ - const buildFlatSectionArray = () => { - let offset = 0; - - // Start with just an empty list header - const flatArray = [{length: 0, offset}]; - - // Build the flat array - for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { - const section = sections[sectionIndex]; - - // Add the section header - const sectionHeaderHeight = section.title && !hideSectionHeaders ? variables.optionsListSectionHeaderHeight : 0; - flatArray.push({length: sectionHeaderHeight, offset}); - offset += sectionHeaderHeight; - - // Add section items - for (let i = 0; i < section.data.length; i++) { - let fullOptionHeight = variables.optionRowHeight; - if (i > 0 && shouldHaveOptionSeparator) { - fullOptionHeight += variables.borderTopWidth; - } - flatArray.push({length: fullOptionHeight, offset}); - offset += fullOptionHeight; +function BaseOptionsList({ + keyboardDismissMode, + onScrollBeginDrag, + onScroll, + listStyles, + focusedIndex, + selectedOptions, + headerMessage, + isLoading, + sections, + onLayout, + hideSectionHeaders, + shouldHaveOptionSeparator, + showTitleTooltip, + optionHoveredStyle, + contentContainerStyles, + sectionHeaderStyle, + showScrollIndicator, + listContainerStyles: listContainerStylesProp, + shouldDisableRowInnerPadding, + shouldPreventDefaultFocusOnSelectRow, + disableFocusOptions, + canSelectMultipleOptions, + shouldShowMultipleOptionSelectorAsButton, + multipleOptionSelectorButtonText, + onAddToSelection, + highlightSelectedOptions, + onSelectRow, + boldStyle, + isDisabled, + innerRef, + isRowMultilineSupported, + isLoadingNewOptions, + nestedScrollEnabled, + bounces, + renderFooterContent, +}) { + const styles = useThemeStyles(); + const flattenedData = useRef(); + const previousSections = usePrevious(sections); + const didLayout = useRef(false); + + const listContainerStyles = listContainerStylesProp || [styles.flex1]; + + /** + * This helper function is used to memoize the computation needed for getItemLayout. It is run whenever section data changes. + * + * @returns {Array} + */ + const buildFlatSectionArray = () => { + let offset = 0; + + // Start with just an empty list header + const flatArray = [{length: 0, offset}]; + + // Build the flat array + for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { + const section = sections[sectionIndex]; + // Add the section header + const sectionHeaderHeight = section.title && !hideSectionHeaders ? variables.optionsListSectionHeaderHeight : 0; + flatArray.push({length: sectionHeaderHeight, offset}); + offset += sectionHeaderHeight; + + // Add section items + for (let i = 0; i < section.data.length; i++) { + let fullOptionHeight = variables.optionRowHeight; + if (i > 0 && shouldHaveOptionSeparator) { + fullOptionHeight += variables.borderTopWidth; } - - // Add the section footer - flatArray.push({length: 0, offset}); + flatArray.push({length: fullOptionHeight, offset}); + offset += fullOptionHeight; } - // Then add the list footer + // Add the section footer flatArray.push({length: 0, offset}); - return flatArray; - }; - - useEffect(() => { - if (_.isEqual(sections, previousSections)) { - return; - } + } + + // Then add the list footer + flatArray.push({length: 0, offset}); + return flatArray; + }; + + useEffect(() => { + if (_.isEqual(sections, previousSections)) { + return; + } + flattenedData.current = buildFlatSectionArray(); + }); + + const onViewableItemsChanged = () => { + if (didLayout.current || !onLayout) { + return; + } + + didLayout.current = true; + onLayout(); + }; + + /** + * This function is used to compute the layout of any given item in our list. + * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList. + * + * @param {Array} data - This is the same as the data we pass into the component + * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks: + * + * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those. + * 2. Each section includes a header, even if we don't provide/render one. + * + * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this: + * + * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}] + * + * @returns {Object} + */ + const getItemLayout = (data, flatDataArrayIndex) => { + if (!_.has(flattenedData.current, flatDataArrayIndex)) { flattenedData.current = buildFlatSectionArray(); - }); - - const onViewableItemsChanged = () => { - if (didLayout.current || !onLayout) { - return; - } + } - didLayout.current = true; - onLayout(); + const targetItem = flattenedData.current[flatDataArrayIndex]; + return { + length: targetItem.length, + offset: targetItem.offset, + index: flatDataArrayIndex, }; - - /** - * This function is used to compute the layout of any given item in our list. - * We need to implement it so that we can programmatically scroll to items outside the virtual render window of the SectionList. - * - * @param {Array} data - This is the same as the data we pass into the component - * @param {Number} flatDataArrayIndex - This index is provided by React Native, and refers to a flat array with data from all the sections. This flat array has some quirks: - * - * 1. It ALWAYS includes a list header and a list footer, even if we don't provide/render those. - * 2. Each section includes a header, even if we don't provide/render one. - * - * For example, given a list with two sections, two items in each section, no header, no footer, and no section headers, the flat array might look something like this: - * - * [{header}, {sectionHeader}, {item}, {item}, {sectionHeader}, {item}, {item}, {footer}] - * - * @returns {Object} - */ - const getItemLayout = (data, flatDataArrayIndex) => { - if (!_.has(flattenedData.current, flatDataArrayIndex)) { - flattenedData.current = buildFlatSectionArray(); + }; + + /** + * Returns the key used by the list + * @param {Object} option + * @return {String} + */ + const extractKey = (option) => option.keyForList; + + /** + * Function which renders a row in the list + * + * @param {Object} params + * @param {Object} params.item + * @param {Number} params.index + * @param {Object} params.section + * + * @return {Component} + */ + const renderItem = ({item, index, section}) => { + const isItemDisabled = isDisabled || section.isDisabled || !!item.isDisabled; + const isSelected = _.some(selectedOptions, (option) => { + if (option.accountID && option.accountID === item.accountID) { + return true; } - const targetItem = flattenedData.current[flatDataArrayIndex]; - return { - length: targetItem.length, - offset: targetItem.offset, - index: flatDataArrayIndex, - }; - }; - - /** - * Returns the key used by the list - * - * @param {Object} option - * @return {String} - */ - const extractKey = (option) => option.keyForList; - - /** - * Function which renders a row in the list - * - * @param {Object} params - * @param {Object} params.item - * @param {Number} params.index - * @param {Object} params.section - * - * @return {Component} - */ - const renderItem = ({item, index, section}) => { - const isItemDisabled = isDisabled || section.isDisabled || !!item.isDisabled; - const isSelected = _.some(selectedOptions, (option) => { - if (option.accountID && option.accountID === item.accountID) { - return true; - } - - if (option.reportID && option.reportID === item.reportID) { - return true; - } - - if (_.isEmpty(option.name)) { - return false; - } - - return option.name === item.searchText; - }); - - return ( - 0 && shouldHaveOptionSeparator} - shouldDisableRowInnerPadding={shouldDisableRowInnerPadding} - shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} - isMultilineSupported={isRowMultilineSupported} - /> - ); - }; - - /** - * Function which renders a section header component - * - * @param {Object} params - * @param {Object} params.section - * @param {String} params.section.title - * @param {Boolean} params.section.shouldShow - * - * @return {Component} - */ - const renderSectionHeader = ({section: {title, shouldShow}}) => { - if (!title && shouldShow && !hideSectionHeaders && sectionHeaderStyle) { - return ; + if (option.reportID && option.reportID === item.reportID) { + return true; } - if (title && shouldShow && !hideSectionHeaders) { - return ( - // Note: The `optionsListSectionHeader` style provides an explicit height to section headers. - // We do this so that we can reference the height in `getItemLayout` – - // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item. - // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well. - - {title} - - ); + if (_.isEmpty(option.name)) { + return false; } - return ; - }; + return option.name === item.searchText; + }); return ( - - {isLoading ? ( - - ) : ( - <> - {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} - {/* This is misleading because we might be in the process of loading fresh options from the server. */} - {!isLoadingNewOptions && headerMessage ? ( - - {headerMessage} - - ) : null} - - - )} - + 0 && shouldHaveOptionSeparator} + shouldDisableRowInnerPadding={shouldDisableRowInnerPadding} + shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} + isMultilineSupported={isRowMultilineSupported} + /> ); - }, -); + }; + + /** + * Function which renders a section header component + * + * @param {Object} params + * @param {Object} params.section + * @param {String} params.section.title + * @param {Boolean} params.section.shouldShow + * + * @return {Component} + */ + const renderSectionHeader = ({section: {title, shouldShow}}) => { + if (!title && shouldShow && !hideSectionHeaders && sectionHeaderStyle) { + return ; + } + + if (title && shouldShow && !hideSectionHeaders) { + return ( + // Note: The `optionsListSectionHeader` style provides an explicit height to section headers. + // We do this so that we can reference the height in `getItemLayout` – + // we need to know the heights of all list items up-front in order to synchronously compute the layout of any given list item. + // So be aware that if you adjust the content of the section header (for example, change the font size), you may need to adjust this explicit height as well. + + {title} + + ); + } + + return ; + }; + + return ( + + {isLoading ? ( + + ) : ( + <> + {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} + {/* This is misleading because we might be in the process of loading fresh options from the server. */} + {!isLoadingNewOptions && headerMessage ? ( + + {headerMessage} + + ) : null} + + + )} + + ); +} BaseOptionsList.propTypes = propTypes; BaseOptionsList.defaultProps = defaultProps; @@ -315,7 +305,13 @@ BaseOptionsList.displayName = 'BaseOptionsList'; // using memo to avoid unnecessary rerenders when parents component rerenders (thus causing this component to rerender because shallow comparison is used for some props). export default memo( - BaseOptionsList, + forwardRef((props, ref) => ( + + )), (prevProps, nextProps) => nextProps.focusedIndex === prevProps.focusedIndex && nextProps.selectedOptions.length === prevProps.selectedOptions.length && diff --git a/src/components/OptionsList/index.js b/src/components/OptionsList/index.js index 6046a6124ccc..36b8e7fccf12 100644 --- a/src/components/OptionsList/index.js +++ b/src/components/OptionsList/index.js @@ -1,4 +1,4 @@ -import React, {forwardRef, memo, useCallback, useEffect, useRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useRef} from 'react'; import {Keyboard} from 'react-native'; import _ from 'underscore'; import withWindowDimensions from '@components/withWindowDimensions'; @@ -64,4 +64,4 @@ const OptionsListWithRef = forwardRef((props, ref) => ( OptionsListWithRef.displayName = 'OptionsListWithRef'; -export default withWindowDimensions(memo(OptionsListWithRef)); +export default withWindowDimensions(OptionsListWithRef); diff --git a/src/components/OptionsList/index.native.js b/src/components/OptionsList/index.native.js index 8a70e1e060b1..ab2db4f20967 100644 --- a/src/components/OptionsList/index.native.js +++ b/src/components/OptionsList/index.native.js @@ -1,4 +1,4 @@ -import React, {forwardRef, memo} from 'react'; +import React, {forwardRef} from 'react'; import {Keyboard} from 'react-native'; import BaseOptionsList from './BaseOptionsList'; import {defaultProps, propTypes} from './optionsListPropTypes'; @@ -8,7 +8,7 @@ const OptionsList = forwardRef((props, ref) => ( // eslint-disable-next-line react/jsx-props-no-spreading {...props} ref={ref} - onScrollBeginDrag={Keyboard.dismiss} + onScrollBeginDrag={() => Keyboard.dismiss()} /> )); @@ -16,4 +16,4 @@ OptionsList.propTypes = propTypes; OptionsList.defaultProps = defaultProps; OptionsList.displayName = 'OptionsList'; -export default memo(OptionsList); +export default OptionsList; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 100d369aa857..197829bb1ea9 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -1,7 +1,7 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {Component} from 'react'; -import {InteractionManager, ScrollView, View} from 'react-native'; +import {ScrollView, View} from 'react-native'; import _ from 'underscore'; import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; import Button from '@components/Button'; @@ -15,7 +15,7 @@ import ShowMoreButton from '@components/ShowMoreButton'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withNavigation from '@components/withNavigation'; +import withNavigationFocus from '@components/withNavigationFocus'; import withTheme, {withThemePropTypes} from '@components/withTheme'; import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; import compose from '@libs/compose'; @@ -40,6 +40,9 @@ const propTypes = { /** List styles for OptionsList */ listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + /** Whether navigation is focused */ + isFocused: PropTypes.bool.isRequired, + /** Whether referral CTA should be displayed */ shouldShowReferralCTA: PropTypes.bool, @@ -79,21 +82,19 @@ class BaseOptionsSelector extends Component { this.calculateAllVisibleOptionsCount = this.calculateAllVisibleOptionsCount.bind(this); this.handleFocusIn = this.handleFocusIn.bind(this); this.handleFocusOut = this.handleFocusOut.bind(this); - this.onLayout = this.onLayout.bind(this); - this.setListRef = this.setListRef.bind(this); this.debouncedUpdateSearchValue = _.debounce(this.updateSearchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); this.relatedTarget = null; this.accessibilityRoles = _.values(CONST.ROLE); this.isWebOrDesktop = [CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform()); - this.focusListener = null; - this.blurListener = null; - this.isFocused = false; + const allOptions = this.flattenSections(); + const sections = this.sliceSections(); + const focusedIndex = this.getInitiallyFocusedIndex(allOptions); this.state = { - sections: [], - allOptions: [], - focusedIndex: 0, + sections, + allOptions, + focusedIndex, shouldDisableRowSelection: false, shouldShowReferralModal: false, errorMessage: '', @@ -104,49 +105,17 @@ class BaseOptionsSelector extends Component { } componentDidMount() { - this.focusListener = this.props.navigation.addListener('focus', () => { - // Screen coming back into focus, for example - // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K. - // Only applies to platforms that support keyboard shortcuts - if (this.isWebOrDesktop) { - this.subscribeToEnterShortcut(); - this.subscribeToCtrlEnterShortcut(); - this.subscribeActiveElement(); - } - - if (this.props.autoFocus && this.textInput) { - this.focusTimeout = setTimeout(() => { - this.textInput.focus(); - }, CONST.ANIMATED_TRANSITION); - } - - this.isFocused = true; - }); + this.subscribeToEnterShortcut(); + this.subscribeToCtrlEnterShortcut(); + this.subscribeActiveElement(); + + if (this.props.isFocused && this.props.autoFocus && this.textInput) { + this.focusTimeout = setTimeout(() => { + this.textInput.focus(); + }, CONST.ANIMATED_TRANSITION); + } - this.blurListener = this.props.navigation.addListener('blur', () => { - if (this.isWebOrDesktop) { - this.unSubscribeFromKeyboardShortcut(); - this.unSubscribeActiveElement(); - } - this.isFocused = false; - }); this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false); - - /** - * Execute the following code after all interactions have been completed. - * Which means once we are sure that all navigation animations are done, - * we will execute the callback passed to `runAfterInteractions`. - */ - this.interactionTask = InteractionManager.runAfterInteractions(() => { - const allOptions = this.flattenSections(); - const sections = this.sliceSections(); - const focusedIndex = this.getInitiallyFocusedIndex(allOptions); - this.setState({ - sections, - allOptions, - focusedIndex, - }); - }); } componentDidUpdate(prevProps, prevState) { @@ -158,6 +127,24 @@ class BaseOptionsSelector extends Component { } } + if (prevProps.isFocused !== this.props.isFocused) { + if (this.props.isFocused) { + this.subscribeToEnterShortcut(); + this.subscribeToCtrlEnterShortcut(); + } else { + this.unSubscribeFromKeyboardShortcut(); + } + } + + // Screen coming back into focus, for example + // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K. + // Only applies to platforms that support keyboard shortcuts + if (this.isWebOrDesktop && !prevProps.isFocused && this.props.isFocused && this.props.autoFocus && this.textInput) { + setTimeout(() => { + this.textInput.focus(); + }, CONST.ANIMATED_TRANSITION); + } + if (prevState.paginationPage !== this.state.paginationPage) { const newSections = this.sliceSections(); @@ -207,24 +194,11 @@ class BaseOptionsSelector extends Component { } componentWillUnmount() { - if (this.interactionTask) { - this.interactionTask.cancel(); - } - this.focusListener(); - this.blurListener(); if (this.focusTimeout) { clearTimeout(this.focusTimeout); } - } - onLayout() { - if (this.props.selectedOptions.length === 0) { - this.scrollToIndex(this.state.focusedIndex, false); - } - - if (this.props.onLayout) { - this.props.onLayout(); - } + this.unSubscribeFromKeyboardShortcut(); } /** @@ -253,10 +227,6 @@ class BaseOptionsSelector extends Component { return defaultIndex; } - setListRef(ref) { - this.list = ref; - } - /** * Maps sections to render only allowed count of them per section. * @@ -383,7 +353,7 @@ class BaseOptionsSelector extends Component { const focusedItemKey = lodashGet(e, ['target', 'attributes', 'id', 'value']); const focusedOption = focusedItemKey ? _.find(this.state.allOptions, (option) => option.keyForList === focusedItemKey) : this.state.allOptions[this.state.focusedIndex]; - if (!focusedOption || !this.isFocused) { + if (!focusedOption || !this.props.isFocused) { return; } @@ -574,7 +544,7 @@ class BaseOptionsSelector extends Component { ); const optionsList = ( (this.list = el)} optionHoveredStyle={optionHoveredStyle} onSelectRow={this.props.onSelectRow ? this.selectRow : undefined} sections={this.state.sections} @@ -591,9 +561,16 @@ class BaseOptionsSelector extends Component { isDisabled={this.props.isDisabled} shouldHaveOptionSeparator={this.props.shouldHaveOptionSeparator} highlightSelectedOptions={this.props.highlightSelectedOptions} - onLayout={this.onLayout} - safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} - contentContainerStyles={this.props.contentContainerStyles} + onLayout={() => { + if (this.props.selectedOptions.length === 0) { + this.scrollToIndex(this.state.focusedIndex, false); + } + + if (this.props.onLayout) { + this.props.onLayout(); + } + }} + contentContainerStyles={[safeAreaPaddingBottomStyle, ...this.props.contentContainerStyles]} sectionHeaderStyle={this.props.sectionHeaderStyle} listContainerStyles={listContainerStyles} listStyles={this.props.listStyles} @@ -749,4 +726,4 @@ class BaseOptionsSelector extends Component { BaseOptionsSelector.defaultProps = defaultProps; BaseOptionsSelector.propTypes = propTypes; -export default compose(withLocalize, withNavigation, withThemeStyles, withTheme)(BaseOptionsSelector); +export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(BaseOptionsSelector); diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 502bdbf83b53..2d6f74f7cd46 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -127,6 +127,14 @@ function PopoverMenu({ {isActive: isVisible}, ); + const onModalHide = () => { + setFocusedIndex(-1); + if (selectedItemIndex.current !== null) { + menuItems[selectedItemIndex.current].onSelected(); + selectedItemIndex.current = null; + } + }; + return ( { - setFocusedIndex(-1); - if (selectedItemIndex.current !== null) { - menuItems[selectedItemIndex.current].onSelected(); - selectedItemIndex.current = null; - } - }} + onModalHide={onModalHide} animationIn={animationIn} animationOut={animationOut} animationInTiming={animationInTiming} diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index bc45e41ae2f9..036b64af1e4b 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -249,7 +249,17 @@ function MoneyRequestView({report, parentReport, parentReportActions, policyCate {!hasReceipt && canEditReceipt && canUseViolations && ( Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT))} + onPress={() => + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.REQUEST, + transaction.transactionID, + report.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ) + } /> )} {canUseViolations && } diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 27447a10a32b..abc7e3954200 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -15,7 +15,6 @@ import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; @@ -30,7 +29,6 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; -import variables from '@styles/variables'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -128,7 +126,6 @@ const defaultProps = { function ReportPreview(props) { const theme = useTheme(); const styles = useThemeStyles(); - const {getLineHeightStyle} = useStyleUtils(); const {translate} = useLocalize(); const [hasMissingSmartscanFields, sethasMissingSmartscanFields] = useState(false); @@ -286,7 +283,7 @@ function ReportPreview(props) { - {getPreviewMessage()} + {getPreviewMessage()} {!iouSettled && hasErrors && ( _.findIndex(flattenedSections.allOptions, (option) => option.keyForList === initiallyFocusedOptionKey)); // Disable `Enter` shortcut if the active element is a button or checkbox - const disableEnterShortcut = activeElement && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX].includes(activeElement.role); + const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX].includes(activeElementRole); /** * Scrolls to the desired item index in the section list @@ -413,6 +414,7 @@ function BaseSelectionList({ }} label={textInputLabel} accessibilityLabel={textInputLabel} + hint={textInputHint} role={CONST.ROLE.PRESENTATION} value={textInputValue} placeholder={textInputPlaceholder} diff --git a/src/components/SignInButtons/AppleAuthWrapper/index.ios.js b/src/components/SignInButtons/AppleAuthWrapper/index.ios.tsx similarity index 81% rename from src/components/SignInButtons/AppleAuthWrapper/index.ios.js rename to src/components/SignInButtons/AppleAuthWrapper/index.ios.tsx index 69882d89b1fe..12ead0267db3 100644 --- a/src/components/SignInButtons/AppleAuthWrapper/index.ios.js +++ b/src/components/SignInButtons/AppleAuthWrapper/index.ios.tsx @@ -5,19 +5,18 @@ import * as Session from '@userActions/Session'; /** * Apple Sign In wrapper for iOS * revokes the session if the credential is revoked. - * - * @returns {null} */ function AppleAuthWrapper() { useEffect(() => { if (!appleAuth.isSupported) { return; } - const listener = appleAuth.onCredentialRevoked(() => { + const removeListener = appleAuth.onCredentialRevoked(() => { Session.signOut(); }); + return () => { - listener.remove(); + removeListener(); }; }, []); diff --git a/src/components/SignInButtons/AppleAuthWrapper/index.js b/src/components/SignInButtons/AppleAuthWrapper/index.tsx similarity index 100% rename from src/components/SignInButtons/AppleAuthWrapper/index.js rename to src/components/SignInButtons/AppleAuthWrapper/index.tsx diff --git a/src/components/SignInButtons/AppleSignIn/index.android.js b/src/components/SignInButtons/AppleSignIn/index.android.tsx similarity index 92% rename from src/components/SignInButtons/AppleSignIn/index.android.js rename to src/components/SignInButtons/AppleSignIn/index.android.tsx index 9dc736789c61..cfd1c48ee8b5 100644 --- a/src/components/SignInButtons/AppleSignIn/index.android.js +++ b/src/components/SignInButtons/AppleSignIn/index.android.tsx @@ -18,9 +18,9 @@ const config = { /** * Apple Sign In method for Android that returns authToken. - * @returns {Promise} + * @returns Promise that returns a string when resolved */ -function appleSignInRequest() { +function appleSignInRequest(): Promise { appleAuthAndroid.configure(config); return appleAuthAndroid .signIn() @@ -32,7 +32,6 @@ function appleSignInRequest() { /** * Apple Sign In button for Android. - * @returns {React.Component} */ function AppleSignIn() { const handleSignIn = () => { diff --git a/src/components/SignInButtons/AppleSignIn/index.desktop.js b/src/components/SignInButtons/AppleSignIn/index.desktop.tsx similarity index 96% rename from src/components/SignInButtons/AppleSignIn/index.desktop.js rename to src/components/SignInButtons/AppleSignIn/index.desktop.tsx index cc7ae5b623a5..792c16ed0b4a 100644 --- a/src/components/SignInButtons/AppleSignIn/index.desktop.js +++ b/src/components/SignInButtons/AppleSignIn/index.desktop.tsx @@ -10,7 +10,6 @@ const appleSignInWebRouteForDesktopFlow = `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL} /** * Apple Sign In button for desktop flow - * @returns {React.Component} */ function AppleSignIn() { const styles = useThemeStyles(); diff --git a/src/components/SignInButtons/AppleSignIn/index.ios.js b/src/components/SignInButtons/AppleSignIn/index.ios.tsx similarity index 91% rename from src/components/SignInButtons/AppleSignIn/index.ios.js rename to src/components/SignInButtons/AppleSignIn/index.ios.tsx index f5c6333dcf7b..3fb1179d0365 100644 --- a/src/components/SignInButtons/AppleSignIn/index.ios.js +++ b/src/components/SignInButtons/AppleSignIn/index.ios.tsx @@ -7,9 +7,9 @@ import CONST from '@src/CONST'; /** * Apple Sign In method for iOS that returns identityToken. - * @returns {Promise} + * @returns Promise that returns a string when resolved */ -function appleSignInRequest() { +function appleSignInRequest(): Promise { return appleAuth .performRequest({ requestedOperation: appleAuth.Operation.LOGIN, @@ -20,7 +20,7 @@ function appleSignInRequest() { .then((response) => appleAuth.getCredentialStateForUser(response.user).then((credentialState) => { if (credentialState !== appleAuth.State.AUTHORIZED) { - Log.alert('[Apple Sign In] Authentication failed. Original response: ', response); + Log.alert('[Apple Sign In] Authentication failed. Original response: ', {response}); throw new Error('Authentication failed'); } return response.identityToken; @@ -30,7 +30,6 @@ function appleSignInRequest() { /** * Apple Sign In button for iOS. - * @returns {React.Component} */ function AppleSignIn() { const handleSignIn = () => { diff --git a/src/components/SignInButtons/AppleSignIn/index.website.js b/src/components/SignInButtons/AppleSignIn/index.website.tsx similarity index 74% rename from src/components/SignInButtons/AppleSignIn/index.website.js rename to src/components/SignInButtons/AppleSignIn/index.website.tsx index adae0a691e13..f256330c2344 100644 --- a/src/components/SignInButtons/AppleSignIn/index.website.js +++ b/src/components/SignInButtons/AppleSignIn/index.website.tsx @@ -1,45 +1,37 @@ -import get from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useEffect, useState} from 'react'; -import Config from 'react-native-config'; +import Config, {NativeConfig} from 'react-native-config'; import getUserLanguage from '@components/SignInButtons/GetUserLanguage'; -import withNavigationFocus from '@components/withNavigationFocus'; +import withNavigationFocus, {WithNavigationFocusProps} from '@components/withNavigationFocus'; import Log from '@libs/Log'; import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; +import {AppleIDSignInOnFailureEvent, AppleIDSignInOnSuccessEvent} from '@src/types/modules/dom'; // react-native-config doesn't trim whitespace on iOS for some reason so we // add a trim() call to lodashGet here to prevent headaches. -const lodashGet = (config, key, defaultValue) => get(config, key, defaultValue).trim(); +const getConfig = (config: NativeConfig, key: string, defaultValue: string) => (config?.[key] ?? defaultValue).trim(); -const requiredPropTypes = { - isDesktopFlow: PropTypes.bool.isRequired, +type AppleSignInDivProps = { + isDesktopFlow: boolean; }; -const singletonPropTypes = { - ...requiredPropTypes, - - // From withNavigationFocus - isFocused: PropTypes.bool.isRequired, +type SingletonAppleSignInButtonProps = AppleSignInDivProps & { + isFocused: boolean; }; -const propTypes = { - // Prop to indicate if this is the desktop flow or not. - isDesktopFlow: PropTypes.bool, -}; -const defaultProps = { - isDesktopFlow: false, +type AppleSignInProps = WithNavigationFocusProps & { + isDesktopFlow?: boolean; }; /** * Apple Sign In Configuration for Web. */ const config = { - clientId: lodashGet(Config, 'ASI_CLIENTID_OVERRIDE', CONFIG.APPLE_SIGN_IN.SERVICE_ID), + clientId: getConfig(Config, 'ASI_CLIENTID_OVERRIDE', CONFIG.APPLE_SIGN_IN.SERVICE_ID), scope: 'name email', // never used, but required for configuration - redirectURI: lodashGet(Config, 'ASI_REDIRECTURI_OVERRIDE', CONFIG.APPLE_SIGN_IN.REDIRECT_URI), + redirectURI: getConfig(Config, 'ASI_REDIRECTURI_OVERRIDE', CONFIG.APPLE_SIGN_IN.REDIRECT_URI), state: '', nonce: '', usePopup: true, @@ -49,23 +41,22 @@ const config = { * Apple Sign In success and failure listeners. */ -const successListener = (event) => { +const successListener = (event: AppleIDSignInOnSuccessEvent) => { const token = event.detail.authorization.id_token; Session.beginAppleSignIn(token); }; -const failureListener = (event) => { +const failureListener = (event: AppleIDSignInOnFailureEvent) => { if (!event.detail || event.detail.error === 'popup_closed_by_user') { return null; } - Log.warn(`Apple sign-in failed: ${event.detail}`); + Log.warn(`Apple sign-in failed: ${event.detail.error}`); }; /** * Apple Sign In button for Web. - * @returns {React.Component} */ -function AppleSignInDiv({isDesktopFlow}) { +function AppleSignInDiv({isDesktopFlow}: AppleSignInDivProps) { useEffect(() => { // `init` renders the button, so it must be called after the div is // first mounted. @@ -108,24 +99,20 @@ function AppleSignInDiv({isDesktopFlow}) { ); } -AppleSignInDiv.propTypes = requiredPropTypes; - // The Sign in with Apple script may fail to render button if there are multiple // of these divs present in the app, as it matches based on div id. So we'll // only mount the div when it should be visible. -function SingletonAppleSignInButton({isFocused, isDesktopFlow}) { +function SingletonAppleSignInButton({isFocused, isDesktopFlow}: SingletonAppleSignInButtonProps) { if (!isFocused) { return null; } return ; } -SingletonAppleSignInButton.propTypes = singletonPropTypes; - // withNavigationFocus is used to only render the button when it is visible. const SingletonAppleSignInButtonWithFocus = withNavigationFocus(SingletonAppleSignInButton); -function AppleSignIn({isDesktopFlow}) { +function AppleSignIn({isDesktopFlow = false}: AppleSignInProps) { const [scriptLoaded, setScriptLoaded] = useState(false); useEffect(() => { if (window.appleAuthScriptLoaded) { @@ -148,7 +135,5 @@ function AppleSignIn({isDesktopFlow}) { return ; } -AppleSignIn.propTypes = propTypes; -AppleSignIn.defaultProps = defaultProps; - +AppleSignIn.displayName = 'AppleSignIn'; export default withNavigationFocus(AppleSignIn); diff --git a/src/components/SignInButtons/GetUserLanguage.js b/src/components/SignInButtons/GetUserLanguage.ts similarity index 50% rename from src/components/SignInButtons/GetUserLanguage.js rename to src/components/SignInButtons/GetUserLanguage.ts index 7f45f1fa1e89..611ba415008b 100644 --- a/src/components/SignInButtons/GetUserLanguage.js +++ b/src/components/SignInButtons/GetUserLanguage.ts @@ -1,11 +1,16 @@ +import {ValueOf} from 'type-fest'; + const localeCodes = { en: 'en_US', es: 'es_ES', -}; +} as const; + +type LanguageCode = keyof typeof localeCodes; +type LocaleCode = ValueOf; -const GetUserLanguage = () => { +const GetUserLanguage = (): LocaleCode => { const userLanguage = navigator.language || navigator.userLanguage; - const languageCode = userLanguage.split('-')[0]; + const languageCode = userLanguage.split('-')[0] as LanguageCode; return localeCodes[languageCode] || 'en_US'; }; diff --git a/src/components/SignInButtons/GoogleSignIn/index.desktop.js b/src/components/SignInButtons/GoogleSignIn/index.desktop.tsx similarity index 78% rename from src/components/SignInButtons/GoogleSignIn/index.desktop.js rename to src/components/SignInButtons/GoogleSignIn/index.desktop.tsx index 9284a5332e3d..3c2abb1679f0 100644 --- a/src/components/SignInButtons/GoogleSignIn/index.desktop.js +++ b/src/components/SignInButtons/GoogleSignIn/index.desktop.tsx @@ -1,19 +1,15 @@ import React from 'react'; import {View} from 'react-native'; import IconButton from '@components/SignInButtons/IconButton'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -const propTypes = {...withLocalizePropTypes}; - const googleSignInWebRouteForDesktopFlow = `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.GOOGLE_SIGN_IN}`; /** * Google Sign In button for desktop flow. - * @returns {React.Component} */ function GoogleSignIn() { const styles = useThemeStyles(); @@ -30,6 +26,5 @@ function GoogleSignIn() { } GoogleSignIn.displayName = 'GoogleSignIn'; -GoogleSignIn.propTypes = propTypes; -export default withLocalize(GoogleSignIn); +export default GoogleSignIn; diff --git a/src/components/SignInButtons/GoogleSignIn/index.native.js b/src/components/SignInButtons/GoogleSignIn/index.native.tsx similarity index 98% rename from src/components/SignInButtons/GoogleSignIn/index.native.js rename to src/components/SignInButtons/GoogleSignIn/index.native.tsx index c7ac763cfb73..2744d8958080 100644 --- a/src/components/SignInButtons/GoogleSignIn/index.native.js +++ b/src/components/SignInButtons/GoogleSignIn/index.native.tsx @@ -43,7 +43,6 @@ function googleSignInRequest() { /** * Google Sign In button for iOS. - * @returns {React.Component} */ function GoogleSignIn() { return ( diff --git a/src/components/SignInButtons/GoogleSignIn/index.website.js b/src/components/SignInButtons/GoogleSignIn/index.website.tsx similarity index 83% rename from src/components/SignInButtons/GoogleSignIn/index.website.js rename to src/components/SignInButtons/GoogleSignIn/index.website.tsx index 8f8a977bdb09..5d419c8744e5 100644 --- a/src/components/SignInButtons/GoogleSignIn/index.website.js +++ b/src/components/SignInButtons/GoogleSignIn/index.website.tsx @@ -1,28 +1,21 @@ -import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; import {View} from 'react-native'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; +import Response from '@src/types/modules/google'; -const propTypes = { - /** Whether we're rendering in the Desktop Flow, if so show a different button. */ - isDesktopFlow: PropTypes.bool, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - isDesktopFlow: false, +type GoogleSignInProps = { + isDesktopFlow?: boolean; }; /** Div IDs for styling the two different Google Sign-In buttons. */ const mainId = 'google-sign-in-main'; const desktopId = 'google-sign-in-desktop'; -const signIn = (response) => { +const signIn = (response: Response) => { Session.beginGoogleSignIn(response.credential); }; @@ -31,12 +24,15 @@ const signIn = (response) => { * We have to load the gis script and then determine if the page is focused before rendering the button. * @returns {React.Component} */ -function GoogleSignIn({translate, isDesktopFlow}) { + +function GoogleSignIn({isDesktopFlow = false}: GoogleSignInProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const loadScript = useCallback(() => { const google = window.google; if (google) { google.accounts.id.initialize({ + // eslint-disable-next-line @typescript-eslint/naming-convention client_id: CONFIG.GOOGLE_SIGN_IN.WEB_CLIENT_ID, callback: signIn, }); @@ -92,7 +88,5 @@ function GoogleSignIn({translate, isDesktopFlow}) { } GoogleSignIn.displayName = 'GoogleSignIn'; -GoogleSignIn.propTypes = propTypes; -GoogleSignIn.defaultProps = defaultProps; -export default withLocalize(GoogleSignIn); +export default GoogleSignIn; diff --git a/src/components/SignInButtons/IconButton.js b/src/components/SignInButtons/IconButton.tsx similarity index 65% rename from src/components/SignInButtons/IconButton.js rename to src/components/SignInButtons/IconButton.tsx index 19a5bd9b27b8..848ca5463854 100644 --- a/src/components/SignInButtons/IconButton.js +++ b/src/components/SignInButtons/IconButton.tsx @@ -1,25 +1,13 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; - -const propTypes = { - /** The on press method */ - onPress: PropTypes.func, - - /** Which provider you are using to sign in */ - provider: PropTypes.string.isRequired, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - onPress: () => {}, -}; +import {TranslationPaths} from '@src/languages/types'; +import IconAsset from '@src/types/utils/IconAsset'; const providerData = { [CONST.SIGN_IN_METHOD.APPLE]: { @@ -30,9 +18,21 @@ const providerData = { icon: Expensicons.GoogleLogo, accessibilityLabel: 'common.signInWithGoogle', }, +} satisfies Record< + ValueOf, + { + icon: IconAsset; + accessibilityLabel: TranslationPaths; + } +>; + +type IconButtonProps = { + onPress?: () => void; + provider: ValueOf; }; -function IconButton({onPress, translate, provider}) { +function IconButton({onPress = () => {}, provider}: IconButtonProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); return ( (null); +const KeyboardStateContext = createContext({ + isKeyboardShown: false, +}); function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null { const [isKeyboardShown, setIsKeyboardShown] = useState(false); diff --git a/src/components/withNavigationFocus.tsx b/src/components/withNavigationFocus.tsx index 90a674a2e56e..bd7a39620114 100644 --- a/src/components/withNavigationFocus.tsx +++ b/src/components/withNavigationFocus.tsx @@ -25,3 +25,5 @@ export default function withNavigationFocus null; - -export default useActiveElement; diff --git a/src/hooks/useActiveElement/types.ts b/src/hooks/useActiveElement/types.ts deleted file mode 100644 index f3b5193975a5..000000000000 --- a/src/hooks/useActiveElement/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -type UseActiveElement = () => Element | null; - -export default UseActiveElement; diff --git a/src/hooks/useActiveElementRole/index.native.ts b/src/hooks/useActiveElementRole/index.native.ts new file mode 100644 index 000000000000..4278014f02a8 --- /dev/null +++ b/src/hooks/useActiveElementRole/index.native.ts @@ -0,0 +1,8 @@ +import type UseActiveElementRole from './types'; + +/** + * Native doesn't have the DOM, so we just return null. + */ +const useActiveElementRole: UseActiveElementRole = () => null; + +export default useActiveElementRole; diff --git a/src/hooks/useActiveElement/index.ts b/src/hooks/useActiveElementRole/index.ts similarity index 59% rename from src/hooks/useActiveElement/index.ts rename to src/hooks/useActiveElementRole/index.ts index 6026b4f5eb80..a98999105ac8 100644 --- a/src/hooks/useActiveElement/index.ts +++ b/src/hooks/useActiveElementRole/index.ts @@ -1,19 +1,19 @@ -import {useEffect, useState} from 'react'; -import type UseActiveElement from './types'; +import {useEffect, useRef} from 'react'; +import type UseActiveElementRole from './types'; /** * Listens for the focusin and focusout events and sets the DOM activeElement to the state. * On native, we just return null. */ -const useActiveElement: UseActiveElement = () => { - const [active, setActive] = useState(document.activeElement); +const useActiveElementRole: UseActiveElementRole = () => { + const activeRoleRef = useRef(document?.activeElement?.role); const handleFocusIn = () => { - setActive(document.activeElement); + activeRoleRef.current = document?.activeElement?.role; }; const handleFocusOut = () => { - setActive(null); + activeRoleRef.current = null; }; useEffect(() => { @@ -26,7 +26,7 @@ const useActiveElement: UseActiveElement = () => { }; }, []); - return active; + return activeRoleRef.current; }; -export default useActiveElement; +export default useActiveElementRole; diff --git a/src/hooks/useActiveElementRole/types.ts b/src/hooks/useActiveElementRole/types.ts new file mode 100644 index 000000000000..c31b8ab7ddbf --- /dev/null +++ b/src/hooks/useActiveElementRole/types.ts @@ -0,0 +1,3 @@ +type UseActiveElementRole = () => string | null | undefined; + +export default UseActiveElementRole; diff --git a/src/hooks/useDebouncedState.ts b/src/hooks/useDebouncedState.ts new file mode 100644 index 000000000000..3677c85f3081 --- /dev/null +++ b/src/hooks/useDebouncedState.ts @@ -0,0 +1,35 @@ +import debounce from 'lodash/debounce'; +import {useEffect, useRef, useState} from 'react'; +import CONST from '@src/CONST'; + +/** + * A React hook that provides a state and its debounced version. + * + * @param initialValue - The initial value of the state. + * @param delay - The debounce delay in milliseconds. Defaults to SEARCH_OPTION_LIST_DEBOUNCE_TIME = 300ms. + * @returns A tuple containing: + * - The current state value. + * - The debounced state value. + * - A function to set both the current and debounced state values. + * + * @template T The type of the state value. + * + * @example + * const [value, debouncedValue, setValue] = useDebouncedState("", 300); + */ +function useDebouncedState(initialValue: T, delay = CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME): [T, T, (value: T) => void] { + const [value, setValue] = useState(initialValue); + const [debouncedValue, setDebouncedValue] = useState(initialValue); + const debouncedSetDebouncedValue = useRef(debounce(setDebouncedValue, delay)).current; + + useEffect(() => () => debouncedSetDebouncedValue.cancel(), [debouncedSetDebouncedValue]); + + const handleSetValue = (newValue: T) => { + setValue(newValue); + debouncedSetDebouncedValue(newValue); + }; + + return [value, debouncedValue, handleSetValue]; +} + +export default useDebouncedState; diff --git a/src/hooks/useKeyboardState.ts b/src/hooks/useKeyboardState.ts index 439f626ddcdd..60ad3b8975b1 100644 --- a/src/hooks/useKeyboardState.ts +++ b/src/hooks/useKeyboardState.ts @@ -6,6 +6,6 @@ import {KeyboardStateContext} from '@components/withKeyboardState'; * Hook for getting current state of keyboard * whether the keyboard is open */ -export default function useKeyboardState(): KeyboardStateContextValue | null { +export default function useKeyboardState(): KeyboardStateContextValue { return useContext(KeyboardStateContext); } diff --git a/src/libs/API.ts b/src/libs/API.ts index d5cfd56cd4af..4305469eafd5 100644 --- a/src/libs/API.ts +++ b/src/libs/API.ts @@ -27,7 +27,7 @@ Request.use(Middleware.Reauthentication); // If an optimistic ID is not used by the server, this will update the remaining serialized requests using that optimistic ID to use the correct ID instead. Request.use(Middleware.HandleUnusedOptimisticID); -// SaveResponseInOnyx - Merges either the successData or failureData into Onyx depending on if the call was successful or not. This needs to be the LAST middleware we use, don't add any +// SaveResponseInOnyx - Merges either the successData or failureData (or finallyData, if included in place of the former two values) into Onyx depending on if the call was successful or not. This needs to be the LAST middleware we use, don't add any // middlewares after this, because the SequentialQueue depends on the result of this middleware to pause the queue (if needed) to bring the app to an up-to-date state. Request.use(Middleware.SaveResponseInOnyx); @@ -35,12 +35,13 @@ type OnyxData = { optimisticData?: OnyxUpdate[]; successData?: OnyxUpdate[]; failureData?: OnyxUpdate[]; + finallyData?: OnyxUpdate[]; }; type ApiRequestType = ValueOf; /** - * All calls to API.write() will be persisted to disk as JSON with the params, successData, and failureData. + * All calls to API.write() will be persisted to disk as JSON with the params, successData, and failureData (or finallyData, if included in place of the former two values). * This is so that if the network is unavailable or the app is closed, we can send the WRITE request later. * * @param command - Name of API command to call. @@ -51,6 +52,7 @@ type ApiRequestType = ValueOf; * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made. * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200. * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200. + * @param [onyxData.finallyData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200 or jsonCode !== 200. */ function write(command: string, apiCommandParameters: Record = {}, onyxData: OnyxData = {}) { Log.info('Called API write', false, {command, ...apiCommandParameters}); @@ -105,6 +107,7 @@ function write(command: string, apiCommandParameters: Record = * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made. * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200. * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200. + * @param [onyxData.finallyData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200 or jsonCode !== 200. * @param [apiRequestType] - Can be either 'read', 'write', or 'makeRequestWithSideEffects'. We use this to either return the chained * response back to the caller or to trigger reconnection callbacks when re-authentication is required. * @returns @@ -152,6 +155,7 @@ function makeRequestWithSideEffects( * @param [onyxData.optimisticData] - Onyx instructions that will be passed to Onyx.update() before the request is made. * @param [onyxData.successData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200. * @param [onyxData.failureData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode !== 200. + * @param [onyxData.finallyData] - Onyx instructions that will be passed to Onyx.update() when the response has jsonCode === 200 or jsonCode !== 200. */ function read(command: string, apiCommandParameters: Record, onyxData: OnyxData = {}) { // Ensure all write requests on the sequential queue have finished responding before running read requests. diff --git a/src/libs/BrickRoadsUtils.ts b/src/libs/BrickRoadsUtils.ts new file mode 100644 index 000000000000..db7cc40a7940 --- /dev/null +++ b/src/libs/BrickRoadsUtils.ts @@ -0,0 +1,74 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import * as OptionsListUtils from './OptionsListUtils'; +import * as ReportActionsUtils from './ReportActionsUtils'; +import * as ReportUtils from './ReportUtils'; + +let allReports: OnyxCollection; + +type BrickRoad = ValueOf | undefined; + +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + +/** + * @param report + * @returns BrickRoad for the policy passed as a param + */ +const getBrickRoadForPolicy = (report: Report): BrickRoad => { + const reportActions = ReportActionsUtils.getAllReportActions(report.reportID); + const reportErrors = OptionsListUtils.getAllReportErrors(report, reportActions); + const doesReportContainErrors = Object.keys(reportErrors ?? {}).length !== 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; + if (doesReportContainErrors) { + return CONST.BRICK_ROAD.RBR; + } + + // To determine if the report requires attention from the current user, we need to load the parent report action + let itemParentReportAction = {}; + if (report.parentReportID) { + const itemParentReportActions = ReportActionsUtils.getAllReportActions(report.parentReportID); + itemParentReportAction = report.parentReportActionID ? itemParentReportActions[report.parentReportActionID] : {}; + } + const reportOption = {...report, isUnread: ReportUtils.isUnread(report), isUnreadWithMention: ReportUtils.isUnreadWithMention(report)}; + const shouldShowGreenDotIndicator = ReportUtils.requiresAttentionFromCurrentUser(reportOption, itemParentReportAction); + return shouldShowGreenDotIndicator ? CONST.BRICK_ROAD.GBR : undefined; +}; + +/** + * @returns a map where the keys are policyIDs and the values are BrickRoads for each policy + */ +function getWorkspacesBrickRoads(): Record { + if (!allReports) { + return {}; + } + + // The key in this map is the workspace id + const workspacesBrickRoadsMap: Record = {}; + + Object.keys(allReports).forEach((report) => { + const policyID = allReports?.[report]?.policyID; + const policyReport = allReports ? allReports[report] : null; + if (!policyID || !policyReport || workspacesBrickRoadsMap[policyID] === CONST.BRICK_ROAD.RBR) { + return; + } + const workspaceBrickRoad = getBrickRoadForPolicy(policyReport); + + if (!workspaceBrickRoad && !!workspacesBrickRoadsMap[policyID]) { + return; + } + + workspacesBrickRoadsMap[policyID] = workspaceBrickRoad; + }); + + return workspacesBrickRoadsMap; +} + +export {getBrickRoadForPolicy, getWorkspacesBrickRoads}; +export type {BrickRoad}; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 02d1b34c69c1..e34fa0b90fc6 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -306,7 +306,7 @@ function getAddedEmojis(currentEmojis: Emoji[], formerEmojis: Emoji[]): Emoji[] * Replace any emoji name in a text with the emoji icon. * If we're on mobile, we also add a space after the emoji granted there's no text after it. */ -function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: 'en' | 'es' = CONST.LOCALES.DEFAULT): ReplacedEmoji { +function replaceEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { // emojisTrie is importing the emoji JSON file on the app starting and we want to avoid it const emojisTrie = require('./EmojiTrie').default; @@ -370,7 +370,7 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI /** * Find all emojis in a text and replace them with their code. */ -function replaceAndExtractEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT): ReplacedEmoji { +function replaceAndExtractEmojis(text: string, preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE, lang: Locale = CONST.LOCALES.DEFAULT): ReplacedEmoji { const {text: convertedText = '', emojis = [], cursorPosition} = replaceEmojis(text, preferredSkinTone, lang); return { diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 3c958336feb2..11dd0f5badda 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -40,9 +40,9 @@ function navigateToStartStepIfScanFileCannotBeRead( } const onFailure = () => { - IOU.setMoneyRequestReceipt_temporaryForRefactor(transactionID, '', ''); + IOU.setMoneyRequestReceipt(transactionID, '', '', true); if (requestType === CONST.IOU.REQUEST_TYPE.MANUAL) { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); return; } navigateToStartMoneyRequestStep(requestType, iouType, transactionID, reportID); diff --git a/src/libs/LocalePhoneNumber.ts b/src/libs/LocalePhoneNumber.ts index e50f3be87c84..933aa7937560 100644 --- a/src/libs/LocalePhoneNumber.ts +++ b/src/libs/LocalePhoneNumber.ts @@ -1,7 +1,7 @@ -import {parsePhoneNumber} from 'awesome-phonenumber'; import Str from 'expensify-common/lib/str'; import Onyx from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; +import {parsePhoneNumber} from './PhoneNumber'; let countryCodeByIP: number; Onyx.connect({ diff --git a/src/libs/LoginUtils.ts b/src/libs/LoginUtils.ts index 742f9bfe16ce..dca84b9b11e0 100644 --- a/src/libs/LoginUtils.ts +++ b/src/libs/LoginUtils.ts @@ -1,9 +1,9 @@ -import {parsePhoneNumber} from 'awesome-phonenumber'; import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST'; import Str from 'expensify-common/lib/str'; import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {parsePhoneNumber} from './PhoneNumber'; let countryCodeByIP: number; Onyx.connect({ diff --git a/src/libs/Middleware/SaveResponseInOnyx.ts b/src/libs/Middleware/SaveResponseInOnyx.ts index 9ed93c4ce393..a9182745098b 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.ts +++ b/src/libs/Middleware/SaveResponseInOnyx.ts @@ -12,9 +12,9 @@ const SaveResponseInOnyx: Middleware = (requestResponse, request) => requestResponse.then((response = {}) => { const onyxUpdates = response?.onyxData ?? []; - // Sometimes we call requests that are successfull but they don't have any response or any success/failure data to set. Let's return early since + // Sometimes we call requests that are successfull but they don't have any response or any success/failure/finally data to set. Let's return early since // we don't need to store anything here. - if (!onyxUpdates && !request.successData && !request.failureData) { + if (!onyxUpdates && !request.successData && !request.failureData && !request.finallyData) { return Promise.resolve(response); } diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 624aaec72bda..b5466a9bbc2f 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -7,6 +7,7 @@ import type {OnyxEntry} from 'react-native-onyx/lib/types'; import Tab from '@userActions/Tab'; import ONYXKEYS from '@src/ONYXKEYS'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import {defaultScreenOptions} from './OnyxTabNavigatorConfig'; type OnyxTabNavigatorOnyxProps = { selectedTab: OnyxEntry; @@ -51,6 +52,7 @@ function OnyxTabNavigator({id, selectedTab = '', children, onTabSelected = () => }, ...(screenListeners ?? {}), }} + screenOptions={defaultScreenOptions} > {children} diff --git a/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts b/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts new file mode 100644 index 000000000000..8f61e38ca531 --- /dev/null +++ b/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts @@ -0,0 +1,8 @@ +const defaultScreenOptions = { + animationEnabled: true, +} as const; + +export { + // eslint-disable-next-line import/prefer-default-export + defaultScreenOptions, +}; diff --git a/src/libs/Navigation/OnyxTabNavigatorConfig/index.website.ts b/src/libs/Navigation/OnyxTabNavigatorConfig/index.website.ts new file mode 100644 index 000000000000..724e8be05123 --- /dev/null +++ b/src/libs/Navigation/OnyxTabNavigatorConfig/index.website.ts @@ -0,0 +1,8 @@ +const defaultScreenOptions = { + animationEnabled: false, +} as const; + +export { + // eslint-disable-next-line import/prefer-default-export + defaultScreenOptions, +}; diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 0abdfdd02224..dc7f8c6f5e0d 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -1,5 +1,4 @@ /* eslint-disable no-continue */ -import {parsePhoneNumber} from 'awesome-phonenumber'; import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import lodashOrderBy from 'lodash/orderBy'; @@ -17,6 +16,7 @@ import ModifiedExpenseMessage from './ModifiedExpenseMessage'; import Navigation from './Navigation/Navigation'; import Permissions from './Permissions'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as PhoneNumber from './PhoneNumber'; import * as ReportActionUtils from './ReportActionsUtils'; import * as ReportUtils from './ReportUtils'; import * as TaskUtils from './TaskUtils'; @@ -129,7 +129,7 @@ Onyx.connect({ * @return {String} */ function addSMSDomainIfPhoneNumber(login) { - const parsedPhoneNumber = parsePhoneNumber(login); + const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(login); if (parsedPhoneNumber.possible && !Str.isValidEmail(login)) { return parsedPhoneNumber.number.e164 + CONST.SMS.DOMAIN; } @@ -1333,7 +1333,7 @@ function getOptions( let recentReportOptions = []; let personalDetailsOptions = []; const reportMapForAccountIDs = {}; - const parsedPhoneNumber = parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue))); + const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue))); const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number.e164 : searchInputValue.toLowerCase(); // Filter out all the reports that shouldn't be displayed @@ -1410,11 +1410,13 @@ function getOptions( ); }); - // We're only picking personal details that have logins set - // This is a temporary fix for all the logic that's been breaking because of the new privacy changes - // See https://github.com/Expensify/Expensify/issues/293465 for more context - // Moreover, we should not override the personalDetails object, otherwise the createOption util won't work properly, it returns incorrect tooltipText - const havingLoginPersonalDetails = !includeP2P ? {} : _.pick(personalDetails, (detail) => Boolean(detail.login) && !detail.isOptimisticPersonalDetail); + /* + We're only picking personal details that have logins and accountIDs set (sometimes the __fake__ account with `ID = 0` is present in the personal details collection) + This is a temporary fix for all the logic that's been breaking because of the new privacy changes + See https://github.com/Expensify/Expensify/issues/293465, https://github.com/Expensify/App/issues/33415 for more context + Moreover, we should not override the personalDetails object, otherwise the createOption util won't work properly, it returns incorrect tooltipText + */ + const havingLoginPersonalDetails = !includeP2P ? {} : _.pick(personalDetails, (detail) => !!detail.login && !!detail.accountID && !detail.isOptimisticPersonalDetail); let allPersonalDetailsOptions = _.map(havingLoginPersonalDetails, (personalDetail) => createOption([personalDetail.accountID], personalDetails, reportMapForAccountIDs[personalDetail.accountID], reportActions, { showChatPreviewLine, @@ -1843,7 +1845,7 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma return Localize.translate(preferredLocale, 'common.maxParticipantsReached', {count: CONST.REPORT.MAXIMUM_PARTICIPANTS}); } - const isValidPhone = parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible; + const isValidPhone = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible; const isValidEmail = Str.isValidEmail(searchValue); diff --git a/src/libs/PhoneNumber.ts b/src/libs/PhoneNumber.ts new file mode 100644 index 000000000000..f92aade2c892 --- /dev/null +++ b/src/libs/PhoneNumber.ts @@ -0,0 +1,43 @@ +// eslint-disable-next-line no-restricted-imports +import {parsePhoneNumber as originalParsePhoneNumber} from 'awesome-phonenumber'; +import type {ParsedPhoneNumber, ParsedPhoneNumberInvalid, PhoneNumberParseOptions} from 'awesome-phonenumber'; +import CONST from '@src/CONST'; + +/** + * Wraps awesome-phonenumber's parsePhoneNumber function to handle the case where we want to treat + * a US phone number that's technically valid as invalid. eg: +115005550009. + * See https://github.com/Expensify/App/issues/28492 + */ +function parsePhoneNumber(phoneNumber: string, options?: PhoneNumberParseOptions): ParsedPhoneNumber { + const parsedPhoneNumber = originalParsePhoneNumber(phoneNumber, options); + if (!parsedPhoneNumber.possible) { + return parsedPhoneNumber; + } + + const phoneNumberWithoutSpecialChars = phoneNumber.replace(CONST.REGEX.SPECIAL_CHARS_WITHOUT_NEWLINE, ''); + if (!/^\+11[0-9]{10}$/.test(phoneNumberWithoutSpecialChars)) { + return parsedPhoneNumber; + } + + const countryCode = phoneNumberWithoutSpecialChars.substring(0, 2); + const phoneNumberWithoutCountryCode = phoneNumberWithoutSpecialChars.substring(2); + + return { + ...parsedPhoneNumber, + valid: false, + possible: false, + number: { + ...parsedPhoneNumber.number, + + // mimic the behavior of awesome-phonenumber + e164: phoneNumberWithoutSpecialChars, + international: `${countryCode} ${phoneNumberWithoutCountryCode}`, + national: phoneNumberWithoutCountryCode, + rfc3966: `tel:${countryCode}-${phoneNumberWithoutCountryCode}`, + significant: phoneNumberWithoutCountryCode, + }, + } as ParsedPhoneNumberInvalid; +} + +// eslint-disable-next-line import/prefer-default-export +export {parsePhoneNumber}; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index d2d9efa8ae4a..9ba11fb16d6a 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -1,4 +1,3 @@ -import {parsePhoneNumber} from 'awesome-phonenumber'; import {addYears, endOfMonth, format, isAfter, isBefore, isSameDay, isValid, isWithinInterval, parse, parseISO, startOfDay, subYears} from 'date-fns'; import {URL_REGEX_WITH_REQUIRED_PROTOCOL} from 'expensify-common/lib/Url'; import isDate from 'lodash/isDate'; @@ -10,6 +9,7 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import * as CardUtils from './CardUtils'; import DateUtils from './DateUtils'; import * as LoginUtils from './LoginUtils'; +import {parsePhoneNumber} from './PhoneNumber'; import StringUtils from './StringUtils'; /** diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index c0691eb86475..3a2241bd5494 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -159,7 +159,7 @@ function getPolicyParamsForOpenOrReconnect(): Promise = { + const defaultData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, @@ -167,14 +167,7 @@ function getOnyxDataForOpenOrReconnect(isOpenApp = false): OnyxData { value: true, }, ], - successData: [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IS_LOADING_REPORT_DATA, - value: false, - }, - ], - failureData: [ + finallyData: [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.IS_LOADING_REPORT_DATA, @@ -194,16 +187,8 @@ function getOnyxDataForOpenOrReconnect(isOpenApp = false): OnyxData { value: true, }, ], - successData: [ - ...defaultData.successData, - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IS_LOADING_APP, - value: false, - }, - ], - failureData: [ - ...defaultData.failureData, + finallyData: [ + ...defaultData.finallyData, { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.IS_LOADING_APP, diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 8f1e185cc57d..ac604019354b 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -264,9 +264,13 @@ function setMoneyRequestParticipants_temporaryForRefactor(transactionID, partici * @param {String} transactionID * @param {String} source * @param {String} filename + * @param {Boolean} isDraft */ -function setMoneyRequestReceipt_temporaryForRefactor(transactionID, source, filename) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {receipt: {source}, filename}); +function setMoneyRequestReceipt(transactionID, source, filename, isDraft) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + receipt: {source}, + filename, + }); } /** @@ -3357,10 +3361,10 @@ function detachReceipt(transactionID) { /** * @param {String} transactionID - * @param {Object} receipt - * @param {String} filePath + * @param {Object} file + * @param {String} source */ -function replaceReceipt(transactionID, receipt, filePath) { +function replaceReceipt(transactionID, file, source) { const transaction = lodashGet(allTransactions, 'transactionID', {}); const oldReceipt = lodashGet(transaction, 'receipt', {}); @@ -3370,10 +3374,10 @@ function replaceReceipt(transactionID, receipt, filePath) { key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, value: { receipt: { - source: filePath, + source, state: CONST.IOU.RECEIPT_STATE.OPEN, }, - filename: receipt.name, + filename: file.name, }, }, ]; @@ -3389,7 +3393,7 @@ function replaceReceipt(transactionID, receipt, filePath) { }, ]; - API.write('ReplaceReceipt', {transactionID, receipt}, {optimisticData, failureData}); + API.write('ReplaceReceipt', {transactionID, receipt: file}, {optimisticData, failureData}); } /** @@ -3515,14 +3519,6 @@ function setMoneyRequestParticipants(participants, isSplitRequest) { Onyx.merge(ONYXKEYS.IOU, {participants, isSplitRequest}); } -/** - * @param {String} receiptPath - * @param {String} receiptFilename - */ -function setMoneyRequestReceipt(receiptPath, receiptFilename) { - Onyx.merge(ONYXKEYS.IOU, {receiptPath, receiptFilename, merchant: ''}); -} - function setUpDistanceTransaction() { const transactionID = NumberUtils.rand64(); Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { @@ -3626,7 +3622,7 @@ export { setMoneyRequestDescription_temporaryForRefactor, setMoneyRequestMerchant_temporaryForRefactor, setMoneyRequestParticipants_temporaryForRefactor, - setMoneyRequestReceipt_temporaryForRefactor, + setMoneyRequestReceipt, setMoneyRequestTag_temporaryForRefactor, setMoneyRequestAmount, setMoneyRequestBillable, @@ -3637,7 +3633,6 @@ export { setMoneyRequestId, setMoneyRequestMerchant, setMoneyRequestParticipantsFromReport, - setMoneyRequestReceipt, setMoneyRequestTag, setMoneyRequestTaxAmount, setMoneyRequestTaxRate, diff --git a/src/libs/actions/InputFocus/index.desktop.ts b/src/libs/actions/InputFocus/index.desktop.ts index 86a562f0531e..2a8fe1b9fd01 100644 --- a/src/libs/actions/InputFocus/index.desktop.ts +++ b/src/libs/actions/InputFocus/index.desktop.ts @@ -1,13 +1,14 @@ import Onyx from 'react-native-onyx'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Modal} from '@src/types/onyx'; function inputFocusChange(focus: boolean) { Onyx.set(ONYXKEYS.INPUT_FOCUSED, focus); } let refSave: HTMLElement | undefined; -function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: {willAlertModalBecomeVisible: boolean; isVisible: boolean}, onyxFocused: boolean) { +function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: Modal, onyxFocused: boolean) { if (isFocused && !onyxFocused) { inputFocusChange(true); ref.focus(); diff --git a/src/libs/actions/InputFocus/index.ts b/src/libs/actions/InputFocus/index.ts index 1840b0625626..6d8706ebdd0e 100644 --- a/src/libs/actions/InputFocus/index.ts +++ b/src/libs/actions/InputFocus/index.ts @@ -1,5 +1,9 @@ -function inputFocusChange() {} -function composerFocusKeepFocusOn() {} -const callback = () => {}; +import type {Modal} from '@src/types/onyx'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function inputFocusChange(focus: boolean) {} +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: Modal, onyxFocused: boolean) {} +const callback = (method: () => void) => method(); export {composerFocusKeepFocusOn, inputFocusChange, callback}; diff --git a/src/libs/actions/InputFocus/index.website.ts b/src/libs/actions/InputFocus/index.website.ts index 8e41e06d7401..541254ac0cda 100644 --- a/src/libs/actions/InputFocus/index.website.ts +++ b/src/libs/actions/InputFocus/index.website.ts @@ -2,13 +2,14 @@ import Onyx from 'react-native-onyx'; import * as Browser from '@libs/Browser'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Modal} from '@src/types/onyx'; function inputFocusChange(focus: boolean) { Onyx.set(ONYXKEYS.INPUT_FOCUSED, focus); } let refSave: HTMLElement | undefined; -function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: {willAlertModalBecomeVisible: boolean; isVisible: boolean}, onyxFocused: boolean) { +function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: Modal, onyxFocused: boolean) { if (isFocused && !onyxFocused) { inputFocusChange(true); ref.focus(); diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 85f569246ec5..d89f10ee9c7b 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -44,6 +44,12 @@ function applyHTTPSOnyxUpdates(request: Request, response: Response) { } return Promise.resolve(); }) + .then(() => { + if (request.finallyData) { + return updateHandler(request.finallyData); + } + return Promise.resolve(); + }) .then(() => { console.debug('[OnyxUpdateManager] Done applying HTTPS update'); return Promise.resolve(response); diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 67f531bff505..bde2954e191a 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -200,16 +200,7 @@ function resendValidateCode(login = credentials.login) { }, }, ]; - const successData: OnyxUpdate[] = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.ACCOUNT, - value: { - loadingForm: null, - }, - }, - ]; - const failureData: OnyxUpdate[] = [ + const finallyData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -225,7 +216,7 @@ function resendValidateCode(login = credentials.login) { const params: RequestNewValidateCodeParams = {email: login}; - API.write('RequestNewValidateCode', params, {optimisticData, successData, failureData}); + API.write('RequestNewValidateCode', params, {optimisticData, finallyData}); } type OnyxData = { @@ -301,11 +292,11 @@ function beginSignIn(email: string) { * 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. */ -function beginAppleSignIn(idToken: string) { +function beginAppleSignIn(idToken: string | undefined | null) { const {optimisticData, successData, failureData} = signInAttemptState(); type BeginAppleSignInParams = { - idToken: string; + idToken: typeof idToken; preferredLocale: ValueOf | null; }; @@ -318,11 +309,11 @@ function beginAppleSignIn(idToken: string) { * 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 */ -function beginGoogleSignIn(token: string) { +function beginGoogleSignIn(token: string | null) { const {optimisticData, successData, failureData} = signInAttemptState(); type BeginGoogleSignInParams = { - token: string; + token: string | null; preferredLocale: ValueOf | null; }; diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index 2fdab08e6675..f215b4167ab6 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -1,4 +1,3 @@ -import {parsePhoneNumber} from 'awesome-phonenumber'; import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; @@ -23,6 +22,7 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import {parsePhoneNumber} from '@libs/PhoneNumber'; import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; import * as Report from '@userActions/Report'; diff --git a/src/pages/EditRequestReceiptPage.js b/src/pages/EditRequestReceiptPage.js index 1525ec162963..40fe64da7eed 100644 --- a/src/pages/EditRequestReceiptPage.js +++ b/src/pages/EditRequestReceiptPage.js @@ -7,7 +7,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import ReceiptSelector from './iou/ReceiptSelector'; +import IOURequestStepScan from './iou/request/step/IOURequestStepScan'; const propTypes = { /** React Navigation route */ @@ -21,16 +21,9 @@ const propTypes = { reportID: PropTypes.string, }), }).isRequired, - - /** The id of the transaction we're editing */ - transactionID: PropTypes.string, -}; - -const defaultProps = { - transactionID: '', }; -function EditRequestReceiptPage({route, transactionID}) { +function EditRequestReceiptPage({route}) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [isDraggingOver, setIsDraggingOver] = useState(false); @@ -49,11 +42,7 @@ function EditRequestReceiptPage({route, transactionID}) { title={translate('common.receipt')} onBackButtonPress={Navigation.goBack} /> - + )} @@ -62,7 +51,6 @@ function EditRequestReceiptPage({route, transactionID}) { } EditRequestReceiptPage.propTypes = propTypes; -EditRequestReceiptPage.defaultProps = defaultProps; EditRequestReceiptPage.displayName = 'EditRequestReceiptPage'; export default EditRequestReceiptPage; diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index 18da2c11a0e6..faa525a318ab 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -1,4 +1,3 @@ -import {parsePhoneNumber} from 'awesome-phonenumber'; import {subYears} from 'date-fns'; import PropTypes from 'prop-types'; import React from 'react'; @@ -17,6 +16,7 @@ import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultPro import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import {parsePhoneNumber} from '@libs/PhoneNumber'; import * as ValidationUtils from '@libs/ValidationUtils'; import AddressForm from '@pages/ReimbursementAccount/AddressForm'; import * as PersonalDetails from '@userActions/PersonalDetails'; diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 211cb303d061..3a58727eddb7 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; @@ -60,6 +60,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i const [selectedOptions, setSelectedOptions] = useState([]); const {isOffline} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); + const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; const headerMessage = OptionsListUtils.getHeaderMessage( @@ -115,7 +116,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i * Removes a selected option from list if already selected. If not already selected add this option to the list. * @param {Object} option */ - function toggleOption(option) { + const toggleOption = (option) => { const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login); let newSelectedOptions; @@ -153,7 +154,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i setFilteredRecentReports(recentReports); setFilteredPersonalDetails(newChatPersonalDetails); setFilteredUserToInvite(userToInvite); - } + }; /** * Creates a new 1:1 chat with the option and the current user, @@ -161,9 +162,9 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i * * @param {Object} option */ - function createChat(option) { + const createChat = (option) => { Report.navigateToAndOpenReport([option.login]); - } + }; /** * Creates a new group chat with all the selected options and the current user, @@ -177,7 +178,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i Report.navigateToAndOpenReport(logins); }; - useEffect(() => { + const updateOptions = useCallback(() => { const { recentReports, personalDetails: newChatPersonalDetails, @@ -207,6 +208,21 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i // eslint-disable-next-line react-hooks/exhaustive-deps }, [reports, personalDetails, searchTerm]); + useEffect(() => { + const interactionTask = InteractionManager.runAfterInteractions(() => { + setDidScreenTransitionEnd(true); + }); + + return interactionTask.cancel; + }, []); + + useEffect(() => { + if (!didScreenTransitionEnd) { + return; + } + updateOptions(); + }, [didScreenTransitionEnd, updateOptions]); + // When search term updates we will fetch any reports const setSearchTermAndSearchInServer = useCallback((text = '') => { Report.searchInServer(text); @@ -238,15 +254,15 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i canSelectMultipleOptions shouldShowMultipleOptionSelectorAsButton multipleOptionSelectorButtonText={translate('newChatPage.addToGroup')} - onAddToSelection={(option) => toggleOption(option)} + onAddToSelection={toggleOption} sections={sections} selectedOptions={selectedOptions} - onSelectRow={(option) => createChat(option)} + onSelectRow={createChat} onChangeText={setSearchTermAndSearchInServer} headerMessage={headerMessage} boldStyle shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} - shouldShowOptions={isOptionsDataReady} + shouldShowOptions={isOptionsDataReady && didScreenTransitionEnd} shouldShowConfirmButton shouldShowReferralCTA referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT} diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js index 8432d25b6ad7..c0c782f176ca 100755 --- a/src/pages/ProfilePage.js +++ b/src/pages/ProfilePage.js @@ -1,4 +1,3 @@ -import {parsePhoneNumber} from 'awesome-phonenumber'; import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; @@ -27,6 +26,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import {parsePhoneNumber} from '@libs/PhoneNumber'; import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; diff --git a/src/pages/ReimbursementAccount/CompanyStep.js b/src/pages/ReimbursementAccount/CompanyStep.js index 9f985e15a95e..8828cce5cc74 100644 --- a/src/pages/ReimbursementAccount/CompanyStep.js +++ b/src/pages/ReimbursementAccount/CompanyStep.js @@ -1,4 +1,3 @@ -import {parsePhoneNumber} from 'awesome-phonenumber'; import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; @@ -20,6 +19,7 @@ import TextLink from '@components/TextLink'; import withLocalize from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import {parsePhoneNumber} from '@libs/PhoneNumber'; import * as ValidationUtils from '@libs/ValidationUtils'; import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; diff --git a/src/pages/SearchPage.js b/src/pages/SearchPage.js index a6e453d9f211..061f43e73de8 100755 --- a/src/pages/SearchPage.js +++ b/src/pages/SearchPage.js @@ -1,8 +1,7 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OptionsSelector from '@components/OptionsSelector'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -43,18 +42,6 @@ const defaultProps = { isSearchingForReports: false, }; -function isSectionsEmpty(sections) { - if (!sections.length) { - return true; - } - - if (!sections[0].data.length) { - return true; - } - - return _.isEmpty(sections[0].data[0]); -} - function SearchPage({betas, personalDetails, reports, isSearchingForReports}) { const [searchValue, setSearchValue] = useState(''); const [searchOptions, setSearchOptions] = useState({ @@ -67,45 +54,21 @@ function SearchPage({betas, personalDetails, reports, isSearchingForReports}) { const {translate} = useLocalize(); const themeStyles = useThemeStyles(); const isMounted = useRef(false); - const interactionTask = useRef(null); const updateOptions = useCallback(() => { - if (interactionTask.current) { - interactionTask.current.cancel(); - } - - /** - * Execute the callback after all interactions are done, which means - * after all animations have finished. - */ - interactionTask.current = InteractionManager.runAfterInteractions(() => { - const { - recentReports: localRecentReports, - personalDetails: localPersonalDetails, - userToInvite: localUserToInvite, - } = OptionsListUtils.getSearchOptions(reports, personalDetails, searchValue.trim(), betas); - - setSearchOptions({ - recentReports: localRecentReports, - personalDetails: localPersonalDetails, - userToInvite: localUserToInvite, - }); + const { + recentReports: localRecentReports, + personalDetails: localPersonalDetails, + userToInvite: localUserToInvite, + } = OptionsListUtils.getSearchOptions(reports, personalDetails, searchValue.trim(), betas); + + setSearchOptions({ + recentReports: localRecentReports, + personalDetails: localPersonalDetails, + userToInvite: localUserToInvite, }); }, [reports, personalDetails, searchValue, betas]); - /** - * Cancel the interaction task when the component unmounts - */ - useEffect( - () => () => { - if (!interactionTask.current) { - return; - } - interactionTask.current.cancel(); - }, - [], - ); - useEffect(() => { Timing.start(CONST.TIMING.SEARCH_RENDER); Performance.markStart(CONST.TIMING.SEARCH_RENDER); @@ -196,7 +159,7 @@ function SearchPage({betas, personalDetails, reports, isSearchingForReports}) { Boolean(searchOptions.userToInvite), searchValue, ); - const sections = getSections(); + return ( + { + Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND)); + }} + style={[ + themeStyles.p5, + themeStyles.w100, + themeStyles.br2, + themeStyles.highlightBG, + themeStyles.flexRow, + themeStyles.justifyContentBetween, + themeStyles.alignItemsCenter, + {gap: 10}, + ]} + accessibilityLabel="referral" + role={CONST.ACCESSIBILITY_ROLE.BUTTON} + > + + {translate(`referralProgram.${CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND}.buttonText1`)} + + {translate(`referralProgram.${CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND}.buttonText2`)} + + + + + + ); +} + +SearchPageFooter.displayName = 'SearchPageFooter'; + +export default SearchPageFooter; diff --git a/src/pages/SearchPage/index.js b/src/pages/SearchPage/index.js new file mode 100644 index 000000000000..211f3622e06c --- /dev/null +++ b/src/pages/SearchPage/index.js @@ -0,0 +1,183 @@ +import PropTypes from 'prop-types'; +import React, {useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import Performance from '@libs/Performance'; +import * as ReportUtils from '@libs/ReportUtils'; +import reportPropTypes from '@pages/reportPropTypes'; +import * as Report from '@userActions/Report'; +import Timing from '@userActions/Timing'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SearchPageFooter from './SearchPageFooter'; + +const propTypes = { + /* Onyx Props */ + + /** Beta features list */ + betas: PropTypes.arrayOf(PropTypes.string), + + /** All reports shared with the user */ + reports: PropTypes.objectOf(reportPropTypes), +}; + +const defaultProps = { + betas: [], + reports: {}, +}; + +const setPerformanceTimersEnd = () => { + Timing.end(CONST.TIMING.SEARCH_RENDER); + Performance.markEnd(CONST.TIMING.SEARCH_RENDER); +}; + +const SearchPageFooterInstance = ; + +function SearchPage({betas, reports}) { + const [isScreenTransitionEnd, setIsScreenTransitionEnd] = useState(false); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const themeStyles = useThemeStyles(); + const personalDetails = usePersonalDetails(); + + const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; + + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + + useEffect(() => { + Timing.start(CONST.TIMING.SEARCH_RENDER); + Performance.markStart(CONST.TIMING.SEARCH_RENDER); + }, []); + + const onChangeText = (text = '') => { + Report.searchInServer(text); + setSearchValue(text); + }; + + const { + recentReports, + personalDetails: localPersonalDetails, + userToInvite, + headerMessage, + } = useMemo(() => { + if (!isScreenTransitionEnd) { + return { + recentReports: {}, + personalDetails: {}, + userToInvite: {}, + headerMessage: '', + }; + } + const options = OptionsListUtils.getSearchOptions(reports, personalDetails, debouncedSearchValue.trim(), betas); + const header = OptionsListUtils.getHeaderMessage(options.recentReports.length + options.personalDetails.length !== 0, Boolean(options.userToInvite), debouncedSearchValue); + return {...options, headerMessage: header}; + }, [debouncedSearchValue, reports, personalDetails, betas, isScreenTransitionEnd]); + + const sections = useMemo(() => { + const newSections = []; + let indexOffset = 0; + + if (recentReports.length > 0) { + newSections.push({ + data: recentReports, + shouldShow: true, + indexOffset, + }); + indexOffset += recentReports.length; + } + + if (localPersonalDetails.length > 0) { + newSections.push({ + data: localPersonalDetails, + shouldShow: true, + indexOffset, + }); + indexOffset += recentReports.length; + } + + if (userToInvite) { + newSections.push({ + data: [userToInvite], + shouldShow: true, + indexOffset, + }); + } + + return newSections; + }, [localPersonalDetails, recentReports, userToInvite]); + + const selectReport = (option) => { + if (!option) { + return; + } + + if (option.reportID) { + setSearchValue(''); + Navigation.dismissModal(option.reportID); + } else { + Report.navigateToAndOpenReport([option.login]); + } + }; + + const handleScreenTransitionEnd = () => { + setIsScreenTransitionEnd(true); + }; + + const isOptionsDataReady = useMemo(() => ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails), [personalDetails]); + + return ( + + {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( + <> + + + + + + )} + + ); +} + +SearchPage.propTypes = propTypes; +SearchPage.defaultProps = defaultProps; +SearchPage.displayName = 'SearchPage'; + +export default withOnyx({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + isSearchingForReports: { + key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, + initWithStoredValues: false, + }, +})(SearchPage); diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 0f332b546f4b..64e48ecd5509 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -11,6 +11,7 @@ import DragAndDropProvider from '@components/DragAndDrop/Provider'; import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {usePersonalDetails} from '@components/OnyxProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; @@ -34,6 +35,7 @@ import reportMetadataPropTypes from '@pages/reportMetadataPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; import * as ComposerActions from '@userActions/Composer'; import * as Report from '@userActions/Report'; +import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -65,8 +67,11 @@ const propTypes = { /** The report metadata loading states */ reportMetadata: reportMetadataPropTypes, - /** Array of report actions for this report */ - reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)), + /** All the report actions for this report */ + reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), + + /** The report's parentReportAction */ + parentReportAction: PropTypes.shape(reportActionPropTypes), /** Whether the composer is full size */ isComposerFullSize: PropTypes.bool, @@ -103,7 +108,8 @@ const propTypes = { const defaultProps = { isSidebarLoaded: false, - reportActions: [], + reportActions: {}, + parentReportAction: {}, report: {}, reportMetadata: { isLoadingInitialReportActions: true, @@ -143,6 +149,7 @@ function ReportScreen({ report, reportMetadata, reportActions, + parentReportAction, accountManagerReportID, personalDetails, markReadyForHydration, @@ -180,18 +187,11 @@ function ReportScreen({ // There are no reportActions at all to display and we are still in the process of loading the next set of actions. const isLoadingInitialReportActions = _.isEmpty(reportActions) && reportMetadata.isLoadingInitialReportActions; - const isOptimisticDelete = lodashGet(report, 'statusNum') === CONST.REPORT.STATUS.CLOSED; - const shouldHideReport = !ReportUtils.canAccessReport(report, policies, betas); - const isLoading = !reportID || !isSidebarLoaded || _.isEmpty(personalDetails); - - const parentReportAction = ReportActionsUtils.getParentReportAction(report); const isSingleTransactionView = ReportUtils.isMoneyRequest(report); - const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] || {}; - const isTopMostReportId = currentReportID === getReportID(route); const didSubscribeToReportLeavingEvents = useRef(false); @@ -234,7 +234,6 @@ function ReportScreen({ policy={policy} personalDetails={personalDetails} isSingleTransactionView={isSingleTransactionView} - parentReportAction={parentReportAction} /> ); } @@ -287,14 +286,52 @@ function ReportScreen({ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(accountManagerReportID)); }, [accountManagerReportID]); + const allPersonalDetails = usePersonalDetails(); + + /** + * @param {String} text + */ + const handleCreateTask = useCallback( + (text) => { + /** + * Matching task rule by group + * Group 1: Start task rule with [] + * Group 2: Optional email group between \s+....\s* start rule with @+valid email + * Group 3: Title is remaining characters + */ + const taskRegex = /^\[\]\s+(?:@([^\s@]+@[\w.-]+\.[a-zA-Z]{2,}))?\s*([\s\S]*)/; + + const match = text.match(taskRegex); + if (!match) { + return false; + } + const title = match[2] ? match[2].trim().replace(/\n/g, ' ') : undefined; + if (!title) { + return false; + } + const email = match[1] ? match[1].trim() : undefined; + let assignee = {}; + if (email) { + assignee = _.find(_.values(allPersonalDetails), (p) => p.login === email) || {}; + } + Task.createTaskAndNavigate(getReportID(route), title, '', assignee.login, assignee.accountID, assignee.assigneeChatReport, report.policyID); + return true; + }, + [allPersonalDetails, report.policyID, route], + ); + /** * @param {String} text */ const onSubmitComment = useCallback( (text) => { + const isTaskCreated = handleCreateTask(text); + if (isTaskCreated) { + return; + } Report.addComment(getReportID(route), text); }, - [route], + [route, handleCreateTask], ); // Clear notifications for the current report when it's opened and re-focused @@ -569,6 +606,17 @@ export default compose( key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`, initialValue: false, }, + parentReportAction: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : 0}`, + selector: (parentReportActions, props) => { + const parentReportActionID = lodashGet(props, 'report.parentReportActionID'); + if (!parentReportActionID) { + return {}; + } + return parentReportActions[parentReportActionID]; + }, + canEvict: false, + }, }, true, ), diff --git a/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js b/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js index 77bcc7bdd38e..e059c2f06019 100644 --- a/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js +++ b/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js @@ -39,17 +39,17 @@ function ListBoundaryLoader({type, isLoadingOlderReportActions, isLoadingInitial // We use two different loading components for the header and footer // to reduce the jumping effect when the user is scrolling to the newer report actions if (type === CONST.LIST_COMPONENTS.FOOTER) { - if (isLoadingOlderReportActions) { - return ; - } + /* + Ensure that the report chat is not loaded until the beginning. + This is to avoid displaying the skeleton view above the "created" action in a newly generated optimistic chat or one with not that many comments. + Additionally, if we are offline and the report is not loaded until the beginning, we assume there are more actions to load; + Therefore, show the skeleton view even though the actions are not actually loading. + */ + const isReportLoadedUntilBeginning = lastReportActionName === CONST.REPORT.ACTIONS.TYPE.CREATED; + const mayLoadMoreActions = !isReportLoadedUntilBeginning && (isLoadingInitialReportActions || isOffline); - // Make sure the report chat is not loaded till the beginning. This is so we do not show the - // skeleton view above the "created" action in a newly generated optimistic chat or one with not - // that many comments. - // Also, if we are offline and the report is not yet loaded till the beginning, we assume there are more actions to load, - // therefore show the skeleton view, even though the actions are not loading. - if (lastReportActionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && (isLoadingInitialReportActions || isOffline)) { - return ; + if (isLoadingOlderReportActions || mayLoadMoreActions) { + return ; } } if (type === CONST.LIST_COMPONENTS.HEADER && isLoadingNewerReportActions) { diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.tsx similarity index 75% rename from src/pages/home/report/ReportActionItemMessageEdit.js rename to src/pages/home/report/ReportActionItemMessageEdit.tsx index dbd3262f30d5..5934c4c333cb 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -1,17 +1,17 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import lodashDebounce from 'lodash/debounce'; +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, Keyboard, View} from 'react-native'; -import _ from 'underscore'; +import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native'; +import type {Emoji} from '@assets/emojis/types'; import Composer from '@components/Composer'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import refPropTypes from '@components/refPropTypes'; import Tooltip from '@components/Tooltip'; import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; import useKeyboardState from '@hooks/useKeyboardState'; @@ -30,48 +30,37 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import setShouldShowComposeInputKeyboardAware from '@libs/setShouldShowComposeInputKeyboardAware'; -import reportPropTypes from '@pages/reportPropTypes'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import * as InputFocus from '@userActions/InputFocus'; import * as Report from '@userActions/Report'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; -import reportActionPropTypes from './reportActionPropTypes'; -const propTypes = { +type ReportActionItemMessageEditProps = { /** All the data of the action */ - action: PropTypes.shape(reportActionPropTypes).isRequired, + action: OnyxTypes.ReportAction; /** Draft message */ - draftMessage: PropTypes.string.isRequired, + draftMessage: string; /** ReportID that holds the comment we're editing */ - reportID: PropTypes.string.isRequired, + reportID: string; /** Position index of the report action in the overall report FlatList view */ - index: PropTypes.number.isRequired, - - /** A ref to forward to the text input */ - forwardedRef: refPropTypes, + index: number; /** The report currently being looked at */ // eslint-disable-next-line react/no-unused-prop-types - report: reportPropTypes, + report?: OnyxTypes.Report; /** Whether or not the emoji picker is disabled */ - shouldDisableEmojiPicker: PropTypes.bool, + shouldDisableEmojiPicker?: boolean; /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), -}; - -const defaultProps = { - forwardedRef: () => {}, - report: {}, - shouldDisableEmojiPicker: false, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, + preferredSkinTone?: number; }; // native ids @@ -80,7 +69,10 @@ const messageEditInput = 'messageEditInput'; const isMobileSafari = Browser.isMobileSafari(); -function ReportActionItemMessageEdit(props) { +function ReportActionItemMessageEdit( + {action, draftMessage, reportID, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE}: ReportActionItemMessageEditProps, + forwardedRef: ForwardedRef, +) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -90,13 +82,13 @@ function ReportActionItemMessageEdit(props) { const {isSmallScreenWidth} = useWindowDimensions(); const getInitialDraft = () => { - if (props.draftMessage === props.action.message[0].html) { + if (draftMessage === action?.message?.[0].html) { // We only convert the report action message to markdown if the draft message is unchanged. const parser = new ExpensiMark(); - return parser.htmlToMarkdown(props.draftMessage).trim(); + return parser.htmlToMarkdown(draftMessage).trim(); } // We need to decode saved draft message because it's escaped before saving. - return Str.htmlDecode(props.draftMessage); + return Str.htmlDecode(draftMessage); }; const getInitialSelection = () => { @@ -107,7 +99,7 @@ function ReportActionItemMessageEdit(props) { const length = getInitialDraft().length; return {start: length, end: length}; }; - const emojisPresentBefore = useRef([]); + const emojisPresentBefore = useRef([]); const [draft, setDraft] = useState(() => { const initialDraft = getInitialDraft(); if (initialDraft) { @@ -115,23 +107,29 @@ function ReportActionItemMessageEdit(props) { } return initialDraft; }); - const [selection, setSelection] = useState(getInitialSelection); - const [isFocused, setIsFocused] = useState(false); + const [selection, setSelection] = useState<{ + start: number; + end: number; + }>(getInitialSelection); + const [isFocused, setIsFocused] = useState(false); const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); - const [modal, setModal] = useState(false); - const [onyxFocused, setOnyxFocused] = useState(false); + const [modal, setModal] = useState({ + willAlertModalBecomeVisible: false, + isVisible: false, + }); + const [onyxFocused, setOnyxFocused] = useState(false); - const textInputRef = useRef(null); - const isFocusedRef = useRef(false); - const insertedEmojis = useRef([]); + const textInputRef = useRef<(HTMLTextAreaElement & TextInput) | null>(null); + const isFocusedRef = useRef(false); + const insertedEmojis = useRef([]); const draftRef = useRef(draft); useEffect(() => { - if (ReportActionsUtils.isDeletedAction(props.action) || props.draftMessage === props.action.message[0].html) { + if (ReportActionsUtils.isDeletedAction(action) || (action.message && draftMessage === action.message[0].html)) { return; } - setDraft(Str.htmlDecode(props.draftMessage)); - }, [props.draftMessage, props.action]); + setDraft(Str.htmlDecode(draftMessage)); + }, [draftMessage, action]); useEffect(() => { // required for keeping last state of isFocused variable @@ -139,14 +137,14 @@ function ReportActionItemMessageEdit(props) { }, [isFocused]); useEffect(() => { - InputFocus.composerFocusKeepFocusOn(textInputRef.current, isFocused, modal, onyxFocused); + InputFocus.composerFocusKeepFocusOn(textInputRef.current as HTMLElement, isFocused, modal, onyxFocused); }, [isFocused, modal, onyxFocused]); useEffect(() => { const unsubscribeOnyxModal = onyxSubscribe({ key: ONYXKEYS.MODAL, callback: (modalArg) => { - if (_.isNull(modalArg)) { + if (modalArg === null) { return; } setModal(modalArg); @@ -156,7 +154,7 @@ function ReportActionItemMessageEdit(props) { const unsubscribeOnyxFocused = onyxSubscribe({ key: ONYXKEYS.INPUT_FOCUSED, callback: (modalArg) => { - if (_.isNull(modalArg)) { + if (modalArg === null) { return; } setOnyxFocused(modalArg); @@ -170,8 +168,8 @@ function ReportActionItemMessageEdit(props) { // We consider the report action active if it's focused, its emoji picker is open or its context menu is open const isActive = useCallback( - () => isFocusedRef.current || EmojiPickerAction.isActive(props.action.reportActionID) || ReportActionContextMenu.isActiveReportAction(props.action.reportActionID), - [props.action.reportActionID], + () => isFocusedRef.current || EmojiPickerAction.isActive(action.reportActionID) || ReportActionContextMenu.isActiveReportAction(action.reportActionID), + [action.reportActionID], ); useEffect(() => { @@ -188,7 +186,9 @@ function ReportActionItemMessageEdit(props) { }); // Scroll content of textInputRef to bottom - textInputRef.current.scrollTop = textInputRef.current.scrollHeight; + if (textInputRef.current) { + textInputRef.current.scrollTop = textInputRef.current.scrollHeight; + } } return () => { @@ -200,10 +200,10 @@ function ReportActionItemMessageEdit(props) { return; } - if (EmojiPickerAction.isActive(props.action.reportActionID)) { + if (EmojiPickerAction.isActive(action.reportActionID)) { EmojiPickerAction.clearActive(); } - if (ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)) { + if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { ReportActionContextMenu.clearActiveReportAction(); } @@ -212,7 +212,7 @@ function ReportActionItemMessageEdit(props) { setShouldShowComposeInputKeyboardAware(true); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- this cleanup needs to be called only on unmount - }, [props.action.reportActionID]); + }, [action.reportActionID]); /** * Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft @@ -221,10 +221,10 @@ function ReportActionItemMessageEdit(props) { */ const debouncedSaveDraft = useMemo( () => - _.debounce((newDraft) => { - Report.saveReportActionDraft(props.reportID, props.action, newDraft); + lodashDebounce((newDraft: string) => { + Report.saveReportActionDraft(reportID, action, newDraft); }, 1000), - [props.reportID, props.action], + [reportID, action], ); /** @@ -233,7 +233,7 @@ function ReportActionItemMessageEdit(props) { */ const debouncedUpdateFrequentlyUsedEmojis = useMemo( () => - _.debounce(() => { + lodashDebounce(() => { User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojis.current)); insertedEmojis.current = []; }, 1000), @@ -246,12 +246,12 @@ function ReportActionItemMessageEdit(props) { * @param {String} newDraftInput */ const updateDraft = useCallback( - (newDraftInput) => { - const {text: newDraft, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale); + (newDraftInput: string) => { + const {text: newDraft, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, preferredSkinTone, preferredLocale); - if (!_.isEmpty(emojis)) { + if (emojis?.length > 0) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); - if (!_.isEmpty(newEmojis)) { + if (newEmojis?.length > 0) { insertedEmojis.current = [...insertedEmojis.current, ...newEmojis]; debouncedUpdateFrequentlyUsedEmojis(); } @@ -261,7 +261,7 @@ function ReportActionItemMessageEdit(props) { setDraft(newDraft); if (newDraftInput !== newDraft) { - const position = Math.max(selection.end + (newDraft.length - draftRef.current.length), cursorPosition || 0); + const position = Math.max(selection.end + (newDraft.length - draftRef.current.length), cursorPosition ?? 0); setSelection({ start: position, end: position, @@ -271,22 +271,22 @@ function ReportActionItemMessageEdit(props) { draftRef.current = newDraft; // We want to escape the draft message to differentiate the HTML from the report action and the HTML the user drafted. - debouncedSaveDraft(_.escape(newDraft)); + debouncedSaveDraft(newDraft); }, - [debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale, selection.end], + [debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, preferredSkinTone, preferredLocale, selection.end], ); useEffect(() => { updateDraft(draft); // eslint-disable-next-line react-hooks/exhaustive-deps -- run this only when language is changed - }, [props.action.reportActionID, preferredLocale]); + }, [action.reportActionID, preferredLocale]); /** * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. */ const deleteDraft = useCallback(() => { debouncedSaveDraft.cancel(); - Report.deleteReportActionDraft(props.reportID, props.action); + Report.deleteReportActionDraft(reportID, action); if (isActive()) { ReportActionComposeFocusManager.clear(); @@ -294,13 +294,13 @@ function ReportActionItemMessageEdit(props) { } // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. - if (props.index === 0) { + if (index === 0) { const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { - reportScrollManager.scrollToIndex(props.index, false); + reportScrollManager.scrollToIndex(index, false); keyboardDidHideListener.remove(); }); } - }, [props.action, debouncedSaveDraft, props.index, props.reportID, reportScrollManager, isActive]); + }, [action, debouncedSaveDraft, index, reportID, reportScrollManager, isActive]); /** * Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with @@ -320,18 +320,25 @@ function ReportActionItemMessageEdit(props) { // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. if (!trimmedNewDraft) { - textInputRef.current.blur(); - ReportActionContextMenu.showDeleteModal(props.reportID, props.action, true, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus())); + textInputRef.current?.blur(); + ReportActionContextMenu.showDeleteModal( + reportID, + action, + true, + deleteDraft, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + () => InteractionManager.runAfterInteractions(() => textInputRef.current?.focus()), + ); return; } - Report.editReportComment(props.reportID, props.action, trimmedNewDraft); + Report.editReportComment(reportID, action, trimmedNewDraft); deleteDraft(); - }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID]); + }, [action, debouncedSaveDraft, deleteDraft, draft, reportID]); /** - * @param {String} emoji + * @param emoji */ - const addEmojiToTextBox = (emoji) => { + const addEmojiToTextBox = (emoji: string) => { setSelection((prevSelection) => ({ start: prevSelection.start + emoji.length + CONST.SPACE_LENGTH, end: prevSelection.start + emoji.length + CONST.SPACE_LENGTH, @@ -345,14 +352,15 @@ function ReportActionItemMessageEdit(props) { * @param {Event} e */ const triggerSaveOrCancel = useCallback( - (e) => { + (e: NativeSyntheticEvent | KeyboardEvent) => { if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) { return; } - if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) { + const keyEvent = e as KeyboardEvent; + if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !keyEvent.shiftKey) { e.preventDefault(); publishDraft(); - } else if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { + } else if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) { e.preventDefault(); deleteDraft(); } @@ -404,11 +412,14 @@ function ReportActionItemMessageEdit(props) { { - ReportActionComposeFocusManager.editComposerRef.current = el; + ref={(el: TextInput & HTMLTextAreaElement) => { textInputRef.current = el; - // eslint-disable-next-line no-param-reassign - props.forwardedRef.current = el; + if (typeof forwardedRef === 'function') { + forwardedRef(el); + } else if (forwardedRef) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = el; + } }} id={messageEditInput} onChangeText={updateDraft} // Debounced saveDraftComment @@ -418,21 +429,22 @@ function ReportActionItemMessageEdit(props) { style={[styles.textInputCompose, styles.flex1, styles.bgTransparent]} onFocus={() => { setIsFocused(true); - reportScrollManager.scrollToIndex(props.index, true); + reportScrollManager.scrollToIndex(index, true); setShouldShowComposeInputKeyboardAware(false); // Clear active report action when another action gets focused - if (!EmojiPickerAction.isActive(props.action.reportActionID)) { + if (!EmojiPickerAction.isActive(action.reportActionID)) { EmojiPickerAction.clearActive(); } - if (!ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)) { + if (!ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { ReportActionContextMenu.clearActiveReportAction(); } }} - onBlur={(event) => { + onBlur={(event: NativeSyntheticEvent) => { setIsFocused(false); - const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id'); - if (_.contains([messageEditInput, emojiButtonID], relatedTargetId)) { + // @ts-expect-error TODO: TextInputFocusEventData doesn't contain relatedTarget. + const relatedTargetId = event.nativeEvent?.relatedTarget?.id; + if (relatedTargetId && [messageEditInput, emojiButtonID].includes(relatedTargetId)) { return; } setShouldShowComposeInputKeyboardAware(true); @@ -443,11 +455,11 @@ function ReportActionItemMessageEdit(props) { focus(true)} onEmojiSelected={addEmojiToTextBox} id={emojiButtonID} - emojiPickerID={props.action.reportActionID} + emojiPickerID={action.reportActionID} /> @@ -478,18 +490,6 @@ function ReportActionItemMessageEdit(props) { ); } -ReportActionItemMessageEdit.propTypes = propTypes; -ReportActionItemMessageEdit.defaultProps = defaultProps; ReportActionItemMessageEdit.displayName = 'ReportActionItemMessageEdit'; -const ReportActionItemMessageEditWithRef = React.forwardRef((props, ref) => ( - -)); - -ReportActionItemMessageEditWithRef.displayName = 'ReportActionItemMessageEditWithRef'; - -export default ReportActionItemMessageEditWithRef; +export default forwardRef(ReportActionItemMessageEdit); diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index fd2e5e7d8f57..dba8ef2e11d0 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -1,7 +1,7 @@ import {useRoute} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter, InteractionManager} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import _ from 'underscore'; @@ -513,4 +513,4 @@ ReportActionsList.propTypes = propTypes; ReportActionsList.defaultProps = defaultProps; ReportActionsList.displayName = 'ReportActionsList'; -export default compose(withWindowDimensions, withPersonalDetails(), withCurrentUserPersonalDetails)(memo(ReportActionsList)); +export default compose(withWindowDimensions, withPersonalDetails(), withCurrentUserPersonalDetails)(ReportActionsList); diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index f3c51cb72bbb..2758437a3962 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -1,7 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import React, {useContext, useEffect, useMemo, useRef} from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import networkPropTypes from '@components/networkPropTypes'; @@ -174,25 +174,25 @@ function ReportActionsView(props) { } }, [props.report, didSubscribeToReportTypingEvents, reportID]); - const oldestReportAction = useMemo(() => _.last(props.reportActions), [props.reportActions]); - /** * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently * displaying. */ - const loadOlderChats = useCallback(() => { + const loadOlderChats = () => { // Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline. if (props.network.isOffline || props.isLoadingOlderReportActions) { return; } + const oldestReportAction = _.last(props.reportActions); + // Don't load more chats if we're already at the beginning of the chat history if (!oldestReportAction || oldestReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { return; } // Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments Report.getOlderActions(reportID, oldestReportAction.reportActionID); - }, [props.network.isOffline, props.isLoadingOlderReportActions, oldestReportAction, reportID]); + }; /** * Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently @@ -229,7 +229,7 @@ function ReportActionsView(props) { /** * Runs when the FlatList finishes laying out */ - const recordTimeToMeasureItemLayout = useCallback(() => { + const recordTimeToMeasureItemLayout = () => { if (didLayout.current) { return; } @@ -244,7 +244,7 @@ function ReportActionsView(props) { } else { Performance.markEnd(CONST.TIMING.SWITCH_REPORT); } - }, [hasCachedActions]); + }; // Comments have not loaded at all yet do nothing if (!_.size(props.reportActions)) { diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 1b7b21d2f8a8..ffcba2048d18 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -1,6 +1,6 @@ /* eslint-disable rulesdir/onyx-props-must-have-default */ import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {InteractionManager, StyleSheet, View} from 'react-native'; import _ from 'underscore'; import LogoComponent from '@assets/images/expensify-wordmark.svg'; @@ -149,8 +149,6 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority ); const viewMode = priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT; - // eslint-disable-next-line react-hooks/exhaustive-deps - const contentContainerStyles = useMemo(() => [styles.sidebarListContainer, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}], [insets]); return ( @@ -189,7 +187,7 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority - + {() => } {shouldDisplayDistanceRequest && ( { - const trackRef = useRef(null); - const shouldShowCamera = useTabNavigatorFocus({ - tabIndex: cameraTabIndex, - }); - - const handleOnUserMedia = (stream) => { - if (props.onUserMedia) { - props.onUserMedia(stream); - } - - const [track] = stream.getVideoTracks(); - const capabilities = track.getCapabilities(); - if (capabilities.torch) { - trackRef.current = track; - } - if (onTorchAvailability) { - onTorchAvailability(!!capabilities.torch); - } - }; - - useEffect(() => { - if (!trackRef.current) { - return; - } - - trackRef.current.applyConstraints({ - advanced: [{torch: torchOn}], - }); - }, [torchOn]); - - if (!shouldShowCamera) { - return null; - } - return ( - - - - ); -}); - -NavigationAwareCamera.propTypes = propTypes; -NavigationAwareCamera.displayName = 'NavigationAwareCamera'; -NavigationAwareCamera.defaultProps = defaultProps; - -export default NavigationAwareCamera; diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js deleted file mode 100644 index 65c17d3cb7ab..000000000000 --- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js +++ /dev/null @@ -1,28 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {Camera} from 'react-native-vision-camera'; -import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus'; - -const propTypes = { - /* The index of the tab that contains this camera */ - cameraTabIndex: PropTypes.number.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, ...props}, ref) => { - const isCameraActive = useTabNavigatorFocus({tabIndex: cameraTabIndex}); - - return ( - - ); -}); - -NavigationAwareCamera.propTypes = propTypes; -NavigationAwareCamera.displayName = 'NavigationAwareCamera'; - -export default NavigationAwareCamera; diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js deleted file mode 100644 index ae871260b03e..000000000000 --- a/src/pages/iou/ReceiptSelector/index.js +++ /dev/null @@ -1,340 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useCallback, useContext, useReducer, useRef, useState} from 'react'; -import {ActivityIndicator, PanResponder, PixelRatio, Text, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import Hand from '@assets/images/hand.svg'; -import ReceiptUpload from '@assets/images/receipt-upload.svg'; -import Shutter from '@assets/images/shutter.svg'; -import AttachmentPicker from '@components/AttachmentPicker'; -import Button from '@components/Button'; -import ConfirmModal from '@components/ConfirmModal'; -import CopyTextToClipboard from '@components/CopyTextToClipboard'; -import {DragAndDropContext} from '@components/DragAndDrop/Provider'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import ImageSVG from '@components/ImageSVG'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as Browser from '@libs/Browser'; -import * as FileUtils from '@libs/fileDownload/FileUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes'; -import ReceiptDropUI from '@pages/iou/ReceiptDropUI'; -import reportPropTypes from '@pages/reportPropTypes'; -import * as IOU from '@userActions/IOU'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import NavigationAwareCamera from './NavigationAwareCamera'; - -const propTypes = { - /** The report on which the request is initiated on */ - report: reportPropTypes, - - /** React Navigation route */ - route: PropTypes.shape({ - /** Params from the route */ - params: PropTypes.shape({ - /** The type of IOU report, i.e. bill, request, send */ - iouType: PropTypes.string, - - /** The report ID of the IOU */ - reportID: PropTypes.string, - }), - - /** The current route path */ - path: PropTypes.string, - }).isRequired, - - /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ - iou: iouPropTypes, - - /** The id of the transaction we're editing */ - transactionID: PropTypes.string, -}; - -const defaultProps = { - report: {}, - iou: iouDefaultProps, - transactionID: '', -}; - -function ReceiptSelector({route, transactionID, iou, report}) { - const theme = useTheme(); - const styles = useThemeStyles(); - const iouType = lodashGet(route, 'params.iouType', ''); - const pageIndex = lodashGet(route, 'params.pageIndex', 1); - - // Grouping related states - const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); - const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); - const [attachmentInvalidReason, setAttachmentValidReason] = useState(''); - - const [receiptImageTopPosition, setReceiptImageTopPosition] = useState(0); - const {isSmallScreenWidth} = useWindowDimensions(); - const {translate} = useLocalize(); - const {isDraggingOver} = useContext(DragAndDropContext); - - const [cameraPermissionState, setCameraPermissionState] = useState('prompt'); - const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false); - const [isTorchAvailable, setIsTorchAvailable] = useState(false); - const cameraRef = useRef(null); - - const hideReciptModal = () => { - setIsAttachmentInvalid(false); - }; - - /** - * Sets the upload receipt error modal content when an invalid receipt is uploaded - * @param {*} isInvalid - * @param {*} title - * @param {*} reason - */ - const setUploadReceiptError = (isInvalid, title, reason) => { - setIsAttachmentInvalid(isInvalid); - setAttachmentInvalidReasonTitle(title); - setAttachmentValidReason(reason); - }; - - function validateReceipt(file) { - const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); - if (!CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(fileExtension.toLowerCase())) { - setUploadReceiptError(true, 'attachmentPicker.wrongFileType', 'attachmentPicker.notAllowedExtension'); - return false; - } - - if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceeded'); - return false; - } - - if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - setUploadReceiptError(true, 'attachmentPicker.attachmentTooSmall', 'attachmentPicker.sizeNotMet'); - return false; - } - - return true; - } - - /** - * Sets the Receipt objects and navigates the user to the next page - * @param {Object} file - * @param {Object} iouObject - * @param {Object} reportObject - */ - const setReceiptAndNavigate = (file, iouObject, reportObject) => { - if (!validateReceipt(file)) { - return; - } - - const filePath = URL.createObjectURL(file); - IOU.setMoneyRequestReceipt(filePath, file.name); - - if (transactionID) { - IOU.replaceReceipt(transactionID, file, filePath); - Navigation.dismissModal(); - return; - } - - IOU.navigateToNextPage(iouObject, iouType, reportObject, route.path); - }; - - const capturePhoto = useCallback(() => { - if (!cameraRef.current.getScreenshot) { - return; - } - const imageBase64 = cameraRef.current.getScreenshot(); - const filename = `receipt_${Date.now()}.png`; - const imageFile = FileUtils.base64ToFile(imageBase64, filename); - const filePath = URL.createObjectURL(imageFile); - IOU.setMoneyRequestReceipt(filePath, imageFile.name); - - if (transactionID) { - IOU.replaceReceipt(transactionID, imageFile, filePath); - Navigation.dismissModal(); - return; - } - - IOU.navigateToNextPage(iou, iouType, report, route.path); - }, [cameraRef, iou, report, iouType, transactionID, route.path]); - - const panResponder = useRef( - PanResponder.create({ - onPanResponderTerminationRequest: () => false, - }), - ).current; - - const mobileCameraView = () => ( - <> - - {(cameraPermissionState === 'prompt' || !cameraPermissionState) && ( - - )} - - {cameraPermissionState === 'denied' && ( - - - {translate('receipt.takePhoto')} - {translate('receipt.cameraAccess')} - - )} - setCameraPermissionState('granted')} - onUserMediaError={() => setCameraPermissionState('denied')} - style={{...styles.videoContainer, display: cameraPermissionState !== 'granted' ? 'none' : 'block'}} - ref={cameraRef} - screenshotFormat="image/png" - videoConstraints={{facingMode: {exact: 'environment'}}} - torchOn={isFlashLightOn} - onTorchAvailability={setIsTorchAvailable} - forceScreenshotSourceSize - cameraTabIndex={pageIndex} - /> - - - - - {({openPicker}) => ( - { - openPicker({ - onPicked: (file) => { - setReceiptAndNavigate(file, iou, report); - }, - }); - }} - > - - - )} - - - - - - - - - - ); - - const desktopUploadView = () => ( - <> - setReceiptImageTopPosition(PixelRatio.roundToNearestPixel(nativeEvent.layout.top))}> - - - - - {translate('receipt.upload')} - - {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')} - - {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')} - - - - - {({openPicker}) => ( -