diff --git a/.eslintrc.js b/.eslintrc.js index 135252825dcf..d24ea9766e19 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -100,7 +100,6 @@ module.exports = { __DEV__: 'readonly', }, rules: { - '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', @@ -167,6 +166,7 @@ module.exports = { // Rulesdir specific rules 'rulesdir/no-default-props': 'error', + 'rulesdir/prefer-type-fest': 'error', 'rulesdir/no-multiple-onyx-in-file': 'off', 'rulesdir/prefer-underscore-method': 'off', 'rulesdir/prefer-import-module-contents': 'off', @@ -235,8 +235,21 @@ module.exports = { ], }, - // Remove once no JS files are left overrides: [ + // Enforces every Onyx type and its properties to have a comment explaining its purpose. + { + files: ['src/types/onyx/**/*.ts'], + rules: { + 'jsdoc/require-jsdoc': [ + 'error', + { + contexts: ['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSPropertySignature'], + }, + ], + }, + }, + + // Remove once no JS files are left { files: ['*.js', '*.jsx'], rules: { diff --git a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts index c486fdbd39f3..5231caa79ed5 100644 --- a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts +++ b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts @@ -1,6 +1,15 @@ import * as core from '@actions/core'; import fs from 'fs'; +type RegressionEntry = { + metadata?: { + creationDate: string; + }; + name: string; + meanDuration: number; + meanCount: number; +}; + const run = () => { // Prefix path to the graphite metric const GRAPHITE_PATH = 'reassure'; @@ -24,11 +33,11 @@ const run = () => { } try { - const current = JSON.parse(entry); + const current: RegressionEntry = JSON.parse(entry); // Extract timestamp, Graphite accepts timestamp in seconds if (current.metadata?.creationDate) { - timestamp = Math.floor(new Date(current.metadata.creationDate as string).getTime() / 1000); + timestamp = Math.floor(new Date(current.metadata.creationDate).getTime() / 1000); } if (current.name && current.meanDuration && current.meanCount && timestamp) { diff --git a/.github/libs/GitUtils.ts b/.github/libs/GitUtils.ts index 684e7c76aa86..dc8ae037be28 100644 --- a/.github/libs/GitUtils.ts +++ b/.github/libs/GitUtils.ts @@ -66,11 +66,11 @@ function getCommitHistoryAsJSON(fromTag: string, toTag: string): Promise { + spawnedProcess.stdout.on('data', (chunk: Buffer) => { console.log(chunk.toString()); stdout += chunk.toString(); }); - spawnedProcess.stderr.on('data', (chunk) => { + spawnedProcess.stderr.on('data', (chunk: Buffer) => { console.error(chunk.toString()); stderr += chunk.toString(); }); diff --git a/.github/scripts/enforceRedirect.sh b/.github/scripts/enforceRedirect.sh new file mode 100755 index 000000000000..4d7d169b01c5 --- /dev/null +++ b/.github/scripts/enforceRedirect.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# HelpDot - Whenever an article is moved/renamed/deleted we should verify that +# we have added a redirect link for it in redirects.csv. This ensures that we don't have broken links. + +declare -r RED='\033[0;31m' +declare -r GREEN='\033[0;32m' +declare -r NC='\033[0m' + +declare -r ARTICLES_DIRECTORY="docs/articles" +declare -r REDIRECTS_FILE="docs/redirects.csv" + +hasRenamedOrDeletedArticle=false +hasModifiedRedirect=false + +if git log origin/main..HEAD --name-status --pretty=format: $ARTICLES_DIRECTORY | grep -q -E "^(R|D)" +then + echo "Articles have been renamed/moved/deleted" + hasRenamedOrDeletedArticle=true +fi + +if git log origin/main..HEAD --name-status --pretty=format: $REDIRECTS_FILE | grep -q -E "^(M)" +then + echo "Redirects.csv has been modified" + hasModifiedRedirect=true +fi + +if [[ $hasRenamedOrDeletedArticle == true && $hasModifiedRedirect == false ]] +then + echo -e "${RED}Articles have been renamed or deleted. Please add a redirect link for the old article links in redirects.csv${NC}" + exit 1 +fi + +echo -e "${GREEN}Articles aren't moved or deleted, or a redirect has been added. Please verify that a redirect has been added for all the files moved or deleted${NC}" +exit 0 diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index cda33d39102e..8a6237832340 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -29,9 +29,12 @@ jobs: env: IS_PR_FROM_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest + continue-on-error: true steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode @@ -42,6 +45,9 @@ jobs: - name: Check for duplicates and cycles in redirects.csv run: ./.github/scripts/verifyRedirect.sh + - name: Enforce that a redirect link has been created + run: ./.github/scripts/enforceRedirect.sh + - name: Build with Jekyll uses: actions/jekyll-build-pages@0143c158f4fa0c5dcd99499a5d00859d79f70b0e with: diff --git a/.github/workflows/testGithubActionsWorkflows.yml b/.github/workflows/testGithubActionsWorkflows.yml index d052b343cf0c..f65319f14be4 100644 --- a/.github/workflows/testGithubActionsWorkflows.yml +++ b/.github/workflows/testGithubActionsWorkflows.yml @@ -25,6 +25,13 @@ jobs: - name: Setup Homebrew uses: Homebrew/actions/setup-homebrew@master + - name: Login to GitHub Container Regstry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: OSBotify + password: ${{ secrets.GITHUB_TOKEN }} + - name: Install Act run: brew install act diff --git a/.gitignore b/.gitignore index aeee5f730bfc..dcbec8a96e46 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,11 @@ local.properties android/app/src/main/java/com/expensify/chat/generated/ .cxx/ +# VIM +*.swp +*.swo +*~ + # Vscode .vscode diff --git a/.storybook/webpack.config.ts b/.storybook/webpack.config.ts index 4d638020cd42..09d881846b1e 100644 --- a/.storybook/webpack.config.ts +++ b/.storybook/webpack.config.ts @@ -3,6 +3,7 @@ /* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/naming-convention */ +import type Environment from 'config/webpack/types'; import dotenv from 'dotenv'; import path from 'path'; import {DefinePlugin} from 'webpack'; @@ -18,6 +19,8 @@ type CustomWebpackConfig = { }; }; +type CustomWebpackFunction = ({file, platform}: Environment) => CustomWebpackConfig; + let envFile: string; switch (process.env.ENV) { case 'production': @@ -31,9 +34,9 @@ switch (process.env.ENV) { } const env = dotenv.config({path: path.resolve(__dirname, `../${envFile}`)}); -const custom: CustomWebpackConfig = require('../config/webpack/webpack.common').default({ - envFile, -}); +const customFunction: CustomWebpackFunction = require('../config/webpack/webpack.common').default; + +const custom: CustomWebpackConfig = customFunction({file: envFile}); const webpackConfig = ({config}: {config: Configuration}) => { if (!config.resolve) { diff --git a/android/app/build.gradle b/android/app/build.gradle index 3d3a4325150b..a450714056fb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -107,8 +107,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001048011 - versionName "1.4.80-11" + versionCode 1001048200 + versionName "1.4.82-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/animations/Abracadabra.lottie b/assets/animations/Abracadabra.lottie new file mode 100644 index 000000000000..8805aed1944e Binary files /dev/null and b/assets/animations/Abracadabra.lottie differ diff --git a/assets/animations/MagicCode.lottie b/assets/animations/MagicCode.lottie new file mode 100644 index 000000000000..ea94f1138f97 Binary files /dev/null and b/assets/animations/MagicCode.lottie differ diff --git a/assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg b/assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg new file mode 100644 index 000000000000..17ff47e6ca12 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 9d397b9557a3..bedd7e50ef94 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -4,14 +4,27 @@ import dotenv from 'dotenv'; import fs from 'fs'; import HtmlWebpackPlugin from 'html-webpack-plugin'; import path from 'path'; -import type {Configuration} from 'webpack'; +import type {Compiler, Configuration} from 'webpack'; import {DefinePlugin, EnvironmentPlugin, IgnorePlugin, ProvidePlugin} from 'webpack'; import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer'; import CustomVersionFilePlugin from './CustomVersionFilePlugin'; import type Environment from './types'; +// importing anything from @vue/preload-webpack-plugin causes an error +type Options = { + rel: string; + as: string; + fileWhitelist: RegExp[]; + include: string; +}; + +type PreloadWebpackPluginClass = { + new (options?: Options): PreloadWebpackPluginClass; + apply: (compiler: Compiler) => void; +}; + // require is necessary, there are no types for this package and the declaration file can't be seen by the build process which causes an error. -const PreloadWebpackPlugin = require('@vue/preload-webpack-plugin'); +const PreloadWebpackPlugin: PreloadWebpackPluginClass = require('@vue/preload-webpack-plugin'); const includeModules = [ 'react-native-animatable', diff --git a/desktop/createDownloadQueue.ts b/desktop/createDownloadQueue.ts index 132848c5da9e..4403f989263c 100644 --- a/desktop/createDownloadQueue.ts +++ b/desktop/createDownloadQueue.ts @@ -17,6 +17,11 @@ type DownloadItem = { options: Options; }; +type CreateDownloadQueue = () => { + enqueueDownloadItem: (item: DownloadItem) => void; + dequeueDownloadItem: () => DownloadItem | undefined; +}; + /** * Returns the filename with extension based on the given name and MIME type. * @param name - The name of the file. @@ -28,7 +33,7 @@ const getFilenameFromMime = (name: string, mime: string): string => { return `${name}.${extensions}`; }; -const createDownloadQueue = () => { +const createDownloadQueue: CreateDownloadQueue = () => { const downloadItemProcessor = (item: DownloadItem): Promise => new Promise((resolve, reject) => { let downloadTimeout: NodeJS.Timeout; @@ -114,3 +119,4 @@ const createDownloadQueue = () => { }; export default createDownloadQueue; +export type {DownloadItem, CreateDownloadQueue}; diff --git a/desktop/main.ts b/desktop/main.ts index 0f4774d3b73b..57ea647cc3e2 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -13,9 +13,10 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type PlatformSpecificUpdater from '@src/setup/platformSetup/types'; import type {Locale} from '@src/types/onyx'; +import type {CreateDownloadQueue, DownloadItem} from './createDownloadQueue'; import ELECTRON_EVENTS from './ELECTRON_EVENTS'; -const createDownloadQueue = require('./createDownloadQueue').default; +const createDownloadQueue: CreateDownloadQueue = require('./createDownloadQueue').default; const port = process.env.PORT ?? 8082; const {DESKTOP_SHORTCUT_ACCELERATOR, LOCALES} = CONST; @@ -617,7 +618,7 @@ const mainWindow = (): Promise => { const downloadQueue = createDownloadQueue(); ipcMain.on(ELECTRON_EVENTS.DOWNLOAD, (event, downloadData) => { - const downloadItem = { + const downloadItem: DownloadItem = { ...downloadData, win: browserWindow, }; diff --git a/docs/README.md b/docs/README.md index 224abe0554a5..235334c95732 100644 --- a/docs/README.md +++ b/docs/README.md @@ -186,9 +186,18 @@ Just update the content for each variable accordingly or remove it if the inform Assume that we want to rename the article `The Free Plan` to `Freemium Features` for the hub `billing and plan types` in New Expensify platform. 1. Go to `docs/articles/new-expensify/billing-and-plan-types` 2. Rename `The-Free-Plan.md` to `Freemium-Features.md`. Use dashes for spaces in the file name. +3. Add an entry in redirects.csv for the old article pointing to the new article. Note: It is important that the file has `.md` extension. +# How to hide an article temporarily +Video demo available [here 🧵](https://expensify.slack.com/archives/C02NK2DQWUX/p1717772272605829?thread_ts=1717523271.137469&cid=C02NK2DQWUX). +1. Open github's in built code editor by pressing `.` on your keyboard. ([instructions here](https://docs.github.com/en/codespaces/the-githubdev-web-based-editor#opening-the-githubdev-editor)) +2. Go to the article that you want to hide `docs/articles/...`. +3. Drag and drop the article inside the hidden folder `docs/Hidden/`. +4. Add a redirect for it in `docs/redirects.csv` to ensure that we don't have broken links. You can choose to point it to the home page or the article's hub. +5. Commit the changes and raise a PR. + # How the site is deployed This site is hosted on Cloudflare pages. Whenever code is merged to main, the github action `deployExpensifyHelp` will run. diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index ad738e44ab44..5fd65532c021 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -71,7 +71,7 @@ platforms: - href: integrations title: Integrations - icon: /assets/images/workflow.svg + icon: /assets/images/simple-illustration__monitor-remotesync.svg description: Integrate with accounting or HR software to streamline expense approvals. - href: spending-insights @@ -131,7 +131,7 @@ platforms: - href: connections title: Connections - icon: /assets/images/workflow.svg + icon: /assets/images/simple-illustration__monitor-remotesync.svg description: Connect to accounting software to streamline expense approvals. - href: settings diff --git a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md index 814bf8fc559b..05366a91d9fa 100644 --- a/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md +++ b/docs/articles/expensify-classic/connect-credit-cards/company-cards/Troubleshooting.md @@ -64,8 +64,9 @@ Make sure you're importing your card in the correct spot in Expensify and select Please note there are some things that cannot be bypassed within Expensify, including two-factor authentication being enabled within your bank account. This will prevent the connection from remaining stable and will need to be turned off on the bank side. ## What are the most reliable bank connections in Expensify?* -The most reliable corporate card to use with Expensify is the Expensify Visa® Commercial Card. We offer daily settlement, unapproved expense limits, and real-time compliance for secure and efficient spending, as well as 2% cash back (_Applies to USD purchases only._) Click here to learn more or apply. -Additionally, we've teamed up with major banks worldwide to ensure a smooth import of credit card transactions into your accounts. Corporate cards from the following banks also offer the most dependable connections in Expensify: +All bank connections listed below are extremely reliable, but we recommend transacting with the Expensify Visa® Commercial Card. It also offers daily and monthly settlement, unapproved expense limits, realtime compliance for secure and efficient spending, and cash back on all US purchases. [Click here to learn more about the Expensify Card](https://use.expensify.com/company-credit-card). + +We've also teamed up with major banks worldwide to ensure a smooth import of credit card transactions into your accounts: - American Express - Bank of America - Brex @@ -75,7 +76,7 @@ Additionally, we've teamed up with major banks worldwide to ensure a smooth impo - Stripe - Wells Fargo -Commercial feeds for company cards are the dependable connections in Expensify. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, MasterCard, and American Express are automatically sent to Expensify. Reach out to your banking relationship manager to check if your card program qualifies for this feature. +Commercial feeds for company cards are the dependable connections in Expensify. If you have a corporate or commercial card account, you might have access to a daily transaction feed where expenses from Visa, Mastercard, and American Express are automatically sent to Expensify. Reach out to your banking relationship manager to check if your card program qualifies for this feature. # Troubleshooting American Express Business diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md index 42d06d45fa87..411cc64eda7f 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-Small-To-Medium-Sized-Businesses.md @@ -194,14 +194,11 @@ As mentioned above, we’ll be able to pull in transactions as they post (daily) ### If you don't have a corporate card, use the Expensify Card (US only) Expensify provides a corporate card with the following features: -- Up to 2% cash back (_Applies to USD purchases only._) -- [SmartLimits](https://help.expensify.com/articles/expensify-classic/expensify-card/Cardholder-Settings-and-Features) to control what each individual cardholder can spend -- A stable, unbreakable real-time connection (third-party bank feeds can run into connectivity issues) -- Receipt compliance - informing notifications (e.g. add a receipt!) for users *as soon as the card is swiped* -- Unlimited Virtual Cards - single-purpose cards with a fixed or monthly limit for specific company purchases -- A 50% discount on the price of all Expensify plans -- Multiple discounts and savings on a host of partner tech suppliers -- Good Karma - 10% of all card interchange we earn goes directly to the Expensify.org Social Justice Community funds +- Finish your expenses in a swipe, we'll take care of everything else +- Get cash back on every US purchase and up to 50% off your monthly Expensify bill +- Stay in control with realtime alerts, spend limits, and auto-reconciliation +- Don't worry about credit checks, annual fees, or personal guarantees +- Create unlimited virtual cards with fixed or monthly limits for specific purchases The Expensify Card is recommended as the most efficient way to manage your company's spending. diff --git a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md index 30d3b3e7732c..0f6df238db5b 100644 --- a/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md +++ b/docs/articles/expensify-classic/getting-started/playbooks/Expensify-Playbook-For-US-Based-VC-Backed-Startups.md @@ -155,11 +155,12 @@ The Expensify Card has many benefits for your company. Two in particular are wor ### If you don't have a corporate card, use the Expensify Card Expensify provides a corporate card with the following features: -- Up to 2% cash back (Applies to USD purchases only) -- [SmartLimits](https://community.expensify.com/discussion/4851/deep-dive-what-are-unapproved-expense-limits#latest) -- Unlimited Virtual Cards - single-purpose cards with a fixed or monthly limit for specific company purchases -- A stable, unbreakable connection (third-party bank feeds can run into connectivity issues) - +- Finish your expenses in a swipe, we'll take care of everything else +- Get cash back on every US purchase and up to 50% off your monthly Expensify bill +- Stay in control with realtime alerts, spend limits, and auto-reconciliation +- Don't worry about credit checks, annual fees, or personal guarantees +- Create unlimited virtual cards with fixed or monthly limits for specific purchases + The Expensify Card is recommended as the most efficient way to manage your company's spending. Here’s how to enable it: diff --git a/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md b/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md index 8cf0a18ba529..96653bc69763 100644 --- a/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/expenses/Connect-a-Business-Bank-Account.md @@ -4,69 +4,51 @@ description: How to connect a business bank account to New Expensify ---
-Adding a business bank account unlocks a myriad of features and automation in Expensify, such as: -- Reimburse expenses via direct bank transfer -- Pay bills -- Collect invoice payments -- Issue the Expensify Card - -# To connect a bank account -1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account > Connect bank account** -2. Click **Connect online with Plaid** -3. Click **Continue** -4. When you reach the **Plaid** screen, you'll be shown a list of compatible banks that offer direct online login access -5. Login to the business bank account: -- If the bank is not listed, click the X to go back to the connection type -- Here you’ll see the option to **Connect Manually** -- Enter your account and routing numbers -6. Enter your bank login credentials: -- If your bank requires additional security measures, you will be directed to obtain and enter a security code -- If you have more than one account available to choose from, you will be directed to choose the desired account - -## Enter company information -This is where you’ll add the legal business name as well as several other company details. - -- **Company address**: The company address must be located in the US and a physical location (If you input a maildrop address, PO box, or UPS Store, the address will be flagged for review, and adding the bank account to Expensify will be delayed) -- **Tax Identification Number**: This is the identification number that was assigned to the business by the IRS -- **Company website**: A company website is required to use most of Expensify’s payment features. When adding the website of the business, format it as, https://www.domain.com -- **Industry Classification Code**: You can locate a list of Industry Classification Codes [here]([url](https://www.census.gov/naics/?input=software&year=2022)) - -## Enter personal information -Whoever is connecting the bank account to Expensify, must enter their details under the Requestor Information section: -- The address must be a physical address -- The address must be located in the US -- The SSN must be US-issued - -This does not need to be a signor on the bank account. If someone other than the Expensify account owner enters their personal information in this section, the details will be flagged for review, and adding the bank account to Expensify will be delayed. - -## Upload ID +To connect a bank account in New Expensify, you must first enable the Make or Track Payments Workflow. +# Step 1: Enable Make or track payments +1. Head to **Workspaces** > **More Features** > **Enable Workflows** +2. From there, a Workflows setting will appear in the left-hand menu +3. Click on **Workflows** +4. Enable **Make or track payments** + +# Step 2: Connect bank account +1. Click Connect bank account +2. Select either Connect online with Plaid (preferred) or Connect manually +3. Enter bank details + +# Step 4: Upload ID After entering your personal details, you’ll be prompted to click a link or scan a QR code so that you can do the following: 1. Upload a photo of the front and back of your ID (this cannot be a photo of an existing image) -2. Use your device to take a selfie and record a short video of yourself +2. Use your device to take a selfie and record a short video of yourself **Your ID must be:** - Issued in the US - Current (ie: the expiration date must be in the future) -## Additional Information -Check the appropriate box under **Additional Information**, accept the agreement terms, and verify that all of the information is true and accurate: -- A Beneficial Owner refers to an **individual** who owns 25% or more of the business. -- If you or another **individual** owns 25% or more of the business, please check the appropriate box +# Step 5: Enter company information +This is where you’ll add the legal business name as well as several other company details. +- **Company address:** The company address must be located in the US and a physical location (If you input a maildrop address, PO box, or UPS Store, the address will be flagged for review, and adding the bank account to Expensify will be delayed) +- **Tax Identification Number:** This is the identification number that was assigned to the business by the IRS +- **Company website:** A company website is required to use most of Expensify’s payment features. +- **Industry Classification Code:** You can locate a list of Industry Classification Codes [here](https://www.census.gov/naics/?input=software&year=2022). + +# Step 6: Additional Information +Check the appropriate box under Additional Information, accept the agreement terms, and verify that all of the information is true and accurate: +- A Beneficial Owner refers to an individual who owns 25% or more of the business. +- If you or another individual owns 25% or more of the business, please check the appropriate box - If someone else owns 25% or more of the business, you will be prompted to provide their personal information If no individual owns more than 25% of the company you do not need to list any beneficial owners. In that case, be sure to leave both boxes unchecked under the Beneficial Owner Additional Information section. -The details you submitted may require additional review. If that's the case, you'll receive a message from the Concierge outlining the next steps. Otherwise, your bank account will be connected automatically. +The details you submitted may require additional review. If that's the case, you'll receive a message from the Concierge outlining the next steps. Otherwise, your bank account will be connected automatically. {% include faq-begin.md %} ## What are the general requirements for adding a business bank account? -To add a business bank account to issue reimbursements via ACH (US), to pay invoices (US), or to issue Expensify Cards: -- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We **cannot** accept a PO Box or MailDrop location. -If you are adding the bank account to Expensify, you must do so from your Expensify account settings. -- If you are adding a bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. For using features related to US ACH, your ID must be issued by the United States. You and any Beneficial Owner (if one exists), must also have a US address -- You must have a valid website for your business to utilize the Expensify Card, or to pay invoices with Expensify. +To add a business bank account to issue reimbursements via ACH (US) or to issue Expensify Cards: +- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We cannot accept a PO Box or MailDrop location. +- If you are adding the bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. Your ID must be issued by the United States to use features related to US ACH. You and any Beneficial Owner (if one exists) must also have a US address. ## What is a Beneficial Owner? @@ -74,25 +56,27 @@ A Beneficial Owner refers to an **individual** who owns 25% or more of the busin ## What do I do if the Beneficial Owner section only asks for personal details, but my organization is owned by another company? -Please indicate you have a Beneficial Owner only if it is an individual who owns 25% or more of the business. +Please indicate you have a Beneficial Owner only if it is an individual who owns 25% or more of the business. ## Why can’t I input my address or upload my ID? -Are you entering a US address? When adding a verified business bank account in Expensify, the individual adding the account and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account and then share access with you once it is verified. +When adding a verified business bank account in Expensify, the individual adding the account and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account and then share access with you once it is verified. ## Why am I asked for documents when adding my bank account? -When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. -If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. +When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. + +If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. If you have any questions regarding the documentation request you received, please contact Concierge and they will be happy to assist. ## I don’t see all three microtransactions I need to validate my bank account. What should I do? -It's a good idea to wait until the end of that second business day. If you still don’t see them, please contact your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify." +Wait until the end of the second business day. If you still don’t see them, please contact your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify." Once that's all set, make sure to contact your account manager or concierge, and our team will be able to re-trigger those three test transactions! + {% include faq-end.md %}
diff --git a/docs/articles/new-expensify/settings/Add-profile-photo.md b/docs/articles/new-expensify/settings/Add-profile-photo.md new file mode 100644 index 000000000000..60e56deaafbc --- /dev/null +++ b/docs/articles/new-expensify/settings/Add-profile-photo.md @@ -0,0 +1,21 @@ +--- +title: Add profile photo +description: Add an image to your profile +--- +
+ +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click your profile image or icon in the bottom left menu. +2. Click the Edit pencil icon next to your profile image or icon and select **Upload Image** to choose a new image from your saved files. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap your profile image or icon at the bottom of the screen. +2. Tap the Edit pencil icon next to your profile image or icon and select **Upload Image** to choose a new image from your saved files. +{% include end-option.html %} + +{% include end-selector.html %} + +
diff --git a/docs/articles/new-expensify/travel/Book-with-Expensify-Travel.md b/docs/articles/new-expensify/travel/Book-with-Expensify-Travel.md new file mode 100644 index 000000000000..5d25670ac5ab --- /dev/null +++ b/docs/articles/new-expensify/travel/Book-with-Expensify-Travel.md @@ -0,0 +1,89 @@ +--- +title: Book with Expensify Travel +description: Book flights, hotels, cars, trains, and more with Expensify Travel +--- +
+ +Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +To book travel from the Expensify web app, + +1. Click the **Travel** tab. +2. Click **Book or manage travel**. +3. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +4. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +5. Select all the details for the arrangement you want to book. +6. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +{% include info.html %} +The travel itinerary is also emailed to the traveler’s [copilots](https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot), if applicable. +{% include end-info.html %} + +
+ +
+Expensify Travel allows members to search and book flights, hotels, cars, and trains globally at the most competitive rates available. + +With Expensify Travel, you can: +- Search and book travel arrangements all in one place +- Book travel for yourself or for someone else +- Get real-time support by chat or phone +- Manage all your T&E expenses in Expensify +- Create specific rules for booking travel +- Enable approvals for out-of-policy trips +- Book with any credit card on the market +- Book with the Expensify Card to get cash back and automatically reconcile transactions + +There is a flat fee of $15 per trip booked. A single trip can include multiple bookings, such as a flight, a hotel, and a car rental. + +# Book travel + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} +1. Click the + icon in the bottom left menu and select **Book travel**. +2. Click **Book or manage travel**. +3. Agree to the terms and conditions and click **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include option.html value="mobile" %} +1. Tap the + icon in the bottom menu and select **Book travel**. +2. Tap **Book or manage travel**. +3. Agree to the terms and conditions and tap **Continue**. +4. Use the icons at the top to select the type of travel arrangement you want to book: flights, hotels, cars, or trains. +5. Enter the travel information relevant to the travel arrangement selected (for example, the destination, dates of travel, etc.). +6. Select all the details for the arrangement you want to book. +7. Review the booking details and click **Book Flight / Book Hotel / Book Car / Book Rail** to complete the booking. +{% include end-option.html %} + +{% include end-selector.html %} + +The traveler is emailed an itinerary of the booking. Additionally, +- Their travel details are added to a Trip chat room under their primary workspace. +- An expense report for the trip is created. +- If booked with an Expensify Card, the trip is automatically reconciled. + +
diff --git a/docs/articles/new-expensify/travel/Coming-Soon.md b/docs/articles/new-expensify/travel/Coming-Soon.md deleted file mode 100644 index 4d32487a14b5..000000000000 --- a/docs/articles/new-expensify/travel/Coming-Soon.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Coming soon -description: Coming soon ---- - -# Coming soon \ No newline at end of file diff --git a/docs/assets/images/simple-illustration__monitor-remotesync.svg b/docs/assets/images/simple-illustration__monitor-remotesync.svg new file mode 100644 index 000000000000..e4ed84a35851 --- /dev/null +++ b/docs/assets/images/simple-illustration__monitor-remotesync.svg @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 1fa9fb223c60..5a5c622228d7 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.80 + 1.4.82 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.80.11 + 1.4.82.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 2796c912e560..29ca21528b16 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.80 + 1.4.82 CFBundleSignature ???? CFBundleVersion - 1.4.80.11 + 1.4.82.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 856fb25fe065..c3fd5ca6f44e 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.80 + 1.4.82 CFBundleVersion - 1.4.80.11 + 1.4.82.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index de363c211cb3..aca46d6b18ed 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1852,7 +1852,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.70): + - RNLiveMarkdown (0.1.83): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -1870,9 +1870,9 @@ PODS: - React-utils - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/common (= 0.1.70) + - RNLiveMarkdown/common (= 0.1.83) - Yoga - - RNLiveMarkdown/common (0.1.70): + - RNLiveMarkdown/common (0.1.83): - glog - hermes-engine - RCT-Folly (= 2022.05.16.00) @@ -2589,7 +2589,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 74b7b3d06d667ba0bbf41da7718f2607ae0dfe8f RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: 23250f3d64c9d5f82ff36c4733c03544af0222d2 + RNLiveMarkdown: 88030b7d9a31f5f6e67743df48ad952d64513b4a RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: df8fe93dbd251f25022f4023d31bc04160d4d65c RNPermissions: 0b61d30d21acbeafe25baaa47d9bae40a0c65216 diff --git a/jest/setup.ts b/jest/setup.ts index 174e59a7e493..416306ce8426 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -21,8 +21,8 @@ jest.mock('react-native-onyx/dist/storage', () => mockStorage); jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter'); // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise -jest.spyOn(console, 'debug').mockImplementation((...params) => { - if (params[0].indexOf('Timing:') === 0) { +jest.spyOn(console, 'debug').mockImplementation((...params: string[]) => { + if (params[0].startsWith('Timing:')) { return; } diff --git a/package-lock.json b/package-lock.json index 8558312cecb6..acf650cd8b7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "new.expensify", - "version": "1.4.80-11", + "version": "1.4.82-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.80-11", + "version": "1.4.82-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.70", + "@expensify/react-native-live-markdown": "0.1.83", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -26,7 +26,7 @@ "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", - "@kie/mock-github": "^1.0.0", + "@kie/mock-github": "2.0.1", "@onfido/react-native-sdk": "10.6.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", @@ -207,7 +207,7 @@ "electron-builder": "24.13.2", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-expensify": "^2.0.50", + "eslint-config-expensify": "^2.0.51", "eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^24.1.0", @@ -254,14 +254,6 @@ "npm": "10.7.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@actions/core": { "version": "1.10.0", "dev": true, @@ -3557,10 +3549,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.70", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.70.tgz", - "integrity": "sha512-HyqBtZyvuJFB4gIUECKIMxWCnTPlPj+GPWmw80VzMBRFV9QiFRKUKRWefNEJ1cXV5hl8a6oOWDQla+dCnjCzOQ==", + "version": "0.1.83", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.83.tgz", + "integrity": "sha512-xGn1P9FbFVueEF8BNKJJ4dQb0wPtsAvrrxND9pwVQT35ZL5cu1KZ4o6nzCqtesISPRB8Dw9Zx0ftIZy2uCQyzA==", + "workspaces": [ + "parser", + "example", + "WebExample" + ], "engines": { "node": ">= 18.0.0" }, @@ -5676,6 +5682,19 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@humanwhocodes/object-schema": { "version": "1.2.1", "dev": true, @@ -7046,48 +7065,12 @@ "act-js": "bin/act" } }, - "node_modules/@kie/act-js/node_modules/@kie/mock-github": { - "version": "2.0.0", - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@octokit/openapi-types-ghec": "^18.0.0", - "ajv": "^8.11.0", - "express": "^4.18.1", - "fast-glob": "^3.2.12", - "fs-extra": "^10.1.0", - "nock": "^13.2.7", - "simple-git": "^3.8.0", - "totalist": "^3.0.0" - } - }, - "node_modules/@kie/act-js/node_modules/@octokit/openapi-types-ghec": { - "version": "18.1.1", - "license": "MIT" - }, - "node_modules/@kie/act-js/node_modules/fs-extra": { - "version": "10.1.0", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@kie/act-js/node_modules/totalist": { - "version": "3.0.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/@kie/mock-github": { - "version": "1.1.0", - "license": "SEE LICENSE IN LICENSE", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@kie/mock-github/-/mock-github-2.0.1.tgz", + "integrity": "sha512-G1FD/jg1KyW7a6NvKI4uEVJCK3eJnzXkh4Ikxn2is5tiNC980lavi8ak6bn1QEFEgpYcfM4DpZM3yHDfOmyLuQ==", "dependencies": { - "@octokit/openapi-types-ghec": "^14.0.0", + "@octokit/openapi-types-ghec": "^18.0.0", "ajv": "^8.11.0", "express": "^4.18.1", "fast-glob": "^3.2.12", @@ -7537,8 +7520,9 @@ "license": "MIT" }, "node_modules/@octokit/openapi-types-ghec": { - "version": "14.0.0", - "license": "MIT" + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types-ghec/-/openapi-types-ghec-18.1.1.tgz", + "integrity": "sha512-5Ri7FLYX4gJSdG+G0Q8QDca/gOLfkPN4YR2hkbVg6hEL+0N62MIsJPTyNaT9pGEXCLd1KbYV6Lh3T2ggsmyBJw==" }, "node_modules/@octokit/plugin-paginate-rest": { "version": "3.1.0", @@ -19365,14 +19349,16 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.50.tgz", - "integrity": "sha512-I+OMkEprqEWlSCZGJBJxpt2Wg4HQ41/QqpKVfcADiQ3xJ76bZ1mBueqz6DR4jfph1xC6XVRl4dqGNlwbeU/2Rg==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.51.tgz", + "integrity": "sha512-qEUPCI9vsAi5c5E6zM4QEYal13hIRHFvonf4U/x0JI4ceMdAejOoq/Zvt9r1ZwKT1RmA8eRoGWWIQ/4O/9hJPg==", "dev": true, "dependencies": { "@lwc/eslint-plugin-lwc": "^1.7.2", + "@typescript-eslint/parser": "^7.12.0", + "@typescript-eslint/utils": "^7.12.0", "babel-eslint": "^10.1.0", - "eslint": "^7.32.0", + "eslint": "^8.56.0", "eslint-config-airbnb": "19.0.4", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-es": "^4.1.0", @@ -19385,6 +19371,468 @@ "underscore": "^1.13.6" } }, + "node_modules/eslint-config-expensify/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/parser": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.12.0.tgz", + "integrity": "sha512-dm/J2UDY3oV3TKius2OUZIFHsomQmpHtsV0FTh1WO8EKgHLQ1QCADUqscPgTpU+ih1e21FQSRjXckHn3txn6kQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.12.0", + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/typescript-estree": "7.12.0", + "@typescript-eslint/visitor-keys": "7.12.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/scope-manager": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.12.0.tgz", + "integrity": "sha512-itF1pTnN6F3unPak+kutH9raIkL3lhH1YRPGgt7QQOh43DQKVJXmWkpb+vpc/TiDHs6RSd9CTbDsc/Y+Ygq7kg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/visitor-keys": "7.12.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/types": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.12.0.tgz", + "integrity": "sha512-o+0Te6eWp2ppKY3mLCU+YA9pVJxhUJE15FV7kxuD9jgwIAa+w/ycGJBMrYDTpVGUM/tgpa9SeMOugSabWFq7bg==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.12.0.tgz", + "integrity": "sha512-5bwqLsWBULv1h6pn7cMW5dXX/Y2amRqLaKqsASVwbBHMZSnHqE/HN4vT4fE0aFsiwxYvr98kqOWh1a8ZKXalCQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/visitor-keys": "7.12.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/utils": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.12.0.tgz", + "integrity": "sha512-Y6hhwxwDx41HNpjuYswYp6gDbkiZ8Hin9Bf5aJQn1bpTs3afYY4GX+MPYxma8jtoIV2GRwTM/UJm/2uGCVv+DQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.12.0", + "@typescript-eslint/types": "7.12.0", + "@typescript-eslint/typescript-estree": "7.12.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/@typescript-eslint/visitor-keys": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.12.0.tgz", + "integrity": "sha512-uZk7DevrQLL3vSnfFl5bj4sL75qC9D6EdjemIdbtkuUmIheWpuiiylSY01JxJE7+zGrOWDZrp1WxOuDntvKrHQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.12.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint-config-expensify/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint-config-expensify/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint-config-expensify/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-expensify/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint-config-expensify/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-config-expensify/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-config-expensify/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-config-expensify/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint-config-expensify/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/eslint-config-expensify/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint-config-expensify/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-config-prettier": { "version": "8.10.0", "dev": true, @@ -20020,22 +20468,6 @@ "dev": true, "license": "MIT" }, - "node_modules/eslint/node_modules/optionator": { - "version": "0.9.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/eslint/node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -29492,6 +29924,23 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -35755,11 +36204,12 @@ } }, "node_modules/ts-api-utils": { - "version": "1.0.1", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", "dev": true, - "license": "MIT", "engines": { - "node": ">=16.13.0" + "node": ">=16" }, "peerDependencies": { "typescript": ">=4.2.0" @@ -37735,6 +38185,15 @@ "version": "4.0.15", "license": "MIT" }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index d05cde427bc7..844736ba5f91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.80-11", + "version": "1.4.82-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -65,7 +65,7 @@ "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.70", + "@expensify/react-native-live-markdown": "0.1.83", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -78,7 +78,7 @@ "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@kie/act-js": "^2.6.0", - "@kie/mock-github": "^1.0.0", + "@kie/mock-github": "2.0.1", "@onfido/react-native-sdk": "10.6.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", @@ -259,7 +259,7 @@ "electron-builder": "24.13.2", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", - "eslint-config-expensify": "^2.0.50", + "eslint-config-expensify": "^2.0.51", "eslint-config-prettier": "^8.8.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-jest": "^24.1.0", diff --git a/patches/@react-navigation+native+6.1.12.patch b/patches/@react-navigation+native+6.1.12.patch index d53f8677d225..0ac57a865dfd 100644 --- a/patches/@react-navigation+native+6.1.12.patch +++ b/patches/@react-navigation+native+6.1.12.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js -index 16fdbef..e660dd6 100644 +index 16fdbef..231a520 100644 --- a/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js +++ b/node_modules/@react-navigation/native/lib/module/createMemoryHistory.js @@ -1,8 +1,23 @@ @@ -68,7 +68,7 @@ index 16fdbef..e660dd6 100644 // Need to keep the hash part of the path if there was no previous history entry // or the previous history entry had the same path - let pathWithHash = path; -+ let pathWithHash = path.replace(/(\/{2,})|(\/$)/g, (match, p1) => (p1 ? '/' : '')); ++ let pathWithHash = path.replace(/(\/{2,})/g, '/'); if (!items.length || items.findIndex(item => item.id === id) < 0) { // There are two scenarios for creating an array with only one history record: // - When loaded id not found in the items array, this function by default will replace diff --git a/src/App.tsx b/src/App.tsx index 6316fa80fba1..9eda57816e9d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import {PickerStateProvider} from 'react-native-picker-select'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import '../wdyr'; import ActiveElementRoleProvider from './components/ActiveElementRoleProvider'; -import ActiveWorkspaceContextProvider from './components/ActiveWorkspace/ActiveWorkspaceProvider'; +import ActiveWorkspaceContextProvider from './components/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackground'; diff --git a/src/CONST.ts b/src/CONST.ts index 288e60af6420..1f10aabc6d5c 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -569,6 +569,7 @@ const CONST = { LICENSES_URL: `${USE_EXPENSIFY_URL}/licenses`, ACH_TERMS_URL: `${USE_EXPENSIFY_URL}/achterms`, WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/walletagreement`, + BANCORP_WALLET_AGREEMENT_URL: `${USE_EXPENSIFY_URL}/bancorp-bank-wallet-terms-of-service`, HELP_LINK_URL: `${USE_EXPENSIFY_URL}/usa-patriot-act`, ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, GITHUB_RELEASE_URL: 'https://api.github.com/repos/expensify/app/releases/latest', @@ -597,7 +598,6 @@ const CONST = { ADMIN_POLICIES_URL: 'admin_policies', ADMIN_DOMAINS_URL: 'admin_domains', INBOX: 'inbox', - DISMMISSED_REASON: '?dismissedReason=missingFeatures', }, SIGN_IN_FORM_WIDTH: 300, @@ -1429,6 +1429,7 @@ const CONST = { }, STEP: { // In the order they appear in the Wallet flow + ADD_BANK_ACCOUNT: 'AddBankAccountStep', ADDITIONAL_DETAILS: 'AdditionalDetailsStep', ADDITIONAL_DETAILS_KBA: 'AdditionalDetailsKBAStep', ONFIDO: 'OnfidoStep', @@ -1465,6 +1466,7 @@ const CONST = { CONCIERGE: 'CONCIERGE_NAVIGATE', }, MTL_WALLET_PROGRAM_ID: '760', + BANCORP_WALLET_PROGRAM_ID: '660', PROGRAM_ISSUERS: { EXPENSIFY_PAYMENTS: 'Expensify Payments LLC', BANCORP_BANK: 'The Bancorp Bank', @@ -1580,7 +1582,7 @@ const CONST = { APPROVE: 'approve', TRACK: 'track', }, - AMOUNT_MAX_LENGTH: 10, + AMOUNT_MAX_LENGTH: 8, RECEIPT_STATE: { SCANREADY: 'SCANREADY', OPEN: 'OPEN', @@ -1829,7 +1831,7 @@ const CONST = { NAME_DISTANCE: 'Distance', DISTANCE_UNIT_MILES: 'mi', DISTANCE_UNIT_KILOMETERS: 'km', - MILEAGE_IRS_RATE: 0.655, + MILEAGE_IRS_RATE: 0.67, DEFAULT_RATE: 'Default Rate', RATE_DECIMALS: 3, FAKE_P2P_ID: '_FAKE_P2P_ID_', @@ -3394,6 +3396,11 @@ const CONST = { * @deprecated Please stop using the accessibilityRole prop and use the role prop instead. */ IMAGE: 'image', + + /** + * @deprecated Please stop using the accessibilityRole prop and use the role prop instead. + */ + TEXTBOX: 'textbox', }, /** * Acceptable values for the `role` attribute on react native components. @@ -3873,10 +3880,10 @@ const CONST = { type: 'meetGuide', autoCompleted: false, title: 'Meet your setup specialist', - description: ({adminsRoomLink, guideCalendarLink}: {adminsRoomLink: string; guideCalendarLink: string}) => + description: ({adminsRoomLink}: {adminsRoomLink: string}) => `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + '\n' + - `Chat with the specialist in your [#admins room](${adminsRoomLink}) or [schedule a call](${guideCalendarLink}) today.`, + `Chat with the specialist in your [#admins room](${adminsRoomLink}).`, }, { type: 'setupCategories', @@ -4753,6 +4760,7 @@ const CONST = { SESSION_STORAGE_KEYS: { INITIAL_URL: 'INITIAL_URL', + ACTIVE_WORKSPACE_ID: 'ACTIVE_WORKSPACE_ID', }, RESERVATION_TYPE: { @@ -4816,6 +4824,14 @@ const CONST = { SUBSCRIPTION_SIZE_LIMIT: 20000, + PAYMENT_CARD_CURRENCY: { + USD: 'USD', + AUD: 'AUD', + GBP: 'GBP', + NZD: 'NZD', + }, + + SUBSCRIPTION_PRICE_FACTOR: 2, SUBSCRIPTION_POSSIBLE_COST_SAVINGS: { COLLECT_PLAN: 10, CONTROL_PLAN: 18, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 85744329d487..0d22d3714fe6 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -630,6 +630,8 @@ type OnyxValuesMapping = { [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf; [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge; + + // The value of this nvp is a string representation of the date when the block expires, or an empty string if the user is not blocked [ONYXKEYS.NVP_BLOCKED_FROM_CHAT]: string; [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string; [ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ed20d388bb87..eb3b439ea1ff 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -140,12 +140,7 @@ const ROUTES = { }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', - SETTINGS_ADD_BANK_ACCOUNT_REFACTOR: 'settings/wallet/add-bank-account-refactor', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - SETTINGS_ENABLE_PAYMENTS_REFACTOR: 'settings/wallet/enable-payments-refactor', - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - SETTINGS_ENABLE_PAYMENTS_TEMPORARY_TERMS: 'settings/wallet/enable-payments-temporary-terms', SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS: { route: 'settings/wallet/card/:domain/digital-details/update-address', getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9f7277a0ad0f..f884cca94ef5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -42,7 +42,6 @@ const SCREENS = { APP_DOWNLOAD_LINKS: 'Settings_App_Download_Links', ADD_DEBIT_CARD: 'Settings_Add_Debit_Card', ADD_BANK_ACCOUNT: 'Settings_Add_Bank_Account', - ADD_BANK_ACCOUNT_REFACTOR: 'Settings_Add_Bank_Account_Refactor', CLOSE: 'Settings_Close', TWO_FACTOR_AUTH: 'Settings_TwoFactorAuth', REPORT_CARD_LOST_OR_DAMAGED: 'Settings_ReportCardLostOrDamaged', @@ -89,10 +88,6 @@ const SCREENS = { TRANSFER_BALANCE: 'Settings_Wallet_Transfer_Balance', CHOOSE_TRANSFER_ACCOUNT: 'Settings_Wallet_Choose_Transfer_Account', ENABLE_PAYMENTS: 'Settings_Wallet_EnablePayments', - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - ENABLE_PAYMENTS_REFACTOR: 'Settings_Wallet_EnablePayments_Refactor', - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - ENABLE_PAYMENTS_TEMPORARY_TERMS: 'Settings_Wallet_EnablePayments_Temporary_Terms', CARD_ACTIVATE: 'Settings_Wallet_Card_Activate', REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud', CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address', diff --git a/src/TIMEZONES.ts b/src/TIMEZONES.ts index 238563134872..69ef89e7467e 100644 --- a/src/TIMEZONES.ts +++ b/src/TIMEZONES.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ // All timezones were taken from: https://raw.githubusercontent.com/leon-do/Timezones/main/timezone.json +import type {TupleToUnion} from 'type-fest'; + const TIMEZONES = [ 'Africa/Abidjan', 'Africa/Accra', @@ -425,7 +427,7 @@ const TIMEZONES = [ * The timezones supported in browser and on native devices differ, so we must map each timezone to its supported equivalent. * Data sourced from https://en.wikipedia.org/wiki/List_of_tz_database_time_zones */ -const timezoneBackwardMap: Record = { +const timezoneBackwardMap: Record> = { 'Africa/Asmera': 'Africa/Nairobi', 'Africa/Timbuktu': 'Africa/Abidjan', 'America/Argentina/ComodRivadavia': 'America/Argentina/Catamarca', diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx b/src/components/ActiveWorkspaceProvider/index.tsx similarity index 80% rename from src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx rename to src/components/ActiveWorkspaceProvider/index.tsx index 884b9a2a2d95..bc7260cdf10b 100644 --- a/src/components/ActiveWorkspace/ActiveWorkspaceProvider.tsx +++ b/src/components/ActiveWorkspaceProvider/index.tsx @@ -1,6 +1,6 @@ import React, {useMemo, useState} from 'react'; +import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import ActiveWorkspaceContext from './ActiveWorkspaceContext'; function ActiveWorkspaceContextProvider({children}: ChildrenProps) { const [activeWorkspaceID, setActiveWorkspaceID] = useState(undefined); @@ -10,7 +10,7 @@ function ActiveWorkspaceContextProvider({children}: ChildrenProps) { activeWorkspaceID, setActiveWorkspaceID, }), - [activeWorkspaceID], + [activeWorkspaceID, setActiveWorkspaceID], ); return {children}; diff --git a/src/components/ActiveWorkspaceProvider/index.website.tsx b/src/components/ActiveWorkspaceProvider/index.website.tsx new file mode 100644 index 000000000000..82e46d70f896 --- /dev/null +++ b/src/components/ActiveWorkspaceProvider/index.website.tsx @@ -0,0 +1,29 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; +import CONST from '@src/CONST'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +function ActiveWorkspaceContextProvider({children}: ChildrenProps) { + const [activeWorkspaceID, updateActiveWorkspaceID] = useState(undefined); + + const setActiveWorkspaceID = useCallback((workspaceID: string | undefined) => { + updateActiveWorkspaceID(workspaceID); + if (workspaceID && sessionStorage) { + sessionStorage?.setItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID, workspaceID); + } else { + sessionStorage?.removeItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID); + } + }, []); + + const value = useMemo( + () => ({ + activeWorkspaceID, + setActiveWorkspaceID, + }), + [activeWorkspaceID, setActiveWorkspaceID], + ); + + return {children}; +} + +export default ActiveWorkspaceContextProvider; diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index 366f14ec9780..eaefa3c5581c 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -18,7 +18,6 @@ import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlocking import FormHelpMessage from './FormHelpMessage'; import Icon from './Icon'; import getBankIcon from './Icon/BankIcons'; -import Picker from './Picker'; import PlaidLink from './PlaidLink'; import RadioButtons from './RadioButtons'; import Text from './Text'; @@ -59,9 +58,6 @@ type AddPlaidBankAccountProps = AddPlaidBankAccountOnyxProps & { /** Are we adding a withdrawal account? */ allowDebit?: boolean; - /** Is displayed in new VBBA */ - isDisplayedInNewVBBA?: boolean; - /** Is displayed in new enable wallet flow */ isDisplayedInWalletFlow?: boolean; @@ -84,7 +80,6 @@ function AddPlaidBankAccount({ bankAccountID = 0, allowDebit = false, isPlaidDisabled, - isDisplayedInNewVBBA = false, errorText = '', onInputChange = () => {}, isDisplayedInWalletFlow = false, @@ -259,62 +254,32 @@ function AddPlaidBankAccount({ return {renderPlaidLink()}; } - if (isDisplayedInNewVBBA || isDisplayedInWalletFlow) { - return ( - - {translate(isDisplayedInWalletFlow ? 'walletPage.chooseYourBankAccount' : 'bankAccount.chooseAnAccount')} - {!!text && {text}} - - - - {bankName} - {selectedPlaidAccountMask.length > 0 && ( - {`${translate('bankAccount.accountEnding')} ${selectedPlaidAccountMask}`} - )} - - - {`${translate('bankAccount.chooseAnAccountBelow')}:`} - - - - ); - } - - // Plaid bank accounts view return ( - {!!text && {text}} - + {translate(isDisplayedInWalletFlow ? 'walletPage.chooseYourBankAccount' : 'bankAccount.chooseAnAccount')} + {!!text && {text}} + - {bankName} - - - + + {bankName} + {selectedPlaidAccountMask.length > 0 && ( + {`${translate('bankAccount.accountEnding')} ${selectedPlaidAccountMask}`} + )} + + {`${translate('bankAccount.chooseAnAccountBelow')}:`} + + ); } diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index a102b715d526..3319a28c58b9 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -12,6 +12,7 @@ import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; import BigNumberPad from './BigNumberPad'; import FormHelpMessage from './FormHelpMessage'; +import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; import type TextInputWithCurrencySymbolProps from './TextInputWithCurrencySymbol/types'; @@ -94,7 +95,7 @@ function AmountForm( if (!textInput.current) { return; } - if (!textInput.current.isFocused()) { + if (!isTextInputFocused(textInput)) { textInput.current.focus(); } }; @@ -143,7 +144,7 @@ function AmountForm( */ const updateAmountNumberPad = useCallback( (key: string) => { - if (shouldUpdateSelection && !textInput.current?.isFocused()) { + if (shouldUpdateSelection && !isTextInputFocused(textInput)) { textInput.current?.focus(); } // Backspace button is pressed @@ -168,7 +169,7 @@ function AmountForm( */ const updateLongPressHandlerState = useCallback((value: boolean) => { setShouldUpdateSelection(!value); - if (!value && !textInput.current?.isFocused()) { + if (!value && !isTextInputFocused(textInput)) { textInput.current?.focus(); } }, []); diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index e5eb09691eba..e641a0c2218a 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -68,7 +68,7 @@ function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) { / {secondaryBreadcrumb.text} diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index 2fbd635ed42e..5e5f126da773 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -28,6 +28,7 @@ function Composer( // On Android the selection prop is required on the TextInput but this prop has issues on IOS selection, value, + isGroupPolicyReport = false, ...props }: ComposerProps, ref: ForwardedRef, @@ -36,7 +37,7 @@ function Composer( const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); const theme = useTheme(); - const markdownStyle = useMarkdownStyle(value); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? ['mentionReport'] : []); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 14762b2d4bc1..eae27440d175 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -71,6 +71,7 @@ function Composer( isReportActionCompose = false, isComposerFullSize = false, shouldContainScroll = false, + isGroupPolicyReport = false, ...props }: ComposerProps, ref: ForwardedRef, @@ -78,7 +79,7 @@ function Composer( const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); const theme = useTheme(); const styles = useThemeStyles(); - const markdownStyle = useMarkdownStyle(value); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? ['mentionReport'] : []); const StyleUtils = useStyleUtils(); const textRef = useRef(null); const textInput = useRef(null); diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 531bcd03f8bf..0ff91111bd07 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -70,6 +70,9 @@ type ComposerProps = TextInputProps & { /** Should make the input only scroll inside the element avoid scroll out to parent */ shouldContainScroll?: boolean; + + /** Indicates whether the composer is in a group policy report. Used for disabling report mentioning style in markdown input */ + isGroupPolicyReport?: boolean; }; export type {TextSelection, ComposerProps}; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index 9d2cd4f60994..5a59585ad72e 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -7,6 +7,7 @@ import {scrollTo} from 'react-native-reanimated'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useLocalize from '@hooks/useLocalize'; @@ -86,7 +87,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r } // If the input is not focused and the new index is out of range, focus the input - if (newIndex < 0 && !searchInputRef.current?.isFocused() && shouldFocusInputOnScreenFocus) { + if (newIndex < 0 && !isTextInputFocused(searchInputRef) && shouldFocusInputOnScreenFocus) { searchInputRef.current?.focus(); } }, @@ -165,12 +166,12 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r // Enable keyboard movement if tab or enter is pressed or if shift is pressed while the input // is not focused, so that the navigation and tab cycling can be done using the keyboard without // interfering with the input behaviour. - if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !searchInputRef.current.isFocused())) { + if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !isTextInputFocused(searchInputRef))) { setIsUsingKeyboardMovement(true); } // We allow typing in the search box if any key is pressed apart from Arrow keys. - if (searchInputRef.current && !searchInputRef.current.isFocused() && ReportUtils.shouldAutoFocusOnKeyPress(keyBoardEvent)) { + if (searchInputRef.current && !isTextInputFocused(searchInputRef) && ReportUtils.shouldAutoFocusOnKeyPress(keyBoardEvent)) { searchInputRef.current.focus(); } }, diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 51f9981f1524..d95fc9e11a31 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -77,8 +77,13 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim mixedUAStyles: {...styles.textSupporting, ...styles.textLineThrough}, contentModel: HTMLContentModel.textual, }), + 'uploading-attachment': HTMLElementModel.fromCustomModel({ + tagName: 'uploading-attachment', + mixedUAStyles: {...styles.mt4}, + contentModel: HTMLContentModel.block, + }), }), - [styles.formError, styles.mb0, styles.colorMuted, styles.textLabelSupporting, styles.lh16, styles.textSupporting, styles.textLineThrough], + [styles.formError, styles.mb0, styles.colorMuted, styles.textLabelSupporting, styles.lh16, styles.textSupporting, styles.textLineThrough, styles.mt4], ); /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/components/Hoverable/ActiveHoverable.tsx b/src/components/Hoverable/ActiveHoverable.tsx index dd3f41c52578..b58433afb17c 100644 --- a/src/components/Hoverable/ActiveHoverable.tsx +++ b/src/components/Hoverable/ActiveHoverable.tsx @@ -8,6 +8,8 @@ import type HoverableProps from './types'; type ActiveHoverableProps = Omit; +type OnMouseEvent = (e: MouseEvent) => void; + function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreezeCapture, children}: ActiveHoverableProps, outerRef: Ref) { const [isHovered, setIsHovered] = useState(false); @@ -98,9 +100,10 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreez const child = useMemo(() => getReturnValue(children, !isScrollingRef.current && isHovered), [children, isHovered]); - const childOnMouseEnter = child.props.onMouseEnter; - const childOnMouseLeave = child.props.onMouseLeave; - const childOnMouseMove = child.props.onMouseMove; + const childOnMouseEnter: OnMouseEvent = child.props.onMouseEnter; + const childOnMouseLeave: OnMouseEvent = child.props.onMouseLeave; + const childOnMouseMove: OnMouseEvent = child.props.onMouseMove; + const childOnBlur: OnMouseEvent = child.props.onBlur; const hoverAndForwardOnMouseEnter = useCallback( (e: MouseEvent) => { @@ -127,9 +130,9 @@ function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, shouldFreez setIsHovered(false); } - child.props.onBlur?.(event); + childOnBlur?.(event); }, - [child.props], + [childOnBlur], ); const handleAndForwardOnMouseMove = useCallback( diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 53b8aa8acb72..3fe36239d631 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -49,6 +49,7 @@ import CompanyCard from '@assets/images/simple-illustrations/simple-illustration import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustration__concierge-bubble.svg'; import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg'; import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; +import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg'; import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; @@ -190,4 +191,5 @@ export { ExpensifyApprovedLogoLight, SendMoney, CheckmarkCircle, + CreditCardEyes, }; diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 6405e3026b1a..6ffb2a5a92ed 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -4,7 +4,7 @@ import {FlashList} from '@shopify/flash-list'; import type {ReactElement} from 'react'; import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {StyleSheet, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import BlockingView from '@components/BlockingViews/BlockingView'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -23,35 +23,27 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import OptionRowLHNData from './OptionRowLHNData'; -import type {LHNOptionsListOnyxProps, LHNOptionsListProps, RenderItemProps} from './types'; +import type {LHNOptionsListProps, RenderItemProps} from './types'; const keyExtractor = (item: string) => `report_${item}`; -function LHNOptionsList({ - style, - contentContainerStyles, - data, - onSelectRow, - optionMode, - shouldDisableFocusOptions = false, - reports = {}, - reportActions = {}, - policy = {}, - preferredLocale = CONST.LOCALES.DEFAULT, - personalDetails = {}, - transactions = {}, - draftComments = {}, - transactionViolations = {}, - onFirstItemRendered = () => {}, -}: LHNOptionsListProps) { +function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optionMode, shouldDisableFocusOptions = false, onFirstItemRendered = () => {}}: LHNOptionsListProps) { const {saveScrollOffset, getScrollOffset} = useContext(ScrollOffsetContext); const flashListRef = useRef>(null); const route = useRoute(); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [policy] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const theme = useTheme(); const styles = useThemeStyles(); const {canUseViolations} = usePermissions(); - const {translate} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const shouldShowEmptyLHN = shouldUseNarrowLayout && data.length === 0; @@ -165,8 +157,8 @@ function LHNOptionsList({ ); const extraData = useMemo( - () => [reportActions, reports, policy, personalDetails, data.length, draftComments], - [reportActions, reports, policy, personalDetails, data.length, draftComments], + () => [reportActions, reports, policy, personalDetails, data.length, draftComments, optionMode], + [reportActions, reports, policy, personalDetails, data.length, draftComments, optionMode], ); const previousOptionMode = usePrevious(optionMode); @@ -246,31 +238,6 @@ function LHNOptionsList({ LHNOptionsList.displayName = 'LHNOptionsList'; -export default withOnyx({ - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - reportActions: { - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - }, - policy: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - preferredLocale: { - key: ONYXKEYS.NVP_PREFERRED_LOCALE, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - transactions: { - key: ONYXKEYS.COLLECTION.TRANSACTION, - }, - draftComments: { - key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, - }, - transactionViolations: { - key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, - }, -})(memo(LHNOptionsList)); +export default memo(LHNOptionsList); export type {LHNOptionsListProps}; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 851e2a9fdd16..1aa3fe47212d 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -221,7 +221,8 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti !!optionItem.isThread || !!optionItem.isMoneyRequestReport || !!optionItem.isInvoiceReport || - ReportUtils.isGroupChat(report) + ReportUtils.isGroupChat(report) || + ReportUtils.isSystemChat(report) } /> {isStatusVisible && ( diff --git a/src/components/LHNOptionsList/types.ts b/src/components/LHNOptionsList/types.ts index 0f0c921747b4..7248742654d2 100644 --- a/src/components/LHNOptionsList/types.ts +++ b/src/components/LHNOptionsList/types.ts @@ -10,32 +10,6 @@ import type {EmptyObject} from '@src/types/utils/EmptyObject'; type OptionMode = ValueOf; -type LHNOptionsListOnyxProps = { - /** The policy which the user has access to and which the report could be tied to */ - policy: OnyxCollection; - - /** All reports shared with the user */ - reports: OnyxCollection; - - /** Array of report actions for this report */ - reportActions: OnyxCollection; - - /** Indicates which locale the user currently has selected */ - preferredLocale: OnyxEntry; - - /** List of users' personal details */ - personalDetails: OnyxEntry; - - /** The transaction from the parent report action */ - transactions: OnyxCollection; - - /** List of draft comments */ - draftComments: OnyxCollection; - - /** The list of transaction violations */ - transactionViolations: OnyxCollection; -}; - type CustomLHNOptionsListProps = { /** Wrapper style for the section list */ style?: StyleProp; @@ -59,7 +33,7 @@ type CustomLHNOptionsListProps = { onFirstItemRendered: () => void; }; -type LHNOptionsListProps = CustomLHNOptionsListProps & LHNOptionsListOnyxProps; +type LHNOptionsListProps = CustomLHNOptionsListProps; type OptionRowLHNDataProps = { /** Whether row should be focused */ @@ -141,4 +115,4 @@ type OptionRowLHNProps = { type RenderItemProps = {item: string}; -export type {LHNOptionsListProps, OptionRowLHNDataProps, OptionRowLHNProps, LHNOptionsListOnyxProps, RenderItemProps}; +export type {LHNOptionsListProps, OptionRowLHNDataProps, OptionRowLHNProps, RenderItemProps}; diff --git a/src/components/LottieAnimations/index.tsx b/src/components/LottieAnimations/index.tsx index 598819e19361..00089f8094f1 100644 --- a/src/components/LottieAnimations/index.tsx +++ b/src/components/LottieAnimations/index.tsx @@ -3,6 +3,11 @@ import variables from '@styles/variables'; import type DotLottieAnimation from './types'; const DotLottieAnimations = { + Abracadabra: { + file: require('@assets/animations/Abracadabra.lottie'), + w: 375, + h: 400, + }, FastMoney: { file: require('@assets/animations/FastMoney.lottie'), w: 375, @@ -46,6 +51,11 @@ const DotLottieAnimations = { h: 400, backgroundColor: colors.ice500, }, + MagicCode: { + file: require('@assets/animations/MagicCode.lottie'), + w: 200, + h: 164, + }, Magician: { file: require('@assets/animations/Magician.lottie'), w: 853, diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 554ae9d4a84a..ce5507ee0e4e 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -207,7 +207,7 @@ type MenuItemBaseProps = { shouldStackHorizontally?: boolean; /** Prop to represent the size of the avatar images to be shown */ - avatarSize?: (typeof CONST.AVATAR_SIZE)[keyof typeof CONST.AVATAR_SIZE]; + avatarSize?: ValueOf; /** Avatars to show on the right of the menu item */ floatRightAvatars?: IconType[]; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index ec52a6158ad7..d3cf50827cec 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; +import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -17,6 +18,7 @@ import * as TransactionActions from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -27,6 +29,7 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; +import type {ActionHandledType} from './ProcessMoneyReportHoldMenu'; import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu'; import SettlementButton from './SettlementButton'; @@ -79,13 +82,14 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || ReportUtils.isTrackExpenseReport(transactionThreadReport)) && !isDeletedParentAction; const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [paymentType, setPaymentType] = useState(); - const [requestType, setRequestType] = useState<'pay' | 'approve'>(); + const [requestType, setRequestType] = useState(); const canAllowSettlement = ReportUtils.hasUpdatedTotal(moneyRequestReport, policy); const policyType = policy?.type; const isPayer = ReportUtils.isPayer(session, moneyRequestReport); const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport); const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); + const navigateBackToAfterDelete = useRef(); const hasScanningReceipt = ReportUtils.getTransactionsWithReceipts(moneyRequestReport?.reportID).some((transaction) => TransactionUtils.isReceiptBeingScanned(transaction)); const transactionIDs = TransactionUtils.getAllReportTransactions(moneyRequestReport?.reportID).map((transaction) => transaction.transactionID); const allHavePendingRTERViolation = TransactionUtils.allHavePendingRTERViolation(transactionIDs); @@ -124,7 +128,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea return; } setPaymentType(type); - setRequestType('pay'); + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) { setIsHoldMenuVisible(true); } else if (ReportUtils.isInvoiceReport(moneyRequestReport)) { @@ -135,7 +139,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea }; const confirmApproval = () => { - setRequestType('approve'); + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) { setIsHoldMenuVisible(true); } else { @@ -147,10 +151,10 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea if (requestParentReportAction) { const iouTransactionID = requestParentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? requestParentReportAction.originalMessage?.IOUTransactionID ?? '' : ''; if (ReportActionsUtils.isTrackExpenseAction(requestParentReportAction)) { - IOU.deleteTrackExpense(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, true); - return; + navigateBackToAfterDelete.current = IOU.deleteTrackExpense(moneyRequestReport?.reportID ?? '', iouTransactionID, requestParentReportAction, true); + } else { + navigateBackToAfterDelete.current = IOU.deleteMoneyRequest(iouTransactionID, requestParentReportAction, true); } - IOU.deleteMoneyRequest(iouTransactionID, requestParentReportAction, true); } setIsDeleteRequestModalVisible(false); @@ -366,6 +370,12 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea isVisible={isDeleteRequestModalVisible} onConfirm={deleteTransaction} onCancel={() => setIsDeleteRequestModalVisible(false)} + onModalHide={() => { + if (!navigateBackToAfterDelete.current) { + return; + } + Navigation.goBack(navigateBackToAfterDelete.current); + }} prompt={translate('iou.deleteConfirmation')} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 1cccfdc720b3..0c3868312c41 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -7,7 +7,9 @@ import * as Browser from '@libs/Browser'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import getOperatingSystem from '@libs/getOperatingSystem'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import shouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually'; import CONST from '@src/CONST'; +import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; @@ -140,6 +142,8 @@ function MoneyRequestAmountInput( }); const forwardDeletePressedRef = useRef(false); + // The ref is used to ignore any onSelectionChange event that happens while we are updating the selection manually in setNewAmount + const willSelectionBeUpdatedManually = useRef(false); /** * Sets the selection and the amount accordingly to the value passed to the input @@ -162,6 +166,7 @@ function MoneyRequestAmountInput( // setCurrentAmount contains another setState(setSelection) making it error-prone since it is leading to setSelection being called twice for a single setCurrentAmount call. This solution introducing the hasSelectionBeenSet flag was chosen for its simplicity and lower risk of future errors https://github.com/Expensify/App/issues/23300#issuecomment-1766314724. + willSelectionBeUpdatedManually.current = true; let hasSelectionBeenSet = false; setCurrentAmount((prevAmount) => { const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(finalAmount); @@ -169,6 +174,7 @@ function MoneyRequestAmountInput( if (!hasSelectionBeenSet) { hasSelectionBeenSet = true; setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : prevAmount.length, strippedAmount.length)); + willSelectionBeUpdatedManually.current = false; } onAmountChange?.(strippedAmount); return strippedAmount; @@ -196,7 +202,7 @@ function MoneyRequestAmountInput( })); useEffect(() => { - if (!currency || typeof amount !== 'number' || (formatAmountOnBlur && textInput.current?.isFocused()) || shouldKeepUserInput) { + if ((!currency || typeof amount !== 'number' || (formatAmountOnBlur && isTextInputFocused(textInput))) ?? shouldKeepUserInput) { return; } const frontendAmount = onFormatAmount(amount, currency); @@ -294,6 +300,10 @@ function MoneyRequestAmountInput( selectedCurrencyCode={currency} selection={selection} onSelectionChange={(e: NativeSyntheticEvent) => { + if (shouldIgnoreSelectionWhenUpdatedManually && willSelectionBeUpdatedManually.current) { + willSelectionBeUpdatedManually.current = false; + return; + } if (!shouldUpdateSelection) { return; } diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 929a464a7180..e7c799fea3b6 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -1280,6 +1280,7 @@ function MoneyRequestConfirmationList({ footerContent={footerContent} listFooterContent={listFooterContent} containerStyle={[styles.flexBasisAuto]} + removeClippedSubviews={false} /> ); diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index e5a61a8fa23c..5d2395d4a8ec 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -1,5 +1,5 @@ import type {ReactNode} from 'react'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -18,6 +18,7 @@ import * as TransactionActions from '@userActions/Transaction'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type {Policy, Report, ReportAction} from '@src/types/onyx'; import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -67,7 +68,9 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow const isDuplicate = TransactionUtils.isDuplicate(transaction?.transactionID ?? ''); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); - // Only the requestor can take delete the expense, admins can only edit it. + const navigateBackToAfterDelete = useRef(); + + // Only the requestor can take delete the request, admins can only edit it. const isActionOwner = typeof parentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && parentReportAction.actorAccountID === session?.accountID; const isPolicyAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN; const isApprover = ReportUtils.isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && session?.accountID === moneyRequestReport?.managerID; @@ -78,10 +81,10 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow if (parentReportAction) { const iouTransactionID = parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage?.IOUTransactionID ?? '' : ''; if (ReportActionsUtils.isTrackExpenseAction(parentReportAction)) { - IOU.deleteTrackExpense(parentReport?.reportID ?? '', iouTransactionID, parentReportAction, true); - return; + navigateBackToAfterDelete.current = IOU.deleteTrackExpense(parentReport?.reportID ?? '', iouTransactionID, parentReportAction, true); + } else { + navigateBackToAfterDelete.current = IOU.deleteMoneyRequest(iouTransactionID, parentReportAction, true); } - IOU.deleteMoneyRequest(iouTransactionID, parentReportAction, true); } setIsDeleteModalVisible(false); @@ -273,6 +276,12 @@ function MoneyRequestHeader({report, parentReportAction, policy, shouldUseNarrow isVisible={isDeleteModalVisible} onConfirm={deleteTransaction} onCancel={() => setIsDeleteModalVisible(false)} + onModalHide={() => { + if (!navigateBackToAfterDelete.current) { + return; + } + Navigation.goBack(navigateBackToAfterDelete.current); + }} prompt={translate('iou.deleteConfirmation')} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 6e81c9d57bc8..01896fb0a3cb 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -4,11 +4,15 @@ import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import {isLinkedTransactionHeld} from '@libs/ReportActionsUtils'; import * as IOU from '@userActions/IOU'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; import DecisionModal from './DecisionModal'; +type ActionHandledType = DeepValueOf; + type ProcessMoneyReportHoldMenuProps = { /** The chat report this report is linked to */ chatReport: OnyxEntry; @@ -35,7 +39,7 @@ type ProcessMoneyReportHoldMenuProps = { paymentType?: PaymentMethodType; /** Type of action handled */ - requestType?: 'pay' | 'approve'; + requestType?: ActionHandledType; }; function ProcessMoneyReportHoldMenu({ @@ -50,7 +54,7 @@ function ProcessMoneyReportHoldMenu({ moneyRequestReport, }: ProcessMoneyReportHoldMenuProps) { const {translate} = useLocalize(); - const isApprove = requestType === 'approve'; + const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE; const onSubmit = (full: boolean) => { if (isApprove) { @@ -82,3 +86,4 @@ function ProcessMoneyReportHoldMenu({ ProcessMoneyReportHoldMenu.displayName = 'ProcessMoneyReportHoldMenu'; export default ProcessMoneyReportHoldMenu; +export type {ActionHandledType}; diff --git a/src/components/ReceiptImage.tsx b/src/components/ReceiptImage.tsx index 65eb9b82ecdc..946856ecec37 100644 --- a/src/components/ReceiptImage.tsx +++ b/src/components/ReceiptImage.tsx @@ -76,9 +76,6 @@ type ReceiptImageProps = ( /** The colod of the fallback icon */ fallbackIconColor?: string; - - /** Whether the component is hovered */ - isHovered?: boolean; }; function ReceiptImage({ @@ -96,7 +93,6 @@ function ReceiptImage({ fallbackIconSize, shouldUseInitialObjectPosition = false, fallbackIconColor, - isHovered = false, }: ReceiptImageProps) { const styles = useThemeStyles(); @@ -134,7 +130,6 @@ function ReceiptImage({ fallbackIconSize={fallbackIconSize} fallbackIconColor={fallbackIconColor} objectPosition={shouldUseInitialObjectPosition ? CONST.IMAGE_OBJECT_POSITION.INITIAL : CONST.IMAGE_OBJECT_POSITION.TOP} - isHovered={isHovered} /> ); } diff --git a/src/components/ReportActionItem/MoneyRequestAction.tsx b/src/components/ReportActionItem/MoneyRequestAction.tsx index 4f91b2084b45..fd82e723c6b9 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.tsx +++ b/src/components/ReportActionItem/MoneyRequestAction.tsx @@ -10,7 +10,6 @@ import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -92,7 +91,6 @@ function MoneyRequestAction({ } const childReportID = action?.childReportID ?? '0'; - Report.openReport(childReportID); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); }; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index b6d6915279cb..e03ed59ffb79 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -163,11 +163,7 @@ function MoneyRequestPreviewContent({ const isTooLong = violationsCount > 1 || violationMessage.length > 15; const hasViolationsAndFieldErrors = violationsCount > 0 && hasFieldErrors; - message += ` ${CONST.DOT_SEPARATOR} ${isTooLong || hasViolationsAndFieldErrors ? translate('violations.reviewRequired') : violationMessage}`; - if (shouldShowHoldMessage) { - message += ` ${CONST.DOT_SEPARATOR} ${translate('iou.hold')}`; - } - return message; + return `${message} ${CONST.DOT_SEPARATOR} ${isTooLong || hasViolationsAndFieldErrors ? translate('violations.reviewRequired') : violationMessage}`; } const isMerchantMissing = TransactionUtils.isMerchantMissing(transaction); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 12a325f6aa19..cc99a3f6e108 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -171,7 +171,8 @@ function MoneyRequestView({ const {getViolationsForField} = useViolations(transactionViolations ?? []); const hasViolations = useCallback( - (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']): boolean => !!canUseViolations && getViolationsForField(field, data).length > 0, + (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string): boolean => + !!canUseViolations && getViolationsForField(field, data, policyHasDependentTags, tagValue).length > 0, [canUseViolations, getViolationsForField], ); @@ -238,7 +239,7 @@ function MoneyRequestView({ const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; const getErrorForField = useCallback( - (field: ViolationField, data?: OnyxTypes.TransactionViolation['data']) => { + (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { // Checks applied when creating a new expense // NOTE: receipt field can return multiple violations, so we need to handle it separately const fieldChecks: Partial> = { @@ -264,14 +265,14 @@ function MoneyRequestView({ } // Return violations if there are any - if (canUseViolations && hasViolations(field, data)) { - const violations = getViolationsForField(field, data); + if (hasViolations(field, data, policyHasDependentTags, tagValue)) { + const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); return ViolationsUtils.getViolationTranslation(violations[0], translate); } return ''; }, - [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, canUseViolations, hasViolations, translate, getViolationsForField], + [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, hasViolations, translate, getViolationsForField], ); const distanceRequestFields = canUseP2PDistanceRequests ? ( @@ -333,6 +334,37 @@ function MoneyRequestView({ ...parentReportAction?.errors, }; + const tagList = policyTagLists.map(({name, orderWeight}, index) => { + const tagError = getErrorForField( + 'tag', + { + tagListIndex: index, + tagListName: name, + }, + PolicyUtils.hasDependentTags(policy, policyTagList), + TransactionUtils.getTagForDisplay(transaction, index), + ); + return ( + + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction?.transactionID ?? '', report.reportID)) + } + brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={tagError} + /> + + ); + }); + return ( {shouldShowAnimatedBackground && } @@ -468,35 +500,7 @@ function MoneyRequestView({ /> )} - {shouldShowTag && - policyTagLists.map(({name, orderWeight}, index) => ( - - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction?.transactionID ?? '', report.reportID), - ) - } - brickRoadIndicator={ - getErrorForField('tag', { - tagListIndex: index, - tagListName: name, - }) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - errorText={getErrorForField('tag', {tagListIndex: index, tagListName: name})} - /> - - ))} + {shouldShowTag && tagList} {isCardTransaction && ( (); + const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(iouReport, policy); + const {isSmallScreenWidth} = useWindowDimensions(); + const [paymentType, setPaymentType] = useState(); + const managerID = iouReport?.managerID ?? 0; const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); @@ -164,6 +173,32 @@ function ReportPreview({ [chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled], ); + const confirmPayment = (type: PaymentMethodType | undefined) => { + if (!type) { + return; + } + setPaymentType(type); + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); + if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + setIsHoldMenuVisible(true); + } else if (chatReport && iouReport) { + if (ReportUtils.isInvoiceReport(iouReport)) { + IOU.payInvoice(type, chatReport, iouReport); + } else { + IOU.payMoneyRequest(type, chatReport, iouReport); + } + } + }; + + const confirmApproval = () => { + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); + if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { + setIsHoldMenuVisible(true); + } else { + IOU.approveMoneyRequest(iouReport ?? {}, true); + } + }; + const getDisplayAmount = (): string => { if (totalDisplaySpend) { return CurrencyUtils.convertToDisplayString(totalDisplaySpend, iouReport?.currency); @@ -282,17 +317,6 @@ function ReportPreview({ }; }, [formattedMerchant, formattedDescription, moneyRequestComment, translate, numberOfRequests, numberOfScanningReceipts, numberOfPendingRequests]); - const confirmPayment = (paymentMethodType?: PaymentMethodType) => { - if (!paymentMethodType || !chatReport || !iouReport) { - return; - } - if (ReportUtils.isInvoiceReport(iouReport)) { - IOU.payInvoice(paymentMethodType, chatReport, iouReport); - } else { - IOU.payMoneyRequest(paymentMethodType, chatReport, iouReport); - } - }; - return ( + {isHoldMenuVisible && iouReport && requestType !== undefined && ( + setIsHoldMenuVisible(false)} + isVisible={isHoldMenuVisible} + paymentType={paymentType} + chatReport={chatReport} + moneyRequestReport={iouReport} + /> + )} ); } diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 1e9cfd23f669..8f5813b47045 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -211,7 +211,7 @@ function ScreenWrapper( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const isAvoidingViewportScroll = useTackInputFocus(shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileSafari()); + const isAvoidingViewportScroll = useTackInputFocus(shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileWebKit()); const contextValue = useMemo(() => ({didScreenTransitionEnd}), [didScreenTransitionEnd]); return ( diff --git a/src/components/Search.tsx b/src/components/Search.tsx index 3d295e72c09e..ac9ec485af73 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -66,7 +66,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { }, [hash, isOffline]); const isLoadingItems = (!isOffline && isLoadingOnyxValue(searchResultsMeta)) || searchResults?.data === undefined; - const isLoadingMoreItems = !isLoadingItems && searchResults?.search?.isLoading; + const isLoadingMoreItems = !isLoadingItems && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; const shouldShowEmptyState = !isLoadingItems && isEmptyObject(searchResults?.data); if (isLoadingItems) { diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx index e7cadbe73b82..49ef1e89cff9 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -86,6 +86,9 @@ type SectionProps = Partial & { /** The height of the icon. */ iconHeight?: number; + + /** Banner to display at the top of the section */ + banner?: ReactNode; }; function isIllustrationLottieAnimation(illustration: DotLottieAnimation | IconAsset | undefined): illustration is DotLottieAnimation { @@ -119,6 +122,7 @@ function Section({ iconWidth, iconHeight, renderSubtitle, + banner = null, }: SectionProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -133,6 +137,7 @@ function Section({ return ( + {banner} {cardLayout === CARD_LAYOUT.ICON_ON_TOP && ( (props: SectionListProps ); } diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index f3f7f56be44f..ae092b8c2729 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -85,6 +85,8 @@ function BaseSelectionList( onEndReachedThreshold, windowSize = 5, updateCellsBatchingPeriod = 50, + removeClippedSubviews = true, + shouldDelayFocus = true, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -506,27 +508,28 @@ function BaseSelectionList( }; }, [debouncedSelectFocusedOption, shouldDebounceRowSelect]); + /** Function to focus text input */ + const focusTextInput = useCallback(() => { + if (!innerTextInputRef.current) { + return; + } + + innerTextInputRef.current.focus(); + }, []); + /** Focuses the text input when the component comes into focus and after any navigation animations finish. */ useFocusEffect( useCallback(() => { - if (!textInputAutoFocus) { - return; - } - if (shouldShowTextInput) { - focusTimeoutRef.current = setTimeout(() => { - if (!innerTextInputRef.current) { - return; - } - innerTextInputRef.current.focus(); - }, CONST.ANIMATED_TRANSITION); - } - return () => { - if (!focusTimeoutRef.current) { - return; + if (textInputAutoFocus && shouldShowTextInput) { + if (shouldDelayFocus) { + focusTimeoutRef.current = setTimeout(focusTextInput, CONST.ANIMATED_TRANSITION); + } else { + focusTextInput(); } - clearTimeout(focusTimeoutRef.current); - }; - }, [shouldShowTextInput, textInputAutoFocus]), + } + + return () => focusTimeoutRef.current && clearTimeout(focusTimeoutRef.current); + }, [shouldShowTextInput, textInputAutoFocus, shouldDelayFocus, focusTextInput]), ); const prevTextInputValue = usePrevious(textInputValue); @@ -677,6 +680,7 @@ function BaseSelectionList( <> {!listHeaderContent && header()} ({ shouldSyncFocus={shouldSyncFocus} hoverStyle={item.isSelected && styles.activeComponentBG} > - {(hovered?: boolean) => ( - - {!isLargeScreenWidth && ( - - )} - - - - - {reportItem?.reportName} - {`${reportItem.transactions.length} ${translate('search.groupedExpenses')}`} - + + {!isLargeScreenWidth && ( + + )} + + + + + {reportItem?.reportName} + {`${reportItem.transactions.length} ${translate('search.groupedExpenses')}`} - - + + + + + {isLargeScreenWidth && ( + <> + {/** We add an empty view with type style to align the total with the table header */} + + + - - {isLargeScreenWidth && ( - <> - {/** We add an empty view with type style to align the total with the table header */} - - - - - - )} - - - {reportItem.transactions.map((transaction) => ( - { - openReportInRHP(transaction); - }} - showItemHeaderOnNarrowLayout={false} - containerStyle={styles.mt3} - isHovered={hovered} - isChildListItem - /> - ))} + + )} - )} + + {reportItem.transactions.map((transaction) => ( + { + openReportInRHP(transaction); + }} + showItemHeaderOnNarrowLayout={false} + containerStyle={styles.mt3} + isChildListItem + /> + ))} + ); } diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index b222631b7273..ecf9264301c2 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -50,16 +50,13 @@ function TransactionListItem({ shouldSyncFocus={shouldSyncFocus} hoverStyle={item.isSelected && styles.activeComponentBG} > - {(hovered?: boolean) => ( - { - onSelectRow(item); - }} - isHovered={hovered} - /> - )} + { + onSelectRow(item); + }} + /> ); } diff --git a/src/components/SelectionList/Search/TransactionListItemRow.tsx b/src/components/SelectionList/Search/TransactionListItemRow.tsx index 50a34be86f61..d17d923a54e1 100644 --- a/src/components/SelectionList/Search/TransactionListItemRow.tsx +++ b/src/components/SelectionList/Search/TransactionListItemRow.tsx @@ -33,10 +33,6 @@ type TransactionCellProps = { transactionItem: TransactionListItemType; } & CellProps; -type ReceiptCellProps = { - isHovered?: boolean; -} & TransactionCellProps; - type ActionCellProps = { onButtonPress: () => void; } & CellProps; @@ -51,7 +47,6 @@ type TransactionListItemRowProps = { onButtonPress: () => void; showItemHeaderOnNarrowLayout?: boolean; containerStyle?: StyleProp; - isHovered?: boolean; isChildListItem?: boolean; }; @@ -68,7 +63,7 @@ const getTypeIcon = (type?: SearchTransactionType) => { } }; -function ReceiptCell({transactionItem, isHovered = false}: ReceiptCellProps) { +function ReceiptCell({transactionItem}: TransactionCellProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -85,7 +80,6 @@ function ReceiptCell({transactionItem, isHovered = false}: ReceiptCellProps) { fallbackIconSize={20} fallbackIconColor={theme.icon} iconSize="x-small" - isHovered={isHovered} /> ); @@ -181,14 +175,14 @@ function TagCell({isLargeScreenWidth, showTooltip, transactionItem}: Transaction return isLargeScreenWidth ? ( ) : ( ); } @@ -209,15 +203,7 @@ function TaxCell({transactionItem, showTooltip}: TransactionCellProps) { ); } -function TransactionListItemRow({ - item, - showTooltip, - onButtonPress, - showItemHeaderOnNarrowLayout = true, - containerStyle, - isHovered = false, - isChildListItem = false, -}: TransactionListItemRowProps) { +function TransactionListItemRow({item, showTooltip, onButtonPress, showItemHeaderOnNarrowLayout = true, containerStyle, isChildListItem = false}: TransactionListItemRowProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isLargeScreenWidth} = useWindowDimensions(); @@ -242,7 +228,6 @@ function TransactionListItemRow({ transactionItem={item} isLargeScreenWidth={false} showTooltip={false} - isHovered={isHovered} /> diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index ec3b22c66043..5d5e7fc3891b 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -398,6 +398,9 @@ type BaseSelectionListProps = Partial & { /** Styles for the section title */ sectionTitleStyles?: StyleProp; + /** This may improve scroll performance for large lists */ + removeClippedSubviews?: boolean; + /** * When true, the list won't be visible until the list layout is measured. This prevents the list from "blinking" as it's scrolled to the bottom which is recommended for large lists. * When false, the list will render immediately and scroll to the bottom which works great for small lists. diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index b6e2a753c829..85954e68c5a9 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo} from 'react'; +import React, {useMemo} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; @@ -8,7 +8,6 @@ import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; -import * as PaymentMethods from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -144,10 +143,6 @@ function SettlementButton({ const {translate} = useLocalize(); const {isOffline} = useNetwork(); - useEffect(() => { - PaymentMethods.openWalletPage(); - }, []); - const session = useSession(); const chatReport = ReportUtils.getReport(chatReportID); const isInvoiceReport = (!isEmptyObject(iouReport) && ReportUtils.isInvoiceReport(iouReport)) || false; diff --git a/src/components/SignInButtons/AppleSignIn/index.android.tsx b/src/components/SignInButtons/AppleSignIn/index.android.tsx index ec669590d029..a528fe7c5a10 100644 --- a/src/components/SignInButtons/AppleSignIn/index.android.tsx +++ b/src/components/SignInButtons/AppleSignIn/index.android.tsx @@ -5,6 +5,7 @@ import Log from '@libs/Log'; import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; +import type {AppleSignInProps} from '.'; /** * Apple Sign In Configuration for Android. @@ -33,7 +34,7 @@ function appleSignInRequest(): Promise { /** * Apple Sign In button for Android. */ -function AppleSignIn() { +function AppleSignIn({onPress = () => {}}: AppleSignInProps) { const handleSignIn = () => { appleSignInRequest() .then((token) => Session.beginAppleSignIn(token)) @@ -46,7 +47,10 @@ function AppleSignIn() { }; return ( { + onPress(); + handleSignIn(); + }} provider={CONST.SIGN_IN_METHOD.APPLE} /> ); diff --git a/src/components/SignInButtons/AppleSignIn/index.ios.tsx b/src/components/SignInButtons/AppleSignIn/index.ios.tsx index 4df8375edad8..57aae97b9c48 100644 --- a/src/components/SignInButtons/AppleSignIn/index.ios.tsx +++ b/src/components/SignInButtons/AppleSignIn/index.ios.tsx @@ -5,6 +5,7 @@ import IconButton from '@components/SignInButtons/IconButton'; import Log from '@libs/Log'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; +import type {AppleSignInProps} from '.'; /** * Apple Sign In method for iOS that returns identityToken. @@ -32,7 +33,7 @@ function appleSignInRequest(): Promise { /** * Apple Sign In button for iOS. */ -function AppleSignIn() { +function AppleSignIn({onPress = () => {}}: AppleSignInProps) { const handleSignIn = () => { appleSignInRequest() .then((token) => Session.beginAppleSignIn(token)) @@ -45,7 +46,10 @@ function AppleSignIn() { }; return ( { + onPress(); + handleSignIn(); + }} provider={CONST.SIGN_IN_METHOD.APPLE} /> ); diff --git a/src/components/SignInButtons/AppleSignIn/index.tsx b/src/components/SignInButtons/AppleSignIn/index.tsx index 9d7322878c98..7ca0261d6c72 100644 --- a/src/components/SignInButtons/AppleSignIn/index.tsx +++ b/src/components/SignInButtons/AppleSignIn/index.tsx @@ -24,6 +24,8 @@ type SingletonAppleSignInButtonProps = AppleSignInDivProps & { type AppleSignInProps = WithNavigationFocusProps & { isDesktopFlow?: boolean; + // eslint-disable-next-line react/no-unused-prop-types + onPress?: () => void; }; /** @@ -139,3 +141,4 @@ function AppleSignIn({isDesktopFlow = false}: AppleSignInProps) { AppleSignIn.displayName = 'AppleSignIn'; export default withNavigationFocus(AppleSignIn); +export type {AppleSignInProps}; diff --git a/src/components/SignInButtons/GoogleSignIn/index.native.tsx b/src/components/SignInButtons/GoogleSignIn/index.native.tsx index 2744d8958080..3fac942c1279 100644 --- a/src/components/SignInButtons/GoogleSignIn/index.native.tsx +++ b/src/components/SignInButtons/GoogleSignIn/index.native.tsx @@ -5,6 +5,7 @@ import Log from '@libs/Log'; import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; +import type {GoogleSignInProps} from '.'; /** * Google Sign In method for iOS and android that returns identityToken. @@ -44,10 +45,13 @@ function googleSignInRequest() { /** * Google Sign In button for iOS. */ -function GoogleSignIn() { +function GoogleSignIn({onPress = () => {}}: GoogleSignInProps) { return ( { + onPress(); + googleSignInRequest(); + }} provider={CONST.SIGN_IN_METHOD.GOOGLE} /> ); diff --git a/src/components/SignInButtons/GoogleSignIn/index.tsx b/src/components/SignInButtons/GoogleSignIn/index.tsx index 3cc4cdebffa6..f12d039209f5 100644 --- a/src/components/SignInButtons/GoogleSignIn/index.tsx +++ b/src/components/SignInButtons/GoogleSignIn/index.tsx @@ -9,6 +9,8 @@ import type Response from '@src/types/modules/google'; type GoogleSignInProps = { isDesktopFlow?: boolean; + // eslint-disable-next-line react/no-unused-prop-types + onPress?: () => void; }; /** Div IDs for styling the two different Google Sign-In buttons. */ @@ -90,3 +92,4 @@ function GoogleSignIn({isDesktopFlow = false}: GoogleSignInProps) { GoogleSignIn.displayName = 'GoogleSignIn'; export default GoogleSignIn; +export type {GoogleSignInProps}; diff --git a/src/components/TextInput/BaseTextInput/isTextInputFocused.ts b/src/components/TextInput/BaseTextInput/isTextInputFocused.ts new file mode 100644 index 000000000000..bf7ae45fa6f4 --- /dev/null +++ b/src/components/TextInput/BaseTextInput/isTextInputFocused.ts @@ -0,0 +1,7 @@ +import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import type {BaseTextInputRef} from './types'; + +/** Checks that text input has the isFocused method and is focused. */ +export default function isTextInputFocused(textInput: React.MutableRefObject): boolean | null { + return textInput.current && 'isFocused' in textInput.current && (textInput.current as AnimatedTextInputRef).isFocused(); +} diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index d4a45f3d93b3..3b89b7c3a7ad 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useState} from 'react'; import type {ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThumbnailDimensions from '@hooks/useThumbnailDimensions'; @@ -48,9 +49,6 @@ type ThumbnailImageProps = { /** The object position of image */ objectPosition?: ImageObjectPosition; - - /** Whether the component is hovered */ - isHovered?: boolean; }; type UpdateImageSizeParams = { @@ -69,7 +67,6 @@ function ThumbnailImage({ fallbackIconSize = variables.iconSizeSuperLarge, fallbackIconColor, objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL, - isHovered = false, }: ThumbnailImageProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -78,6 +75,7 @@ function ThumbnailImage({ const cachedDimensions = shouldDynamicallyResize && typeof previewSourceURL === 'string' ? thumbnailDimensionsCache.get(previewSourceURL) : null; const [imageDimensions, setImageDimensions] = useState({width: cachedDimensions?.width ?? imageWidth, height: cachedDimensions?.height ?? imageHeight}); const {thumbnailDimensionsStyles} = useThumbnailDimensions(imageDimensions.width, imageDimensions.height); + const StyleUtils = useStyleUtils(); useEffect(() => { setFailedToLoad(false); @@ -110,7 +108,7 @@ function ThumbnailImage({ if (failedToLoad || previewSourceURL === '') { return ( - + { updateTargetAndMousePosition(e); if (React.isValidElement(children)) { - children.props.onMouseEnter?.(e); + const onMouseEnter: (e: MouseEvent) => void | undefined = children.props.onMouseEnter; + onMouseEnter?.(e); } }, [children, updateTargetAndMousePosition], diff --git a/src/components/Tooltip/PopoverAnchorTooltip.tsx b/src/components/Tooltip/PopoverAnchorTooltip.tsx index 792977ff7c43..693de83fa5d7 100644 --- a/src/components/Tooltip/PopoverAnchorTooltip.tsx +++ b/src/components/Tooltip/PopoverAnchorTooltip.tsx @@ -10,8 +10,14 @@ function PopoverAnchorTooltip({shouldRender = true, children, ...props}: Tooltip const isPopoverRelatedToTooltipOpen = useMemo(() => { // eslint-disable-next-line @typescript-eslint/dot-notation - const tooltipNode = tooltipRef.current?.['_childNode'] ?? null; - if (isOpen && popover?.anchorRef?.current && tooltipNode && (tooltipNode.contains(popover.anchorRef.current) || tooltipNode === popover.anchorRef.current)) { + const tooltipNode: Node | null = tooltipRef.current?.['_childNode'] ?? null; + + if ( + isOpen && + popover?.anchorRef?.current && + tooltipNode && + ((popover.anchorRef.current instanceof Node && tooltipNode.contains(popover.anchorRef.current)) || tooltipNode === popover.anchorRef.current) + ) { return true; } diff --git a/src/components/ValidateCode/JustSignedInModal.tsx b/src/components/ValidateCode/JustSignedInModal.tsx index 19e67b0c56fe..527493c778cb 100644 --- a/src/components/ValidateCode/JustSignedInModal.tsx +++ b/src/components/ValidateCode/JustSignedInModal.tsx @@ -2,7 +2,8 @@ import React from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; +import Lottie from '@components/Lottie'; +import LottieAnimations from '@components/LottieAnimations'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -22,10 +23,12 @@ function JustSignedInModal({is2FARequired}: JustSignedInModalProps) { - diff --git a/src/components/ValidateCode/ValidateCodeModal.tsx b/src/components/ValidateCode/ValidateCodeModal.tsx index 1e42773c2dc2..a1d5c4ff3faa 100644 --- a/src/components/ValidateCode/ValidateCodeModal.tsx +++ b/src/components/ValidateCode/ValidateCodeModal.tsx @@ -4,7 +4,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; +import Lottie from '@components/Lottie'; +import LottieAnimations from '@components/LottieAnimations'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; @@ -37,10 +38,12 @@ function ValidateCodeModal({code, accountID, session = {}}: ValidateCodeModalPro - {translate('validateCodeModal.title')} diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index bc7c32729c5a..8230f9132d00 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -245,10 +245,10 @@ function BaseVideoPlayer({ ); // update shared video elements useEffect(() => { - if (shouldUseSharedVideoElement || url !== currentlyPlayingURL || isFullScreenRef.current) { + if (shouldUseSharedVideoElement || url !== currentlyPlayingURL) { return; } - shareVideoPlayerElements(videoPlayerRef.current, videoPlayerElementParentRef.current, videoPlayerElementRef.current, isUploading); + shareVideoPlayerElements(videoPlayerRef.current, videoPlayerElementParentRef.current, videoPlayerElementRef.current, isUploading || isFullScreenRef.current); }, [currentlyPlayingURL, shouldUseSharedVideoElement, shareVideoPlayerElements, url, isUploading, isFullScreenRef]); // append shared video element to new parent (used for example in attachment modal) diff --git a/src/components/VideoPlayerContexts/PlaybackContext.tsx b/src/components/VideoPlayerContexts/PlaybackContext.tsx index 499dd2b07f67..0e5e7f993eea 100644 --- a/src/components/VideoPlayerContexts/PlaybackContext.tsx +++ b/src/components/VideoPlayerContexts/PlaybackContext.tsx @@ -51,12 +51,12 @@ function PlaybackContextProvider({children}: ChildrenProps) { ); const shareVideoPlayerElements = useCallback( - (ref: VideoWithOnFullScreenUpdate | null, parent: View | HTMLDivElement | null, child: View | HTMLDivElement | null, isUploading: boolean) => { + (ref: VideoWithOnFullScreenUpdate | null, parent: View | HTMLDivElement | null, child: View | HTMLDivElement | null, shouldNotAutoPlay: boolean) => { currentVideoPlayerRef.current = ref; setOriginalParent(parent); setSharedElement(child); // Prevents autoplay when uploading the attachment - if (!isUploading) { + if (!shouldNotAutoPlay) { playVideo(); } }, diff --git a/src/components/VideoPlayerContexts/types.ts b/src/components/VideoPlayerContexts/types.ts index 6216fd5dc85b..85dabf958d0d 100644 --- a/src/components/VideoPlayerContexts/types.ts +++ b/src/components/VideoPlayerContexts/types.ts @@ -1,6 +1,7 @@ import type {MutableRefObject} from 'react'; import type {View} from 'react-native'; import type {SharedValue} from 'react-native-reanimated'; +import type {TupleToUnion} from 'type-fest'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {VideoWithOnFullScreenUpdate} from '@components/VideoPlayer/types'; import type WindowDimensions from '@hooks/useWindowDimensions/types'; @@ -42,6 +43,6 @@ type FullScreenContext = { type StatusCallback = (isPlaying: boolean) => void; -type PlaybackSpeed = (typeof CONST.VIDEO_PLAYER.PLAYBACK_SPEEDS)[number]; +type PlaybackSpeed = TupleToUnion; export type {PlaybackContext, VolumeContext, VideoPopoverMenuContext, FullScreenContext, StatusCallback, PlaybackSpeed}; diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx index 76efec0879a8..ea7da01e73eb 100644 --- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx +++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.tsx @@ -30,7 +30,7 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}: Video return ( {thumbnailUrl && ( - + { + const navigate = (event: MessageEvent) => { if (!event.data?.type || (event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE)) { return; } diff --git a/src/components/WalletStatementModal/types.ts b/src/components/WalletStatementModal/types.ts index 567202730b7d..d9a6d299da65 100644 --- a/src/components/WalletStatementModal/types.ts +++ b/src/components/WalletStatementModal/types.ts @@ -11,4 +11,9 @@ type WalletStatementProps = WalletStatementOnyxProps & { statementPageURL: string; }; -export type {WalletStatementProps, WalletStatementOnyxProps}; +type WalletStatementMessage = { + url: string; + type: string; +}; + +export type {WalletStatementProps, WalletStatementOnyxProps, WalletStatementMessage}; diff --git a/src/components/WorkspaceSwitcherButton.tsx b/src/components/WorkspaceSwitcherButton.tsx index 7f7715f6f827..f12b60ec9228 100644 --- a/src/components/WorkspaceSwitcherButton.tsx +++ b/src/components/WorkspaceSwitcherButton.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useRef} from 'react'; +import React, {memo, useMemo, useRef} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; @@ -26,7 +26,7 @@ function WorkspaceSwitcherButton({policy}: WorkspaceSwitcherButtonProps) { const pressableRef = useRef(null); - const {source, name, type, id} = useMemo(() => { + const mainAvatar = useMemo(() => { if (!policy) { return {source: Expensicons.ExpensifyAppIcon, name: CONST.WORKSPACE_SWITCHER.NAME, type: CONST.ICON_TYPE_AVATAR}; } @@ -56,7 +56,7 @@ function WorkspaceSwitcherButton({policy}: WorkspaceSwitcherButtonProps) { > {({hovered}) => ( ({ excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], @@ -67,8 +69,13 @@ export default function useArrowKeyFocusManager({ [isActive, shouldExcludeTextAreaNodes, allowHorizontalArrowKeys], ); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => onFocusedIndexChange(focusedIndex), [focusedIndex]); + useEffect(() => { + if (prevIsFocusedIndex === focusedIndex) { + return; + } + onFocusedIndexChange(focusedIndex); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusedIndex, prevIsFocusedIndex]); const arrowUpCallback = useCallback(() => { if (maxIndex < 0 || !isFocused) { diff --git a/src/hooks/useAutoFocusInput.ts b/src/hooks/useAutoFocusInput.ts index c1f58901cee0..2e2d9a086ab1 100644 --- a/src/hooks/useAutoFocusInput.ts +++ b/src/hooks/useAutoFocusInput.ts @@ -49,6 +49,9 @@ export default function useAutoFocusInput(): UseAutoFocusInput { const inputCallbackRef = (ref: TextInput | null) => { inputRef.current = ref; + if (isInputInitialized) { + return; + } setIsInputInitialized(true); }; diff --git a/src/hooks/useBasePopoverReactionList/index.ts b/src/hooks/useBasePopoverReactionList/index.ts index 87c9d23c6e35..90fadc85e785 100644 --- a/src/hooks/useBasePopoverReactionList/index.ts +++ b/src/hooks/useBasePopoverReactionList/index.ts @@ -22,6 +22,7 @@ export default function useBasePopoverReactionList({emojiName, emojiReactions, a emojiCodes: [], hasUserReacted: false, users: [], + isReady: false, }; } @@ -34,6 +35,7 @@ export default function useBasePopoverReactionList({emojiName, emojiReactions, a reactionCount, hasUserReacted, users, + isReady: true, }; } diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index d1af33aa9da5..77ad5ce816f5 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -5,12 +5,25 @@ import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; import useTheme from './useTheme'; -function useMarkdownStyle(message: string | null = null): MarkdownStyle { +function useMarkdownStyle(message: string | null = null, excludeStyles: Array = []): MarkdownStyle { const theme = useTheme(); const emojiFontSize = containsOnlyEmojis(message ?? '') ? variables.fontSizeOnlyEmojis : variables.fontSizeNormal; - const markdownStyle = useMemo( + // this map is used to reset the styles that are not needed - passing undefined value can break the native side + const nonStylingDefaultValues: Record = useMemo( () => ({ + color: theme.text, + backgroundColor: 'transparent', + marginLeft: 0, + paddingLeft: 0, + borderColor: 'transparent', + borderWidth: 0, + }), + [theme], + ); + + const markdownStyle = useMemo(() => { + const styling = { syntax: { color: theme.syntax, }, @@ -59,9 +72,21 @@ function useMarkdownStyle(message: string | null = null): MarkdownStyle { color: theme.mentionText, backgroundColor: theme.mentionBG, }, - }), - [theme, emojiFontSize], - ); + }; + + if (excludeStyles.length) { + excludeStyles.forEach((key) => { + const style: Record = styling[key]; + if (style) { + Object.keys(style).forEach((styleKey) => { + style[styleKey] = nonStylingDefaultValues[styleKey] ?? style[styleKey]; + }); + } + }); + } + + return styling; + }, [theme, emojiFontSize, excludeStyles, nonStylingDefaultValues]); return markdownStyle; } diff --git a/src/hooks/usePreferredCurrency.ts b/src/hooks/usePreferredCurrency.ts new file mode 100644 index 000000000000..c801d8007749 --- /dev/null +++ b/src/hooks/usePreferredCurrency.ts @@ -0,0 +1,33 @@ +import {useMemo} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type PreferredCurrency = ValueOf; + +/** + * Get user's preferred currency in the following order: + * + * 1. Payment card currency + * 2. User's local currency (if it's a valid payment card currency) + * 3. USD (default currency) + * + */ +function usePreferredCurrency(): PreferredCurrency { + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); + + const paymentCardCurrency = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard)?.accountData?.currency, [fundList]); + + if (paymentCardCurrency) { + return paymentCardCurrency; + } + + const currentUserLocalCurrency = (personalDetails?.[session?.accountID ?? '-1']?.localCurrencyCode ?? CONST.PAYMENT_CARD_CURRENCY.USD) as PreferredCurrency; + + return Object.values(CONST.PAYMENT_CARD_CURRENCY).includes(currentUserLocalCurrency) ? currentUserLocalCurrency : CONST.PAYMENT_CARD_CURRENCY.USD; +} + +export default usePreferredCurrency; diff --git a/src/hooks/useStepFormSubmit.ts b/src/hooks/useStepFormSubmit.ts index d825b12a4701..883fe980a338 100644 --- a/src/hooks/useStepFormSubmit.ts +++ b/src/hooks/useStepFormSubmit.ts @@ -1,4 +1,5 @@ import {useCallback} from 'react'; +import type {TupleToUnion} from 'type-fest'; import type {FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; import * as FormActions from '@userActions/FormActions'; import type {OnyxFormKey, OnyxFormValuesMapping, OnyxValues} from '@src/ONYXKEYS'; @@ -26,7 +27,7 @@ export default function useStepFormSubmit const stepValues = fieldIds.reduce((acc, key) => { acc[key] = values[key]; return acc; - }, {} as Record<(typeof fieldIds)[number], OnyxValues[T][Exclude]>); + }, {} as Record, OnyxValues[T][Exclude]>); FormActions.setDraftValues(formId, stepValues); } diff --git a/src/hooks/useSubscriptionPrice.ts b/src/hooks/useSubscriptionPrice.ts new file mode 100644 index 000000000000..0b71fe62c7c8 --- /dev/null +++ b/src/hooks/useSubscriptionPrice.ts @@ -0,0 +1,62 @@ +import {useOnyx} from 'react-native-onyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import usePreferredCurrency from './usePreferredCurrency'; +import useSubscriptionPlan from './useSubscriptionPlan'; + +const SUBSCRIPTION_PRICES = { + [CONST.PAYMENT_CARD_CURRENCY.USD]: { + [CONST.POLICY.TYPE.CORPORATE]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 900, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1800, + }, + [CONST.POLICY.TYPE.TEAM]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 500, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1000, + }, + }, + [CONST.PAYMENT_CARD_CURRENCY.AUD]: { + [CONST.POLICY.TYPE.CORPORATE]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 1500, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 3000, + }, + [CONST.POLICY.TYPE.TEAM]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + }, + [CONST.PAYMENT_CARD_CURRENCY.GBP]: { + [CONST.POLICY.TYPE.CORPORATE]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 700, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1400, + }, + [CONST.POLICY.TYPE.TEAM]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 400, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 800, + }, + }, + [CONST.PAYMENT_CARD_CURRENCY.NZD]: { + [CONST.POLICY.TYPE.CORPORATE]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 1600, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 3200, + }, + [CONST.POLICY.TYPE.TEAM]: { + [CONST.SUBSCRIPTION.TYPE.ANNUAL]: 800, + [CONST.SUBSCRIPTION.TYPE.PAYPERUSE]: 1600, + }, + }, +} as const; + +function useSubscriptionPrice(): number { + const preferredCurrency = usePreferredCurrency(); + const subscriptionPlan = useSubscriptionPlan(); + const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); + + if (!subscriptionPlan || !privateSubscription?.type) { + return 0; + } + + return SUBSCRIPTION_PRICES[preferredCurrency][subscriptionPlan][privateSubscription.type]; +} + +export default useSubscriptionPrice; diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index d1cc7ec70181..83c725a48db0 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -62,12 +62,12 @@ function useViolations(violations: TransactionViolation[]) { }, [violations]); const getViolationsForField = useCallback( - (field: ViolationField, data?: TransactionViolation['data']) => { + (field: ViolationField, data?: TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { const currentViolations = violationsByField.get(field) ?? []; // someTagLevelsRequired has special logic becase data.errorIndexes is a bit unique in how it denotes the tag list that has the violation // tagListIndex can be 0 so we compare with undefined - if (currentViolations[0]?.name === 'someTagLevelsRequired' && data?.tagListIndex !== undefined && Array.isArray(currentViolations[0]?.data?.errorIndexes)) { + if (currentViolations[0]?.name === CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED && data?.tagListIndex !== undefined && Array.isArray(currentViolations[0]?.data?.errorIndexes)) { return currentViolations .filter((violation) => violation.data?.errorIndexes?.includes(data?.tagListIndex ?? -1)) .map((violation) => ({ @@ -79,8 +79,28 @@ function useViolations(violations: TransactionViolation[]) { })); } + // missingTag has special logic for policies with dependent tags, because only one violation is returned for all tags + // when no tags are present, so the tag name isn't set in the violation data. That's why we add it here + if (policyHasDependentTags && currentViolations[0]?.name === CONST.VIOLATIONS.MISSING_TAG && data?.tagListName) { + return [ + { + ...currentViolations[0], + data: { + ...currentViolations[0].data, + tagName: data?.tagListName, + }, + }, + ]; + } + // tagOutOfPolicy has special logic because we have to account for multi-level tags and use tagName to find the right tag to put the violation on - if (currentViolations[0]?.name === 'tagOutOfPolicy' && data?.tagListName !== undefined && currentViolations[0]?.data?.tagName) { + if (currentViolations[0]?.name === CONST.VIOLATIONS.TAG_OUT_OF_POLICY && data?.tagListName !== undefined && currentViolations[0]?.data?.tagName) { + return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); + } + + // allTagLevelsRequired has special logic because it is returned when one but not all the tags are set, + // so we need to return the violation for the tag fields without a tag set + if (currentViolations[0]?.name === CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED && tagValue) { return currentViolations.filter((violation) => violation.data?.tagName === data?.tagListName); } diff --git a/src/languages/en.ts b/src/languages/en.ts index 76e4d5d5a143..5e58e4fdde9a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -360,7 +360,7 @@ export default { errorWhileSelectingCorruptedAttachment: 'An error occurred while selecting a corrupted attachment, please try another file.', takePhoto: 'Take photo', chooseFromGallery: 'Choose from gallery', - chooseDocument: 'Choose document', + chooseDocument: 'Choose file', attachmentTooLarge: 'Attachment too large', sizeExceeded: 'Attachment size is larger than 24 MB limit.', attachmentTooSmall: 'Attachment too small', @@ -689,7 +689,6 @@ export default { finished: 'Finished', sendInvoice: ({amount}: RequestAmountParams) => `Send ${amount} invoice`, submitAmount: ({amount}: RequestAmountParams) => `submit ${amount}`, - trackAmount: ({amount}: RequestAmountParams) => `track ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `submitted ${formattedAmount}${comment ? ` for ${comment}` : ''}`, trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? ` for ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`, @@ -1622,6 +1621,8 @@ export default { facialScan: 'Onfido’s Facial Scan Policy and Release', tryAgain: 'Try again', verifyIdentity: 'Verify identity', + letsVerifyIdentity: "Let's verify your identity.", + butFirst: `But first, the boring stuff. Read up on the legalese in the next step and click "Accept" when you're ready.`, genericError: 'There was an error while processing this step. Please try again.', cameraPermissionsNotGranted: 'Enable camera access', cameraRequestMessage: 'We need access to your camera to complete bank account verification. Please enable via Settings > New Expensify.', @@ -1663,8 +1664,8 @@ export default { noOverdraftOrCredit: 'No overdraft/credit feature.', electronicFundsWithdrawal: 'Electronic funds withdrawal', standard: 'Standard', - takeALookAtSomeFees: 'Take a look at some fees.', - checkPlease: 'Check please.', + reviewTheFees: 'Please review the fees below.', + checkTheBoxes: 'Please check the boxes below.', agreeToTerms: 'Agree to the terms and you’ll be good to go!', shortTermsForm: { expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `The Expensify Wallet is issued by ${walletProgram}.`, @@ -2249,8 +2250,8 @@ export default { }, connectionsWarningModal: { featureEnabledTitle: 'Not so fast...', - featureEnabledText: 'To enable or disable this feature change your accounting import settings.', - disconnectText: 'Disconnect your accounting connection from the workspace if you want to disable Accounting.', + featureEnabledText: "To enable or disable this feature, you'll need to change your accounting import settings.", + disconnectText: "To disable accounting, you'll need to disconnect your accounting connection from your workspace.", manageSettings: 'Manage settings', }, }, @@ -2361,7 +2362,7 @@ export default { noVBACopy: 'Connect a bank account to issue Expensify Cards to your workspace members, and access these incredible benefits and more:', VBANoECardCopy: 'Add a work email address to issue unlimited Expensify Cards for your workspace members, as well as all of these incredible benefits:', VBAWithECardCopy: 'Access these incredible benefits and more:', - benefit1: 'Up to 2% cash back', + benefit1: 'Cash back on every US purchase', benefit2: 'Digital and physical cards', benefit3: 'No personal liability', benefit4: 'Customizable limits', @@ -3226,8 +3227,8 @@ export default { title: 'Your plan', collect: { title: 'Collect', - priceAnnual: 'From $5/active member with the Expensify Card, $10/active member without the Expensify Card.', - pricePayPerUse: 'From $10/active member with the Expensify Card, $20/active member without the Expensify Card.', + priceAnnual: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + pricePayPerUse: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, benefit1: 'Unlimited SmartScans and distance tracking', benefit2: 'Expensify Cards with Smart Limits', benefit3: 'Bill pay and invoicing', @@ -3238,8 +3239,8 @@ export default { }, control: { title: 'Control', - priceAnnual: 'From $9/active member with the Expensify Card, $18/active member without the Expensify Card.', - pricePayPerUse: 'From $18/active member with the Expensify Card, $36/active member without the Expensify Card.', + priceAnnual: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, + pricePayPerUse: ({lower, upper}) => `From ${lower}/active member with the Expensify Card, ${upper}/active member without the Expensify Card.`, benefit1: 'Everything in Collect, plus:', benefit2: 'NetSuite and Sage Intacct integrations', benefit3: 'Certinia and Workday sync', @@ -3267,7 +3268,7 @@ export default { eachMonth: 'Each month, your subscription covers up to the number of active members set above. Any time you increase your subscription size, you’ll start a new 12-month subscription at that new size.', note: 'Note: An active member is anyone who has created, edited, submitted, approved, reimbursed, or exported expense data tied to your company workspace.', - confirmDetails: 'Confirm your new annual subscription details', + confirmDetails: 'Confirm your new annual subscription details:', subscriptionSize: 'Subscription size', activeMembers: ({size}) => `${size} active members/month`, subscriptionRenews: 'Subscription renews', diff --git a/src/languages/es.ts b/src/languages/es.ts index d03e13d1a9ff..2cf03b40a3c7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -355,7 +355,7 @@ export default { errorWhileSelectingCorruptedAttachment: 'Ha ocurrido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.', takePhoto: 'Hacer una foto', chooseFromGallery: 'Elegir de la galería', - chooseDocument: 'Elegir documento', + chooseDocument: 'Elegir un archivo', attachmentTooLarge: 'Archivo adjunto demasiado grande', sizeExceeded: 'El archivo adjunto supera el límite de 24 MB.', attachmentTooSmall: 'Archivo adjunto demasiado pequeño', @@ -683,7 +683,6 @@ export default { finished: 'Finalizado', sendInvoice: ({amount}: RequestAmountParams) => `Enviar factura de ${amount}`, submitAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, - trackAmount: ({amount}: RequestAmountParams) => `seguimiento de ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `solicitó ${formattedAmount}${comment ? ` para ${comment}` : ''}`, trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `realizó un seguimiento de ${formattedAmount}${comment ? ` para ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, @@ -1642,6 +1641,8 @@ export default { facialScan: 'Política y lanzamiento de la exploración facial de Onfido', tryAgain: 'Intentar otra vez', verifyIdentity: 'Verificar identidad', + letsVerifyIdentity: '¡Vamos a verificar tu identidad!', + butFirst: 'Pero primero, lo aburrido. Lee la jerga legal en el siguiente paso y haz clic en "Aceptar" cuando estés listo.', genericError: 'Hubo un error al procesar este paso. Inténtalo de nuevo.', cameraPermissionsNotGranted: 'Permiso para acceder a la cámara', cameraRequestMessage: 'Necesitamos acceso a tu cámara para completar la verificación de tu cuenta de banco. Por favor habilita los permisos en Configuración > Nuevo Expensify.', @@ -1685,8 +1686,8 @@ export default { noOverdraftOrCredit: 'Sin función de sobregiro/crédito', electronicFundsWithdrawal: 'Retiro electrónico de fondos', standard: 'Estándar', - takeALookAtSomeFees: 'Echa un vistazo a algunas tarifas.', - checkPlease: 'Por favor, revisa.', + reviewTheFees: 'Por favor, revisa las siguientes tarifas.', + checkTheBoxes: 'Por favor, marca las siguientes casillas.', agreeToTerms: 'Debes aceptar los términos y condiciones para continuar.', shortTermsForm: { expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `La billetera Expensify es emitida por ${walletProgram}.`, @@ -2289,7 +2290,7 @@ export default { connectionsWarningModal: { featureEnabledTitle: 'No tan rápido...', featureEnabledText: 'Para activar o desactivar esta función, cambia la configuración de importación contable.', - disconnectText: 'Desconecta tu conexión contable del espacio de trabajo si deseas desactivar la Contabilidad.', + disconnectText: 'Para desactivar la contabilidad, desconecta tu conexión contable del espacio de trabajo.', manageSettings: 'Gestionar la configuración', }, }, @@ -2538,7 +2539,7 @@ export default { VBANoECardCopy: 'Añade tu correo electrónico de trabajo para emitir Tarjetas Expensify ilimitadas para los miembros de tu espacio de trabajo y acceder a todas estas increíbles ventajas:', VBAWithECardCopy: 'Acceda a estos increíbles beneficios y más:', - benefit1: 'Hasta un 2% de devolución en tus gastos', + benefit1: 'Devolución de dinero en cada compra en Estados Unidos', benefit2: 'Tarjetas digitales y físicas', benefit3: 'Sin responsabilidad personal', benefit4: 'Límites personalizables', @@ -3733,8 +3734,8 @@ export default { title: 'Tu plan', collect: { title: 'Recolectar', - priceAnnual: 'Desde $5/miembro activo con la Tarjeta Expensify, $10/miembro activo sin la Tarjeta Expensify.', - pricePayPerUse: 'Desde $10/miembro activo con la Tarjeta Expensify, $20/miembro activo sin la Tarjeta Expensify.', + priceAnnual: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + pricePayPerUse: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, benefit1: 'SmartScans ilimitados y seguimiento de la distancia', benefit2: 'Tarjetas Expensify con Límites Inteligentes', benefit3: 'Pago de facturas y facturación', @@ -3745,8 +3746,8 @@ export default { }, control: { title: 'Control', - priceAnnual: 'Desde $9/miembro activo con la Tarjeta Expensify, $18/miembro activo sin la Tarjeta Expensify.', - pricePayPerUse: 'Desde $18/miembro activo con la Tarjeta Expensify, $36/miembro activo sin la Tarjeta Expensify.', + priceAnnual: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, + pricePayPerUse: ({lower, upper}) => `Desde ${lower}/miembro activo con la Tarjeta Expensify, ${upper}/miembro activo sin la Tarjeta Expensify.`, benefit1: 'Todo en Recolectar, más:', benefit2: 'Integraciones con NetSuite y Sage Intacct', benefit3: 'Sincronización de Certinia y Workday', @@ -3774,7 +3775,7 @@ export default { eachMonth: 'Cada mes, tu suscripción cubre hasta el número de miembros activos establecido anteriormente. Cada vez que aumentes el tamaño de tu suscripción, iniciarás una nueva suscripción de 12 meses con ese nuevo tamaño.', note: 'Nota: Un miembro activo es cualquiera que haya creado, editado, enviado, aprobado, reembolsado, o exportado datos de gastos vinculados al espacio de trabajo de tu empresa.', - confirmDetails: 'Confirma los datos de tu nueva suscripción anual', + confirmDetails: 'Confirma los datos de tu nueva suscripción anual:', subscriptionSize: 'Tamaño de suscripción', activeMembers: ({size}) => `${size} miembros activos/mes`, subscriptionRenews: 'Renovación de la suscripción', diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index bfa1b95836f8..fc4585b9ef68 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -7,6 +7,7 @@ import * as Pusher from '@libs/Pusher/pusher'; import * as Request from '@libs/Request'; import * as PersistedRequests from '@userActions/PersistedRequests'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxRequest from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; import pkg from '../../../package.json'; @@ -39,6 +40,14 @@ type OnyxData = { finallyData?: OnyxUpdate[]; }; +// For all write requests, we'll send the lastUpdateID that is applied to this client. This will +// allow us to calculate previousUpdateID faster. +let lastUpdateIDAppliedToClient = 0; +Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0), +}); + /** * 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. @@ -82,6 +91,7 @@ function write(command: TCommand, apiCommandParam // This should be removed once we are no longer using deprecatedAPI https://github.com/Expensify/Expensify/issues/215650 shouldRetry: true, canCancel: true, + clientUpdateID: lastUpdateIDAppliedToClient, }, ...onyxDataWithoutOptimisticData, }; @@ -130,6 +140,7 @@ function makeRequestWithSideEffects) => !!account?.isLoading && account.loadingForm === (account.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM); -export default {isValidateCodeFormSubmitting}; +const isAccountIDOddNumber = (accountID: number) => accountID % 2 === 1; + +export default {isValidateCodeFormSubmitting, isAccountIDOddNumber}; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 52530b1b3bb9..7b54fbf0bed7 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -129,6 +129,26 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR }); } +/** + * Given the amount in the "cents", convert it to a short string (no decimals) for display in the UI. + * The backend always handle things in "cents" (subunit equal to 1/100) + * + * @param amountInCents – should be an integer. Anything after a decimal place will be dropped. + * @param currency - IOU currency + */ +function convertToShortDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD): string { + const convertedAmount = convertToFrontendAmountAsInteger(amountInCents); + + return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + style: 'currency', + currency, + + // There will be no decimals displayed (e.g. $9) + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); +} + /** * Given an amount, convert it to a string for display in the UI. * @@ -184,4 +204,5 @@ export { convertAmountToDisplayString, convertToDisplayStringWithoutCurrency, isValidCurrencyCode, + convertToShortDisplayString, }; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 50cb9a20dff6..8e462e109bf9 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -43,7 +43,7 @@ import * as CurrentDate from './actions/CurrentDate'; import * as Localize from './Localize'; import Log from './Log'; -type CustomStatusTypes = (typeof CONST.CUSTOM_STATUS_TYPES)[keyof typeof CONST.CUSTOM_STATUS_TYPES]; +type CustomStatusTypes = ValueOf; type Locale = ValueOf; type WeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; @@ -380,7 +380,8 @@ function getDBTime(timestamp: string | number = ''): string { */ function getDBTimeWithSkew(timestamp: string | number = ''): string { if (networkTimeSkew > 0) { - return getDBTime(new Date(timestamp).valueOf() + networkTimeSkew); + const datetime = timestamp ? new Date(timestamp) : new Date(); + return getDBTime(datetime.valueOf() + networkTimeSkew); } return getDBTime(timestamp); } diff --git a/src/libs/EmojiTrie.ts b/src/libs/EmojiTrie.ts index a8155d3f2fd6..88a1a7a275d4 100644 --- a/src/libs/EmojiTrie.ts +++ b/src/libs/EmojiTrie.ts @@ -1,3 +1,4 @@ +import type {TupleToUnion} from 'type-fest'; import emojis, {localeEmojis} from '@assets/emojis'; import type {Emoji, HeaderEmoji, PickerEmoji} from '@assets/emojis/types'; import CONST from '@src/CONST'; @@ -17,7 +18,7 @@ Timing.start(CONST.TIMING.TRIE_INITIALIZATION); const supportedLanguages = [CONST.LOCALES.DEFAULT, CONST.LOCALES.ES] as const; -type SupportedLanguage = (typeof supportedLanguages)[number]; +type SupportedLanguage = TupleToUnion; type EmojiTrie = { [key in SupportedLanguage]?: Trie; diff --git a/src/libs/Environment/betaChecker/index.android.ts b/src/libs/Environment/betaChecker/index.android.ts index 258bf80aab13..b6cc8f671991 100644 --- a/src/libs/Environment/betaChecker/index.android.ts +++ b/src/libs/Environment/betaChecker/index.android.ts @@ -4,7 +4,7 @@ import * as AppUpdate from '@libs/actions/AppUpdate'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import pkg from '../../../../package.json'; -import type IsBetaBuild from './types'; +import type {IsBetaBuild} from './types'; let isLastSavedBeta = false; Onyx.connect({ diff --git a/src/libs/Environment/betaChecker/index.ios.ts b/src/libs/Environment/betaChecker/index.ios.ts index dae79dabfd2b..3220d9b00e05 100644 --- a/src/libs/Environment/betaChecker/index.ios.ts +++ b/src/libs/Environment/betaChecker/index.ios.ts @@ -1,5 +1,5 @@ import {NativeModules} from 'react-native'; -import type IsBetaBuild from './types'; +import type {IsBetaBuild} from './types'; /** * Check to see if the build is staging (TestFlight) or production diff --git a/src/libs/Environment/betaChecker/index.ts b/src/libs/Environment/betaChecker/index.ts index ce1668759c8c..eaa8bfbc1500 100644 --- a/src/libs/Environment/betaChecker/index.ts +++ b/src/libs/Environment/betaChecker/index.ts @@ -1,4 +1,4 @@ -import type IsBetaBuild from './types'; +import type {IsBetaBuild} from './types'; /** * There's no beta build in non native diff --git a/src/libs/Environment/betaChecker/types.ts b/src/libs/Environment/betaChecker/types.ts index 61ce4bc9cec4..ef205425a309 100644 --- a/src/libs/Environment/betaChecker/types.ts +++ b/src/libs/Environment/betaChecker/types.ts @@ -1,3 +1,7 @@ type IsBetaBuild = Promise; -export default IsBetaBuild; +type EnvironmentCheckerModule = { + isBeta: () => IsBetaBuild; +}; + +export type {IsBetaBuild, EnvironmentCheckerModule}; diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts index d26d449b5fd9..4a7551beca77 100644 --- a/src/libs/Fullstory/index.native.ts +++ b/src/libs/Fullstory/index.native.ts @@ -1,5 +1,6 @@ import FullStory, {FSPage} from '@fullstory/react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import * as Environment from '@src/libs/Environment/Environment'; import type {UserMetadata} from '@src/types/onyx'; /** @@ -40,9 +41,13 @@ const FS = { // anonymize FullStory user identity metadata FullStory.anonymize(); } else { - // define FullStory user identity - FullStory.identify(String(metadata.accountID), { - properties: metadata, + Environment.getEnvironment().then((envName: string) => { + // define FullStory user identity + const localMetadata = metadata; + localMetadata.environment = envName; + FullStory.identify(String(localMetadata.accountID), { + properties: localMetadata, + }); }); } }, diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts index 24c725acf81a..a9c75ad838e9 100644 --- a/src/libs/Fullstory/index.ts +++ b/src/libs/Fullstory/index.ts @@ -63,7 +63,11 @@ const FS = { } FS.onReady().then(() => { FS.consent(true); - FS.fsIdentify(value); + if (value) { + const localMetadata = value; + localMetadata.environment = envName; + FS.fsIdentify(localMetadata); + } }); }); } catch (e) { diff --git a/src/libs/LocaleUtils.ts b/src/libs/LocaleUtils.ts index 1a63cbf579d5..1802a26d6ffe 100644 --- a/src/libs/LocaleUtils.ts +++ b/src/libs/LocaleUtils.ts @@ -1,7 +1,7 @@ -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -function getLanguageFromLocale(locale: ValueOf): (typeof CONST.LANGUAGES)[number] { +function getLanguageFromLocale(locale: ValueOf): TupleToUnion { switch (locale) { case CONST.LOCALES.ES_ES: case CONST.LOCALES.ES_ES_ONFIDO: diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 003074aa55f9..c1c2165fd3a7 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -3,12 +3,12 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyTagList, ReportAction} from '@src/types/onyx'; +import type {ModifiedExpense} from '@src/types/onyx/OriginalMessage'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import getReportPolicyID from './getReportPolicyID'; import * as Localize from './Localize'; import * as PolicyUtils from './PolicyUtils'; -import type {ExpenseOriginalMessage} from './ReportUtils'; import * as TransactionUtils from './TransactionUtils'; let allPolicyTags: OnyxCollection = {}; @@ -109,7 +109,7 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE) { return ''; } - const reportActionOriginalMessage = reportAction?.originalMessage as ExpenseOriginalMessage | undefined; + const reportActionOriginalMessage = reportAction?.originalMessage as ModifiedExpense | undefined; const policyID = getReportPolicyID(reportID) ?? ''; const removalFragments: string[] = []; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 1d55c0f49356..67ba9f62421d 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -37,31 +37,16 @@ function addLeadingZero(amount: string): string { return amount.startsWith('.') ? `0${amount}` : amount; } -/** - * Calculate the length of the amount with leading zeroes - */ -function calculateAmountLength(amount: string, decimals: number): number { - const leadingZeroes = amount.match(/^0+/); - const leadingZeroesLength = leadingZeroes?.[0]?.length ?? 0; - const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 10 ** decimals).toFixed(2)).toString(); - - if (/\D/.test(absAmount)) { - return CONST.IOU.AMOUNT_MAX_LENGTH + 1; - } - - return leadingZeroesLength + (absAmount === '0' ? 2 : absAmount.length); -} - /** * Check if amount is a decimal up to 3 digits */ function validateAmount(amount: string, decimals: number, amountMaxLength: number = CONST.IOU.AMOUNT_MAX_LENGTH): boolean { const regexString = decimals === 0 - ? `^\\d+(,\\d*)*$` // Don't allow decimal point if decimals === 0 - : `^\\d+(,\\d*)*(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point + ? `^\\d{1,${amountMaxLength}}$` // Don't allow decimal point if decimals === 0 + : `^\\d{1,${amountMaxLength}}(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point const decimalNumberRegex = new RegExp(regexString, 'i'); - return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount, decimals) <= amountMaxLength); + return amount === '' || decimalNumberRegex.test(amount); } /** diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 807c938e21dd..0577fdcfc5aa 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -205,19 +205,14 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/Card/GetPhysicalCardConfirm').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: () => require('../../../../pages/settings/Wallet/TransferBalancePage').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: () => require('../../../../pages/settings/Wallet/ChooseTransferAccountPage').default as React.ComponentType, - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: () => require('../../../../pages/EnablePayments/EnablePaymentsPage').default as React.ComponentType, - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_REFACTOR]: () => require('../../../../pages/EnablePayments/PersonalInfo/PersonalInfo').default as React.ComponentType, - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_TEMPORARY_TERMS]: () => require('../../../../pages/EnablePayments/FeesAndTerms/FeesAndTerms').default as React.ComponentType, + [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS]: () => require('../../../../pages/EnablePayments/EnablePayments').default as React.ComponentType, [SCREENS.SETTINGS.ADD_DEBIT_CARD]: () => require('../../../../pages/settings/Wallet/AddDebitCardPage').default as React.ComponentType, [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../../pages/AddPersonalBankAccountPage').default as React.ComponentType, - [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_REFACTOR]: () => require('../../../../pages/EnablePayments/AddBankAccount/AddBankAccount').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS]: () => require('../../../../pages/settings/Profile/CustomStatus/StatusPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: () => require('../../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType, - [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize/SubscriptionSizePage').default as React.ComponentType, + [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize').default as React.ComponentType, [SCREENS.SETTINGS.SUBSCRIPTION.DISABLE_AUTO_RENEW_SURVEY]: () => require('../../../../pages/settings/Subscription/DisableAutoRenewSurveyPage').default as React.ComponentType, [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage').default as React.ComponentType, [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default as React.ComponentType, diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx similarity index 91% rename from src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx rename to src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx index 25bec235ed45..9b2e9449c672 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar/index.tsx @@ -1,5 +1,5 @@ import {useNavigation, useNavigationState} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import React, {memo, useCallback, useEffect} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; @@ -12,6 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@libs/actions/Session'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -24,6 +25,7 @@ import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -36,9 +38,8 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {activeWorkspaceID} = useActiveWorkspace(); - const navigation = useNavigation(); + const {activeWorkspaceID} = useActiveWorkspace(); useEffect(() => { const navigationState = navigation.getState() as State | undefined; @@ -68,13 +69,16 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps const chatTabBrickRoad = getChatTabBrickRoad(activeWorkspaceID); + const navigateToChats = useCallback(() => { + const route = activeWorkspaceID ? (`/w/${activeWorkspaceID}/home` as Route) : ROUTES.HOME; + Navigation.navigate(route); + }, [activeWorkspaceID]); + return ( { - Navigation.navigate(ROUTES.HOME); - }} + onPress={navigateToChats} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.inbox')} wrapperStyle={styles.flex1} @@ -96,7 +100,7 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps { - Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.TAB_SEARCH.ALL)); + interceptAnonymousUser(() => Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.TAB_SEARCH.ALL))); }} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('common.search')} @@ -127,4 +131,4 @@ export default withOnyx; +}; +type PurposeForUsingExpensifyModalProps = PurposeForUsingExpensifyModalOnyxProps; + +function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const navigation = useNavigation(); + const {activeWorkspaceID: contextActiveWorkspaceID} = useActiveWorkspace(); + const activeWorkspaceID = sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.ACTIVE_WORKSPACE_ID) ?? contextActiveWorkspaceID; + + useEffect(() => { + const navigationState = navigation.getState() as State | undefined; + const routes = navigationState?.routes; + const currentRoute = routes?.[navigationState?.index ?? 0]; + // When we are redirected to the Settings tab from the OldDot, we don't want to call the Welcome.show() method. + // To prevent this, the value of the bottomTabRoute?.name is checked here + if (!!(currentRoute && currentRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR && currentRoute.name !== NAVIGATORS.CENTRAL_PANE_NAVIGATOR) || Session.isAnonymousUser()) { + return; + } + + Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_ROOT)}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoadingApp]); + + // Parent navigator of the bottom tab bar is the root navigator. + const currentTabName = useNavigationState((state) => { + const topmostCentralPaneRoute = getTopmostCentralPaneRoute(state); + + if (topmostCentralPaneRoute && topmostCentralPaneRoute.name === SCREENS.SEARCH.CENTRAL_PANE) { + return SCREENS.SEARCH.CENTRAL_PANE; + } + + const topmostBottomTabRoute = getTopmostBottomTabRoute(state); + return topmostBottomTabRoute?.name ?? SCREENS.HOME; + }); + + const chatTabBrickRoad = getChatTabBrickRoad(activeWorkspaceID); + + const navigateToChats = useCallback(() => { + const route = activeWorkspaceID ? (`/w/${activeWorkspaceID}/home` as Route) : ROUTES.HOME; + Navigation.navigate(route); + }, [activeWorkspaceID]); + + return ( + + + + + + {chatTabBrickRoad && ( + + )} + + + + + { + interceptAnonymousUser(() => Navigation.navigate(ROUTES.SEARCH.getRoute(CONST.TAB_SEARCH.ALL))); + }} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('common.search')} + wrapperStyle={styles.flex1} + style={styles.bottomTabBarItem} + > + + + + + + + + + + + ); +} + +BottomTabBar.displayName = 'BottomTabBar'; + +export default withOnyx({ + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, +})(BottomTabBar); diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index bc5ccb494b20..eaaf5eae12c0 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -18,6 +18,7 @@ import linkingConfig from './linkingConfig'; import customGetPathFromState from './linkingConfig/customGetPathFromState'; import getAdaptedStateFromPath from './linkingConfig/getAdaptedStateFromPath'; import Navigation, {navigationRef} from './Navigation'; +import setupCustomAndroidBackHandler from './setupCustomAndroidBackHandler'; import type {RootStackParamList} from './types'; type NavigationRootProps = { @@ -109,6 +110,8 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N useEffect(() => { if (firstRenderRef.current) { + setupCustomAndroidBackHandler(); + // we don't want to make the report back button go back to LHN if the user // started on the small screen so we don't set it on the first render // making it only work on consecutive changes of the screen size diff --git a/src/libs/Navigation/linkTo/index.ts b/src/libs/Navigation/linkTo/index.ts index 313f525f390b..7d6d62b9a5aa 100644 --- a/src/libs/Navigation/linkTo/index.ts +++ b/src/libs/Navigation/linkTo/index.ts @@ -1,6 +1,6 @@ import {getActionFromState} from '@react-navigation/core'; -import {findFocusedRoute} from '@react-navigation/native'; import type {NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; import {omitBy} from 'lodash'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import extractPolicyIDsFromState from '@libs/Navigation/linkingConfig/extractPolicyIDsFromState'; @@ -86,9 +86,12 @@ export default function linkTo(navigation: NavigationContainerRef)?.policyID !== (matchingBottomTabRoute?.params as Record)?.policyID; - if (topmostBottomTabRoute && (topmostBottomTabRoute.name !== matchingBottomTabRoute.name || isNewPolicyID)) { + ((topmostBottomTabRoute?.params as Record)?.policyID ?? '') !== + ((matchingBottomTabRoute?.params as Record)?.policyID ?? ''); + + if (topmostBottomTabRoute && (topmostBottomTabRoute.name !== matchingBottomTabRoute.name || isNewPolicyID || isOpeningSearch)) { root.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.PUSH, payload: matchingBottomTabRoute, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index bb002ec2c01f..07dd45701029 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -166,16 +166,6 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_ENABLE_PAYMENTS, exact: true, }, - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_REFACTOR]: { - path: ROUTES.SETTINGS_ENABLE_PAYMENTS_REFACTOR, - exact: true, - }, - // TODO: Added temporarily for testing purposes, remove after refactor - https://github.com/Expensify/App/issues/36648 - [SCREENS.SETTINGS.WALLET.ENABLE_PAYMENTS_TEMPORARY_TERMS]: { - path: ROUTES.SETTINGS_ENABLE_PAYMENTS_TEMPORARY_TERMS, - exact: true, - }, [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: { path: ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE, exact: true, @@ -204,10 +194,6 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT, exact: true, }, - [SCREENS.SETTINGS.ADD_BANK_ACCOUNT_REFACTOR]: { - path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT_REFACTOR, - exact: true, - }, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: { path: ROUTES.SETTINGS_PRONOUNS, exact: true, diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index e6baacc1cea9..31f025c05446 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -1,5 +1,6 @@ import type {NavigationState, PartialState, Route} from '@react-navigation/native'; import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; +import type {TupleToUnion} from 'type-fest'; import {isAnonymousUser} from '@libs/actions/Session'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import type {BottomTabName, CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; @@ -17,7 +18,7 @@ import replacePathInNestedState from './replacePathInNestedState'; const RHP_SCREENS_OPENED_FROM_LHN = [SCREENS.SETTINGS.SHARE_CODE, SCREENS.SETTINGS.PROFILE.STATUS] as const; -type RHPScreenOpenedFromLHN = (typeof RHP_SCREENS_OPENED_FROM_LHN)[number]; +type RHPScreenOpenedFromLHN = TupleToUnion; type Metainfo = { // Sometimes modal screens don't have information about what should be visible under the overlay. diff --git a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts index fd45685acf23..4b4ed25959f0 100644 --- a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts +++ b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts @@ -21,6 +21,15 @@ function getMatchingBottomTabRouteForState(state: State, pol } const tabName = CENTRAL_PANE_TO_TAB_MAPPING[topmostCentralPaneRoute.name]; + + if (tabName === SCREENS.SEARCH.BOTTOM_TAB) { + const topmostCentralPaneRouteParams = topmostCentralPaneRoute.params as Record; + delete topmostCentralPaneRouteParams?.policyIDs; + if (policyID) { + topmostCentralPaneRouteParams.policyID = policyID; + } + return {name: tabName, params: topmostCentralPaneRouteParams}; + } return {name: tabName, params: paramsWithPolicyID}; } diff --git a/src/libs/Navigation/setupCustomAndroidBackHandler/index.android.ts b/src/libs/Navigation/setupCustomAndroidBackHandler/index.android.ts new file mode 100644 index 000000000000..ac272155289d --- /dev/null +++ b/src/libs/Navigation/setupCustomAndroidBackHandler/index.android.ts @@ -0,0 +1,68 @@ +import {findFocusedRoute, StackActions} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; +import {BackHandler} from 'react-native'; +import getTopmostCentralPaneRoute from '@navigation/getTopmostCentralPaneRoute'; +import navigationRef from '@navigation/navigationRef'; +import type {BottomTabNavigatorParamList, RootStackParamList, State} from '@navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +type SearchPageProps = StackScreenProps; + +// We need to do some custom handling for the back button on Android for actions related to the search page. +function setupCustomAndroidBackHandler() { + const onBackPress = () => { + const rootState = navigationRef.getRootState(); + + const bottomTabRoute = rootState.routes.find((route) => route.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR); + const bottomTabRoutes = bottomTabRoute?.state?.routes; + const focusedRoute = findFocusedRoute(rootState); + + // Shoudn't happen but for type safety. + if (!bottomTabRoutes) { + return false; + } + + // Handle back press on the search page. + // We need to pop two screens, from the central pane and from the bottom tab. + if (bottomTabRoutes[bottomTabRoutes.length - 1].name === SCREENS.SEARCH.BOTTOM_TAB && focusedRoute?.name === SCREENS.SEARCH.CENTRAL_PANE) { + navigationRef.dispatch({...StackActions.pop(), target: bottomTabRoute?.state?.key}); + navigationRef.dispatch({...StackActions.pop()}); + + const centralPaneRouteAfterPop = getTopmostCentralPaneRoute({routes: [rootState.routes.at(-2)]} as State); + const bottomTabRouteAfterPop = bottomTabRoutes.at(-2); + + // It's possible that central pane search is desynchronized with the bottom tab search. + // e.g. opening a tab different than search will wipe out central pane screens. + // In that case we have to push the proper one. + if ( + bottomTabRouteAfterPop && + bottomTabRouteAfterPop.name === SCREENS.SEARCH.BOTTOM_TAB && + (!centralPaneRouteAfterPop || centralPaneRouteAfterPop.name !== SCREENS.SEARCH.CENTRAL_PANE) + ) { + const {policyID, ...restParams} = bottomTabRoutes[bottomTabRoutes.length - 2].params as SearchPageProps['route']['params']; + navigationRef.dispatch({...StackActions.push(NAVIGATORS.CENTRAL_PANE_NAVIGATOR, {screen: SCREENS.SEARCH.CENTRAL_PANE, params: {...restParams, policyIDs: policyID}})}); + } + + return true; + } + + // Handle back press to go back to the search page. + // It's possible that central pane search is desynchronized with the bottom tab search. + // e.g. opening a tab different than search will wipe out central pane screens. + // In that case we have to push the proper one. + if (bottomTabRoutes && bottomTabRoutes?.length >= 2 && bottomTabRoutes[bottomTabRoutes.length - 2].name === SCREENS.SEARCH.BOTTOM_TAB && rootState.routes.length === 1) { + const {policyID, ...restParams} = bottomTabRoutes[bottomTabRoutes.length - 2].params as SearchPageProps['route']['params']; + navigationRef.dispatch({...StackActions.push(NAVIGATORS.CENTRAL_PANE_NAVIGATOR, {screen: SCREENS.SEARCH.CENTRAL_PANE, params: {...restParams, policyIDs: policyID}})}); + navigationRef.dispatch({...StackActions.pop(), target: bottomTabRoute?.state?.key}); + return true; + } + + // Handle all other cases with default handler. + return false; + }; + + BackHandler.addEventListener('hardwareBackPress', onBackPress); +} + +export default setupCustomAndroidBackHandler; diff --git a/src/libs/Navigation/setupCustomAndroidBackHandler/index.ts b/src/libs/Navigation/setupCustomAndroidBackHandler/index.ts new file mode 100644 index 000000000000..aa9077e1220f --- /dev/null +++ b/src/libs/Navigation/setupCustomAndroidBackHandler/index.ts @@ -0,0 +1,4 @@ +// Do nothing for platforms different than Android. +function setupCustomAndroidBackHandler() {} + +export default setupCustomAndroidBackHandler; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 5597e7ce00da..e87f0380a2da 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -854,7 +854,13 @@ type WelcomeVideoModalNavigatorParamList = { type BottomTabNavigatorParamList = { [SCREENS.HOME]: {policyID?: string}; - [SCREENS.SEARCH.BOTTOM_TAB]: {policyID?: string}; + [SCREENS.SEARCH.BOTTOM_TAB]: { + query: string; + policyID?: string; + offset?: number; + sortBy?: SearchColumnType; + sortOrder?: SortOrder; + }; [SCREENS.SETTINGS.ROOT]: {policyID?: string}; }; diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index bf68a694523a..10df3c703c92 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import * as ActiveClientManager from '@libs/ActiveClientManager'; +import Log from '@libs/Log'; import * as Request from '@libs/Request'; import * as RequestThrottle from '@libs/RequestThrottle'; import * as PersistedRequests from '@userActions/PersistedRequests'; @@ -26,10 +27,11 @@ let isQueuePaused = false; */ function pause() { if (isQueuePaused) { + Log.info('[SequentialQueue] Queue already paused'); return; } - console.debug('[SequentialQueue] Pausing the queue'); + Log.info('[SequentialQueue] Pausing the queue'); isQueuePaused = true; } @@ -40,6 +42,7 @@ function flushOnyxUpdatesQueue() { // The only situation where the queue is paused is if we found a gap between the app current data state and our server's. If that happens, // we'll trigger async calls to make the client updated again. While we do that, we don't want to insert anything in Onyx. if (isQueuePaused) { + Log.info('[SequentialQueue] Queue already paused'); return; } QueuedOnyxUpdates.flushQueue(); @@ -56,11 +59,18 @@ function flushOnyxUpdatesQueue() { function process(): Promise { // When the queue is paused, return early. This prevents any new requests from happening. The queue will be flushed again when the queue is unpaused. if (isQueuePaused) { + Log.info('[SequentialQueue] Unable to process. Queue is paused.'); + return Promise.resolve(); + } + + if (NetworkStore.isOffline()) { + Log.info('[SequentialQueue] Unable to process. We are offline.'); return Promise.resolve(); } const persistedRequests = PersistedRequests.getAll(); - if (persistedRequests.length === 0 || NetworkStore.isOffline()) { + if (persistedRequests.length === 0) { + Log.info('[SequentialQueue] Unable to process. No requests to process.'); return Promise.resolve(); } @@ -72,6 +82,7 @@ function process(): Promise { // A response might indicate that the queue should be paused. This happens when a gap in onyx updates is detected between the client and the server and // that gap needs resolved before the queue can continue. if (response?.shouldPauseQueue) { + Log.info("[SequentialQueue] Handled 'shouldPauseQueue' in response. Pausing the queue."); pause(); } PersistedRequests.remove(requestToProcess); @@ -102,16 +113,24 @@ function process(): Promise { function flush() { // When the queue is paused, return early. This will keep an requests in the queue and they will get flushed again when the queue is unpaused if (isQueuePaused) { + Log.info('[SequentialQueue] Unable to flush. Queue is paused.'); + return; + } + + if (isSequentialQueueRunning) { + Log.info('[SequentialQueue] Unable to flush. Queue is already running.'); return; } - if (isSequentialQueueRunning || PersistedRequests.getAll().length === 0) { + if (PersistedRequests.getAll().length === 0) { + Log.info('[SequentialQueue] Unable to flush. No requests to process.'); return; } // ONYXKEYS.PERSISTED_REQUESTS is shared across clients, thus every client/tab will have a copy // It is very important to only process the queue from leader client otherwise requests will be duplicated. if (!ActiveClientManager.isClientTheLeader()) { + Log.info('[SequentialQueue] Unable to flush. Client is not the leader.'); return; } @@ -128,6 +147,7 @@ function flush() { callback: () => { Onyx.disconnect(connectionID); process().finally(() => { + Log.info('[SequentialQueue] Finished processing queue.'); isSequentialQueueRunning = false; if (NetworkStore.isOffline() || PersistedRequests.getAll().length === 0) { resolveIsReadyPromise?.(); @@ -144,6 +164,7 @@ function flush() { */ function unpause() { if (!isQueuePaused) { + Log.info('[SequentialQueue] Unable to unpause queue. We are already processing.'); return; } diff --git a/src/libs/NetworkConnection.ts b/src/libs/NetworkConnection.ts index 2d020ec778ae..3fc0fbdeb534 100644 --- a/src/libs/NetworkConnection.ts +++ b/src/libs/NetworkConnection.ts @@ -37,13 +37,13 @@ const triggerReconnectionCallbacks = throttle( * Called when the offline status of the app changes and if the network is "reconnecting" (going from offline to online) * then all of the reconnection callbacks are triggered */ -function setOfflineStatus(isCurrentlyOffline: boolean): void { - NetworkActions.setIsOffline(isCurrentlyOffline); +function setOfflineStatus(isCurrentlyOffline: boolean, reason = ''): void { + NetworkActions.setIsOffline(isCurrentlyOffline, reason); // When reconnecting, ie, going from offline to online, all the reconnection callbacks // are triggered (this is usually Actions that need to re-download data from the server) if (isOffline && !isCurrentlyOffline) { - NetworkActions.setIsBackendReachable(true); + NetworkActions.setIsBackendReachable(true, 'moved from offline to online'); triggerReconnectionCallbacks('offline status changed'); } @@ -74,13 +74,13 @@ Onyx.connect({ } shouldForceOffline = currentShouldForceOffline; if (shouldForceOffline) { - setOfflineStatus(true); + setOfflineStatus(true, 'shouldForceOffline was detected in the Onyx data'); Log.info(`[NetworkStatus] Setting "offlineStatus" to "true" because user is under force offline`); } else { // If we are no longer forcing offline fetch the NetInfo to set isOffline appropriately NetInfo.fetch().then((state) => { - const isInternetReachable = !!state.isInternetReachable; - setOfflineStatus(isInternetReachable); + const isInternetReachable = (state.isInternetReachable ?? false) === false; + setOfflineStatus(isInternetReachable, 'NetInfo checked if the internet is reachable'); Log.info( `[NetworkStatus] The force-offline mode was turned off. Getting the device network status from NetInfo. Network state: ${JSON.stringify( state, @@ -122,20 +122,20 @@ function subscribeToBackendAndInternetReachability(): () => void { }) .then((isBackendReachable: boolean) => { if (isBackendReachable) { - NetworkActions.setIsBackendReachable(true); + NetworkActions.setIsBackendReachable(true, 'successfully completed API request'); return; } + NetworkActions.setIsBackendReachable(false, 'request succeeded, but internet reachability test failed'); checkInternetReachability().then((isInternetReachable: boolean) => { - setOfflineStatus(!isInternetReachable); + setOfflineStatus(!isInternetReachable, 'checkInternetReachability was called after api/ping returned a non-200 jsonCode'); setNetWorkStatus(isInternetReachable); - NetworkActions.setIsBackendReachable(false); }); }) .catch(() => { + NetworkActions.setIsBackendReachable(false, 'request failed and internet reachability test failed'); checkInternetReachability().then((isInternetReachable: boolean) => { - setOfflineStatus(!isInternetReachable); + setOfflineStatus(!isInternetReachable, 'checkInternetReachability was called after api/ping request failed'); setNetWorkStatus(isInternetReachable); - NetworkActions.setIsBackendReachable(false); }); }); }, CONST.NETWORK.BACKEND_CHECK_INTERVAL_MS); @@ -163,7 +163,7 @@ function subscribeToNetworkStatus(): () => void { Log.info('[NetworkConnection] Not setting offline status because shouldForceOffline = true'); return; } - setOfflineStatus(state.isInternetReachable === false); + setOfflineStatus(state.isInternetReachable === false, 'NetInfo received a state change event'); Log.info(`[NetworkStatus] NetInfo.addEventListener event coming, setting "offlineStatus" to ${!!state.isInternetReachable} with network state: ${JSON.stringify(state)}`); setNetWorkStatus(state.isInternetReachable); }); diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 8cd2615dc4a6..d6d9fdda8ebc 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -290,7 +290,7 @@ Onyx.connect({ // If the report is a one-transaction report and has , we need to return the combined reportActions so that the LHN can display modifications // to the transaction thread or the report itself - const transactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(reportID, actions[reportActions[0]], true); + const transactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(reportID, actions[reportActions[0]]); if (transactionThreadReportID) { const transactionThreadReportActionsArray = Object.values(actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); sortedReportActions = ReportActionUtils.getCombinedReportActions(reportActionsArray, transactionThreadReportActionsArray, reportID); @@ -601,6 +601,26 @@ function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine = : LocalePhoneNumber.formatPhoneNumber(option.participantsList && option.participantsList.length > 0 ? option.participantsList[0].login ?? '' : ''); } +function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchValue: string) { + let memberDetails = ''; + if (personalDetail.login) { + memberDetails += ` ${personalDetail.login}`; + } + if (personalDetail.firstName) { + memberDetails += ` ${personalDetail.firstName}`; + } + if (personalDetail.lastName) { + memberDetails += ` ${personalDetail.lastName}`; + } + if (personalDetail.displayName) { + memberDetails += ` ${PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail)}`; + } + if (personalDetail.phoneNumber) { + memberDetails += ` ${personalDetail.phoneNumber}`; + } + return isSearchStringMatch(searchValue.trim(), memberDetails.toLowerCase()); +} + /** * Get the last message text from the report directly or from other sources for special cases. */ @@ -1686,26 +1706,6 @@ function getUserToInviteOption({ return userToInvite; } -/** - * Check whether report has violations - */ -function shouldShowViolations(report: Report, betas: OnyxEntry, transactionViolations: OnyxCollection) { - if (!Permissions.canUseViolations(betas)) { - return false; - } - const {parentReportID, parentReportActionID} = report ?? {}; - const canGetParentReport = parentReportID && parentReportActionID && allReportActions; - if (!canGetParentReport) { - return false; - } - const parentReportActions = allReportActions ? allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? {} : {}; - const parentReportAction = parentReportActions[parentReportActionID] ?? null; - if (!parentReportAction) { - return false; - } - return ReportUtils.shouldDisplayTransactionThreadViolations(report, transactionViolations, parentReportAction); -} - /** * filter options based on specific conditions */ @@ -1813,7 +1813,13 @@ function getOptions( // Filter out all the reports that shouldn't be displayed const filteredReportOptions = options.reports.filter((option) => { const report = option.item; - const doesReportHaveViolations = shouldShowViolations(report, betas, transactionViolations); + + const {parentReportID, parentReportActionID} = report ?? {}; + const canGetParentReport = parentReportID && parentReportActionID && allReportActions; + const parentReportActions = allReportActions ? allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? {} : {}; + const parentReportAction = canGetParentReport ? parentReportActions[parentReportActionID] ?? null : null; + const doesReportHaveViolations = + (betas?.includes(CONST.BETAS.VIOLATIONS) && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction)) ?? false; return ReportUtils.shouldReportBeInOptionList({ report, @@ -1947,7 +1953,8 @@ function getOptions( const isCurrentUserOwnedPolicyExpenseChatThatCouldShow = reportOption.isPolicyExpenseChat && reportOption.ownerAccountID === currentUserAccountID && includeOwnedWorkspaceChats && !reportOption.isArchivedRoom; - const shouldShowInvoiceRoom = includeInvoiceRooms && ReportUtils.isInvoiceRoom(reportOption.item) && ReportUtils.isPolicyAdmin(reportOption.policyID ?? '', policies); + const shouldShowInvoiceRoom = + includeInvoiceRooms && ReportUtils.isInvoiceRoom(reportOption.item) && ReportUtils.isPolicyAdmin(reportOption.policyID ?? '', policies) && !reportOption.isArchivedRoom; /** Exclude the report option if it doesn't meet any of the following conditions: @@ -2484,6 +2491,7 @@ export { getPersonalDetailsForAccountIDs, getIOUConfirmationOptionsFromPayeePersonalDetail, getSearchText, + isSearchStringMatchUserDetails, getAllReportErrors, getPolicyExpenseReportOption, getParticipantsOption, @@ -2507,7 +2515,6 @@ export { getTaxRatesSection, getFirstKeyForList, getUserToInviteOption, - shouldShowViolations, }; export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree}; diff --git a/src/libs/Performance.tsx b/src/libs/Performance.tsx index 9302b538a621..f5e84b8f6ef3 100644 --- a/src/libs/Performance.tsx +++ b/src/libs/Performance.tsx @@ -3,7 +3,7 @@ import isObject from 'lodash/isObject'; import lodashTransform from 'lodash/transform'; import React, {forwardRef, Profiler} from 'react'; import {Alert, InteractionManager} from 'react-native'; -import type {PerformanceEntry, PerformanceMark, PerformanceMeasure, Performance as RNPerformance} from 'react-native-performance'; +import type {PerformanceEntry, PerformanceMark, PerformanceMeasure, ReactNativePerformance, Performance as RNPerformance} from 'react-native-performance'; import type {PerformanceObserverEntryList} from 'react-native-performance/lib/typescript/performance-observer'; import CONST from '@src/CONST'; import isE2ETestSession from './E2E/isE2ETestSession'; @@ -86,7 +86,7 @@ const Performance: PerformanceModule = { }; if (Metrics.canCapturePerformanceMetrics()) { - const perfModule = require('react-native-performance'); + const perfModule: ReactNativePerformance = require('react-native-performance'); perfModule.setResourceLoggingEnabled(true); rnPerformance = perfModule.default; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 7678de592a6f..ab7c71c5be3e 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -137,7 +137,7 @@ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry): ValueOf, isOffline: boolean): boolean { return ( !!policy && - (policy?.isPolicyExpenseChatEnabled || !!policy?.isJoinRequestPending) && + (policy?.type !== CONST.POLICY.TYPE.PERSONAL || !!policy?.isJoinRequestPending) && (isOffline || policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || Object.keys(policy.errors ?? {}).length > 0) ); } @@ -424,6 +424,13 @@ function canSendInvoice(policies: OnyxCollection): boolean { return getActiveAdminWorkspaces(policies).length > 0; } +function hasDependentTags(policy: OnyxEntry, policyTagList: OnyxEntry) { + if (!policy?.hasMultipleTagLists) { + return false; + } + return Object.values(policyTagList ?? {}).some((tagList) => Object.values(tagList.tags).some((tag) => !!tag.rules?.parentTagsFilter || !!tag.parentTagsFilter)); +} + /** Get the Xero organizations connected to the policy */ function getXeroTenants(policy: Policy | undefined): Tenant[] { // Due to the way optional chain is being handled in this useMemo we are forced to use this approach to properly handle undefined values @@ -516,6 +523,7 @@ export { shouldShowPolicy, getActiveAdminWorkspaces, canSendInvoice, + hasDependentTags, getXeroTenants, findCurrentXeroOrganization, getCurrentXeroOrganizationName, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 1830edd91b68..e5fa2f9f998f 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -11,6 +11,7 @@ import type { ActionName, ChangeLog, IOUMessage, + JoinWorkspaceResolution, OriginalMessageActionableMentionWhisper, OriginalMessageActionableReportMentionWhisper, OriginalMessageActionableTrackedExpenseWhisper, @@ -872,18 +873,11 @@ function isTaskAction(reportAction: OnyxEntry): boolean { * Gets the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions. * Returns a reportID if there is exactly one transaction thread for the report, and null otherwise. */ -function getOneTransactionThreadReportID( - reportID: string, - reportActions: OnyxEntry | ReportAction[], - skipReportTypeCheck: boolean | undefined = undefined, - isOffline: boolean | undefined = undefined, -): string | null { - if (!skipReportTypeCheck) { - // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report. - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) { - return null; - } +function getOneTransactionThreadReportID(reportID: string, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined): string | null { + // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report. + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (report?.type !== CONST.REPORT.TYPE.IOU && report?.type !== CONST.REPORT.TYPE.EXPENSE && report?.type !== CONST.REPORT.TYPE.INVOICE) { + return null; } const reportActionsArray = Object.values(reportActions ?? {}); @@ -1205,7 +1199,9 @@ function isActionableJoinRequest(reportAction: OnyxEntry): reportA */ function isActionableJoinRequestPending(reportID: string): boolean { const sortedReportActions = getSortedReportActions(Object.values(getAllReportActions(reportID))); - const findPendingRequest = sortedReportActions.find((reportActionItem) => isActionableJoinRequest(reportActionItem) && reportActionItem.originalMessage.choice === ''); + const findPendingRequest = sortedReportActions.find( + (reportActionItem) => isActionableJoinRequest(reportActionItem) && reportActionItem.originalMessage.choice === ('' as JoinWorkspaceResolution), + ); return !!findPendingRequest; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4c2372886d50..33971530ec77 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7,7 +7,7 @@ import lodashIntersection from 'lodash/intersection'; import lodashIsEqual from 'lodash/isEqual'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; import type {FileObject} from '@components/AttachmentModal'; import {FallbackAvatar} from '@components/Icon/Expensicons'; import * as defaultGroupAvatars from '@components/Icon/GroupDefaultAvatars'; @@ -40,13 +40,15 @@ import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type { ChangeLog, IOUMessage, + ModifiedExpense, OriginalMessageActionName, + OriginalMessageApproved, OriginalMessageCreated, OriginalMessageDismissedViolation, OriginalMessageReimbursementDequeued, OriginalMessageRenamed, + OriginalMessageSubmitted, PaymentMethodType, - ReimbursementDeQueuedMessage, } from '@src/types/onyx/OriginalMessage'; import type {Status} from '@src/types/onyx/PersonalDetails'; import type {NotificationPreference, Participants, PendingChatMember, Participant as ReportParticipant} from '@src/types/onyx/Report'; @@ -87,30 +89,6 @@ type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | type WelcomeMessage = {showReportName: boolean; phrase1?: string; phrase2?: string}; -type ExpenseOriginalMessage = { - oldComment?: string; - newComment?: string; - comment?: string; - merchant?: string; - oldCreated?: string; - created?: string; - oldMerchant?: string; - oldAmount?: number; - amount?: number; - oldCurrency?: string; - currency?: string; - category?: string; - oldCategory?: string; - tag?: string; - oldTag?: string; - billable?: string; - oldBillable?: string; - oldTaxAmount?: number; - taxAmount?: number; - taxRate?: string; - oldTaxRate?: string; -}; - type SpendBreakdown = { nonReimbursableSpend: number; reimbursableSpend: number; @@ -211,12 +189,12 @@ type ReportOfflinePendingActionAndErrors = { }; type OptimisticApprovedReportAction = Pick< - ReportAction, + ReportAction & OriginalMessageApproved, 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachment' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction' >; type OptimisticSubmittedReportAction = Pick< - ReportAction, + ReportAction & OriginalMessageSubmitted, | 'actionName' | 'actorAccountID' | 'adminAccountID' @@ -2208,7 +2186,7 @@ function getReimbursementDeQueuedActionMessage( report: OnyxEntry | EmptyObject, isLHNPreview = false, ): string { - const originalMessage = reportAction?.originalMessage as ReimbursementDeQueuedMessage | undefined; + const originalMessage = reportAction?.originalMessage; const amount = originalMessage?.amount; const currency = originalMessage?.currency; const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); @@ -2426,6 +2404,14 @@ function isReportFieldOfTypeTitle(reportField: OnyxEntry): bo return reportField?.type === 'formula' && reportField?.fieldID === CONST.REPORT_FIELD_TITLE_FIELD_ID; } +/** + * Check if Report has any held expenses + */ +function isHoldCreator(transaction: OnyxEntry, reportID: string): boolean { + const holdReportAction = ReportActionsUtils.getReportAction(reportID, `${transaction?.comment?.hold ?? ''}`); + return isActionCreator(holdReportAction); +} + /** * Check if report fields are available to use in a report */ @@ -2743,6 +2729,71 @@ function canEditReportAction(reportAction: OnyxEntry): boolean { ); } +function canHoldUnholdReportAction(reportAction: OnyxEntry): {canHoldRequest: boolean; canUnholdRequest: boolean} { + if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { + return {canHoldRequest: false, canUnholdRequest: false}; + } + + const moneyRequestReportID = reportAction?.originalMessage?.IOUReportID ?? 0; + const moneyRequestReport = getReport(String(moneyRequestReportID)); + + if (!moneyRequestReportID || !moneyRequestReport) { + return {canHoldRequest: false, canUnholdRequest: false}; + } + + const isRequestSettled = isSettled(moneyRequestReport?.reportID); + const isApproved = isReportApproved(moneyRequestReport); + const transactionID = moneyRequestReport ? reportAction?.originalMessage?.IOUTransactionID : 0; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); + + const parentReport = getReport(String(moneyRequestReport.parentReportID)); + const parentReportAction = ReportActionsUtils.getParentReportAction(moneyRequestReport); + + const isRequestIOU = parentReport?.type === 'iou'; + const isRequestHoldCreator = isHoldCreator(transaction, moneyRequestReport?.reportID) && isRequestIOU; + const isTrackExpenseMoneyReport = isTrackExpenseReport(moneyRequestReport); + const isActionOwner = + typeof parentReportAction?.actorAccountID === 'number' && + typeof currentUserPersonalDetails?.accountID === 'number' && + parentReportAction.actorAccountID === currentUserPersonalDetails?.accountID; + const isApprover = isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && currentUserPersonalDetails?.accountID === moneyRequestReport?.managerID; + const isOnHold = TransactionUtils.isOnHold(transaction); + const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); + + const canModifyStatus = !isTrackExpenseMoneyReport && (isPolicyAdmin || isActionOwner || isApprover); + const isDeletedParentAction = isEmptyObject(parentReportAction) || ReportActionsUtils.isDeletedAction(parentReportAction); + + const canHoldOrUnholdRequest = !isRequestSettled && !isApproved && !isDeletedParentAction; + const canHoldRequest = canHoldOrUnholdRequest && !isOnHold && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus)) && !isScanning; + const canUnholdRequest = !!(canHoldOrUnholdRequest && isOnHold && (isRequestHoldCreator || (!isRequestIOU && canModifyStatus))); + + return {canHoldRequest, canUnholdRequest}; +} + +const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry): void => { + if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { + return; + } + const moneyRequestReportID = reportAction?.originalMessage?.IOUReportID ?? 0; + + const moneyRequestReport = getReport(String(moneyRequestReportID)); + if (!moneyRequestReportID || !moneyRequestReport) { + return; + } + + const transactionID = reportAction?.originalMessage?.IOUTransactionID ?? ''; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); + const isOnHold = TransactionUtils.isOnHold(transaction); + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport.policyID}`] ?? null; + + if (isOnHold) { + IOU.unholdRequest(transactionID, reportAction.childReportID ?? ''); + } else { + const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams()); + Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, transactionID, reportAction.childReportID ?? '', activeRoute)); + } +}; + /** * Gets all transactions on an IOU report with a receipt */ @@ -3024,8 +3075,8 @@ function getModifiedExpenseOriginalMessage( transactionChanges: TransactionChanges, isFromExpenseReport: boolean, policy: OnyxEntry, -): ExpenseOriginalMessage { - const originalMessage: ExpenseOriginalMessage = {}; +): ModifiedExpense { + const originalMessage: ModifiedExpense = {}; // Remark: Comment field is the only one which has new/old prefixes for the keys (newComment/ oldComment), // all others have old/- pattern such as oldCreated/created if ('comment' in transactionChanges) { @@ -3592,7 +3643,7 @@ function buildOptimisticAddCommentReportAction( htmlForNewComment = commentText; textForNewComment = parser.htmlToText(htmlForNewComment); } else { - htmlForNewComment = `${commentText}\n${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; + htmlForNewComment = `${commentText}${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; textForNewComment = `${parser.htmlToText(commentText)}\n${CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML}`; } @@ -5386,6 +5437,17 @@ function shouldReportBeInOptionList({ return true; } +/** + * Returns the system report from the list of reports. + */ +function getSystemChat(): OnyxEntry { + if (!allReports) { + return null; + } + + return Object.values(allReports ?? {}).find((report) => report?.chatType === CONST.REPORT.CHAT_TYPE.SYSTEM) ?? null; +} + /** * Attempts to find a report in onyx with the provided list of participants. Does not include threads, task, expense, room, and policy expense chat. */ @@ -6013,7 +6075,7 @@ function getReportOfflinePendingActionAndErrors(report: OnyxEntry): Repo /** * Check if the report can create the expense with type is iouType */ -function canCreateRequest(report: OnyxEntry, policy: OnyxEntry, iouType: (typeof CONST.IOU.TYPE)[keyof typeof CONST.IOU.TYPE]): boolean { +function canCreateRequest(report: OnyxEntry, policy: OnyxEntry, iouType: ValueOf): boolean { const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); if (!canUserPerformWriteAction(report)) { return false; @@ -6060,7 +6122,7 @@ function shouldDisableRename(report: OnyxEntry, policy: OnyxEntry, policy: OnyxEntry): boolean { - return PolicyUtils.isPolicyAdmin(policy) && !isAdminRoom(report) && !isArchivedRoom(report) && !isThread(report); + return PolicyUtils.isPolicyAdmin(policy) && !isAdminRoom(report) && !isArchivedRoom(report) && !isThread(report) && !isInvoiceRoom(report); } /** @@ -6113,18 +6175,27 @@ function getTaskAssigneeChatOnyxData( }, ); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`, - value: { - pendingFields: { - createChat: null, + // BE will send different report's participants and assigneeAccountID. We clear the optimistic ones to avoid duplicated entries + successData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${assigneeChatReportID}`, + value: { + pendingFields: { + createChat: null, + }, + isOptimisticReport: false, + participants: {[assigneeAccountID]: null}, }, - isOptimisticReport: false, - // BE will send a different participant. We clear the optimistic one to avoid duplicated entries - participants: {[assigneeAccountID]: null}, }, - }); + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [assigneeAccountID]: null, + }, + }, + ); failureData.push( { @@ -6399,14 +6470,6 @@ function navigateToPrivateNotes(report: OnyxEntry, session: OnyxEntry, reportID: string): boolean { - const holdReportAction = ReportActionsUtils.getReportAction(reportID, `${transaction?.comment?.hold ?? ''}`); - return isActionCreator(holdReportAction); -} - /** * Get all held transactions of a iouReport */ @@ -6626,7 +6689,7 @@ function canBeAutoReimbursed(report: OnyxEntry, policy: OnyxEntry; const reimbursableTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; const autoReimbursementLimit = policy.autoReimbursementLimit ?? 0; const isAutoReimbursable = @@ -6918,6 +6981,7 @@ export { canCreateTaskInReport, canCurrentUserOpenReport, canDeleteReportAction, + canHoldUnholdReportAction, canEditFieldOfMoneyRequest, canEditMoneyRequest, canEditPolicyDescription, @@ -7005,6 +7069,7 @@ export { getRoomWelcomeMessage, getRootParentReport, getRouteFromLink, + getSystemChat, getTaskAssigneeChatOnyxData, getTransactionDetails, getTransactionReportName, @@ -7145,13 +7210,13 @@ export { shouldShowMerchantColumn, isCurrentUserInvoiceReceiver, isDraftReport, + changeMoneyRequestHoldStatus, createDraftWorkspaceAndNavigateToConfirmationScreen, }; export type { Ancestor, DisplayNameWithTooltips, - ExpenseOriginalMessage, OptimisticAddCommentReportAction, OptimisticChatReport, OptimisticClosedReportAction, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 2d77d66b5f4a..119bbc3ba12a 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -12,6 +12,7 @@ import type PriorityMode from '@src/types/onyx/PriorityMode'; import type Report from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import AccountUtils from './AccountUtils'; import * as CollectionUtils from './CollectionUtils'; import {hasValidDraftComment} from './DraftCommentUtils'; import localeCompare from './LocaleCompare'; @@ -80,49 +81,46 @@ function getOrderedReportIDs( const allReportsDictValues = Object.values(allReports ?? {}); // Filter out all the reports that shouldn't be displayed - let reportsToDisplay: Array = []; - - allReportsDictValues.forEach((report) => { + let reportsToDisplay = allReportsDictValues.filter((report) => { if (!report) { - return; + return false; } + const parentReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`; + const parentReportActions = allReportActions?.[parentReportActionsKey]; const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {}; - const doesReportHaveViolations = OptionsListUtils.shouldShowViolations(report, betas ?? [], transactionViolations); + const parentReportAction = parentReportActions?.find((action) => action && action?.reportActionID === report.parentReportActionID); + const doesReportHaveViolations = !!( + betas?.includes(CONST.BETAS.VIOLATIONS) && + !!parentReportAction && + ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction as OnyxEntry) + ); const isHidden = report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; const isFocused = report.reportID === currentReportId; const allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions) ?? {}; const hasErrorsOtherThanFailedReceipt = doesReportHaveViolations || Object.values(allReportErrors).some((error) => error?.[0] !== 'report.genericSmartscanFailureMessage'); - - if (hasErrorsOtherThanFailedReceipt) { - reportsToDisplay.push({ - ...report, - hasErrorsOtherThanFailedReceipt: true, - }); - return; - } - const isSystemChat = ReportUtils.isSystemChat(report); const shouldOverrideHidden = hasErrorsOtherThanFailedReceipt || isFocused || isSystemChat || report.isPinned; - if (isHidden && !shouldOverrideHidden) { - return; + return false; } - if ( - ReportUtils.shouldReportBeInOptionList({ - report, - currentReportId: currentReportId ?? '', - isInFocusMode, - betas, - policies: policies as OnyxCollection, - excludeEmptyChats: true, - doesReportHaveViolations, - includeSelfDM: true, - }) - ) { - reportsToDisplay.push(report); + const participantAccountIDs = Object.keys(report?.participants ?? {}).map(Number); + + if (currentUserAccountID && AccountUtils.isAccountIDOddNumber(currentUserAccountID) && participantAccountIDs.includes(CONST.ACCOUNT_ID.NOTIFICATIONS)) { + return true; } + + return ReportUtils.shouldReportBeInOptionList({ + report, + currentReportId: currentReportId ?? '', + isInFocusMode, + betas, + policies: policies as OnyxCollection, + excludeEmptyChats: true, + doesReportHaveViolations, + includeSelfDM: true, + }); }); // The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: @@ -138,7 +136,6 @@ function getOrderedReportIDs( const draftReports: Array> = []; const nonArchivedReports: Array> = []; const archivedReports: Array> = []; - const errorReports: Array> = []; if (currentPolicyID || policyMemberAccountIDs.length > 0) { reportsToDisplay = reportsToDisplay.filter( @@ -147,7 +144,7 @@ function getOrderedReportIDs( } // There are a few properties that need to be calculated for the report which are used when sorting reports. reportsToDisplay.forEach((reportToDisplay) => { - let report = reportToDisplay; + let report = reportToDisplay as OnyxEntry; if (report) { report = { ...report, @@ -163,8 +160,6 @@ function getOrderedReportIDs( draftReports.push(report); } else if (ReportUtils.isArchivedRoom(report)) { archivedReports.push(report); - } else if (report?.hasErrorsOtherThanFailedReceipt) { - errorReports.push(report); } else { nonArchivedReports.push(report); } @@ -172,8 +167,6 @@ function getOrderedReportIDs( // Sort each group of reports accordingly pinnedAndGBRReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0)); - errorReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0)); - draftReports.sort((a, b) => (a?.displayName && b?.displayName ? localeCompare(a.displayName, b.displayName) : 0)); if (isInDefaultMode) { @@ -194,7 +187,7 @@ function getOrderedReportIDs( // Now that we have all the reports grouped and sorted, they must be flattened into an array and only return the reportID. // The order the arrays are concatenated in matters and will determine the order that the groups are displayed in the sidebar. - const LHNReports = [...pinnedAndGBRReports, ...errorReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report?.reportID ?? ''); + const LHNReports = [...pinnedAndGBRReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report?.reportID ?? ''); return LHNReports; } diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 6e2ee68b4eff..dfdabff3666a 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -683,7 +683,7 @@ function hasViolation(transactionID: string, transactionViolations: OnyxCollecti * Checks if any violations for the provided transaction are of type 'notice' */ function hasNoticeTypeViolation(transactionID: string, transactionViolations: OnyxCollection): boolean { - return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === 'notice'); + return !!transactionViolations?.[ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + transactionID]?.some((violation: TransactionViolation) => violation.type === CONST.VIOLATION_TYPES.NOTICE); } /** diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 2ceecb42dba5..686db5e6a6c5 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -7,7 +7,7 @@ import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PolicyCategories, PolicyTagList, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {PolicyCategories, PolicyTagList, Transaction, TransactionViolation, ViolationName} from '@src/types/onyx'; /** * Calculates tag out of policy and missing tag violations for the given transaction @@ -49,17 +49,41 @@ function getTagViolationsForSingleLevelTags( } /** - * Calculates some tag levels required and missing tag violations for the given transaction + * Calculates missing tag violations for policies with dependent tags */ -function getTagViolationsForMultiLevelTags( - updatedTransaction: Transaction, - transactionViolations: TransactionViolation[], - policyRequiresTags: boolean, - policyTagList: PolicyTagList, -): TransactionViolation[] { +function getTagViolationsForDependentTags(policyTagList: PolicyTagList, transactionViolations: TransactionViolation[], tagName: string) { + const tagViolations = [...transactionViolations]; + + if (!tagName) { + Object.values(policyTagList).forEach((tagList) => + tagViolations.push({ + name: CONST.VIOLATIONS.MISSING_TAG, + type: CONST.VIOLATION_TYPES.VIOLATION, + data: {tagName: tagList.name}, + }), + ); + } else { + const tags = TransactionUtils.getTagArrayFromName(tagName); + if (Object.keys(policyTagList).length !== tags.length || tags.includes('')) { + tagViolations.push({ + name: CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED, + type: CONST.VIOLATION_TYPES.VIOLATION, + data: {}, + }); + } + } + + return tagViolations; +} + +/** + * Calculates missing tag violations for policies with independent tags + */ +function getTagViolationForIndependentTags(policyTagList: PolicyTagList, transactionViolations: TransactionViolation[], transaction: Transaction) { const policyTagKeys = getSortedTagKeys(policyTagList); - const selectedTags = updatedTransaction.tag?.split(CONST.COLON) ?? []; + const selectedTags = transaction.tag?.split(CONST.COLON) ?? []; let newTransactionViolations = [...transactionViolations]; + newTransactionViolations = newTransactionViolations.filter( (violation) => violation.name !== CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED && violation.name !== CONST.VIOLATIONS.TAG_OUT_OF_POLICY, ); @@ -109,6 +133,30 @@ function getTagViolationsForMultiLevelTags( return newTransactionViolations; } +/** + * Calculates tag violations for a transaction on a policy with multi level tags + */ +function getTagViolationsForMultiLevelTags( + updatedTransaction: Transaction, + transactionViolations: TransactionViolation[], + policyTagList: PolicyTagList, + hasDependentTags: boolean, +): TransactionViolation[] { + const tagViolations = [ + CONST.VIOLATIONS.SOME_TAG_LEVELS_REQUIRED, + CONST.VIOLATIONS.TAG_OUT_OF_POLICY, + CONST.VIOLATIONS.MISSING_TAG, + CONST.VIOLATIONS.ALL_TAG_LEVELS_REQUIRED, + ] as ViolationName[]; + const filteredTransactionViolations = transactionViolations.filter((violation) => !tagViolations.includes(violation.name)); + + if (hasDependentTags) { + return getTagViolationsForDependentTags(policyTagList, filteredTransactionViolations, updatedTransaction.tag ?? ''); + } + + return getTagViolationForIndependentTags(policyTagList, filteredTransactionViolations, updatedTransaction); +} + const ViolationsUtils = { /** * Checks a transaction for policy violations and returns an object with Onyx method, key and updated transaction @@ -121,6 +169,7 @@ const ViolationsUtils = { policyTagList: PolicyTagList, policyRequiresCategories: boolean, policyCategories: PolicyCategories, + hasDependentTags: boolean, ): OnyxUpdate { const isPartialTransaction = TransactionUtils.isPartialMerchant(TransactionUtils.getMerchant(updatedTransaction)) && TransactionUtils.isAmountMissing(updatedTransaction); if (isPartialTransaction) { @@ -166,7 +215,7 @@ const ViolationsUtils = { newTransactionViolations = Object.keys(policyTagList).length === 1 ? getTagViolationsForSingleLevelTags(updatedTransaction, newTransactionViolations, policyRequiresTags, policyTagList) - : getTagViolationsForMultiLevelTags(updatedTransaction, newTransactionViolations, policyRequiresTags, policyTagList); + : getTagViolationsForMultiLevelTags(updatedTransaction, newTransactionViolations, policyTagList, hasDependentTags); } return { diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index cddb2c371a60..023358cff248 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -87,10 +87,9 @@ function openPersonalBankAccountSetupView(exitReportID?: string) { } /** - * TODO: remove the previous function and rename this function to openPersonalBankAccountSetupView after migrating to the new flow - * Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished. + * Open the personal bank account setup flow using Plaid, with an optional exitReportID to redirect to once the flow is finished. */ -function openPersonalBankAccountSetupViewRefactor(exitReportID?: string) { +function openPersonalBankAccountSetupWithPlaid(exitReportID?: string) { clearPlaid().then(() => { if (exitReportID) { Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID}); @@ -232,6 +231,13 @@ function addPersonalBankAccount(account: PlaidBankAccount) { shouldShowSuccess: true, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.USER_WALLET, + value: { + currentStep: CONST.WALLET.STEP.ADDITIONAL_DETAILS, + }, + }, ], failureData: [ { @@ -562,7 +568,7 @@ export { validateBankAccount, verifyIdentityForBankAccount, setReimbursementAccountLoading, - openPersonalBankAccountSetupViewRefactor, + openPersonalBankAccountSetupWithPlaid, updateAddPersonalBankAccountDraft, clearPersonalBankAccountSetupType, validatePlaidSelection, diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 756ef902d913..9a011d88e582 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -5,7 +5,7 @@ import type {ActivatePhysicalExpensifyCardParams, ReportVirtualExpensifyCardFrau import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Response} from '@src/types/onyx'; +import type {ExpensifyCardDetails} from '@src/types/onyx/Card'; type ReplacementReason = 'damaged' | 'stolen'; @@ -158,7 +158,7 @@ function clearCardListErrors(cardID: number) { * * @returns promise with card details object */ -function revealVirtualCardDetails(cardID: number): Promise { +function revealVirtualCardDetails(cardID: number): Promise { return new Promise((resolve, reject) => { const parameters: RevealExpensifyCardDetailsParams = {cardID}; @@ -170,7 +170,7 @@ function revealVirtualCardDetails(cardID: number): Promise { reject('cardPage.cardDetailsLoadingFailure'); return; } - resolve(response); + resolve(response as ExpensifyCardDetails); }) // eslint-disable-next-line prefer-promise-reject-errors .catch(() => reject('cardPage.cardDetailsLoadingFailure')); diff --git a/src/libs/actions/ExitSurvey.ts b/src/libs/actions/ExitSurvey.ts index ef3ecd6d3e31..67ac39d81bd6 100644 --- a/src/libs/actions/ExitSurvey.ts +++ b/src/libs/actions/ExitSurvey.ts @@ -65,7 +65,8 @@ function switchToOldDot() { }, ]; - API.write( + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects( 'SwitchToOldDot', { reason: exitReason, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 1bd4de43acfb..c4484ca04b0c 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -506,6 +506,7 @@ function buildOnyxDataForMoneyRequest( ...iouReport, lastMessageText: iouAction.message?.[0]?.text, lastMessageHtml: iouAction.message?.[0]?.html, + lastVisibleActionCreated: iouAction.created, pendingFields: { ...(shouldCreateNewMoneyRequestReport ? {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : {preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, @@ -822,7 +823,15 @@ function buildOnyxDataForMoneyRequest( return [optimisticData, successData, failureData]; } - const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}); + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + !!policy.requiresTag, + policyTagList ?? {}, + !!policy.requiresCategory, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ); if (violationsOnyxData) { optimisticData.push(violationsOnyxData); @@ -1136,7 +1145,15 @@ function buildOnyxDataForInvoice( return [optimisticData, successData, failureData]; } - const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}); + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + !!policy.requiresTag, + policyTagList ?? {}, + !!policy.requiresCategory, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ); if (violationsOnyxData) { optimisticData.push(violationsOnyxData); @@ -1505,7 +1522,15 @@ function buildOnyxDataForTrackExpense( return [optimisticData, successData, failureData]; } - const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}); + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData( + transaction, + [], + !!policy.requiresTag, + policyTagList ?? {}, + !!policy.requiresCategory, + policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), + ); if (violationsOnyxData) { optimisticData.push(violationsOnyxData); @@ -1983,6 +2008,7 @@ function getMoneyRequestInformation( reportPreviewAction = ReportUtils.updateReportPreview(iouReport, reportPreviewAction as ReportPreviewAction, false, comment, optimisticTransaction); } else { reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, iouReport, comment, optimisticTransaction); + chatReport.lastVisibleActionCreated = reportPreviewAction.created; // Generated ReportPreview action is a parent report action of the iou report. // We are setting the iou report's parentReportActionID to display subtitle correctly in IOU page when offline. @@ -2675,6 +2701,7 @@ function getUpdateMoneyRequestParams( policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}, + PolicyUtils.hasDependentTags(policy, policyTagList ?? {}), ), ); failureData.push({ @@ -3835,6 +3862,7 @@ function createSplitsAndOnyxData( splitChatReport.lastMessageText = splitIOUReportAction.message?.[0]?.text; splitChatReport.lastMessageHtml = splitIOUReportAction.message?.[0]?.html; splitChatReport.lastActorAccountID = currentUserAccountID; + splitChatReport.lastVisibleActionCreated = splitIOUReportAction.created; let splitChatReportNotificationPreference = splitChatReport.notificationPreference; if (splitChatReportNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { @@ -4639,7 +4667,7 @@ function startSplitBill({ API.write(WRITE_COMMANDS.START_SPLIT_BILL, parameters, {optimisticData, successData, failureData}); Navigation.dismissModalWithReport(splitChatReport); - Report.notifyNewAction(splitChatReport.chatReportID ?? '', currentUserAccountID); + Report.notifyNewAction(splitChatReport.reportID ?? '', currentUserAccountID); } /** Used for editing a split expense while it's still scanning or when SmartScan fails, it completes a split expense started by startSplitBill above. @@ -5130,6 +5158,7 @@ function editRegularMoneyRequest( policyTags, !!policy.requiresCategory, policyCategories, + PolicyUtils.hasDependentTags(policy, policyTags), ); optimisticData.push(updatedViolationsOnyxData); failureData.push({ @@ -5510,13 +5539,12 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor // STEP 7: Navigate the user depending on which page they are on and which resources were deleted if (iouReport && isSingleTransactionView && shouldDeleteTransactionThread && !shouldDeleteIOUReport) { // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(iouReport.reportID)); - return; + return ROUTES.REPORT_WITH_ID.getRoute(iouReport.reportID); } if (iouReport?.chatReportID && shouldDeleteIOUReport) { // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(iouReport.chatReportID)); + return ROUTES.REPORT_WITH_ID.getRoute(iouReport.chatReportID); } } @@ -5536,7 +5564,7 @@ function deleteTrackExpense(chatReportID: string, transactionID: string, reportA // STEP 7: Navigate the user depending on which page they are on and which resources were deleted if (isSingleTransactionView && shouldDeleteTransactionThread) { // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID ?? '')); + return ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID ?? ''); } } diff --git a/src/libs/actions/Network.ts b/src/libs/actions/Network.ts index 9c88403b0e98..883e336d6c90 100644 --- a/src/libs/actions/Network.ts +++ b/src/libs/actions/Network.ts @@ -1,12 +1,24 @@ import Onyx from 'react-native-onyx'; +import Log from '@libs/Log'; import type {NetworkStatus} from '@libs/NetworkConnection'; import ONYXKEYS from '@src/ONYXKEYS'; -function setIsBackendReachable(isBackendReachable: boolean) { +function setIsBackendReachable(isBackendReachable: boolean, reason: string) { + if (isBackendReachable) { + Log.info(`[Network] Backend is reachable because: ${reason}`); + } else { + Log.info(`[Network] Backend is not reachable because: ${reason}`); + } Onyx.merge(ONYXKEYS.NETWORK, {isBackendReachable}); } -function setIsOffline(isOffline: boolean) { +function setIsOffline(isOffline: boolean, reason = '') { + if (reason) { + let textToLog = '[Network] Client is'; + textToLog += isOffline ? ' entering offline mode' : ' back online'; + textToLog += ` because: ${reason}`; + Log.info(textToLog); + } Onyx.merge(ONYXKEYS.NETWORK, {isOffline}); } diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts index 294712666bdf..5db8be5b8a5a 100644 --- a/src/libs/actions/Policy/Category.ts +++ b/src/libs/actions/Policy/Category.ts @@ -279,7 +279,6 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string [policyCategory.newName]: { ...policyCategoryToUpdate, name: policyCategory.newName, - unencodedName: decodeURIComponent(policyCategory.newName), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: { name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -298,7 +297,6 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string [policyCategory.newName]: { ...policyCategoryToUpdate, name: policyCategory.newName, - unencodedName: decodeURIComponent(policyCategory.newName), errors: null, pendingAction: null, pendingFields: { @@ -317,7 +315,6 @@ function renamePolicyCategory(policyID: string, policyCategory: {oldName: string [policyCategory.oldName]: { ...policyCategoryToUpdate, name: policyCategory.oldName, - unencodedName: decodeURIComponent(policyCategory.oldName), errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.updateFailureMessage'), pendingAction: null, }, diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 6b0a9c5c3ee0..f3f8dfb4f3c7 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -530,12 +530,12 @@ function updateDistanceTaxClaimableValue(policyID: string, customUnit: CustomUni for (const rateID of Object.keys(customUnit.rates)) { if (rateIDs.includes(rateID)) { const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID); - optimisticRates[rateID] = {...foundRate, pendingFields: {rate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; - successRates[rateID] = {...foundRate, pendingFields: {rate: null}}; + optimisticRates[rateID] = {...foundRate, pendingFields: {taxClaimablePercentage: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; + successRates[rateID] = {...foundRate, pendingFields: {taxClaimablePercentage: null}}; failureRates[rateID] = { ...currentRates[rateID], - pendingFields: {rate: null}, - errorFields: {rate: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + pendingFields: {taxClaimablePercentage: null}, + errorFields: {taxClaimablePercentage: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, }; } } @@ -601,12 +601,12 @@ function updateDistanceTaxRate(policyID: string, customUnit: CustomUnit, customU for (const rateID of Object.keys(customUnit.rates)) { if (rateIDs.includes(rateID)) { const foundRate = customUnitRates.find((rate) => rate.customUnitRateID === rateID); - optimisticRates[rateID] = {...foundRate, pendingFields: {rate: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; - successRates[rateID] = {...foundRate, pendingFields: {rate: null}}; + optimisticRates[rateID] = {...foundRate, pendingFields: {taxRateExternalID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}}; + successRates[rateID] = {...foundRate, pendingFields: {taxRateExternalID: null}}; failureRates[rateID] = { ...currentRates[rateID], - pendingFields: {rate: null}, - errorFields: {rate: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, + pendingFields: {taxRateExternalID: null}, + errorFields: {taxRateExternalID: ErrorUtils.getMicroSecondOnyxError('common.genericErrorMessage')}, }; } } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 0e168f973078..8d72b0a5ac65 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1452,6 +1452,7 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol customUnits, makeMeAdmin, autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, employeeList: { [sessionEmail]: { role: CONST.POLICY.ROLE.ADMIN, @@ -1574,6 +1575,7 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName outputCurrency, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, harvesting: { enabled: true, @@ -1837,6 +1839,7 @@ function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policy outputCurrency, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, harvesting: { enabled: true, @@ -2109,6 +2112,7 @@ function createWorkspaceFromIOUPayment(iouReport: Report | EmptyObject): string outputCurrency: CONST.CURRENCY.USD, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, harvesting: { enabled: true, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 1262e8af7d44..1c2e18a885ae 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -7,6 +7,7 @@ import Onyx from 'react-native-onyx'; import type {PartialDeep, ValueOf} from 'type-fest'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; +import AccountUtils from '@libs/AccountUtils'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import * as API from '@libs/API'; import type { @@ -163,14 +164,6 @@ Onyx.connect({ }, }); -let guideCalendarLink: string | undefined; -Onyx.connect({ - key: ONYXKEYS.ACCOUNT, - callback: (value) => { - guideCalendarLink = value?.guideCalendarLink ?? undefined; - }, -}); - let preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE; Onyx.connect({ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, @@ -477,7 +470,7 @@ function addActions(reportID: string, text = '', file?: FileObject) { const lastCommentText = ReportUtils.formatReportLastMessageText(lastComment?.text ?? ''); const optimisticReport: Partial = { - lastVisibleActionCreated: currentTime, + lastVisibleActionCreated: lastAction?.created, lastMessageTranslationKey: lastComment?.translationKey ?? '', lastMessageText: lastCommentText, lastMessageHtml: lastCommentText, @@ -1027,7 +1020,6 @@ function navigateToAndOpenReportWithAccountIDs(participantAccountIDs: number[]) */ function navigateToAndOpenChildReport(childReportID = '0', parentReportAction: Partial = {}, parentReportID = '0') { if (childReportID !== '0') { - openReport(childReportID); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); } else { const participantAccountIDs = [...new Set([currentUserAccountID, Number(parentReportAction.actorAccountID)])]; @@ -2016,6 +2008,17 @@ function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageA } } +/** + * Navigates to the 1:1 system chat + */ +function navigateToSystemChat() { + const systemChatReport = ReportUtils.getSystemChat(); + + if (systemChatReport?.reportID) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(systemChatReport.reportID)); + } +} + /** Add a policy report (workspace room) optimistically and navigate to it. */ function addPolicyReport(policyReport: ReportUtils.OptimisticChatReport) { const createdReportAction = ReportUtils.buildOptimisticCreatedReportAction(CONST.POLICY.OWNER_EMAIL_FAKE); @@ -3123,7 +3126,9 @@ function completeOnboarding( }, adminsChatReportID?: string, ) { - const targetEmail = CONST.EMAIL.CONCIERGE; + const isAccountIDOdd = AccountUtils.isAccountIDOddNumber(currentUserAccountID ?? 0); + const targetEmail = isAccountIDOdd ? CONST.EMAIL.NOTIFICATIONS : CONST.EMAIL.CONCIERGE; + const actorAccountID = PersonalDetailsUtils.getAccountIDsByLogins([targetEmail])[0]; const targetChatReport = ReportUtils.getChatByParticipants([actorAccountID, currentUserAccountID]); const {reportID: targetChatReportID = '', policyID: targetChatPolicyID = ''} = targetChatReport ?? {}; @@ -3163,7 +3168,6 @@ function completeOnboarding( typeof task.description === 'function' ? task.description({ adminsRoomLink: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.REPORT_WITH_ID.getRoute(adminsChatReportID ?? '')}`, - guideCalendarLink: guideCalendarLink ?? CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL, }) : task.description; const currentTask = ReportUtils.buildOptimisticTaskReport( @@ -3295,6 +3299,50 @@ function completeOnboarding( return acc; }, []); + const tasksForSuccessData = tasksData.reduce((acc, {currentTask, taskCreatedAction, taskReportAction, completedTaskReportAction}) => { + acc.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [taskReportAction.reportAction.reportActionID]: {pendingAction: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${currentTask.reportID}`, + value: { + pendingFields: { + createChat: null, + reportName: null, + description: null, + managerID: null, + }, + isOptimisticReport: false, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [taskCreatedAction.reportActionID]: {pendingAction: null}, + }, + }, + ); + + if (completedTaskReportAction) { + acc.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [completedTaskReportAction.reportActionID]: {pendingAction: null}, + }, + }); + } + + return acc; + }, []); + const optimisticData: OnyxUpdate[] = [ ...tasksForOptimisticData, { @@ -3319,6 +3367,7 @@ function completeOnboarding( }, ]; const successData: OnyxUpdate[] = [ + ...tasksForSuccessData, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, @@ -3413,6 +3462,7 @@ function completeOnboarding( engagementChoice, firstName, lastName, + actorAccountID, guidedSetupData: JSON.stringify(guidedSetupData), }; @@ -3692,6 +3742,7 @@ export { saveReportActionDraft, deleteReportComment, navigateToConciergeChat, + navigateToSystemChat, addPolicyReport, deleteReport, navigateToConciergeChatAndDeleteReport, diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 55d898d3d4f3..57b551510d58 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -131,10 +131,10 @@ function createTaskAndNavigate( const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `task for ${title}`, parentReportID); optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID; - const currentTime = DateUtils.getDBTime(); + const currentTime = DateUtils.getDBTimeWithSkew(); const lastCommentText = ReportUtils.formatReportLastMessageText(optimisticAddCommentReport?.reportAction?.message?.[0]?.text ?? ''); const optimisticParentReport = { - lastVisibleActionCreated: currentTime, + lastVisibleActionCreated: optimisticAddCommentReport.reportAction.created, lastMessageText: lastCommentText, lastActorAccountID: currentUserAccountID, lastReadTime: currentTime, diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index 045cc34f39ef..c12c44c1ed74 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -61,10 +61,6 @@ function setAdditionalDetailsErrors(errorFields: OnyxCommon.ErrorFields) { Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {errorFields}); } -function setAdditionalDetailsErrorMessage(additionalErrorMessage: string) { - Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {additionalErrorMessage}); -} - /** * Save the source that triggered the KYC wall and optionally the chat report ID associated with the IOU */ @@ -222,7 +218,7 @@ function openEnablePaymentsPage() { API.read(READ_COMMANDS.OPEN_ENABLE_PAYMENTS_PAGE, {}); } -function updateCurrentStep(currentStep: ValueOf) { +function updateCurrentStep(currentStep: ValueOf | null) { Onyx.merge(ONYXKEYS.USER_WALLET, {currentStep}); } @@ -304,7 +300,6 @@ export { openInitialSettingsPage, openEnablePaymentsPage, setAdditionalDetailsErrors, - setAdditionalDetailsErrorMessage, setAdditionalDetailsQuestions, updateCurrentStep, answerQuestionsForWallet, diff --git a/src/libs/models/BankAccount.ts b/src/libs/models/BankAccount.ts index a8dea594daa5..a86a30b57e86 100644 --- a/src/libs/models/BankAccount.ts +++ b/src/libs/models/BankAccount.ts @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -import type {AdditionalData} from '@src/types/onyx/BankAccount'; +import type {BankAccountAdditionalData} from '@src/types/onyx/BankAccount'; import type BankAccountJSON from '@src/types/onyx/BankAccount'; type State = ValueOf; @@ -194,7 +194,7 @@ class BankAccount { /** * Get the additional data of a bankAccount */ - getAdditionalData(): Partial { + getAdditionalData(): Partial { return this.json.accountData?.additionalData ?? {}; } diff --git a/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.android.tsx b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.android.tsx new file mode 100644 index 000000000000..289c56ad69be --- /dev/null +++ b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.android.tsx @@ -0,0 +1,5 @@ +import type ShouldIgnoreSelectionWhenUpdatedManually from './types'; + +const shouldIgnoreSelectionWhenUpdatedManually: ShouldIgnoreSelectionWhenUpdatedManually = true; + +export default shouldIgnoreSelectionWhenUpdatedManually; diff --git a/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.tsx b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.tsx new file mode 100644 index 000000000000..744a94aa1f32 --- /dev/null +++ b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/index.tsx @@ -0,0 +1,5 @@ +import type ShouldIgnoreSelectionWhenUpdatedManually from './types'; + +const shouldIgnoreSelectionWhenUpdatedManually: ShouldIgnoreSelectionWhenUpdatedManually = false; + +export default shouldIgnoreSelectionWhenUpdatedManually; diff --git a/src/libs/shouldIgnoreSelectionWhenUpdatedManually/types.ts b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/types.ts new file mode 100644 index 000000000000..56394183ef7d --- /dev/null +++ b/src/libs/shouldIgnoreSelectionWhenUpdatedManually/types.ts @@ -0,0 +1,3 @@ +type ShouldIgnoreSelectionWhenUpdatedManually = boolean; + +export default ShouldIgnoreSelectionWhenUpdatedManually; diff --git a/src/libs/updateMultilineInputRange/index.ts b/src/libs/updateMultilineInputRange/index.ts index a0ad76096922..8bdccd83db90 100644 --- a/src/libs/updateMultilineInputRange/index.ts +++ b/src/libs/updateMultilineInputRange/index.ts @@ -15,9 +15,9 @@ const updateMultilineInputRange: UpdateMultilineInputRange = (input, shouldAutoF } if ('value' in input && input.value && input.setSelectionRange) { - const length = input.value.length; + const length = input.value.length as number; if (shouldAutoFocus) { - input.setSelectionRange(length, length); + (input as HTMLInputElement).setSelectionRange(length, length); } // eslint-disable-next-line no-param-reassign input.scrollTop = input.scrollHeight; diff --git a/src/pages/AddPersonalBankAccountPage.tsx b/src/pages/AddPersonalBankAccountPage.tsx index ac02cd26879b..e306fa0550ac 100644 --- a/src/pages/AddPersonalBankAccountPage.tsx +++ b/src/pages/AddPersonalBankAccountPage.tsx @@ -89,7 +89,9 @@ function AddPersonalBankAccountPage({personalBankAccount, plaidData}: AddPersona > Navigation.goBack()} receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()} selectedPlaidAccountID={selectedPlaidAccountId} diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index 5cd26dafef44..0ff04deb09f0 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -193,6 +193,7 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa showLoadingPlaceholder={!areOptionsInitialized || !isScreenTransitionEnd} footerContent={!isDismissed && ChatFinderPageFooterInstance} isLoadingNewOptions={!!isSearchingForReports} + shouldDelayFocus={false} /> ); diff --git a/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx b/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx index 79c91af178c3..a274990ea6a7 100644 --- a/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx +++ b/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -12,6 +12,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@navigation/Navigation'; import * as BankAccounts from '@userActions/BankAccounts'; import * as PaymentMethods from '@userActions/PaymentMethods'; +import * as Wallet from '@userActions/Wallet'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -44,7 +45,6 @@ function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraf if (selectedPlaidBankAccount) { BankAccounts.addPersonalBankAccount(selectedPlaidBankAccount); - Navigation.navigate(ROUTES.SETTINGS_ENABLE_PAYMENTS); } }, [personalBankAccountDraft?.plaidAccountID, plaidData?.bankAccounts]); @@ -54,7 +54,6 @@ function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraf const exitFlow = (shouldContinue = false) => { const exitReportID = personalBankAccount?.exitReportID; - // TODO: https://github.com/Expensify/App/issues/36648 This should be updated to the correct route once the refactor is complete const onSuccessFallbackRoute = personalBankAccount?.onSuccessFallbackRoute ?? ''; if (exitReportID) { @@ -75,18 +74,19 @@ function AddBankAccount({personalBankAccount, plaidData, personalBankAccountDraf } if (screenIndex === 0) { BankAccounts.clearPersonalBankAccount(); + Wallet.updateCurrentStep(null); + Navigation.goBack(ROUTES.SETTINGS_WALLET); return; } prevScreen(); }; - useEffect(() => BankAccounts.clearPersonalBankAccount, []); - return ( {!!plaidDesktopMessage && ( - {translate(plaidDesktopMessage)} + {translate(plaidDesktopMessage)} )}