diff --git a/.github/actions/composite/setupGitForOSBotify/action.yml b/.github/actions/composite/setupGitForOSBotify/action.yml index adf90789976c..0c216e400248 100644 --- a/.github/actions/composite/setupGitForOSBotify/action.yml +++ b/.github/actions/composite/setupGitForOSBotify/action.yml @@ -2,6 +2,9 @@ name: 'Setup Git for OSBotify' description: 'Setup Git for OSBotify' inputs: + OP_VAULT: + description: 1Password vault where OSBotify GPG key can be found + required: true OP_SERVICE_ACCOUNT_TOKEN: description: "1Password service account token" required: true @@ -16,7 +19,7 @@ runs: shell: bash env: OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.OP_SERVICE_ACCOUNT_TOKEN }} - run: op read "op://${{ vars.OP_VAULT }}/OSBotify-private-key.asc/OSBotify-private-key.asc" --force --out-file ./OSBotify-private-key.asc + run: op read "op://${{ inputs.OP_VAULT }}/OSBotify-private-key.asc/OSBotify-private-key.asc" --force --out-file ./OSBotify-private-key.asc - name: Import OSBotify GPG Key shell: bash diff --git a/.github/actions/composite/setupGitForOSBotifyApp/action.yml b/.github/actions/composite/setupGitForOSBotifyApp/action.yml index 559bff9a648b..246f9d29e6ac 100644 --- a/.github/actions/composite/setupGitForOSBotifyApp/action.yml +++ b/.github/actions/composite/setupGitForOSBotifyApp/action.yml @@ -5,6 +5,9 @@ name: "Setup Git for OSBotify" description: "Setup Git for OSBotify" inputs: + OP_VAULT: + description: 1Password vault where OSBotify GPG key can be found + required: true OP_SERVICE_ACCOUNT_TOKEN: description: "1Password service account token" required: true @@ -31,7 +34,7 @@ runs: shell: bash env: OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.OP_SERVICE_ACCOUNT_TOKEN }} - run: op read "op://${{ vars.OP_VAULT }}/OSBotify-private-key.asc/OSBotify-private-key.asc" --force --out-file ./OSBotify-private-key.asc + run: op read "op://${{ inputs.OP_VAULT }}/OSBotify-private-key.asc/OSBotify-private-key.asc" --force --out-file ./OSBotify-private-key.asc - name: Import OSBotify GPG Key shell: bash diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index 5816de115e86..19882cac0148 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -27,6 +27,7 @@ jobs: id: setupGitForOSBotify uses: ./.github/actions/composite/setupGitForOSBotifyApp with: + OP_VAULT: ${{ vars.OP_VAULT }} OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} diff --git a/.github/workflows/createNewVersion.yml b/.github/workflows/createNewVersion.yml index 34fea70f9dac..ca1f377196df 100644 --- a/.github/workflows/createNewVersion.yml +++ b/.github/workflows/createNewVersion.yml @@ -64,6 +64,7 @@ jobs: uses: ./.github/actions/composite/setupGitForOSBotify id: setupGitForOSBotify with: + OP_VAULT: ${{ vars.OP_VAULT }} OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} - name: Generate new E/App version diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e0d8d0db7119..1973c58ac4b6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,6 +32,7 @@ jobs: uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify with: + OP_VAULT: ${{ vars.OP_VAULT }} OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} @@ -377,6 +378,7 @@ jobs: run: | op read "op://${{ vars.OP_VAULT }}/NewApp_AppStore/NewApp_AppStore.mobileprovision" --force --out-file ./NewApp_AppStore.mobileprovision op read "op://${{ vars.OP_VAULT }}/NewApp_AppStore_Notification_Service/NewApp_AppStore_Notification_Service.mobileprovision" --force --out-file ./NewApp_AppStore_Notification_Service.mobileprovision + op read "op://${{ vars.OP_VAULT }}/NewApp_AppStore_Share_Extension/NewApp_AppStore_Share_Extension.mobileprovision" --force --out-file ./NewApp_AppStore_Share_Extension.mobileprovision op read "op://${{ vars.OP_VAULT }}/New Expensify Distribution Certificate/Certificates.p12" --force --out-file ./Certificates.p12 op read "op://${{ vars.OP_VAULT }}/ios-fastlane-json-key.json/ios-fastlane-json-key.json" --force --out-file ./ios-fastlane-json-key.json diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index ca030e95de1d..ef24ba98fd61 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -22,6 +22,7 @@ jobs: uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify with: + OP_VAULT: ${{ vars.OP_VAULT }} OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} @@ -87,6 +88,7 @@ jobs: id: setupGitForOSBotify uses: ./.github/actions/composite/setupGitForOSBotifyApp with: + OP_VAULT: ${{ vars.OP_VAULT }} OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} @@ -128,6 +130,7 @@ jobs: - name: Setup git for OSBotify uses: ./.github/actions/composite/setupGitForOSBotifyApp with: + OP_VAULT: ${{ vars.OP_VAULT }} OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index 10ca10882464..8f9c00f314ca 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -104,6 +104,7 @@ jobs: - name: Setup Git for OSBotify uses: ./.github/actions/composite/setupGitForOSBotifyApp with: + OP_VAULT: ${{ vars.OP_VAULT }} OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index e6af1475d604..fc337b868392 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -186,7 +186,7 @@ jobs: run: | op read "op://${{ vars.OP_VAULT }}/NewApp_AdHoc/NewApp_AdHoc.mobileprovision" --force --out-file ./NewApp_AdHoc.mobileprovision op read "op://${{ vars.OP_VAULT }}/NewApp_AdHoc_Notification_Service/NewApp_AdHoc_Notification_Service.mobileprovision" --force --out-file ./NewApp_AdHoc_Notification_Service.mobileprovision - op read "op://${{ vars.OP_VAULT }}/NewApp_AdHoc_Share_Extension.mobileprovision/NewApp_AdHoc_Share_Extension.mobileprovision" --force --out-file ./NewApp_AdHoc_Share_Extension.mobileprovision + op read "op://${{ vars.OP_VAULT }}/NewApp_AdHoc_Share_Extension/NewApp_AdHoc_Share_Extension.mobileprovision" --force --out-file ./NewApp_AdHoc_Share_Extension.mobileprovision op read "op://${{ vars.OP_VAULT }}/New Expensify Distribution Certificate/Certificates.p12" --force --out-file ./Certificates.p12 - name: Configure AWS Credentials diff --git a/Mobile-Expensify b/Mobile-Expensify index 5aa866093c9e..c92e59b1cc08 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 5aa866093c9eed80a527ede55f8926c4c22a947d +Subproject commit c92e59b1cc08497d1b00adce8fe471cbfe23caa0 diff --git a/android/app/build.gradle b/android/app/build.gradle index 34dc321c4b64..f2e22693e65b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009009600 - versionName "9.0.96-0" + versionCode 1009009800 + versionName "9.0.98-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/images/simple-illustrations/simple-illustration__creditcards--green.svg b/assets/images/simple-illustrations/simple-illustration__creditcards--green.svg new file mode 100644 index 000000000000..bd74430af00a --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__creditcards--green.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/contributingGuides/APPLE_GOOGLE_SIGNIN.md b/contributingGuides/APPLE_GOOGLE_SIGNIN.md index cc3e256be399..dbbd7a564d7b 100644 --- a/contributingGuides/APPLE_GOOGLE_SIGNIN.md +++ b/contributingGuides/APPLE_GOOGLE_SIGNIN.md @@ -157,7 +157,7 @@ index 4286a26033..850f8944ca 100644 index ca2da6f56b..2c191598f0 100644 --- a/src/libs/Navigation/linkingConfig/prefixes.ts +++ b/src/libs/Navigation/linkingConfig/prefixes.ts - @@ -8,6 +8,7 @@ const prefixes: LinkingOptions['prefixes'] = [ + @@ -8,6 +8,7 @@ const prefixes: LinkingOptions['prefixes'] = [ 'https://www.expensify.cash', 'https://staging.expensify.cash', 'https://dev.new.expensify.com', diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 6c0a5b460654..f0db6723b4e5 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -207,4 +207,4 @@ The action for the first step created with `getMinimalAction` looks like this: ``` ### Deeplinking -There is no minimal action for deeplinking directly to the `Profile` screen. But because the `Settings_root` is not on the stack, pressing UP will reset the params for navigators to the correct ones. +There is no minimal action for deeplinking directly to the `Profile` screen. But because the `Settings_root` is not on the stack, pressing UP will reset the params for navigators to the correct ones. \ No newline at end of file diff --git a/contributingGuides/NAVIGATION_TESTS.md b/contributingGuides/NAVIGATION_TESTS.md new file mode 100644 index 000000000000..adb98e79712e --- /dev/null +++ b/contributingGuides/NAVIGATION_TESTS.md @@ -0,0 +1,270 @@ +# Navigation tests + +#### There should be a proper report under attachment screen after reload + +1. Open any report with image attachment on narrow layout. +2. Open attachment. +3. Reload the page. +4. Verify that after pressing back arrow in the header you are on the report where you sent the attachment. + + +#### There is a proper split navigator under RHP with a sidebar screen only for screens that can be opened from the sidebar + +1. Open the browser on narrow layout with url `/settings/profile/status`. +2. Reload the page. +3. Verify that after pressing back arrow in the header you are on the settings root page. + + +#### There is a proper split navigator under the overlay after refreshing page with RHP/LHP on wide screen + +1. Open the browser on wide screen with url `/settings/profile/display-name`. +2. Verify that you can see settings profile page under the overlay of RHP. + + +#### There is a proper split navigator under the overlay after deeplinking to page with RHP/LHP on wide screen + +1. Open the browser on wide screen. +2. Open any report. +3. Send message with url `/settings/profile/display-name`. +4. Press the sent link +5. Verify that the settings profile screen is now visible under the overlay + +#### The Workspace list page is displayed (SCREENS.SETTINGS.WORKSPACES) after clicking the Settings tab from the Workspace settings screen + +1. Open any workspace settings (Settings → Workspaces → Select any workspace) +2. Click the Settings button on the bottom tab. +3. Verify that the Workspace list is displayed (`/settings/workspaces`) +4. Select any workspace again. +5. Reload the page. +6. Click the Settings button on the bottom tab. +7. Verify that the Workspace list is displayed (`/settings/workspaces`) + + +#### The last visited screen in the settings tab is saved when switching between tabs + +1. Open the app. +2. Go to the settings tab. +3. Open the workspace list. +4. Select any workspace. +5. Switch between tabs and open the settings tabs again. +6. Verify that the last visited page in this tab is displayed. + + +#### The Workspace selected in the application is reset when you select a chat that does not belong to the current policy + +1. Open the home page. +2. Click on the Expensify icon in the upper left corner. +3. Select any workspace. +4. Click on the magnifying glass above the list of available chats. +5. Select a chat that does not belong to the workspace selected in the third step. +6. Verify if the chat is opened and the global workspace is selected. + + +#### The selected workspace is saved between Search and Inbox tabs + +1. Open the Inbox tab. +2. Change the workspace using the workspace switcher. +3. Switch to the Search tab and verify if the workspace selected in the second step is also selected in the Search. +4. Change the workspace once again. +5. Go back to the Inbox. +6. Verify if the workspace selected in the fourth step is also selected in the Inbox tab. + +#### Going up to the workspace list page after refreshing on the workspace settings and pressing the up button + +1. Open the workspace settings from the deep link (use a link in format: `/settings/workspaces/:policyID:/profile`) +2. Click the app’s back button. +3. Verify if the workspace list is displayed. + +#### Going up to the RHP screen provided in the backTo parameter in the url + +1. Open the settings tab. +2. Go to the Profile page. +3. Click the Address button. +4. Click the Country button. +5. Reload the page. +6. Click the app’s back button. +7. Verify if the Profile address page is displayed (`/settings/profile/address`) + +#### There is proper split navigator under the overlay after refreshing page in RHP that includes valid reportID in params + +wide layout : + +1. Open any report. +2. Open report details (press the chat header). +3. Reload the app. +4. Verify that the report under the overlay is the same as the one opened in report details. + +narrow layout : + +1. Open any report +2. Open report details (press the chat header). +3. Reload the app. +4. Verify that after pressing back arrow in the header you are on the report previously seen in the details page. + +#### Navigating back to the Workspace Switcher from the created workspace + +1. Open the app and go to the Inbox tab. +2. Open the workspace switcher (Click on the button in the upper left corner). +3. Create a new workspace by clicking on the + button. +4. Navigate back using the back button in the app. +5. Verify if the workspace switcher is displayed with the report screen below it + +#### Going up to the sidebar screen + +Linked issue: https://github.com/Expensify/App/pull/44138 + +1. Go to Subscription page in the settings tab. +2. Click on Request refund button +3. Verify that modal shown +4. Next click Downgrade... +5. Verify that modal got closed, your account is downgraded and the Home page is opened. + +#### Navigating back from the Search page with invalid query parameters + +1. Open the search page with invalid query parameters (e.g `/search?q=from%3a`) +2. Press the app's back button on the not found page. +3. Verify that the Search page with default query parameters is displayed. + +#### Navigating to the chat from the link in the thread + +1. Open any chat. +2. If there are no messages in the chat, send a message. +3. Press reply in thread. +4. Press the "From" link in the displayed header. +5. Verify if the link correctly redirects to the chat opened in the first step. + +#### Expense - App does not open destination report after submitting expense + +Linked issue: https://github.com/Expensify/App/pull/49539#issuecomment-2432400819 + +1. Launch the app. +2. Open FAB > Submit expense > Manual. +3. Submit a manual expense to any user (as long as the user is not the currrently opened report and the receiver is not workspace chat). +4. Verify if the destination report is opened after submitting expense. + +#### QBO - Preferred exporter/Export date tab do not auto-close after value selected + +Linked issue: https://github.com/Expensify/App/pull/49539#issuecomment-2433342220 + +Precondition: Workspace with QBO integration connected. + +1. Go to Workspace > Accounting. +2. Click on Export > Preferred exporter (or Export date). +3. Click on value. +4. Verify if the value chosen in the third step is selected and the app redirects to the Export page. + +#### Web - Hold - App flickers after entering reason and saving it when holding expense + +Linked issue: https://github.com/Expensify/App/pull/49539#issuecomment-2433389682 + +1. Launch the app. +2. Open DM with any user. +3. Submit two expenses to them. +4. Click on the expense preview to go to expense report. +5. Click on any preview to go to transaction thread. +6. Go back to expense report. +7. Right click on the expense preview in Step 5 > Hold. +8. Enter a reason and save it. +9. Verify if the app does not flicker after entering reason and saving it. + +#### Group - App returns to group settings page after saving group name + +Linked issue: https://github.com/Expensify/App/pull/49539#issuecomment-2433381800 + +1. Launch the app. +2. Create a group chat. +3. Go to group chat. +4. Click on the group chat header. +5. Click Group name field. +6. Click Save. +7. Verify if the app returs to group details RHP after saving group name. + +#### Going up to a screen with any params + +Linked issue: https://github.com/Expensify/App/pull/49539#issuecomment-2432694948 + +1. Press the FAB. +2. Select "Book travel". +3. Press "Book travel" in the new RHP pane. +4. Press "Country". +5. Select any country. +6. Verify that the country you selected is actually visible in the form. + +#### Change params of existing attachments screens instead of pushing new screen on the stack + +Linked issue: https://github.com/Expensify/App/pull/49539#issuecomment-2432360626 + +1. Open any chat. +2. Send at least two images. +3. Open attachment by pressing on image. +4. Press arrow on the side of attachment modal to navigate to the second image. +5. Close the modal with X in the corner. +6. Verify that the modal is now fully closed. + +#### Navigate instead of push for reports with same reportID + +Linked issue: https://github.com/Expensify/App/pull/49539#issuecomment-2433351709 + +1. Open app on wide layout web. +2. Go to report A (any report). +3. Go to report B (any report with message). +4. Press reply in thread. +5. Press on header subtitle. +6. Press on the report B in the sidebar. +7. Verify that the message you replied to is no longer highlighted. +8. Press the browsers back button. +9. Verify that you are on the A report. + + +#### Don't push the default full screen route if not necessary. + +1. Open app on wide layout web. +2. Open search tab. +3. Press track expense. +4. Verify that the split navigator hasn't changed under the overlay. + +#### BA - Back button on connect bank account modal opens incorporation state modal + +Linked issue: https://github.com/Expensify/App/pull/49539#issuecomment-2433261611 + +Precondition: Use staging server (it can be set in Settings >> Troubleshoot) + +1. Launch the app. +2. Navigate to Settings >> Workspaces >> Workspace >> Workflows. +3. Select Connect with Plaid option. +4. Go through the Plaid flow (Added Wells Fargo details). +5. Complete the Personal info, Company info & agreements section. +6. Note user redirected to page with the header Connect bank account and the option to disconnect your now set up bank account. +7. Tap back button on connect bank account modal. +8. Verify if the connect bank account modal is closed and the Workflows page is opened with the bank account added. + +#### App opens room details page when tapping RHP back button after saving Private notes in DM + +Linked issue: https://github.com/Expensify/App/pull/49539#issuecomment-2433321607 + +1. Launch the app. +2. Open DM with any user that does not have content in Private notes. +3. Click on the chat header. +4. Click Private notes. +5. Enter anything and click Save. +6. Click on the RHP back button. +7. Verify if the Profile RHP Page is opened (URL in the format /a/:accountID). + +#### Opening particular onboarding pages from a link and going back + +Linked issue: https://github.com/Expensify/App/issues/50177 + +1. Sign in as a new user. +2. Select Something else from the onboarding flow. +3. Reopen/refresh the app. +4. Verify the Personal detail step is shown. +5. Go back. +6. Verify you are navigated back to the Purpose step. +7. Select Manage my team. +8. Choose the employee size. +9. Reopen/refresh the app. +10. Verify the connection integration step is shown. +11. Go back. +12. Verify you are navigated back to the employee size step. +13. Go back. +14. Verify you are navigated back to the Purpose step. \ No newline at end of file diff --git a/docs/articles/expensify-classic/expensify-billing/Billing-Overview.md b/docs/articles/expensify-classic/expensify-billing/Billing-Overview.md index ae2c790bb3ae..9a9e4b1ceec4 100644 --- a/docs/articles/expensify-classic/expensify-billing/Billing-Overview.md +++ b/docs/articles/expensify-classic/expensify-billing/Billing-Overview.md @@ -1,86 +1,52 @@ --- -title: Billing Overview -description: Learn about Expensify billing, including active member charges, annual subscription savings, and pay-per-use options. Discover how the Expensify Card can reduce costs and maximize value. +title: Billing and Subscriptions +description: Expensify Billing Overview --- -Expensify’s billing is based on monthly member activity. You’ll be charged for the previous month’s usage at the beginning of each month. Your bill depends on: -- Plan type: Annual subscription or pay-per-use. -- Expensify Visa® Commercial Card usage: Discounts are available based on your card spending. +At the beginning of each month, the workspace's Billing Owner is billed for the previous month’s activity. +Your Expensify bill is determined by the following: +- The number of active members in your workspace +- Whether you have a Collect or Control plan +- Whether you’re on pay-per-use or an annual subscription +- Whether you’re using the Expensify Visa® Commercial Card ---- -# How Billing Works -- Billing occurs on the first of each month for the previous month’s usage. -- Only Group Workspace owners are billed. -- View billing receipts in: - Settings > Account > Payments > Billing History. +An active member is any member who creates, submits, approves, reimburses, or exports a report in Expensify in a given month. This includes Copilots and automatic actions by Concierge. -**Tip: Designate one billing owner for all Group Workspaces to streamline billing management.** +Your billing receipts can be viewed under **Settings** > **Account** > **Payments** > **Billing History**. We recommend appointing a single billing owner for each Group Workspace. ---- -## What is an Active Member? -An active member is anyone who performs any of these actions in Expensify during a month: -- Chats -- Creates, submits, approves, reimburses, or exports a report -- Uses the Copilot feature to take an action in another user's account +# Save Money on Your Expensify Bill ---- -# Annual Subscription - -## Key Benefits: -- Save 50% per active member compared to pay-per-use billing. - - Collect plan: $10 per member (vs. $20). - - Control plan: $18 per member (vs. $36). -- Set your monthly active member count upfront and pay a fixed rate. - -## How It Works: -- You’ll be billed for the number of members set in your subscription. -- Extra active members beyond your subscription size are charged at the pay-per-use rate. - -**Example:** -- Plan: Control -- Subscription size: 10 members - - Cost: $18/member x 10 members = $180/month -- Scenario: 12 active members in one month - - Cost for additional two members: $36/member = $72 - - Total bill: $252 - -**Adjustments:** -- You can increase your subscription size by extending your subscription period. -- Reductions are only allowed after your current subscription ends. ---- -# Pay-Per-Use Billing -- Charges apply at full rates with no discounts. - - Collect plan: $20 per active member. - - Control plan: $36 per active member. +## Annual Subscription + Expensify Card ---- -# How the Expensify Card Reduces Your Bill +Save the most money on Expensify by pairing an annual subscription with the Expensify Visa® Commercial Card. Then, if at least 50% of your total settled US spend in a given month is on the Expensify Card, you’ll pay the best possible price for Expensify: -## Bundling Benefits: -- Combine an Expensify Card with an annual subscription for the lowest price per member. -- Spending at least 50% of your total settled US spend on Expensify Cards earns a further 50% discount. +- **Collect Plan:** $5 per active member per month +- **Control Plan:** $9 per active member per month -## Discount Breakdown: -- Collect plan: $5/member. -- Control plan: $9/member. +**You also get cash back!** Earn 1% cash back on all Expensify Card purchases or 2% if card spending reaches $250,000 or more monthly (for US purchases only). Cash back first applies to your Expensify bill, with any remainder deposited directly into your bank account. -## Additional Savings: -- Earn 1% cash back on Expensify Card purchases. - - 2% cash back if total card spend exceeds $250k (US purchases only). - - Cashback is first applied to your bill, reducing costs further. Any surplus is deposited into your bank account. +Use Expensify’s [savings calculator](https://use.expensify.com/resource-center/tools/savings-calculator) to see your potential savings with the Expensify Card. -## Savings Calculator -Use our [savings calculator](https://use.expensify.com/price-savings-calculator) to estimate potential savings and earnings with the Expensify Card. Enter your details to see the results! +## Annual Subscription vs Pay-per-use ---- -# FAQ +**Annual Subscription** + +You get a 50% discount with an annual subscription: + +- **Collect Plan:** $10 per active member per month +- **Control Plan:** $18 per active member per month + +If your active members exceed your subscription size, additional members are billed at the pay-per-use rate for that month. You can increase your subscription size at any time (extending your annual term) but can only reduce it once your current subscription period ends. + +**Pay-per-use** + +Rates for pay-per-use plans are applied at full price: + +- **Collect plan:** $20 per active member per month +- **Control plan:** $36 per active member per month -## What if less than 50% of the spend is on Expensify Cards? -Discounts are applied on a sliding scale based on your Expensify Card spend percentage. +# Transfer Ownership of Billing -**Example:** -- Annual subscription discount: 50% -- Expensify Card spend (US purchases): 20% of total spend -- Expensify Card discount: 20% -- Total savings: 70% discount on the per-member price for that month. +If another member needs to own the billing on a given workspace, they must first be a Workspace Admin. Then, from their account, the Admin will head to **Settings > Workspaces > [Workspace Name] > Members > [Current Workspace Owner] > Transfer Ownership**. +From there, the new billing owner will add a payment card and be charged for the member activity on the workspace on the first of the month. diff --git a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md index 23c1bc58e5fc..943879e1ca8a 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md +++ b/docs/articles/new-expensify/expenses-&-payments/Create-an-expense.md @@ -172,6 +172,10 @@ Yes, you can [split an expense](https://help.expensify.com/articles/new-expensif Yes, you can edit an expense until it is paid. When an expense is submitted, the details can be edited except for the amount and date. +**Can I add multiple receipts to a newly created expense?** + +Yes, you can add multiple receipt images to a new expense. One possible way to do this is to combine all receipts into a single PDF file. From there, simply upload the combined PDF to the expense. + **What are expense reports?** In Expensify, expense reports group expenses in a batch to be paid or reconciled. When a draft report is open, all new expenses are added to it. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index b9930ca92324..0c1a74e35f67 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -353,6 +353,10 @@ platform :ios do path: "./NewApp_AppStore_Notification_Service.mobileprovision" ) + install_provisioning_profile( + path: "./NewApp_AppStore_Share_Extension.mobileprovision" + ) + build_app( workspace: "./ios/NewExpensify.xcworkspace", scheme: "New Expensify", @@ -361,6 +365,7 @@ platform :ios do provisioningProfiles: { "com.chat.expensify.chat" => "(NewApp) AppStore", "com.chat.expensify.chat.NotificationServiceExtension" => "(NewApp) AppStore: Notification Service", + "com.chat.expensify.chat.ShareViewController" => "(NewApp) AppStore: Share Extension", }, manageAppVersionAndBuildNumber: false } @@ -485,6 +490,10 @@ platform :ios do path: "./NewApp_AdHoc_Notification_Service.mobileprovision" ) + install_provisioning_profile( + path: "./NewApp_AdHoc_Share_Extension.mobileprovision" + ) + build_app( workspace: "./ios/NewExpensify.xcworkspace", skip_profile_detection: true, @@ -495,6 +504,7 @@ platform :ios do provisioningProfiles: { "com.expensify.chat.adhoc" => "(NewApp) AdHoc", "com.expensify.chat.adhoc.NotificationServiceExtension" => "(NewApp) AdHoc: Notification Service", + "com.expensify.chat.adhoc.ShareViewController" => "(NewApp) AdHoc: Share Extension", }, manageAppVersionAndBuildNumber: false } diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 273195e3d3ef..39ce30e2ad64 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.96 + 9.0.98 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.0.96.0 + 9.0.98.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index efc2b7c9e3d3..8b582bea0735 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.96 + 9.0.98 CFBundleSignature ???? CFBundleVersion - 9.0.96.0 + 9.0.98.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 86725f46a824..90b593948766 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.96 + 9.0.98 CFBundleVersion - 9.0.96.0 + 9.0.98.0 NSExtension NSExtensionPointIdentifier diff --git a/jest.config.js b/jest.config.js index 13645e720c8e..ea9a23f01362 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,7 @@ module.exports = { `/tests/ui/**/*.${testFileExtension}`, `/tests/unit/**/*.${testFileExtension}`, `/tests/actions/**/*.${testFileExtension}`, + `/tests/navigation/**/*.${testFileExtension}`, `/?(*.)+(spec|test).${testFileExtension}`, ], transform: { diff --git a/package-lock.json b/package-lock.json index ef6566356d88..66f776456fe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.96-0", + "version": "9.0.98-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.96-0", + "version": "9.0.98-0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 864de1116a38..ff13d99660ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.96-0", + "version": "9.0.98-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.", diff --git a/patches/@react-navigation+stack+6.3.29+002+dontDetachScreen.patch b/patches/@react-navigation+stack+6.3.29+002+dontDetachScreen.patch index c65ebbb98007..44e6d591b748 100644 --- a/patches/@react-navigation+stack+6.3.29+002+dontDetachScreen.patch +++ b/patches/@react-navigation+stack+6.3.29+002+dontDetachScreen.patch @@ -43,7 +43,7 @@ index 7558eb3..b7bb75e 100644 }) : STATE_TRANSITIONING_OR_BELOW_TOP; } + -+ const isHomeScreenAndNotOnTop = (route.name === 'BottomTabNavigator' || route.name === 'Workspace_Initial') && isScreenActive !== STATE_ON_TOP; ++ const shouldNotDetachScreen = route?.dontDetachScreen && isScreenActive !== STATE_ON_TOP; + const { headerShown = true, @@ -53,7 +53,7 @@ index 7558eb3..b7bb75e 100644 style: StyleSheet.absoluteFill, enabled: detachInactiveScreens, - active: isScreenActive, -+ active: isHomeScreenAndNotOnTop ? STATE_TRANSITIONING_OR_BELOW_TOP : isScreenActive, ++ active: shouldNotDetachScreen ? STATE_TRANSITIONING_OR_BELOW_TOP : isScreenActive, freezeOnBlur: freezeOnBlur, pointerEvents: "box-none" }, /*#__PURE__*/React.createElement(CardContainer, { @@ -62,7 +62,7 @@ index 7558eb3..b7bb75e 100644 onTransitionEnd: onTransitionEnd, isNextScreenTransparent: isNextScreenTransparent, - detachCurrentScreen: detachCurrentScreen -+ detachCurrentScreen: isHomeScreenAndNotOnTop ? false : detachCurrentScreen, ++ detachCurrentScreen: shouldNotDetachScreen ? false : detachCurrentScreen, })); })), isFloatHeaderAbsolute ? floatingHeader : null); } diff --git a/src/App.tsx b/src/App.tsx index 3513cb23953b..f3d37fe87c0b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,12 +6,12 @@ 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/ActiveWorkspaceProvider'; import ColorSchemeWrapper from './components/ColorSchemeWrapper'; import ComposeProviders from './components/ComposeProviders'; import CustomStatusBarAndBackground from './components/CustomStatusBarAndBackground'; import CustomStatusBarAndBackgroundContextProvider from './components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContextProvider'; import ErrorBoundary from './components/ErrorBoundary'; +import FullScreenBlockingViewContextProvider from './components/FullScreenBlockingViewContextProvider'; import HTMLEngineProvider from './components/HTMLEngineProvider'; import InitialURLContextProvider from './components/InitialURLContextProvider'; import {InputBlurContextProvider} from './components/InputBlurContext'; @@ -36,7 +36,6 @@ import CONFIG from './CONFIG'; import Expensify from './Expensify'; import {CurrentReportIDContextProvider} from './hooks/useCurrentReportID'; import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; -import {ReportIDsContextProvider} from './hooks/useReportIDs'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; import type {Route} from './ROUTES'; @@ -90,8 +89,6 @@ function App({url}: AppProps) { EnvironmentProvider, CustomStatusBarAndBackgroundContextProvider, ActiveElementRoleProvider, - ActiveWorkspaceContextProvider, - ReportIDsContextProvider, PlaybackContextProvider, FullScreenContextProvider, VolumeContextProvider, @@ -101,6 +98,7 @@ function App({url}: AppProps) { SearchRouterContextProvider, ProductTrainingContextProvider, InputBlurContextProvider, + FullScreenBlockingViewContextProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index e747ea4afa33..55f0dafd8517 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -7,6 +7,7 @@ import invertBy from 'lodash/invertBy'; import Config from 'react-native-config'; import * as KeyCommand from 'react-native-key-command'; import type {ValueOf} from 'type-fest'; +import type ResponsiveLayoutResult from './hooks/useResponsiveLayout/types'; import type {Video} from './libs/actions/Report'; import type {MileageRate} from './libs/DistanceRequestUtils'; import BankAccount from './libs/models/BankAccount'; @@ -4816,13 +4817,15 @@ const CONST = { SF_COORDINATES: [-122.4194, 37.7749], NAVIGATION: { - TYPE: { - UP: 'UP', - }, ACTION_TYPE: { REPLACE: 'REPLACE', PUSH: 'PUSH', NAVIGATE: 'NAVIGATE', + + /** These action types are custom for RootNavigator */ + SWITCH_POLICY_ID: 'SWITCH_POLICY_ID', + DISMISS_MODAL: 'DISMISS_MODAL', + OPEN_WORKSPACE_SPLIT: 'OPEN_WORKSPACE_SPLIT', }, }, TIME_PERIOD: { @@ -6635,6 +6638,22 @@ const CONST = { SCAN_TEST_TOOLTIP: 'scanTestTooltip', }, SMART_BANNER_HEIGHT: 152, + + NAVIGATION_TESTS: { + DEFAULT_PARENT_ROUTE: {key: 'parentRouteKey', name: 'ParentNavigator'}, + DEFAULT_USE_RESPONSIVE_LAYOUT_VALUE: { + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: false, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: false, + } as ResponsiveLayoutResult, + }, + TRAVEL: { DEFAULT_DOMAIN: 'domain', PROVISIONING: { diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts index b7c7a71c2828..6014ab22e953 100644 --- a/src/NAVIGATORS.ts +++ b/src/NAVIGATORS.ts @@ -4,7 +4,6 @@ * */ export default { CENTRAL_PANE_NAVIGATOR: 'CentralPaneNavigator', - BOTTOM_TAB_NAVIGATOR: 'BottomTabNavigator', LEFT_MODAL_NAVIGATOR: 'LeftModalNavigator', RIGHT_MODAL_NAVIGATOR: 'RightModalNavigator', ONBOARDING_MODAL_NAVIGATOR: 'OnboardingModalNavigator', @@ -12,5 +11,7 @@ export default { WELCOME_VIDEO_MODAL_NAVIGATOR: 'WelcomeVideoModalNavigator', EXPLANATION_MODAL_NAVIGATOR: 'ExplanationModalNavigator', MIGRATED_USER_MODAL_NAVIGATOR: 'MigratedUserModalNavigator', - FULL_SCREEN_NAVIGATOR: 'FullScreenNavigator', + REPORTS_SPLIT_NAVIGATOR: 'ReportsSplitNavigator', + SETTINGS_SPLIT_NAVIGATOR: 'SettingsSplitNavigator', + WORKSPACE_SPLIT_NAVIGATOR: 'WorkspaceSplitNavigator', } as const; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 61f2054c57e3..b98717b51f5d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -152,16 +152,12 @@ const ROUTES = { SETTINGS_ADD_DELEGATE: 'settings/security/delegate', SETTINGS_DELEGATE_ROLE: { route: 'settings/security/delegate/:login/role/:role', - getRoute: (login: string, role?: string) => `settings/security/delegate/${encodeURIComponent(login)}/role/${role}` as const, + getRoute: (login: string, role?: string, backTo?: string) => getUrlWithBackToParam(`settings/security/delegate/${encodeURIComponent(login)}/role/${role}`, backTo), }, SETTINGS_UPDATE_DELEGATE_ROLE: { route: 'settings/security/delegate/:login/update-role/:currentRole', getRoute: (login: string, currentRole: string) => `settings/security/delegate/${encodeURIComponent(login)}/update-role/${currentRole}` as const, }, - SETTINGS_UPDATE_DELEGATE_ROLE_MAGIC_CODE: { - route: 'settings/security/delegate/:login/update-role/:role/magic-code', - getRoute: (login: string, role: string) => `settings/security/delegate/${encodeURIComponent(login)}/update-role/${role}/magic-code` as const, - }, SETTINGS_DELEGATE_CONFIRM: { route: 'settings/security/delegate/:login/role/:role/confirm', getRoute: (login: string, role: string, showValidateActionModal?: boolean) => { @@ -199,15 +195,15 @@ const ROUTES = { }, SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: { route: 'settings/wallet/card/:domain/get-physical/name', - getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/name` as const, + getRoute: (domain: string, backTo?: string) => getUrlWithBackToParam(`settings/wallet/card/${domain}/get-physical/name`, backTo), }, SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: { route: 'settings/wallet/card/:domain/get-physical/phone', - getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/phone` as const, + getRoute: (domain: string, backTo?: string) => getUrlWithBackToParam(`settings/wallet/card/${domain}/get-physical/phone`, backTo), }, SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: { route: 'settings/wallet/card/:domain/get-physical/address', - getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/address` as const, + getRoute: (domain: string, backTo?: string) => getUrlWithBackToParam(`settings/wallet/card/${domain}/get-physical/address`, backTo), }, SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: { route: 'settings/wallet/card/:domain/get-physical/confirm', @@ -692,7 +688,7 @@ const ROUTES = { }, MONEY_REQUEST_STEP_PARTICIPANTS: { route: ':action/:iouType/participants/:transactionID/:reportID', - getRoute: (iouType: IOUType, transactionID: string | undefined, reportID: string, backTo = '', action: IOUAction = 'create') => + getRoute: (iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '', action: IOUAction = 'create') => getUrlWithBackToParam(`${action as string}/${iouType as string}/participants/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_SPLIT_PAYER: { @@ -1254,7 +1250,7 @@ const ROUTES = { }, WORKSPACE_EDIT_TAGS: { route: 'settings/workspaces/:policyID/tags/:orderWeight/edit', - getRoute: (policyID: string, orderWeight: number) => `settings/workspaces/${policyID}/tags/${orderWeight}/edit` as const, + getRoute: (policyID: string, orderWeight: number, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/tags/${orderWeight}/edit` as const, backTo), }, WORKSPACE_TAG_EDIT: { route: 'settings/workspaces/:policyID/tag/:orderWeight/:tagName/edit', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index e1f4e505b42b..19fcf8b4a367 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -36,7 +36,7 @@ const SCREENS = { PUBLIC_DOMAIN_ERROR: 'Travel_PublicDomainError', }, SEARCH: { - CENTRAL_PANE: 'Search_Central_Pane', + ROOT: 'Search_Root', REPORT_RHP: 'Search_Report_RHP', ADVANCED_FILTERS_RHP: 'Search_Advanced_Filters_RHP', ADVANCED_FILTERS_DATE_RHP: 'Search_Advanced_Filters_Date_RHP', @@ -61,7 +61,6 @@ const SCREENS = { SAVED_SEARCH_RENAME_RHP: 'Search_Saved_Search_Rename_RHP', ADVANCED_FILTERS_IN_RHP: 'Search_Advanced_Filters_In_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', - BOTTOM_TAB: 'Search_Bottom_Tab', }, SETTINGS: { ROOT: 'Settings_Root', @@ -150,7 +149,6 @@ const SCREENS = { DELEGATE_ROLE: 'Settings_Delegate_Role', DELEGATE_CONFIRM: 'Settings_Delegate_Confirm', UPDATE_DELEGATE_ROLE: 'Settings_Delegate_Update_Role', - UPDATE_DELEGATE_ROLE_MAGIC_CODE: 'Settings_Delegate_Update_Magic_Code', }, }, SAVE_THE_WORLD: { diff --git a/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx b/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx index 466f0f492c8e..140c21ff8dd4 100644 --- a/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx +++ b/src/components/ActiveWorkspace/ActiveWorkspaceContext.tsx @@ -1,11 +1,14 @@ import {createContext} from 'react'; type ActiveWorkspaceContextType = { - activeWorkspaceID?: string; - setActiveWorkspaceID: (activeWorkspaceID?: string) => void; + activeWorkspaceID: string | undefined; + setActiveWorkspaceID: (workspaceID: string | undefined) => void; }; -const ActiveWorkspaceContext = createContext({activeWorkspaceID: undefined, setActiveWorkspaceID: () => undefined}); +const ActiveWorkspaceContext = createContext({ + activeWorkspaceID: undefined, + setActiveWorkspaceID: () => {}, +}); export default ActiveWorkspaceContext; -export {type ActiveWorkspaceContextType}; +export type {ActiveWorkspaceContextType}; diff --git a/src/components/ActiveWorkspaceProvider/index.tsx b/src/components/ActiveWorkspaceProvider/index.tsx index bc7260cdf10b..98a72e770638 100644 --- a/src/components/ActiveWorkspaceProvider/index.tsx +++ b/src/components/ActiveWorkspaceProvider/index.tsx @@ -1,16 +1,27 @@ -import React, {useMemo, useState} from 'react'; +import {useNavigationState} from '@react-navigation/native'; +import React, {useEffect, useMemo, useState} from 'react'; import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; +import getPolicyIDFromState from '@libs/Navigation/helpers/getPolicyIDFromState'; +import type {RootNavigatorParamList, State} from '@libs/Navigation/types'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; function ActiveWorkspaceContextProvider({children}: ChildrenProps) { - const [activeWorkspaceID, setActiveWorkspaceID] = useState(undefined); + const policyID = useNavigationState((state) => getPolicyIDFromState(state as State)); + + const [activeWorkspaceID, setActiveWorkspaceID] = useState(policyID); + + useEffect(() => { + setActiveWorkspaceID(policyID); + }, [policyID]); const value = useMemo( () => ({ activeWorkspaceID, + + // We are exporting setActiveWorkspace to speed up updating this value after changing activeWorkspaceID to avoid flickering of workspace avatar. setActiveWorkspaceID, }), - [activeWorkspaceID, setActiveWorkspaceID], + [activeWorkspaceID], ); return {children}; diff --git a/src/components/ActiveWorkspaceProvider/index.website.tsx b/src/components/ActiveWorkspaceProvider/index.website.tsx deleted file mode 100644 index 82e46d70f896..000000000000 --- a/src/components/ActiveWorkspaceProvider/index.website.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 4286c6c834eb..e8da2b56cab9 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -294,8 +294,8 @@ function AttachmentModal({ const deleteAndCloseModal = useCallback(() => { detachReceipt(transaction?.transactionID); setIsDeleteReceiptConfirmModalVisible(false); - Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report?.reportID)); - }, [transaction, report]); + Navigation.goBack(); + }, [transaction]); const isValidFile = useCallback( (fileObject: FileObject) => @@ -435,9 +435,11 @@ function AttachmentModal({ text: translate('common.replace'), onSelected: () => { closeModal(true); - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, report?.reportID, Navigation.getActiveRouteWithoutParams()), - ); + Navigation.isNavigationReady().then(() => { + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, report?.reportID, Navigation.getActiveRouteWithoutParams()), + ); + }); }, }); } diff --git a/src/components/BlockingViews/ForceFullScreenView/index.tsx b/src/components/BlockingViews/ForceFullScreenView/index.tsx index 8a02028168fa..f83c35aa038c 100644 --- a/src/components/BlockingViews/ForceFullScreenView/index.tsx +++ b/src/components/BlockingViews/ForceFullScreenView/index.tsx @@ -1,10 +1,24 @@ -import React from 'react'; +import {useRoute} from '@react-navigation/native'; +import React, {useContext, useEffect} from 'react'; import {View} from 'react-native'; +import {FullScreenBlockingViewContext} from '@components/FullScreenBlockingViewContextProvider'; import useThemeStyles from '@hooks/useThemeStyles'; import type ForceFullScreenViewProps from './types'; function ForceFullScreenView({children, shouldForceFullScreen = false}: ForceFullScreenViewProps) { + const route = useRoute(); const styles = useThemeStyles(); + const {addRouteKey, removeRouteKey} = useContext(FullScreenBlockingViewContext); + + useEffect(() => { + if (!shouldForceFullScreen) { + return; + } + + addRouteKey(route.key); + + return () => removeRouteKey(route.key); + }, [addRouteKey, removeRouteKey, route, shouldForceFullScreen]); if (shouldForceFullScreen) { return {children}; diff --git a/src/components/BlockingViews/FullPageNotFoundView.tsx b/src/components/BlockingViews/FullPageNotFoundView.tsx index 7c4ce77c8a99..d751d8bc666b 100644 --- a/src/components/BlockingViews/FullPageNotFoundView.tsx +++ b/src/components/BlockingViews/FullPageNotFoundView.tsx @@ -63,7 +63,7 @@ function FullPageNotFoundView({ onBackButtonPress = () => Navigation.goBack(), shouldShowLink = true, shouldShowBackButton = true, - onLinkPress = () => Navigation.dismissModal(), + onLinkPress = () => Navigation.goBackToHome(), shouldForceFullScreen = false, subtitleStyle, shouldDisplaySearchRouter, diff --git a/src/components/DeeplinkWrapper/index.website.tsx b/src/components/DeeplinkWrapper/index.website.tsx index 158bf43b2dfe..da344514040e 100644 --- a/src/components/DeeplinkWrapper/index.website.tsx +++ b/src/components/DeeplinkWrapper/index.website.tsx @@ -1,9 +1,9 @@ import {Str} from 'expensify-common'; import {useEffect, useRef, useState} from 'react'; import {isMobile} from '@libs/Browser'; +import shouldPreventDeeplinkPrompt from '@libs/Navigation/helpers/shouldPreventDeeplinkPrompt'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; -import shouldPreventDeeplinkPrompt from '@libs/Navigation/shouldPreventDeeplinkPrompt'; import {beginDeepLinkRedirect, beginDeepLinkRedirectAfterTransition} from '@userActions/App'; import {getInternalNewExpensifyPath} from '@userActions/Link'; import {isAnonymousUser} from '@userActions/Session'; diff --git a/src/components/DelegateNoAccessWrapper.tsx b/src/components/DelegateNoAccessWrapper.tsx index c49d15890112..f810b60d8ca9 100644 --- a/src/components/DelegateNoAccessWrapper.tsx +++ b/src/components/DelegateNoAccessWrapper.tsx @@ -23,6 +23,7 @@ type DelegateNoAccessWrapperProps = { accessDeniedVariants?: AccessDeniedVariants[]; shouldForceFullScreen?: boolean; children?: (() => React.ReactNode) | React.ReactNode; + onBackButtonPress?: () => void; }; function isDelegate(account: OnyxEntry) { @@ -35,7 +36,7 @@ function isSubmitter(account: OnyxEntry) { return isDelegateOnlySubmitter; } -function DelegateNoAccessWrapper({accessDeniedVariants = [], shouldForceFullScreen, ...props}: DelegateNoAccessWrapperProps) { +function DelegateNoAccessWrapper({accessDeniedVariants = [], shouldForceFullScreen, onBackButtonPress, ...props}: DelegateNoAccessWrapperProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const isPageAccessDenied = accessDeniedVariants.reduce((acc, variant) => { const accessDeniedFunction = DENIED_ACCESS_VARIANTS[variant]; @@ -49,6 +50,10 @@ function DelegateNoAccessWrapper({accessDeniedVariants = [], shouldForceFullScre shouldShow shouldForceFullScreen={shouldForceFullScreen} onBackButtonPress={() => { + if (onBackButtonPress) { + onBackButtonPress(); + return; + } if (shouldUseNarrowLayout) { Navigation.dismissModal(); return; diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx index 5c9263e911b0..32c86a0af476 100644 --- a/src/components/FeatureTrainingModal.tsx +++ b/src/components/FeatureTrainingModal.tsx @@ -1,6 +1,6 @@ import type {VideoReadyForDisplayEvent} from 'expo-av'; import type {ImageContentFit} from 'expo-image'; -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; @@ -10,6 +10,7 @@ import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import {parseFSAttributes} from '@libs/Fullstory'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import {dismissTrackTrainingModal} from '@userActions/User'; @@ -264,6 +265,14 @@ function FeatureTrainingModal({ onConfirm?.(); }, [onConfirm, closeModal]); + /** + * Extracts values from the non-scraped attribute WEB_PROP_ATTR at build time + * to ensure necessary properties are available for further processing. + * Reevaluates "fs-class" to dynamically apply styles or behavior based on + * updated attribute values. + */ + useLayoutEffect(parseFSAttributes, []); + return ( {({safeAreaPaddingBottomStyle}) => ( @@ -286,7 +295,11 @@ function FeatureTrainingModal({ ...modalInnerContainerStyle, }} > - + {renderIllustration()} diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 8ca495ede289..687d952c195e 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -6,12 +6,11 @@ import {Platform} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Svg, {Path} from 'react-native-svg'; -import useBottomTabIsFocused from '@hooks/useBottomTabIsFocused'; -import useIsCurrentRouteHome from '@hooks/useIsCurrentRouteHome'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getPlatform from '@libs/getPlatform'; +import useIsHomeRouteActive from '@navigation/helpers/useIsHomeRouteActive'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -58,9 +57,12 @@ type FloatingActionButtonProps = { /* An accessibility role for the button */ role: Role; + + /* If the tooltip is allowed to be shown */ + isTooltipAllowed: boolean; }; -function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: FloatingActionButtonProps, ref: ForwardedRef) { +function FloatingActionButton({onPress, isActive, accessibilityLabel, role, isTooltipAllowed}: FloatingActionButtonProps, ref: ForwardedRef) { const {success, buttonDefaultBG, textLight, textDark} = useTheme(); const styles = useThemeStyles(); const borderRadius = styles.floatingActionButton.borderRadius; @@ -68,13 +70,12 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo const {shouldUseNarrowLayout} = useResponsiveLayout(); const platform = getPlatform(); const isNarrowScreenOnWeb = shouldUseNarrowLayout && platform === CONST.PLATFORM.WEB; - const isFocused = useBottomTabIsFocused(); const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {initialValue: false}); - const isActiveRouteHome = useIsCurrentRouteHome(); + const isHomeRouteActive = useIsHomeRouteActive(shouldUseNarrowLayout); const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.GLOBAL_CREATE_TOOLTIP, // On Home screen, We need to wait for the sidebar to load before showing the tooltip because there is the Concierge tooltip which is higher priority - isFocused && (!isActiveRouteHome || isSidebarLoaded), + isTooltipAllowed && (!isHomeRouteActive || isSidebarLoaded), ); const sharedValue = useSharedValue(isActive ? 1 : 0); const buttonRef = ref; diff --git a/src/components/FocusTrap/BOTTOM_TAB_SCREENS.ts b/src/components/FocusTrap/BOTTOM_TAB_SCREENS.ts deleted file mode 100644 index f6a4f5ba6e83..000000000000 --- a/src/components/FocusTrap/BOTTOM_TAB_SCREENS.ts +++ /dev/null @@ -1,6 +0,0 @@ -import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; - -const BOTTOM_TAB_SCREENS = [SCREENS.HOME, SCREENS.SETTINGS.ROOT, NAVIGATORS.BOTTOM_TAB_NAVIGATOR, SCREENS.SEARCH.BOTTOM_TAB]; - -export default BOTTOM_TAB_SCREENS; diff --git a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx index cf7db5442aa8..33f8aa85d6cf 100644 --- a/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForScreen/index.web.tsx @@ -1,11 +1,11 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {FocusTrap} from 'focus-trap-react'; import React, {useMemo} from 'react'; -import BOTTOM_TAB_SCREENS from '@components/FocusTrap/BOTTOM_TAB_SCREENS'; import sharedTrapStack from '@components/FocusTrap/sharedTrapStack'; import TOP_TAB_SCREENS from '@components/FocusTrap/TOP_TAB_SCREENS'; import WIDE_LAYOUT_INACTIVE_SCREENS from '@components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {isSidebarScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import CONST from '@src/CONST'; import type FocusTrapProps from './FocusTrapProps'; @@ -18,8 +18,8 @@ function FocusTrapForScreen({children, focusTrapSettings}: FocusTrapProps) { if (typeof focusTrapSettings?.active !== 'undefined') { return focusTrapSettings.active; } - // Focus trap can't be active on bottom tab screens because it would block access to the tab bar. - if (BOTTOM_TAB_SCREENS.find((screen) => screen === route.name)) { + // Focus trap can't be active on sidebar screens because it would block access to the tab bar. + if (isSidebarScreenName(route.name)) { return false; } diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index 32e063f03109..7941c2c575a9 100644 --- a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -1,4 +1,3 @@ -import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; /** @@ -6,7 +5,6 @@ import SCREENS from '@src/SCREENS'; * focus trap when rendered on a wide screen to allow navigation between them using the keyboard */ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ - NAVIGATORS.BOTTOM_TAB_NAVIGATOR, SCREENS.HOME, SCREENS.SETTINGS.ROOT, SCREENS.REPORT, @@ -31,7 +29,7 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.WORKSPACE.EXPENSIFY_CARD, SCREENS.WORKSPACE.COMPANY_CARDS, SCREENS.WORKSPACE.DISTANCE_RATES, - SCREENS.SEARCH.CENTRAL_PANE, + SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.TROUBLESHOOT, SCREENS.SETTINGS.SAVE_THE_WORLD, SCREENS.WORKSPACE.RULES, diff --git a/src/components/FullScreenBlockingViewContextProvider.tsx b/src/components/FullScreenBlockingViewContextProvider.tsx new file mode 100644 index 000000000000..fed3e33412b2 --- /dev/null +++ b/src/components/FullScreenBlockingViewContextProvider.tsx @@ -0,0 +1,61 @@ +import React, {createContext, useCallback, useMemo, useState} from 'react'; + +type FullScreenBlockingViewContextValue = { + addRouteKey: (key: string) => void; + removeRouteKey: (key: string) => void; + isBlockingViewVisible: boolean; +}; + +type FullScreenBlockingViewContextProviderProps = { + children: React.ReactNode; +}; + +const defaultValue: FullScreenBlockingViewContextValue = { + addRouteKey: () => {}, + removeRouteKey: () => {}, + isBlockingViewVisible: false, +}; + +const FullScreenBlockingViewContext = createContext(defaultValue); + +/** + * Provides a context for getting information about the visibility of a full-screen blocking view. + * This context allows the blocking view to add or remove route keys, which determine + * whether the blocking view is displayed on a screen. If there are any route keys present, + * the blocking view is considered visible. + * This information is necessary because we don't want to show the TopLevelBottomTabBar when the blocking view is visible. + */ +function FullScreenBlockingViewContextProvider({children}: FullScreenBlockingViewContextProviderProps) { + const [routeKeys, setRouteKeys] = useState>(new Set()); + + const addRouteKey = useCallback((key: string) => { + setRouteKeys((prevKeys) => new Set(prevKeys).add(key)); + }, []); + + const removeRouteKey = useCallback((key: string) => { + setRouteKeys((prevKeys) => { + const newKeys = new Set(prevKeys); + newKeys.delete(key); + return newKeys; + }); + }, []); + + const isBlockingViewVisible = useMemo(() => routeKeys.size > 0, [routeKeys]); + + const contextValue = useMemo( + () => ({ + addRouteKey, + removeRouteKey, + isBlockingViewVisible, + }), + [addRouteKey, removeRouteKey, isBlockingViewVisible], + ); + + return {children}; +} + +export default FullScreenBlockingViewContextProvider; + +export {FullScreenBlockingViewContext}; + +export type {FullScreenBlockingViewContextProviderProps, FullScreenBlockingViewContextValue}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index fcae31dd7d2f..1caeaa3fd152 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -10,14 +10,12 @@ import Text from '@components/Text'; import useCurrentReportID from '@hooks/useCurrentReportID'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; -import type {RootStackParamList, State} from '@libs/Navigation/types'; -import Navigation, {navigationRef} from '@navigation/Navigation'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; +import ROUTES from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import MentionReportContext from './MentionReportContext'; @@ -74,9 +72,8 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender const {reportID, mentionDisplayText} = mentionDetails; let navigationRoute: Route | undefined = reportID ? ROUTES.REPORT_WITH_ID.getRoute(reportID) : undefined; - const topmostCentralPaneRoute = getTopmostCentralPaneRoute(navigationRef.getRootState() as State); const backTo = Navigation.getActiveRoute(); - if (topmostCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE) { + if (isSearchTopmostFullScreenRoute()) { navigationRoute = reportID ? ROUTES.SEARCH_REPORT.getRoute({reportID, backTo}) : undefined; } const isCurrentRoomMention = reportID === currentReportIDValue; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index fd5eb9f6fd58..93c233321916 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -89,6 +89,7 @@ import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustra 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 CreditCardsNewGreen from '@assets/images/simple-illustrations/simple-illustration__creditcards--green.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; import EmptyState from '@assets/images/simple-illustrations/simple-illustration__empty-state.svg'; import EnvelopeReceipt from '@assets/images/simple-illustrations/simple-illustration__envelopereceipt.svg'; @@ -198,6 +199,7 @@ export { MoneyReceipts, PinkBill, CreditCardsNew, + CreditCardsNewGreen, InvoiceBlue, LaptopwithSecondScreenandHourglass, LockOpen, diff --git a/src/components/ImportOnyxState/index.native.tsx b/src/components/ImportOnyxState/index.native.tsx index bdd805241c55..e3eca1993313 100644 --- a/src/components/ImportOnyxState/index.native.tsx +++ b/src/components/ImportOnyxState/index.native.tsx @@ -1,18 +1,17 @@ import React, {useState} from 'react'; import ReactNativeBlobUtil from 'react-native-blob-util'; -import Onyx, {useOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; +import {setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; +import {rollbackOngoingRequest} from '@libs/actions/PersistedRequests'; +import {cleanAndTransformState, importState} from '@libs/ImportOnyxStateUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; -import {cleanAndTransformState} from './utils'; - -const CHUNK_SIZE = 100; function readOnyxFile(fileUri: string) { const filePath = decodeURIComponent(fileUri.replace('file://', '')); @@ -25,27 +24,6 @@ function readOnyxFile(fileUri: string) { }); } -function chunkArray(array: T[], size: number): T[][] { - const result = []; - for (let i = 0; i < array.length; i += size) { - result.push(array.slice(i, i + size)); - } - return result; -} - -function applyStateInChunks(state: OnyxValues) { - const entries = Object.entries(state); - const chunks = chunkArray(entries, CHUNK_SIZE); - - let promise = Promise.resolve(); - chunks.forEach((chunk) => { - const partialOnyxState = Object.fromEntries(chunk) as Partial; - promise = promise.then(() => Onyx.multiSet(partialOnyxState)); - }); - - return promise; -} - export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); const [session] = useOnyx(ONYXKEYS.SESSION); @@ -58,19 +36,23 @@ export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { setIsLoading(true); readOnyxFile(file.uri) .then((fileContent: string) => { + rollbackOngoingRequest(); const transformedState = cleanAndTransformState(fileContent); const currentUserSessionCopy = {...session}; setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); - Onyx.clear(KEYS_TO_PRESERVE).then(() => { - applyStateInChunks(transformedState).then(() => { - setIsUsingImportedState(true); - Navigation.navigate(ROUTES.HOME); - }); - }); + return importState(transformedState); }) - .catch(() => { + .then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); + }) + .catch((error) => { + console.error('Error importing state:', error); setIsErrorModalVisible(true); + }) + .finally(() => { + setIsLoading(false); }); }; diff --git a/src/components/ImportOnyxState/index.tsx b/src/components/ImportOnyxState/index.tsx index 2f9a2b70b65b..8f199a176539 100644 --- a/src/components/ImportOnyxState/index.tsx +++ b/src/components/ImportOnyxState/index.tsx @@ -1,15 +1,16 @@ import React, {useState} from 'react'; -import Onyx, {useOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; +import {setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; +import {rollbackOngoingRequest} from '@libs/actions/PersistedRequests'; +import {cleanAndTransformState, importState} from '@libs/ImportOnyxStateUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; -import {cleanAndTransformState} from './utils'; export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); @@ -21,26 +22,33 @@ export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { } setIsLoading(true); + const blob = new Blob([file as BlobPart]); const response = new Response(blob); response .text() .then((text) => { + rollbackOngoingRequest(); const fileContent = text; + const transformedState = cleanAndTransformState(fileContent); + const currentUserSessionCopy = {...session}; setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); - Onyx.clear(KEYS_TO_PRESERVE).then(() => { - Onyx.multiSet(transformedState).then(() => { - setIsUsingImportedState(true); - Navigation.navigate(ROUTES.HOME); - }); - }); + + return importState(transformedState); + }) + .then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); }) - .catch(() => { + .catch((error) => { + console.error('Error importing state:', error); setIsErrorModalVisible(true); + }) + .finally(() => { setIsLoading(false); }); }; diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 4e6327ec5661..307acae4d5d3 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -106,7 +106,7 @@ function KYCWall({ if (paymentMethod === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) { openPersonalBankAccountSetupView(); } else if (paymentMethod === CONST.PAYMENT_METHODS.DEBIT_CARD) { - Navigation.navigate(addDebitCardRoute); + Navigation.navigate(addDebitCardRoute ?? ROUTES.HOME); } else if (paymentMethod === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT) { if (iouReport && isIOUReport(iouReport)) { const {policyID, workspaceChatReportID, reportPreviewReportActionID, adminsChatReportID} = createWorkspaceFromIOUPayment(iouReport) ?? {}; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 1e7a9f796641..5ab05797a02b 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -16,9 +16,9 @@ import SubscriptAvatar from '@components/SubscriptAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; -import useIsCurrentRouteHome from '@hooks/useIsCurrentRouteHome'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRootNavigationState from '@hooks/useRootNavigationState'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -43,6 +43,7 @@ import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import variables from '@styles/variables'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OptionRowLHNProps} from './types'; @@ -64,16 +65,17 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const isOnboardingGuideAssigned = introSelected?.choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !session?.email?.includes('+'); const isChatUsedForOnboarding = isChatUsedForOnboardingReportUtils(report, introSelected?.choice); const shouldShowGetStartedTooltip = isOnboardingGuideAssigned ? isAdminRoom(report) && isChatUsedForOnboarding : isConciergeChatReport(report); - const isActiveRouteHome = useIsCurrentRouteHome(); + + const isReportsSplitNavigatorLast = useRootNavigationState((state) => state?.routes?.at(-1)?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); const {tooltipToRender, shouldShowTooltip} = useMemo(() => { const tooltip = shouldShowGetStartedTooltip ? CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONCEIRGE_LHN_GBR : CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.LHN_WORKSPACE_CHAT_TOOLTIP; const shouldShowTooltips = shouldShowWokspaceChatTooltip || shouldShowGetStartedTooltip; - const shouldTooltipBeVisible = shouldUseNarrowLayout ? isScreenFocused && isActiveRouteHome : isActiveRouteHome && !isFullscreenVisible; + const shouldTooltipBeVisible = shouldUseNarrowLayout ? isScreenFocused && isReportsSplitNavigatorLast : isReportsSplitNavigatorLast && !isFullscreenVisible; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return {tooltipToRender: tooltip, shouldShowTooltip: shouldShowTooltips && shouldTooltipBeVisible}; - }, [shouldShowGetStartedTooltip, shouldShowWokspaceChatTooltip, isScreenFocused, shouldUseNarrowLayout, isActiveRouteHome, isFullscreenVisible]); + }, [shouldShowGetStartedTooltip, shouldShowWokspaceChatTooltip, isScreenFocused, shouldUseNarrowLayout, isReportsSplitNavigatorLast, isFullscreenVisible]); const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(tooltipToRender, shouldShowTooltip); diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx index 9fb61ea99f75..5fc99560a27c 100644 --- a/src/components/Lottie/index.tsx +++ b/src/components/Lottie/index.tsx @@ -9,7 +9,7 @@ import useAppState from '@hooks/useAppState'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import {getBrowser, isMobile} from '@libs/Browser'; -import isSideModalNavigator from '@libs/Navigation/isSideModalNavigator'; +import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator'; import CONST from '@src/CONST'; import {useSplashScreenStateContext} from '@src/SplashScreenStateContext'; diff --git a/src/components/MigratedUserWelcomeModal.tsx b/src/components/MigratedUserWelcomeModal.tsx index 312f6e0f77d0..6a1221797bda 100644 --- a/src/components/MigratedUserWelcomeModal.tsx +++ b/src/components/MigratedUserWelcomeModal.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useLayoutEffect} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -6,6 +6,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {dismissProductTraining} from '@libs/actions/Welcome'; import convertToLTR from '@libs/convertToLTR'; +import {parseFSAttributes} from '@libs/Fullstory'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {FeatureListItem} from './FeatureList'; @@ -36,6 +37,14 @@ function OnboardingWelcomeVideo() { const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + /** + * Extracts values from the non-scraped attribute WEB_PROP_ATTR at build time + * to ensure necessary properties are available for further processing. + * Reevaluates "fs-class" to dynamically apply styles or behavior based on + * updated attribute values. + */ + useLayoutEffect(parseFSAttributes, []); + return ( - + {ExpensifyFeatures.map(({translationKey, icon}) => ( isScanRequestUtil(transaction), [transaction]); + const isCreateExpenseFlow = transaction?.isFromGlobalCreate && !isPerDiemRequest; const transactionID = transaction?.transactionID; const customUnitRateID = getRateID(transaction); @@ -339,18 +339,6 @@ function MoneyRequestConfirmationList({ const isCategoryRequired = !!policy?.requiresCategory; - const shouldDisableParticipant = (participant: Participant): boolean => { - if (isDraftReport(participant.reportID)) { - return true; - } - - if (!participant.isInvoiceRoom && !participant.isPolicyExpenseChat && !participant.isSelfDM && isOptimisticPersonalDetail(participant.accountID ?? CONST.DEFAULT_NUMBER_ID)) { - return true; - } - - return false; - }; - useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -596,7 +584,7 @@ function MoneyRequestConfirmationList({ return { ...participantOption, isSelected: false, - isInteractive: !shouldDisableParticipant(participantOption), + isInteractive: false, rightElement: ( {amount ? convertToDisplayString(amount, iouCurrencyCode) : ''} @@ -613,7 +601,7 @@ function MoneyRequestConfirmationList({ ...participantOption, tabIndex: -1, isSelected: false, - isInteractive: !shouldDisableParticipant(participantOption), + isInteractive: false, rightElement: ( ({ ...participant, isSelected: false, - isInteractive: transaction?.isFromGlobalCreate, - shouldShowRightIcon: transaction?.isFromGlobalCreate, + isInteractive: isCreateExpenseFlow, + shouldShowRightIcon: isCreateExpenseFlow, })); options.push({ title: translate('common.to'), @@ -730,7 +718,7 @@ function MoneyRequestConfirmationList({ } return options; - }, [isTypeSplit, translate, payeePersonalDetails, getSplitSectionHeader, splitParticipants, selectedParticipants, transaction?.isFromGlobalCreate]); + }, [isTypeSplit, translate, payeePersonalDetails, getSplitSectionHeader, splitParticipants, selectedParticipants, isCreateExpenseFlow]); useEffect(() => { if (!isDistanceRequest || (isMovingTransactionFromTrackExpense && !isPolicyExpenseChat) || !transactionID) { @@ -805,11 +793,11 @@ function MoneyRequestConfirmationList({ * Navigate to the participant step */ const navigateToParticipantPage = () => { - if (!transaction?.isFromGlobalCreate) { + if (!isCreateExpenseFlow) { return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.CREATE, transactionID, transaction.reportID)); }; /** @@ -821,7 +809,7 @@ function MoneyRequestConfirmationList({ return; } if (iouType === CONST.IOU.TYPE.INVOICE && !hasInvoicingDetails(policy)) { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.getRoute(iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.getRoute(iouType, transactionID, reportID, Navigation.getActiveRoute())); return; } diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 0c934ff12500..4e7f271b2cf2 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -373,7 +373,7 @@ function MoneyRequestConfirmationListFooter({ return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} disabled={didConfirm} interactive={!isReadOnly} @@ -396,7 +396,7 @@ function MoneyRequestConfirmationListFooter({ return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} brickRoadIndicator={shouldDisplayDistanceRateError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} disabled={didConfirm} @@ -472,10 +472,7 @@ function MoneyRequestConfirmationListFooter({ return; } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute(), reportActionID), - CONST.NAVIGATION.ACTION_TYPE.PUSH, - ); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute(), reportActionID)); }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} @@ -577,7 +574,7 @@ function MoneyRequestConfirmationListFooter({ return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} interactive shouldRenderAsHTML @@ -623,7 +620,7 @@ function MoneyRequestConfirmationListFooter({ if (!transactionID) { return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.getRoute(action, iouType, transactionID, reportID, index, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.getRoute(action, iouType, transactionID, reportID, index, Navigation.getActiveRoute())); }} disabled={didConfirm} interactive={!isReadOnly} @@ -768,7 +765,7 @@ function MoneyRequestConfirmationListFooter({ if (!transaction?.transactionID) { return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID, reportID, Navigation.getActiveRoute())); }} style={styles.moneyRequestMenuItem} labelStyle={styles.mt2} @@ -793,7 +790,7 @@ function MoneyRequestConfirmationListFooter({ if (!transactionID) { return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESTINATION_EDIT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESTINATION_EDIT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} disabled={didConfirm} interactive={!isReadOnly} @@ -809,7 +806,7 @@ function MoneyRequestConfirmationListFooter({ if (!transactionID) { return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME_EDIT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME_EDIT.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} disabled={didConfirm} interactive={!isReadOnly} @@ -832,9 +829,7 @@ function MoneyRequestConfirmationListFooter({ return; } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), - ); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRoute())); }} /> ))} diff --git a/src/components/MultiGestureCanvas/usePinchGesture.ts b/src/components/MultiGestureCanvas/usePinchGesture.ts index 7f5cecc4e949..6f4cf761a0e4 100644 --- a/src/components/MultiGestureCanvas/usePinchGesture.ts +++ b/src/components/MultiGestureCanvas/usePinchGesture.ts @@ -104,6 +104,11 @@ const usePinchGesture = ({ .enabled(pinchEnabled) // The first argument is not used, but must be defined .onTouchesDown((_evt, state) => { + // react-compiler optimization unintentionally make all the callbacks run on the JS thread. + // Adding the worklet directive here will make all the callbacks run on UI thread back. + + 'worklet'; + // We don't want to activate pinch gesture when we are swiping in the pager if (!shouldDisableTransformationGestures.get()) { return; diff --git a/src/components/Navigation/BottomTabBar/BOTTOM_TABS.ts b/src/components/Navigation/BottomTabBar/BOTTOM_TABS.ts new file mode 100644 index 000000000000..96eeeb5e66ea --- /dev/null +++ b/src/components/Navigation/BottomTabBar/BOTTOM_TABS.ts @@ -0,0 +1,7 @@ +const BOTTOM_TABS = { + HOME: 'HOME', + SEARCH: 'SEARCH', + SETTINGS: 'SETTINGS', +} as const; + +export default BOTTOM_TABS; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/components/Navigation/BottomTabBar/index.tsx similarity index 62% rename from src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx rename to src/components/Navigation/BottomTabBar/index.tsx index dc76523044f1..f516eb43d04f 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/components/Navigation/BottomTabBar/index.tsx @@ -1,41 +1,46 @@ import React, {memo, useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; +import DebugTabView from '@components/Navigation/DebugTabView'; import {PressableWithFeedback} from '@components/Pressable'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; import type {SearchQueryString} from '@components/Search/types'; import Text from '@components/Text'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; -import useBottomTabIsFocused from '@hooks/useBottomTabIsFocused'; import useLocalize from '@hooks/useLocalize'; import {useReportIDs} from '@hooks/useReportIDs'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; import getPlatform from '@libs/getPlatform'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import Navigation from '@libs/Navigation/Navigation'; -import type {AuthScreensParamList, RootStackParamList, State} from '@libs/Navigation/types'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import {getChatTabBrickRoad} from '@libs/WorkspacesSettingsUtils'; +import {getPreservedSplitNavigatorState} from '@navigation/AppNavigator/createSplitNavigator/usePreserveSplitNavigatorState'; +import {isFullScreenName} from '@navigation/helpers/isNavigatorName'; +import Navigation from '@navigation/Navigation'; import navigationRef from '@navigation/navigationRef'; +import type {AuthScreensParamList, RootNavigatorParamList, State, WorkspaceSplitNavigatorParamList} from '@navigation/types'; import BottomTabAvatar from '@pages/home/sidebar/BottomTabAvatar'; import BottomTabBarFloatingActionButton from '@pages/home/sidebar/BottomTabBarFloatingActionButton'; import variables from '@styles/variables'; 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'; -import DebugTabView from './DebugTabView'; +import BOTTOM_TABS from './BOTTOM_TABS'; type BottomTabBarProps = { - selectedTab: string | undefined; + selectedTab: ValueOf; + isTooltipAllowed?: boolean; }; /** @@ -66,7 +71,7 @@ function handleQueryWithPolicyID(query: SearchQueryString, activePolicyID?: stri return SearchQueryUtils.buildSearchQueryString(queryJSON); } -function BottomTabBar({selectedTab}: BottomTabBarProps) { +function BottomTabBar({selectedTab, isTooltipAllowed = false}: BottomTabBarProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -74,13 +79,13 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { const {orderedReportIDs} = useReportIDs(); const [user] = useOnyx(ONYXKEYS.USER); const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const [chatTabBrickRoad, setChatTabBrickRoad] = useState(undefined); - const isFocused = useBottomTabIsFocused(); const platform = getPlatform(); const isWebOrDesktop = platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.DESKTOP; const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.BOTTOM_NAV_INBOX_TOOLTIP, - selectedTab !== SCREENS.HOME && isFocused, + isTooltipAllowed && selectedTab !== BOTTOM_TABS.HOME, ); useEffect(() => { setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID, orderedReportIDs)); @@ -89,25 +94,25 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { }, [activeWorkspaceID, orderedReportIDs, reportActions]); const navigateToChats = useCallback(() => { - if (selectedTab === SCREENS.HOME) { + if (selectedTab === BOTTOM_TABS.HOME) { return; } + hideProductTrainingTooltip(); - const route = activeWorkspaceID ? (`/w/${activeWorkspaceID}/${ROUTES.HOME}` as Route) : ROUTES.HOME; - Navigation.navigate(route); - }, [activeWorkspaceID, selectedTab, hideProductTrainingTooltip]); + Navigation.navigate(ROUTES.HOME); + }, [hideProductTrainingTooltip, selectedTab]); const navigateToSearch = useCallback(() => { - if (selectedTab === SCREENS.SEARCH.BOTTOM_TAB) { + if (selectedTab === BOTTOM_TABS.SEARCH) { return; } clearSelectedText(); interceptAnonymousUser(() => { - const rootState = navigationRef.getRootState() as State; - const lastSearchRoute = rootState.routes.filter((route) => route.name === SCREENS.SEARCH.CENTRAL_PANE).at(-1); + const rootState = navigationRef.getRootState() as State; + const lastSearchRoute = rootState.routes.findLast((route) => route.name === SCREENS.SEARCH.ROOT); if (lastSearchRoute) { - const {q, ...rest} = lastSearchRoute.params as AuthScreensParamList[typeof SCREENS.SEARCH.CENTRAL_PANE]; + const {q, ...rest} = lastSearchRoute.params as AuthScreensParamList[typeof SCREENS.SEARCH.ROOT]; const cleanedQuery = handleQueryWithPolicyID(q, activeWorkspaceID); Navigation.navigate( @@ -126,6 +131,73 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { }); }, [activeWorkspaceID, selectedTab]); + /** + * The settings tab is related to SettingsSplitNavigator and WorkspaceSplitNavigator. + * If the user opens this tab from another tab, it is necessary to check whether it has not been opened before. + * If so, all previously opened screens have be pushed to the navigation stack to maintain the order of screens within the tab. + * If the user clicks on the settings tab while on this tab, this button should go back to the previous screen within the tab. + */ + const showSettingsPage = useCallback(() => { + const rootState = navigationRef.getRootState(); + const topmostFullScreenRoute = rootState.routes.findLast((route) => isFullScreenName(route.name)); + + if (!topmostFullScreenRoute) { + return; + } + + const lastRouteOfTopmostFullScreenRoute = 'state' in topmostFullScreenRoute ? topmostFullScreenRoute.state?.routes.at(-1) : undefined; + + if (lastRouteOfTopmostFullScreenRoute && lastRouteOfTopmostFullScreenRoute.name === SCREENS.SETTINGS.WORKSPACES && shouldUseNarrowLayout) { + Navigation.goBack(ROUTES.SETTINGS); + return; + } + + if (topmostFullScreenRoute.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR) { + Navigation.goBack(ROUTES.SETTINGS); + return; + } + + interceptAnonymousUser(() => { + const lastSettingsOrWorkspaceNavigatorRoute = rootState.routes.findLast( + (rootRoute) => rootRoute.name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR || rootRoute.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, + ); + + // If there is no settings or workspace navigator route, then we should open the settings navigator. + if (!lastSettingsOrWorkspaceNavigatorRoute) { + Navigation.navigate(ROUTES.SETTINGS); + return; + } + + const state = lastSettingsOrWorkspaceNavigatorRoute.state ?? getPreservedSplitNavigatorState(lastSettingsOrWorkspaceNavigatorRoute.key); + + // If there is a workspace navigator route, then we should open the workspace initial screen as it should be "remembered". + if (lastSettingsOrWorkspaceNavigatorRoute.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR) { + const params = state?.routes.at(0)?.params as WorkspaceSplitNavigatorParamList[typeof SCREENS.WORKSPACE.INITIAL]; + + // Screens of this navigator should always have policyID + if (params.policyID) { + // This action will put settings split under the workspace split to make sure that we can swipe back to settings split. + navigationRef.dispatch({ + type: CONST.NAVIGATION.ACTION_TYPE.OPEN_WORKSPACE_SPLIT, + payload: { + policyID: params.policyID, + }, + }); + } + return; + } + + // If there is settings workspace screen in the settings navigator, then we should open the settings workspaces as it should be "remembered". + if (state?.routes?.at(-1)?.name === SCREENS.SETTINGS.WORKSPACES) { + Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); + return; + } + + // Otherwise we should simply open the settings navigator. + Navigation.navigate(ROUTES.SETTINGS); + }); + }, [shouldUseNarrowLayout]); + return ( <> {!!user?.isDebugModeEnabled && ( @@ -158,7 +230,7 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { @@ -171,7 +243,7 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { styles.textSmall, styles.textAlignCenter, styles.mt1Half, - selectedTab === SCREENS.HOME ? styles.textBold : styles.textSupporting, + selectedTab === BOTTOM_TABS.HOME ? styles.textBold : styles.textSupporting, styles.bottomTabBarLabel, ]} > @@ -189,7 +261,7 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { @@ -199,16 +271,19 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) { styles.textSmall, styles.textAlignCenter, styles.mt1Half, - selectedTab === SCREENS.SEARCH.BOTTOM_TAB ? styles.textBold : styles.textSupporting, + selectedTab === BOTTOM_TABS.SEARCH ? styles.textBold : styles.textSupporting, styles.bottomTabBarLabel, ]} > {translate('common.reports')} - + - + diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx b/src/components/Navigation/DebugTabView.tsx similarity index 100% rename from src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx rename to src/components/Navigation/DebugTabView.tsx diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/components/Navigation/TopBar.tsx similarity index 100% rename from src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx rename to src/components/Navigation/TopBar.tsx diff --git a/src/components/Navigation/TopLevelBottomTabBar/SCREENS_WITH_BOTTOM_TAB_BAR.ts b/src/components/Navigation/TopLevelBottomTabBar/SCREENS_WITH_BOTTOM_TAB_BAR.ts new file mode 100644 index 000000000000..87c6da192ad1 --- /dev/null +++ b/src/components/Navigation/TopLevelBottomTabBar/SCREENS_WITH_BOTTOM_TAB_BAR.ts @@ -0,0 +1,6 @@ +import {SIDEBAR_TO_SPLIT} from '@libs/Navigation/linkingConfig/RELATIONS'; +import SCREENS from '@src/SCREENS'; + +const SCREENS_WITH_BOTTOM_TAB_BAR = [...Object.keys(SIDEBAR_TO_SPLIT), SCREENS.SEARCH.ROOT, SCREENS.SETTINGS.WORKSPACES]; + +export default SCREENS_WITH_BOTTOM_TAB_BAR; diff --git a/src/components/Navigation/TopLevelBottomTabBar/getIsBottomTabVisibleDirectly.ts b/src/components/Navigation/TopLevelBottomTabBar/getIsBottomTabVisibleDirectly.ts new file mode 100644 index 000000000000..418f2cb1c3cc --- /dev/null +++ b/src/components/Navigation/TopLevelBottomTabBar/getIsBottomTabVisibleDirectly.ts @@ -0,0 +1,9 @@ +import type {NavigationState} from '@react-navigation/native'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; + +// Visible directly means not through the overlay. So the full screen (split navigator or search) has to be the last route on the root stack. +function getIsBottomTabVisibleDirectly(state: NavigationState) { + return isFullScreenName(state?.routes.at(-1)?.name); +} + +export default getIsBottomTabVisibleDirectly; diff --git a/src/components/Navigation/TopLevelBottomTabBar/getIsScreenWithBottomTabFocused.ts b/src/components/Navigation/TopLevelBottomTabBar/getIsScreenWithBottomTabFocused.ts new file mode 100644 index 000000000000..ad3dbae4905c --- /dev/null +++ b/src/components/Navigation/TopLevelBottomTabBar/getIsScreenWithBottomTabFocused.ts @@ -0,0 +1,14 @@ +import {findFocusedRoute} from '@react-navigation/native'; +import type {NavigationState} from '@react-navigation/native'; +import {isSplitNavigatorName} from '@libs/Navigation/helpers/isNavigatorName'; +import SCREENS_WITH_BOTTOM_TAB_BAR from './SCREENS_WITH_BOTTOM_TAB_BAR'; + +function getIsScreenWithBottomTabFocused(state: NavigationState) { + const focusedRoute = findFocusedRoute(state); + + // We are checking if the focused route is a split navigator because there may be a brief moment where the navigator doesn't have state yet. + // That mens we don't have screen with bottom tab focused. This caused glitching. + return SCREENS_WITH_BOTTOM_TAB_BAR.includes(focusedRoute?.name ?? '') || isSplitNavigatorName(focusedRoute?.name); +} + +export default getIsScreenWithBottomTabFocused; diff --git a/src/components/Navigation/TopLevelBottomTabBar/getSelectedTab.ts b/src/components/Navigation/TopLevelBottomTabBar/getSelectedTab.ts new file mode 100644 index 000000000000..6ea83f6d1817 --- /dev/null +++ b/src/components/Navigation/TopLevelBottomTabBar/getSelectedTab.ts @@ -0,0 +1,12 @@ +import type {NavigationState} from '@react-navigation/native'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; +import {FULLSCREEN_TO_TAB} from '@libs/Navigation/linkingConfig/RELATIONS'; +import type {FullScreenName} from '@libs/Navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; + +function getSelectedTab(state: NavigationState) { + const topmostFullScreenRoute = state?.routes.findLast((route) => isFullScreenName(route.name)); + return FULLSCREEN_TO_TAB[(topmostFullScreenRoute?.name as FullScreenName) ?? NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]; +} + +export default getSelectedTab; diff --git a/src/components/Navigation/TopLevelBottomTabBar/index.tsx b/src/components/Navigation/TopLevelBottomTabBar/index.tsx new file mode 100644 index 000000000000..9da5f63d306b --- /dev/null +++ b/src/components/Navigation/TopLevelBottomTabBar/index.tsx @@ -0,0 +1,71 @@ +import type {ParamListBase} from '@react-navigation/native'; +import React, {useContext, useEffect, useRef, useState} from 'react'; +import {InteractionManager, View} from 'react-native'; +import {FullScreenBlockingViewContext} from '@components/FullScreenBlockingViewContextProvider'; +import BottomTabBar from '@components/Navigation/BottomTabBar'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; +import getIsBottomTabVisibleDirectly from './getIsBottomTabVisibleDirectly'; +import getIsScreenWithBottomTabFocused from './getIsScreenWithBottomTabFocused'; +import getSelectedTab from './getSelectedTab'; + +type TopLevelBottomTabBarProps = { + state: PlatformStackNavigationState; +}; + +/** + * TopLevelBottomTabBar is displayed when the user can interact with the bottom tab bar. + * We hide it when: + * 1. The bottom tab bar is not visible. + * 2. There is transition between screens with and without the bottom tab bar. + * 3. The bottom tab bar is under the overlay. + * For cases 2 and 3, local bottom tab bar mounted on the screen will be displayed. + */ +function TopLevelBottomTabBar({state}: TopLevelBottomTabBarProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {paddingBottom} = useStyledSafeAreaInsets(); + const [isAfterClosingTransition, setIsAfterClosingTransition] = useState(false); + const cancelAfterInteractions = useRef | undefined>(); + const {isBlockingViewVisible} = useContext(FullScreenBlockingViewContext); + + // That means it's visible and it's not covered by the overlay. + const isBottomTabVisibleDirectly = getIsBottomTabVisibleDirectly(state); + const selectedTab = getSelectedTab(state); + const isScreenWithBottomTabFocused = getIsScreenWithBottomTabFocused(state); + + const shouldDisplayBottomBar = shouldUseNarrowLayout ? isScreenWithBottomTabFocused : isBottomTabVisibleDirectly; + const isReadyToDisplayBottomBar = isAfterClosingTransition && shouldDisplayBottomBar && !isBlockingViewVisible; + + useEffect(() => { + if (!shouldDisplayBottomBar) { + // If the bottom tab is not visible, that means there is a screen covering it. + // In that case we need to set the flag to true because there will be a transition for which we need to wait. + setIsAfterClosingTransition(false); + } else { + // If the bottom tab should be visible, we want to wait for transition to finish. + cancelAfterInteractions.current = InteractionManager.runAfterInteractions(() => { + setIsAfterClosingTransition(true); + }); + return () => cancelAfterInteractions.current?.cancel(); + } + }, [shouldDisplayBottomBar]); + + return ( + + {/* We are not rendering BottomTabBar conditionally for two reasons + 1. It's faster to hide/show it than mount a new when needed. + 2. We need to hide tooltips as well if they were displayed. */} + + + ); +} + +TopLevelBottomTabBar.displayName = 'TopLevelBottomTabBar'; + +export default TopLevelBottomTabBar; diff --git a/src/components/Navigation/TopLevelBottomTabBar/useIsBottomTabVisibleDirectly.ts b/src/components/Navigation/TopLevelBottomTabBar/useIsBottomTabVisibleDirectly.ts new file mode 100644 index 000000000000..52b7a215d4e0 --- /dev/null +++ b/src/components/Navigation/TopLevelBottomTabBar/useIsBottomTabVisibleDirectly.ts @@ -0,0 +1,10 @@ +import {useNavigationState} from '@react-navigation/native'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; + +// Visible directly means not through the overlay. So the full screen (split navigator or search) has to be the last route on the root stack. +function useIsBottomTabVisibleDirectly() { + const isBottomTabVisibleDirectly = useNavigationState((state) => isFullScreenName(state?.routes.at(-1)?.name)); + return isBottomTabVisibleDirectly; +} + +export default useIsBottomTabVisibleDirectly; diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index f9d84fb423bb..3e554db813ce 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -47,7 +47,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); if (isVisibleAction) { // Pop the chat report screen before navigating to the linked report action. - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(parentReportID, parentReportActionID), true); + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(parentReportID, parentReportActionID)); } }} accessibilityLabel={translate('threads.parentNavigationSummary', {reportName, workspaceName})} diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx index 0d8a115f2a4e..820552901452 100644 --- a/src/components/ProductTrainingContext/index.tsx +++ b/src/components/ProductTrainingContext/index.tsx @@ -1,4 +1,4 @@ -import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import React, {createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -9,6 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import {parseFSAttributes} from '@libs/Fullstory'; import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -174,10 +175,21 @@ const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shou }; }, [tooltipName, registerTooltip, unregisterTooltip, shouldShow]); + /** + * Extracts values from the non-scraped attribute WEB_PROP_ATTR at build time + * to ensure necessary properties are available for further processing. + * Reevaluates "fs-class" to dynamically apply styles or behavior based on + * updated attribute values. + */ + useLayoutEffect(parseFSAttributes, []); + const renderProductTrainingTooltip = useCallback(() => { const tooltip = TOOLTIPS[tooltipName]; return ( - + ); + const targetedReportID = reportID ?? reportAction?.childReportID ?? ''; - if (topmostCentralPaneRoute?.name !== SCREENS.SEARCH.CENTRAL_PANE && isTextHold) { + if (!isSearchTopmostFullScreenRoute() && isTextHold) { changeMoneyRequestHoldStatus(reportAction, ROUTES.REPORT_WITH_ID.getRoute(targetedReportID)); return; } diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 638ef0737ed5..ee543765169d 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {isMobile, isMobileWebKit, isSafari} from '@libs/Browser'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {AuthScreensParamList, RootStackParamList} from '@libs/Navigation/types'; +import type {ReportsSplitNavigatorParamList, RootNavigatorParamList} from '@libs/Navigation/types'; import addViewportResizeListener from '@libs/VisualViewport'; import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; @@ -42,6 +42,9 @@ type ScreenWrapperProps = { /** Returns a function as a child to pass insets to or a node to render without insets */ children: ReactNode | React.FC; + /** Content to display under the offline indicator */ + bottomContent?: ReactNode; + /** A unique ID to find the screen wrapper in tests */ testID: string; @@ -98,7 +101,7 @@ type ScreenWrapperProps = { * * This is required because transitionEnd event doesn't trigger in the testing environment. */ - navigation?: PlatformStackNavigationProp | PlatformStackNavigationProp; + navigation?: PlatformStackNavigationProp | PlatformStackNavigationProp; /** Whether to show offline indicator on wide screens */ shouldShowOfflineIndicatorInWideScreen?: boolean; @@ -137,6 +140,7 @@ function ScreenWrapper( shouldShowOfflineIndicatorInWideScreen = false, shouldUseCachedViewportHeight = false, focusTrapSettings, + bottomContent, }: ScreenWrapperProps, ref: ForwardedRef, ) { @@ -147,7 +151,7 @@ function ScreenWrapper( * so in other places where ScreenWrapper is used, we need to * fallback to useNavigation. */ - const navigationFallback = useNavigation>(); + const navigationFallback = useNavigation>(); const navigation = navigationProp ?? navigationFallback; const isFocused = useIsFocused(); const {windowHeight} = useWindowDimensions(shouldUseCachedViewportHeight); @@ -258,22 +262,23 @@ function ScreenWrapper( }, []); const {insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle, unmodifiedPaddings} = useStyledSafeAreaInsets(); - const paddingStyle: StyleProp = {}; + const paddingTopStyle: StyleProp = {}; + const paddingBottomStyle: StyleProp = {}; const isSafeAreaTopPaddingApplied = includePaddingTop; if (includePaddingTop) { - paddingStyle.paddingTop = paddingTop; + paddingTopStyle.paddingTop = paddingTop; } if (includePaddingTop && ignoreInsetsConsumption) { - paddingStyle.paddingTop = unmodifiedPaddings.top; + paddingTopStyle.paddingTop = unmodifiedPaddings.top; } // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked. if (includeSafeAreaPaddingBottom) { - paddingStyle.paddingBottom = paddingBottom; + paddingBottomStyle.paddingBottom = paddingBottom; } if (includeSafeAreaPaddingBottom && ignoreInsetsConsumption) { - paddingStyle.paddingBottom = unmodifiedPaddings.bottom; + paddingBottomStyle.paddingBottom = unmodifiedPaddings.bottom; } const isAvoidingViewportScroll = useTackInputFocus(isFocused && shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && isMobileWebKit()); @@ -293,7 +298,7 @@ function ScreenWrapper( > @@ -347,6 +352,7 @@ function ScreenWrapper( + {bottomContent} ); diff --git a/src/components/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx index aba45db33e00..f715e137f79c 100644 --- a/src/components/ScrollOffsetContextProvider.tsx +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -2,9 +2,9 @@ import type {ParamListBase} from '@react-navigation/native'; import React, {createContext, useCallback, useEffect, useMemo, useRef} from 'react'; import {useOnyx} from 'react-native-onyx'; import usePrevious from '@hooks/usePrevious'; +import {isSidebarScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {NavigationPartialRoute, State} from '@libs/Navigation/types'; -import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -78,14 +78,11 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp }, []); const cleanStaleScrollOffsets: ScrollOffsetContextValue['cleanStaleScrollOffsets'] = useCallback((state) => { - const bottomTabNavigator = state.routes.find((route) => route.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR); - if (bottomTabNavigator?.state && 'routes' in bottomTabNavigator.state) { - const bottomTabNavigatorRoutes = bottomTabNavigator.state.routes; - const scrollOffsetkeysOfExistingScreens = bottomTabNavigatorRoutes.map((route) => getKey(route)); - for (const key of Object.keys(scrollOffsetsRef.current)) { - if (!scrollOffsetkeysOfExistingScreens.includes(key)) { - delete scrollOffsetsRef.current[key]; - } + const sidebarRoutes = state.routes.filter((route) => isSidebarScreenName(route.name)); + const scrollOffsetkeysOfExistingScreens = sidebarRoutes.map((route) => getKey(route)); + for (const key of Object.keys(scrollOffsetsRef.current)) { + if (!scrollOffsetkeysOfExistingScreens.includes(key)) { + delete scrollOffsetsRef.current[key]; } } }, []); diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 6c6b4dc689d3..5e201a921e0d 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,4 +1,4 @@ -import {useNavigationState} from '@react-navigation/native'; +import {findFocusedRoute, useNavigationState} from '@react-navigation/native'; import isEqual from 'lodash/isEqual'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -27,11 +27,13 @@ import type {OptionData} from '@libs/ReportUtils'; import {getAutocompleteQueryWithComma, getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils'; import {getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import Navigation from '@navigation/Navigation'; +import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import variables from '@styles/variables'; import {navigateToAndOpenReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import type Report from '@src/types/onyx/Report'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; @@ -91,7 +93,11 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const textInputRef = useRef(null); const contextualReportID = useNavigationState, string | undefined>((state) => { - return state?.routes.at(-1)?.params?.reportID; + const focusedRoute = findFocusedRoute(state); + if (focusedRoute?.name === SCREENS.REPORT) { + // We're guaranteed that the type of params is of SCREENS.REPORT + return (focusedRoute.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]).reportID; + } }); const getAdditionalSections: GetAdditionalSectionsCallback = useCallback( diff --git a/src/components/Search/SearchRouter/SearchRouterContext.tsx b/src/components/Search/SearchRouter/SearchRouterContext.tsx index 7aa7e7305bc8..947dcc9c48c5 100644 --- a/src/components/Search/SearchRouter/SearchRouterContext.tsx +++ b/src/components/Search/SearchRouter/SearchRouterContext.tsx @@ -1,6 +1,6 @@ import React, {useContext, useMemo, useRef, useState} from 'react'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; -import isSearchTopmostCentralPane from '@navigation/isSearchTopmostCentralPane'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import * as Modal from '@userActions/Modal'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -49,7 +49,7 @@ function SearchRouterContextProvider({children}: ChildrenProps) { // So we need a function that is based on ref to correctly open/close it // When user is on `/search` page we focus the Input instead of showing router const toggleSearch = () => { - const isUserOnSearchPage = isSearchTopmostCentralPane(); + const isUserOnSearchPage = isSearchTopmostFullScreenRoute(); if (isUserOnSearchPage && searchPageInputRef.current) { if (searchPageInputRef.current.isFocused()) { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 153e635da7b6..406afcbb5e2d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -19,7 +19,7 @@ import {createTransactionThread, search} from '@libs/actions/Search'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import memoize from '@libs/memoize'; -import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import {generateReportID} from '@libs/ReportUtils'; import {buildSearchQueryString} from '@libs/SearchQueryUtils'; @@ -330,7 +330,7 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo useEffect( () => () => { - if (isSearchTopmostCentralPane()) { + if (isSearchTopmostFullScreenRoute()) { return; } clearSelectedTransactions(); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 8f55ecd7a924..2edf248e12d4 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -96,6 +96,7 @@ function BaseSelectionList( sectionTitleStyles, textInputAutoFocus = true, shouldShowTextInputAfterHeader = false, + shouldShowHeaderMessageAfterHeader = false, includeSafeAreaPaddingBottom = true, shouldTextInputInterceptSwipe = false, listHeaderContent, @@ -123,6 +124,7 @@ function BaseSelectionList( listItemTitleStyles, initialNumToRender = 12, listItemTitleContainerStyles, + isScreenFocused = false, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -380,6 +382,9 @@ function BaseSelectionList( */ const selectRow = useCallback( (item: TItem, indexToFocus?: number) => { + if (!isFocused && !isScreenFocused) { + return; + } // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item if (canSelectMultiple) { if (sections.length > 1 && !item.isSelected) { @@ -412,6 +417,8 @@ function BaseSelectionList( setFocusedIndex, onSelectRow, shouldPreventDefaultFocusOnSelectRow, + isFocused, + isScreenFocused, ], ); @@ -805,6 +812,14 @@ function BaseSelectionList( }, ); + const headerMessageContent = () => + (!isLoadingNewOptions || headerMessage !== translate('common.noResultsFound') || (flattenedSections.allOptions.length === 0 && !showLoadingPlaceholder)) && + !!headerMessage && ( + + {headerMessage} + + ); + const {safeAreaPaddingBottomStyle} = useStyledSafeAreaInsets(); // TODO: test _every_ component that uses SelectionList @@ -813,11 +828,7 @@ function BaseSelectionList( {shouldShowTextInput && !shouldShowTextInputAfterHeader && renderInput()} {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} {/* This is misleading because we might be in the process of loading fresh options from the server. */} - {(!isLoadingNewOptions || headerMessage !== translate('common.noResultsFound') || (flattenedSections.allOptions.length === 0 && !showLoadingPlaceholder)) && !!headerMessage && ( - - {headerMessage} - - )} + {!shouldShowHeaderMessageAfterHeader && headerMessageContent()} {!!headerContent && headerContent} {flattenedSections.allOptions.length === 0 && (showLoadingPlaceholder || shouldShowListEmptyContent) ? ( renderListEmptyContent() @@ -859,6 +870,7 @@ function BaseSelectionList( <> {listHeaderContent} {renderInput()} + {shouldShowHeaderMessageAfterHeader && headerMessageContent()} ) : ( listHeaderContent diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index ffdb1c956d8b..fc3dd2758ab7 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -469,6 +469,9 @@ type BaseSelectionListProps = Partial & { /** Whether the text input should be shown after list header */ shouldShowTextInputAfterHeader?: boolean; + /** Whether the header message should be shown after list header */ + shouldShowHeaderMessageAfterHeader?: boolean; + /** Whether to include padding bottom */ includeSafeAreaPaddingBottom?: boolean; @@ -659,6 +662,9 @@ type BaseSelectionListProps = Partial & { /** Initial number of items to render */ initialNumToRender?: number; + + /** Whether the screen is focused or not. (useIsFocused state does not work in tab screens, e.g. SearchPageBottomTab) */ + isScreenFocused?: boolean; } & TRightHandSideComponent; type SelectionListHandle = { diff --git a/src/components/SelectionListWithModal/index.tsx b/src/components/SelectionListWithModal/index.tsx index a4c8b83b29d8..81afd267abea 100644 --- a/src/components/SelectionListWithModal/index.tsx +++ b/src/components/SelectionListWithModal/index.tsx @@ -106,6 +106,7 @@ function SelectionListWithModal( ref={ref} sections={sections} onLongPressRow={handleLongPressRow} + isScreenFocused={isScreenFocused} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> diff --git a/src/components/Tooltip/BaseGenericTooltip/index.native.tsx b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx index 0408d9bd20e2..ad37f59c8ef5 100644 --- a/src/components/Tooltip/BaseGenericTooltip/index.native.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.native.tsx @@ -1,5 +1,5 @@ import {Portal} from '@gorhom/portal'; -import React, {useMemo, useRef, useState} from 'react'; +import React, {useLayoutEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {View as RNView} from 'react-native'; @@ -8,6 +8,7 @@ import AnimatedPressableWithoutFeedback from '@components/AnimatedPressableWitho import TransparentOverlay from '@components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; +import {parseFSAttributes} from '@libs/Fullstory'; import CONST from '@src/CONST'; import type {BaseGenericTooltipProps} from './types'; @@ -101,14 +102,31 @@ function BaseGenericTooltip({ }); }); + /** + * Extracts values from the non-scraped attribute WEB_PROP_ATTR at build time + * to ensure necessary properties are available for further processing. + * Reevaluates "fs-class" to dynamically apply styles or behavior based on + * updated attribute values. + */ + useLayoutEffect(parseFSAttributes, []); + let content; if (renderTooltipContent) { - content = {renderTooltipContent()}; + content = ( + + {renderTooltipContent()} + + ); } else { content = ( {text} diff --git a/src/components/Tooltip/BaseGenericTooltip/index.tsx b/src/components/Tooltip/BaseGenericTooltip/index.tsx index 9ae49defc0fa..bfa84cd1830d 100644 --- a/src/components/Tooltip/BaseGenericTooltip/index.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.tsx @@ -8,6 +8,7 @@ import TransparentOverlay from '@components/AutoCompleteSuggestions/AutoComplete import {PopoverContext} from '@components/PopoverProvider'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; +import {parseFSAttributes} from '@libs/Fullstory'; import CONST from '@src/CONST'; import textRef from '@src/types/utils/textRef'; import viewRef from '@src/types/utils/viewRef'; @@ -120,14 +121,32 @@ function BaseGenericTooltip({ return StyleUtils.getTooltipAnimatedStyles({tooltipContentWidth: contentMeasuredWidth, tooltipWrapperHeight: wrapperMeasuredHeight, currentSize: animation}); }); + /** + * Extracts values from the non-scraped attribute WEB_PROP_ATTR at build time + * to ensure necessary properties are available for further processing. + * Reevaluates "fs-class" to dynamically apply styles or behavior based on + * updated attribute values. + */ + useLayoutEffect(parseFSAttributes, []); + let content; if (renderTooltipContent) { - content = {renderTooltipContent()}; + content = ( + + {renderTooltipContent()} + + ); } else { content = ( ; -}; - -export default function withNavigation( - WrappedComponent: ComponentType>, -): (props: Omit, ref: ForwardedRef) => React.JSX.Element | null { - function WithNavigation(props: Omit, ref: ForwardedRef) { - const navigation = useNavigation(); - return ( - - ); - } - - WithNavigation.displayName = `withNavigation(${getComponentDisplayName(WrappedComponent)})`; - return React.forwardRef(WithNavigation); -} diff --git a/src/components/withNavigationTransitionEnd.tsx b/src/components/withNavigationTransitionEnd.tsx index 69e04ff22e35..0bb6f1ffa448 100644 --- a/src/components/withNavigationTransitionEnd.tsx +++ b/src/components/withNavigationTransitionEnd.tsx @@ -3,14 +3,14 @@ import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; import React, {useEffect, useState} from 'react'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {RootStackParamList} from '@libs/Navigation/types'; +import type {RootNavigatorParamList} from '@libs/Navigation/types'; type WithNavigationTransitionEndProps = {didScreenTransitionEnd: boolean}; export default function (WrappedComponent: ComponentType>): React.ComponentType> { function WithNavigationTransitionEnd(props: TProps, ref: ForwardedRef) { const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const navigation = useNavigation>(); + const navigation = useNavigation>(); useEffect(() => { const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => { diff --git a/src/components/withPrepareCentralPaneScreen/index.native.tsx b/src/components/withPrepareCentralPaneScreen/index.native.tsx deleted file mode 100644 index 84ba31cd63fd..000000000000 --- a/src/components/withPrepareCentralPaneScreen/index.native.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type React from 'react'; -import freezeScreenWithLazyLoading from '@libs/freezeScreenWithLazyLoading'; - -/** - * This higher-order function is dependent on the platform. On native platforms, screens that aren't already displayed in the navigation stack should be frozen to prevent unnecessary rendering. - * It's handled this way only on mobile platforms because on the web, more than one screen is displayed in a wide layout, so these screens shouldn't be frozen. - */ -export default function withPrepareCentralPaneScreen(lazyComponent: () => React.ComponentType) { - return freezeScreenWithLazyLoading(lazyComponent); -} diff --git a/src/components/withPrepareCentralPaneScreen/index.tsx b/src/components/withPrepareCentralPaneScreen/index.tsx deleted file mode 100644 index f53368188b3d..000000000000 --- a/src/components/withPrepareCentralPaneScreen/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type React from 'react'; - -/** - * This higher-order function is dependent on the platform. On native platforms, screens that aren't already displayed in the navigation stack should be frozen to prevent unnecessary rendering. - * It's handled this way only on mobile platforms because on the web, more than one screen is displayed in a wide layout, so these screens shouldn't be frozen. - */ -export default function withPrepareCentralPaneScreen(lazyComponent: () => React.ComponentType) { - return lazyComponent; -} diff --git a/src/hooks/useActiveCentralPaneRoute.ts b/src/hooks/useActiveCentralPaneRoute.ts deleted file mode 100644 index 05354e810c3d..000000000000 --- a/src/hooks/useActiveCentralPaneRoute.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {useContext} from 'react'; -import ActiveCentralPaneRouteContext from '@libs/Navigation/AppNavigator/Navigators/ActiveCentralPaneRouteContext'; -import type {AuthScreensParamList, NavigationPartialRoute} from '@libs/Navigation/types'; - -function useActiveCentralPaneRoute(): NavigationPartialRoute | undefined { - return useContext(ActiveCentralPaneRouteContext); -} - -export default useActiveCentralPaneRoute; diff --git a/src/hooks/useActiveWorkspace.ts b/src/hooks/useActiveWorkspace.ts index cce3c2a4b31f..0ba5426895e1 100644 --- a/src/hooks/useActiveWorkspace.ts +++ b/src/hooks/useActiveWorkspace.ts @@ -1,6 +1,6 @@ import {useContext} from 'react'; -import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; import type {ActiveWorkspaceContextType} from '@components/ActiveWorkspace/ActiveWorkspaceContext'; +import ActiveWorkspaceContext from '@components/ActiveWorkspace/ActiveWorkspaceContext'; function useActiveWorkspace(): ActiveWorkspaceContextType { return useContext(ActiveWorkspaceContext); diff --git a/src/hooks/useActiveWorkspaceFromNavigationState.ts b/src/hooks/useActiveWorkspaceFromNavigationState.ts deleted file mode 100644 index 0308ece138a6..000000000000 --- a/src/hooks/useActiveWorkspaceFromNavigationState.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {useNavigationState} from '@react-navigation/native'; -import Log from '@libs/Log'; -import type {BottomTabNavigatorParamList} from '@libs/Navigation/types'; -import SCREENS from '@src/SCREENS'; - -/** - * Get the currently selected policy ID stored in the navigation state. This hook should only be called only from screens in BottomTab. - * Differences between this hook and useActiveWorkspace: - * - useActiveWorkspaceFromNavigationState reads the active workspace id directly from the navigation state, it's a bit slower than useActiveWorkspace and it can be called only from BottomTabScreens. - * It allows to read a value of policyID immediately after the update. - * - useActiveWorkspace allows to read the current policyID anywhere, it's faster because it doesn't require searching in the navigation state. - */ -function useActiveWorkspaceFromNavigationState() { - // The last policyID value is always stored in the last route in BottomTabNavigator. - const activeWorkspaceID = useNavigationState((state) => { - // SCREENS.HOME is a screen located in the BottomTabNavigator, if it's not in state.routeNames it means that this hook was called from a screen in another navigator. - if (!state.routeNames.includes(SCREENS.HOME)) { - Log.warn('useActiveWorkspaceFromNavigationState should be called only from BottomTab screens'); - } - - const lastHomeParams = state.routes.findLast((route) => route.name === SCREENS.HOME)?.params ?? {}; - - if ('policyID' in lastHomeParams) { - return lastHomeParams.policyID; - } - }); - - return activeWorkspaceID; -} - -export default useActiveWorkspaceFromNavigationState; diff --git a/src/hooks/useBottomTabIsFocused.ts b/src/hooks/useBottomTabIsFocused.ts deleted file mode 100644 index 90b74097ecd8..000000000000 --- a/src/hooks/useBottomTabIsFocused.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type {EventArg, NavigationContainerEventMap} from '@react-navigation/native'; -import {useIsFocused, useNavigationState} from '@react-navigation/native'; -import {useEffect, useState} from 'react'; -import CENTRAL_PANE_SCREENS from '@libs/Navigation/AppNavigator/CENTRAL_PANE_SCREENS'; -import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; -import getTopmostFullScreenRoute from '@libs/Navigation/getTopmostFullScreenRoute'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import type {CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; -import SCREENS from '@src/SCREENS'; -import useResponsiveLayout from './useResponsiveLayout'; - -const useBottomTabIsFocused = () => { - const [isScreenFocused, setIsScreenFocused] = useState(false); - useEffect(() => { - const listener = (event: EventArg<'state', false, NavigationContainerEventMap['state']['data']>) => { - const routName = Navigation.getRouteNameFromStateEvent(event); - if (routName === SCREENS.SEARCH.CENTRAL_PANE || routName === SCREENS.SETTINGS_CENTRAL_PANE || routName === SCREENS.HOME) { - setIsScreenFocused(true); - return; - } - setIsScreenFocused(false); - }; - navigationRef.addListener('state', listener); - return () => navigationRef.removeListener('state', listener); - }, []); - - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const isFocused = useIsFocused(); - const topmostFullScreenName = useNavigationState | undefined>(getTopmostFullScreenRoute); - const topmostCentralPane = useNavigationState | undefined>(getTopmostCentralPaneRoute); - - // If there is a full screen view such as Workspace Settings or Not Found screen, the bottom tab should not be considered focused - if (topmostFullScreenName) { - return false; - } - // On the Search screen, isFocused returns false, but it is actually focused - if (shouldUseNarrowLayout) { - return isFocused || isScreenFocused; - } - // On desktop screen sizes, isFocused always returns false, so we cannot rely on it alone to determine if the bottom tab is focused - return isFocused || Object.keys(CENTRAL_PANE_SCREENS).includes(topmostCentralPane?.name ?? ''); -}; - -export default useBottomTabIsFocused; diff --git a/src/hooks/useIsCurrentRouteHome.ts b/src/hooks/useIsCurrentRouteHome.ts deleted file mode 100644 index e4950a9accc7..000000000000 --- a/src/hooks/useIsCurrentRouteHome.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {useNavigationState} from '@react-navigation/native'; -import getTopmostRouteName from '@libs/Navigation/getTopmostRouteName'; -import SCREENS from '@src/SCREENS'; - -/** Determine if the current route is the home screen */ -function useIsCurrentRouteHome() { - const activeRoute = useNavigationState(getTopmostRouteName); - const isActiveRouteHome = activeRoute === SCREENS.HOME; - return isActiveRouteHome; -} - -export default useIsCurrentRouteHome; diff --git a/src/hooks/useRootNavigationState.ts b/src/hooks/useRootNavigationState.ts new file mode 100644 index 000000000000..289fe164be10 --- /dev/null +++ b/src/hooks/useRootNavigationState.ts @@ -0,0 +1,33 @@ +import type {NavigationState} from '@react-navigation/routers'; +import {useEffect, useRef, useState} from 'react'; +import navigationRef from '@libs/Navigation/navigationRef'; + +type Selector = (state: NavigationState) => T; + +/** + * Hook to get a value from the current root navigation state using a selector. + * + * @param selector Selector function to get a value from the state. + */ +function useRootNavigationState(selector: Selector): T { + const [result, setResult] = useState(() => selector(navigationRef.getRootState())); + + // We store the selector in a ref to avoid re-subscribing listeners every render + const selectorRef = useRef(selector); + + useEffect(() => { + selectorRef.current = selector; + }); + + useEffect(() => { + const unsubscribe = navigationRef.addListener('state', (e) => { + setResult(selectorRef.current(e.data.state as NavigationState)); + }); + + return unsubscribe; + }, []); + + return result; +} + +export default useRootNavigationState; diff --git a/src/languages/en.ts b/src/languages/en.ts index df4e2f1cb397..c5d88693f61f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -175,6 +175,7 @@ import type { UnapproveWithIntegrationWarningParams, UnshareParams, UntilTimeParams, + UpdateAutoReportingFrequencyParams, UpdatedTheDistanceMerchantParams, UpdatedTheRequestParams, UpdateRoleParams, @@ -2526,15 +2527,12 @@ const translations = { toLearnMore: ' to learn more.', termsAndConditions: { header: 'Before we continue...', - title: 'Please read the Terms & Conditions for travel', - subtitle: 'To enable travel on your workspace you must agree to our ', + title: 'Terms & conditions', + subtitle: 'Please agree to the Expensify Travel ', termsconditions: 'terms & conditions', travelTermsAndConditions: 'terms & conditions', - helpDocIntro: 'Check out this ', - helpDocOutro: 'for more information or reach out to Concierge or your Account Manager.', - helpDoc: 'Help Doc', - agree: 'I agree to the travel ', - error: 'You must accept the Terms & Conditions for travel to continue', + agree: 'I agree to the ', + error: 'You must agree to the Expensify Travel terms & conditions to continue', }, flight: 'Flight', flightDetails: { @@ -2709,6 +2707,15 @@ const translations = { return 'Member'; } }, + frequency: { + manual: 'Manually', + instant: 'Instant', + immediate: 'Daily', + trip: 'By trip', + weekly: 'Weekly', + semimonthly: 'Twice a month', + monthly: 'Monthly', + }, planType: 'Plan type', submitExpense: 'Submit your expenses below:', defaultCategory: 'Default category', @@ -3796,6 +3803,9 @@ const translations = { noAccountsFound: 'No accounts found', defaultCard: 'Default card', noAccountsFoundDescription: ({connection}: ConnectionParams) => `Please add the account in ${connection} and sync the connection again.`, + expensifyCardBannerTitle: 'Get the Expensify Card', + expensifyCardBannerSubtitle: 'Enjoy cash back on every US purchase, up to 50% off your Expensify bill, unlimited virtual cards, and so much more.', + expensifyCardBannerLearnMoreButton: 'Learn more', }, workflows: { title: 'Workflows', @@ -5081,6 +5091,8 @@ const translations = { leftWorkspace: ({nameOrEmail}: LeftWorkspaceParams) => `${nameOrEmail} left the workspace`, removeMember: ({email, role}: AddEmployeeParams) => `removed ${role === 'member' || role === 'user' ? 'member' : 'admin'} ${email}`, removedConnection: ({connectionName}: ConnectionNameParams) => `removed connection to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, + updateAutoReportingFrequency: ({oldFrequency, newFrequency}: UpdateAutoReportingFrequencyParams) => + `updated the submission frequency to "${newFrequency}" (previously "${oldFrequency}")`, }, }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index a4507194aedd..54292ec0f511 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -174,6 +174,7 @@ import type { UnapproveWithIntegrationWarningParams, UnshareParams, UntilTimeParams, + UpdateAutoReportingFrequencyParams, UpdatedTheDistanceMerchantParams, UpdatedTheRequestParams, UpdateRoleParams, @@ -2551,15 +2552,12 @@ const translations = { toLearnMore: ' para obtener más información.', termsAndConditions: { header: 'Antes de continuar...', - title: 'Por favor, lee los Términos y condiciones para reservar viajes', - subtitle: 'Para permitir la opción de reservar viajes en tu espacio de trabajo debe aceptar nuestros ', + title: 'Términos y condiciones de Expensify Travel', + subtitle: 'Por favor, acepta los ', termsconditions: 'términos y condiciones', - travelTermsAndConditions: 'términos y condiciones de viaje', - helpDocIntro: 'Consulta este ', - helpDocOutro: 'para obtener más información o comunícate con Concierge o tu gestor de cuentas.', - helpDoc: 'documento de ayuda', + travelTermsAndConditions: 'términos y condiciones', agree: 'Acepto los ', - error: 'Debes aceptar los Términos y condiciones para que el viaje continúe', + error: 'Debes aceptar los términos y condiciones de Expensify Travel para continuar', }, flight: 'Vuelo', flightDetails: { @@ -2734,6 +2732,15 @@ const translations = { return 'Miembro'; } }, + frequency: { + manual: 'Manualmente', + instant: 'Instantáneo', + immediate: 'Diaria', + trip: 'Por viaje', + weekly: 'Semanal', + semimonthly: 'Dos veces al mes', + monthly: 'Mensual', + }, planType: 'Tipo de plan', submitExpense: 'Envía tus gastos a continuación:', defaultCategory: 'Categoría predeterminada', @@ -3841,6 +3848,10 @@ const translations = { noAccountsFound: 'No se han encontrado cuentas', defaultCard: 'Tarjeta predeterminada', noAccountsFoundDescription: ({connection}: ConnectionParams) => `Añade la cuenta en ${connection} y sincroniza la conexión de nuevo.`, + expensifyCardBannerTitle: 'Obtén la Tarjeta Expensify', + expensifyCardBannerSubtitle: + 'Disfruta de una devolución en cada compra en Estados Unidos, hasta un 50% de descuento en tu factura de Expensify, tarjetas virtuales ilimitadas y mucho más.', + expensifyCardBannerLearnMoreButton: 'Más información', }, workflows: { title: 'Flujos de trabajo', @@ -5133,6 +5144,8 @@ const translations = { leftWorkspace: ({nameOrEmail}: LeftWorkspaceParams) => `${nameOrEmail} salió del espacio de trabajo`, removeMember: ({email, role}: AddEmployeeParams) => `eliminado ${role === 'miembro' || role === 'user' ? 'miembro' : 'administrador'} ${email}`, removedConnection: ({connectionName}: ConnectionNameParams) => `eliminó la conexión a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}`, + updateAutoReportingFrequency: ({oldFrequency, newFrequency}: UpdateAutoReportingFrequencyParams) => + `actualizó la frecuencia de envíos a "${newFrequency}" (previamente "${oldFrequency}")`, }, }, }, diff --git a/src/languages/params.ts b/src/languages/params.ts index e1d3e5b34c4e..2a04be84ae21 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -349,6 +349,11 @@ type ConnectionNameParams = { connectionName: AllConnectionName; }; +type UpdateAutoReportingFrequencyParams = { + oldFrequency: string; + newFrequency: string; +}; + type LastSyncDateParams = { connectionName: string; formattedDate: string; @@ -810,6 +815,7 @@ export type { UpdateRoleParams, LeftWorkspaceParams, RemoveMemberParams, + UpdateAutoReportingFrequencyParams, DateParams, FiltersAmountBetweenParams, StatementPageTitleParams, diff --git a/src/libs/API/parameters/SyncPolicyToNSQSParams.ts b/src/libs/API/parameters/SyncPolicyToNSQSParams.ts index 319ccb2f1d50..aa867403586d 100644 --- a/src/libs/API/parameters/SyncPolicyToNSQSParams.ts +++ b/src/libs/API/parameters/SyncPolicyToNSQSParams.ts @@ -1,5 +1,6 @@ type SyncPolicyToNSQSParams = { policyID: string; + netSuiteAccountID: string; idempotencyKey: string; }; diff --git a/src/libs/Environment/getEnvironment/index.native.ts b/src/libs/Environment/getEnvironment/index.native.ts index 6d298c3fdae9..5dac0ad1d73e 100644 --- a/src/libs/Environment/getEnvironment/index.native.ts +++ b/src/libs/Environment/getEnvironment/index.native.ts @@ -1,3 +1,4 @@ +import {NativeModules} from 'react-native'; import Config from 'react-native-config'; import betaChecker from '@libs/Environment/betaChecker'; import CONST from '@src/CONST'; @@ -28,6 +29,12 @@ function getEnvironment(): Promise { return; } + // If we don't use Development, and we're in the HybridApp, we should use Production + if (NativeModules.HybridAppModule) { + environment = CONST.ENVIRONMENT.PRODUCTION; + return; + } + // If we haven't set the environment yet and we aren't on dev/adhoc, check to see if this is a beta build betaChecker.isBetaBuild().then((isBeta) => { environment = isBeta ? CONST.ENVIRONMENT.STAGING : CONST.ENVIRONMENT.PRODUCTION; diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index a2d83259c52b..a183666fbdcc 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -26,13 +26,13 @@ function navigateToStartMoneyRequestStep(requestType: IOURequestType, iouType: I // If the participants were automatically added to the transaction, then the user needs taken back to the starting step switch (requestType) { case CONST.IOU.REQUEST_TYPE.DISTANCE: - Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)); + Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), {compareParams: false}); break; case CONST.IOU.REQUEST_TYPE.SCAN: - Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)); + Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), {compareParams: false}); break; default: - Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID)); + Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID), {compareParams: false}); break; } } diff --git a/src/components/ImportOnyxState/utils.ts b/src/libs/ImportOnyxStateUtils.ts similarity index 51% rename from src/components/ImportOnyxState/utils.ts rename to src/libs/ImportOnyxStateUtils.ts index 94779868384d..249924291a92 100644 --- a/src/components/ImportOnyxState/utils.ts +++ b/src/libs/ImportOnyxStateUtils.ts @@ -1,6 +1,10 @@ import cloneDeep from 'lodash/cloneDeep'; +import type {OnyxEntry, OnyxKey} from 'react-native-onyx'; import type {UnknownRecord} from 'type-fest'; +import type {OnyxCollectionKey, OnyxCollectionValuesMapping, OnyxValues} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; +import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; +import {clearOnyxStateBeforeImport, importOnyxCollectionState, importOnyxRegularState} from './actions/ImportOnyxState'; // List of Onyx keys from the .txt file we want to keep for the local override const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.PREFERRED_THEME]; @@ -12,7 +16,7 @@ function isRecord(value: unknown): value is Record { function transformNumericKeysToArray(data: UnknownRecord): UnknownRecord | unknown[] { const dataCopy = cloneDeep(data); if (!isRecord(dataCopy)) { - return Array.isArray(dataCopy) ? (dataCopy as UnknownRecord[]).map(transformNumericKeysToArray) : (dataCopy as UnknownRecord); + return Array.isArray(dataCopy) ? (dataCopy as UnknownRecord[]).map(transformNumericKeysToArray) : dataCopy; } const keys = Object.keys(dataCopy); @@ -50,4 +54,35 @@ function cleanAndTransformState(state: string): T { return transformedState; } -export {transformNumericKeysToArray, cleanAndTransformState}; +function importState(transformedState: OnyxValues): Promise { + const collectionKeys = [...new Set(Object.values(ONYXKEYS.COLLECTION))]; + const collectionsMap = new Map>(); + const regularState: Partial>> = {}; + + Object.entries(transformedState).forEach(([entryKey, entryValue]) => { + const key = entryKey as OnyxKey; + const value = entryValue as NonNullable>; + + const collectionKey = collectionKeys.find((cKey) => key.startsWith(cKey)); + if (collectionKey) { + if (!collectionsMap.has(collectionKey)) { + collectionsMap.set(collectionKey, {}); + } + + const collection = collectionsMap.get(collectionKey); + if (!collection) { + return; + } + + collection[key as OnyxCollectionKey] = value; + } else { + regularState[key] = value; + } + }); + + return clearOnyxStateBeforeImport() + .then(() => importOnyxCollectionState(collectionsMap)) + .then(() => importOnyxRegularState(regularState)); +} + +export {cleanAndTransformState, importState, transformNumericKeysToArray}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 9968251e226a..ec1c06b2bbaf 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -1,23 +1,22 @@ +import type {RouteProp} from '@react-navigation/native'; import {findFocusedRoute, useNavigation} from '@react-navigation/native'; -import React, {memo, useEffect, useMemo, useRef, useState} from 'react'; -import {NativeModules, View} from 'react-native'; +import React, {memo, useEffect, useMemo, useRef} from 'react'; +import {NativeModules} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx, {withOnyx} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import ActiveGuidesEventListener from '@components/ActiveGuidesEventListener'; +import ActiveWorkspaceContextProvider from '@components/ActiveWorkspaceProvider'; import ComposeProviders from '@components/ComposeProviders'; import OptionsListContextProvider from '@components/OptionListContextProvider'; import {SearchContextProvider} from '@components/Search/SearchContext'; import {useSearchRouterContext} from '@components/Search/SearchRouter/SearchRouterContext'; import SearchRouterModal from '@components/Search/SearchRouter/SearchRouterModal'; import TestToolsModal from '@components/TestToolsModal'; -import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnboardingFlowRouter from '@hooks/useOnboardingFlow'; -import usePermissions from '@hooks/usePermissions'; +import {ReportIDsContextProvider} from '@hooks/useReportIDs'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; import setFullscreenVisibility from '@libs/actions/setFullscreenVisibility'; import {READ_COMMANDS} from '@libs/API/types'; import HttpUtils from '@libs/HttpUtils'; @@ -25,18 +24,15 @@ import KeyboardShortcut from '@libs/KeyboardShortcut'; import Log from '@libs/Log'; import NavBarManager from '@libs/NavBarManager'; import getCurrentUrl from '@libs/Navigation/currentUrl'; +import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation'; -import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types'; -import shouldOpenOnAdminRoom from '@libs/Navigation/shouldOpenOnAdminRoom'; -import type {AuthScreensParamList, CentralPaneName, CentralPaneScreensParamList} from '@libs/Navigation/types'; -import {isOnboardingFlowName} from '@libs/NavigationUtils'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; import NetworkConnection from '@libs/NetworkConnection'; import onyxSubscribe from '@libs/onyxSubscribe'; import * as Pusher from '@libs/Pusher/pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; -import * as ReportUtils from '@libs/ReportUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import * as SessionUtils from '@libs/SessionUtils'; import ConnectionCompletePage from '@pages/ConnectionCompletePage'; @@ -62,21 +58,17 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; -import beforeRemoveReportOpenedFromSearchRHP from './beforeRemoveReportOpenedFromSearchRHP'; -import CENTRAL_PANE_SCREENS from './CENTRAL_PANE_SCREENS'; -import createResponsiveStackNavigator from './createResponsiveStackNavigator'; +import createRootStackNavigator from './createRootStackNavigator'; +import {reportsSplitsWithEnteringAnimation, workspaceSplitsWithoutEnteringAnimation} from './createRootStackNavigator/GetStateForActionHandlers'; import defaultScreenOptions from './defaultScreenOptions'; -import hideKeyboardOnSwipe from './hideKeyboardOnSwipe'; -import BottomTabNavigator from './Navigators/BottomTabNavigator'; import ExplanationModalNavigator from './Navigators/ExplanationModalNavigator'; import FeatureTrainingModalNavigator from './Navigators/FeatureTrainingModalNavigator'; -import FullScreenNavigator from './Navigators/FullScreenNavigator'; import LeftModalNavigator from './Navigators/LeftModalNavigator'; import MigratedUserWelcomeModalNavigator from './Navigators/MigratedUserWelcomeModalNavigator'; import OnboardingModalNavigator from './Navigators/OnboardingModalNavigator'; import RightModalNavigator from './Navigators/RightModalNavigator'; import WelcomeVideoModalNavigator from './Navigators/WelcomeVideoModalNavigator'; -import useRootNavigatorOptions from './useRootNavigatorOptions'; +import useRootNavigatorScreenOptions from './useRootNavigatorScreenOptions'; type AuthScreensProps = { /** Session of currently logged in user */ @@ -101,29 +93,10 @@ const loadReportAvatar = () => require('../../../pages/Rep const loadReceiptView = () => require('../../../pages/TransactionReceiptPage').default; const loadWorkspaceJoinUser = () => require('@pages/workspace/WorkspaceJoinUserPage').default; -function getCentralPaneScreenInitialParams(screenName: CentralPaneName, initialReportID?: string): Partial> { - if (screenName === SCREENS.SEARCH.CENTRAL_PANE) { - // Generate default query string with buildSearchQueryString without argument. - return {q: SearchQueryUtils.buildSearchQueryString()}; - } - - if (screenName === SCREENS.REPORT) { - return { - openOnAdminRoom: shouldOpenOnAdminRoom() ? true : undefined, - reportID: initialReportID, - }; - } - - return undefined; -} - -function getCentralPaneScreenListeners(screenName: CentralPaneName) { - if (screenName === SCREENS.REPORT) { - return {beforeRemove: beforeRemoveReportOpenedFromSearchRHP}; - } - - return {}; -} +const loadReportSplitNavigator = () => require('./Navigators/ReportsSplitNavigator').default; +const loadSettingsSplitNavigator = () => require('./Navigators/SettingsSplitNavigator').default; +const loadWorkspaceSplitNavigator = () => require('./Navigators/WorkspaceSplitNavigator').default; +const loadSearchPage = () => require('@pages/Search/SearchPage').default; function initializePusher() { return Pusher.init({ @@ -203,7 +176,7 @@ function handleNetworkReconnect() { } } -const RootStack = createResponsiveStackNavigator(); +const RootStack = createRootStackNavigator(); // We want to delay the re-rendering for components(e.g. ReportActionCompose) // that depends on modal visibility until Modal is completely closed and its focused // When modal screen is focused, update modal visibility in Onyx @@ -242,11 +215,8 @@ const modalScreenListenersWithCancelSearch = { function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDAppliedToClient}: AuthScreensProps) { const theme = useTheme(); - const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const rootNavigatorOptions = useRootNavigatorOptions(); - const {canUseDefaultRooms} = usePermissions(); - const {activeWorkspaceID} = useActiveWorkspace(); + const rootNavigatorScreenOptions = useRootNavigatorScreenOptions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {toggleSearch} = useSearchRouterContext(); @@ -266,17 +236,6 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie return NativeModules.HybridAppModule && Navigation.getActiveRoute().includes(ROUTES.ONBOARDING_EMPLOYEES.route) && isOnboardingCompleted === true; }, [isOnboardingCompleted]); - const [initialReportID] = useState(() => { - const currentURL = getCurrentUrl(); - const reportIdFromPath = currentURL && new URL(currentURL).pathname.match(CONST.REGEX.REPORT_ID_FROM_PATH)?.at(1); - if (reportIdFromPath) { - return reportIdFromPath; - } - - const initialReport = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, shouldOpenOnAdminRoom(), activeWorkspaceID); - return initialReport?.reportID; - }); - useEffect(() => { NavBarManager.setButtonStyle(theme.navigationBarButtonsStyle); @@ -427,10 +386,49 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); + // Animation is disabled when navigating to the sidebar screen + const getWorkspaceSplitNavigatorOptions = ({route}: {route: RouteProp}) => { + // We don't need to do anything special for the wide screen. + if (!shouldUseNarrowLayout) { + return rootNavigatorScreenOptions.splitNavigator; + } + + // On the narrow screen, we want to animate this navigator if it is opened from the settings split. + // If it is opened from other tab, we don't want to animate it on the entry. + // There is a hook inside the workspace navigator that changes animation to SLIDE_FROM_RIGHT after entering. + // This way it can be animated properly when going back to the settings split. + const animationEnabled = !workspaceSplitsWithoutEnteringAnimation.has(route.key); + + return { + ...rootNavigatorScreenOptions.splitNavigator, + + // Allow swipe to go back from this split navigator to the settings navigator. + gestureEnabled: true, + animation: animationEnabled ? Animations.SLIDE_FROM_RIGHT : Animations.NONE, + }; + }; + + // Animation is enabled when navigating to the report screen + const getReportsSplitNavigatorOptions = ({route}: {route: RouteProp}) => { + // We don't need to do anything special for the wide screen. + if (!shouldUseNarrowLayout) { + return rootNavigatorScreenOptions.splitNavigator; + } + + // On the narrow screen, we want to animate this navigator if pushed ReportsSplitNavigator includes ReportScreen + const animationEnabled = reportsSplitsWithEnteringAnimation.has(route.key); + + return { + ...rootNavigatorScreenOptions.splitNavigator, + animation: animationEnabled ? Animations.SLIDE_FROM_RIGHT : Animations.NONE, + }; + }; + const clearStatus = () => { User.clearCustomStatus(); User.clearDraftCustomStatus(); }; + useEffect(() => { if (!currentUserPersonalDetails.status?.clearAfter) { return; @@ -453,198 +451,184 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie clearStatus(); }, [currentUserPersonalDetails.status?.clearAfter]); - const CentralPaneScreenOptions: PlatformStackNavigationOptions = { - ...hideKeyboardOnSwipe, - headerShown: false, - title: 'New Expensify', - web: { - // Prevent unnecessary scrolling - cardStyle: styles.cardStyleNavigator, - }, - }; - return ( - - - - + + + {/* This has to be the first navigator in auth screens. */} + + + + + + + + + + + + + + + + + + + + + + {(isOnboardingCompleted === false || shouldRenderOnboardingExclusivelyOnHybridApp) && ( { + Modal.setDisableDismissOnEscape(true); + }, + beforeRemove: () => Modal.setDisableDismissOnEscape(false), }} - listeners={fullScreenListeners} - getComponent={loadValidateLoginPage} - /> - - - - - - - - - - - - - - - - - - {(isOnboardingCompleted === false || shouldRenderOnboardingExclusivelyOnHybridApp) && ( - { - Modal.setDisableDismissOnEscape(true); - }, - beforeRemove: () => Modal.setDisableDismissOnEscape(false), - }} - /> - )} - - - - - {Object.entries(CENTRAL_PANE_SCREENS).map(([screenName, componentGetter]) => { - const centralPaneName = screenName as CentralPaneName; - return ( - - ); - })} - - - - + )} + + + + + + + ); diff --git a/src/libs/Navigation/AppNavigator/CENTRAL_PANE_SCREENS.tsx b/src/libs/Navigation/AppNavigator/CENTRAL_PANE_SCREENS.tsx deleted file mode 100644 index 5bbe2046040a..000000000000 --- a/src/libs/Navigation/AppNavigator/CENTRAL_PANE_SCREENS.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type {CentralPaneName} from '@libs/Navigation/types'; -import withPrepareCentralPaneScreen from '@src/components/withPrepareCentralPaneScreen'; -import SCREENS from '@src/SCREENS'; -import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; - -type Screens = Partial React.ComponentType>>; - -const CENTRAL_PANE_SCREENS = { - [SCREENS.SETTINGS.WORKSPACES]: withPrepareCentralPaneScreen(() => require('../../../pages/workspace/WorkspacesListPage').default), - [SCREENS.SETTINGS.PREFERENCES.ROOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Preferences/PreferencesPage').default), - [SCREENS.SETTINGS.SECURITY]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Security/SecuritySettingsPage').default), - [SCREENS.SETTINGS.PROFILE.ROOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Profile/ProfilePage').default), - [SCREENS.SETTINGS.WALLET.ROOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Wallet/WalletPage').default), - [SCREENS.SETTINGS.ABOUT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/AboutPage/AboutPage').default), - [SCREENS.SETTINGS.TROUBLESHOOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Troubleshoot/TroubleshootPage').default), - [SCREENS.SETTINGS.SAVE_THE_WORLD]: withPrepareCentralPaneScreen(() => require('../../../pages/TeachersUnite/SaveTheWorldPage').default), - [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: withPrepareCentralPaneScreen(() => require('../../../pages/settings/Subscription/SubscriptionSettingsPage').default), - [SCREENS.SEARCH.CENTRAL_PANE]: withPrepareCentralPaneScreen(() => require('../../../pages/Search/SearchPage').default), - [SCREENS.REPORT]: withPrepareCentralPaneScreen(() => require('../../../pages/home/ReportScreen').default), -} satisfies Screens; - -export default CENTRAL_PANE_SCREENS; diff --git a/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.native.ts b/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.native.ts new file mode 100644 index 000000000000..a0e386be0e0d --- /dev/null +++ b/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.native.ts @@ -0,0 +1,11 @@ +import type {NavigationState} from '@react-navigation/native'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; + +function getIsScreenBlurred(state: NavigationState, currentRouteKey: string) { + // If the screen is one of the last two fullscreen routes in the stack, it is not freezed on native platforms. + // One screen below the focused one should not be freezed to allow users to return by swiping left. + const lastTwoFullScreenRoutes = state.routes.filter((route) => isFullScreenName(route.name)).slice(-2); + return !lastTwoFullScreenRoutes.some((route) => route.key === currentRouteKey); +} + +export default getIsScreenBlurred; diff --git a/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.ts b/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.ts new file mode 100644 index 000000000000..0b51e817a0ba --- /dev/null +++ b/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.ts @@ -0,0 +1,9 @@ +import type {NavigationState} from '@react-navigation/native'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; + +function getIsScreenBlurred(state: NavigationState, currentRouteKey: string) { + const lastFullScreenRoute = state.routes.findLast((route) => isFullScreenName(route.name)); + return lastFullScreenRoute?.key !== currentRouteKey; +} + +export default getIsScreenBlurred; diff --git a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx new file mode 100644 index 000000000000..d61166fde419 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.native.tsx @@ -0,0 +1,23 @@ +import {useNavigation, useRoute} from '@react-navigation/native'; +import React, {useEffect, useState} from 'react'; +import {Freeze} from 'react-freeze'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import getIsScreenBlurred from './getIsScreenBlurred'; + +function FreezeWrapper({children}: ChildrenProps) { + const navigation = useNavigation(); + const currentRoute = useRoute(); + + const [isScreenBlurred, setIsScreenBlurred] = useState(false); + + useEffect(() => { + const unsubscribe = navigation.addListener('state', (e) => setIsScreenBlurred(getIsScreenBlurred(e.data.state, currentRoute.key))); + return () => unsubscribe(); + }, [currentRoute.key, navigation]); + + return {children}; +} + +FreezeWrapper.displayName = 'FreezeWrapper'; + +export default FreezeWrapper; diff --git a/src/libs/Navigation/AppNavigator/FreezeWrapper/index.tsx b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.tsx new file mode 100644 index 000000000000..129543ce7ba5 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/FreezeWrapper/index.tsx @@ -0,0 +1,30 @@ +import {useNavigation, useRoute} from '@react-navigation/native'; +import React, {useEffect, useLayoutEffect, useState} from 'react'; +import {Freeze} from 'react-freeze'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import getIsScreenBlurred from './getIsScreenBlurred'; + +function FreezeWrapper({children}: ChildrenProps) { + const navigation = useNavigation(); + const currentRoute = useRoute(); + + const [isScreenBlurred, setIsScreenBlurred] = useState(false); + const [freezed, setFreezed] = useState(false); + + useEffect(() => { + const unsubscribe = navigation.addListener('state', (e) => setIsScreenBlurred(getIsScreenBlurred(e.data.state, currentRoute.key))); + return () => unsubscribe(); + }, [currentRoute.key, navigation]); + + // Decouple the Suspense render task so it won't be interrupted by React's concurrent mode + // and stuck in an infinite loop + useLayoutEffect(() => { + setFreezed(isScreenBlurred); + }, [isScreenBlurred]); + + return {children}; +} + +FreezeWrapper.displayName = 'FreezeWrapper'; + +export default FreezeWrapper; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index ba585268cd6c..404e60609eaf 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -598,8 +598,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage').default, [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: () => require('../../../../pages/settings/Security/AddDelegate/ConfirmDelegatePage').default, - [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE]: () => - require('../../../../pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage').default, [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: () => require('../../../../pages/workspace/rules/RulesCustomNamePage').default, [SCREENS.WORKSPACE.RULES_AUTO_APPROVE_REPORTS_UNDER]: () => require('../../../../pages/workspace/rules/RulesAutoApproveReportsUnderPage').default, [SCREENS.WORKSPACE.RULES_RANDOM_REPORT_AUDIT]: () => require('../../../../pages/workspace/rules/RulesRandomReportAuditPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/ActiveCentralPaneRouteContext.ts b/src/libs/Navigation/AppNavigator/Navigators/ActiveCentralPaneRouteContext.ts deleted file mode 100644 index 6f37126584a2..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/ActiveCentralPaneRouteContext.ts +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import type {AuthScreensParamList, NavigationPartialRoute} from '@libs/Navigation/types'; - -const ActiveCentralPaneRouteContext = React.createContext | undefined>(undefined); - -export default ActiveCentralPaneRouteContext; diff --git a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx deleted file mode 100644 index b4b71549f7ec..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import {useNavigationState} from '@react-navigation/native'; -import React from 'react'; -import createCustomBottomTabNavigator from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator'; -import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; -import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; -import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {BottomTabNavigatorParamList, CentralPaneName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; -import SidebarScreen from '@pages/home/sidebar/SidebarScreen'; -import SearchPageBottomTab from '@pages/Search/SearchPageBottomTab'; -import SCREENS from '@src/SCREENS'; -import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; -import ActiveCentralPaneRouteContext from './ActiveCentralPaneRouteContext'; - -const loadInitialSettingsPage = () => require('../../../../pages/settings/InitialSettingsPage').default; -const Tab = createCustomBottomTabNavigator(); - -const screenOptions: PlatformStackNavigationOptions = { - animation: Animations.FADE, - headerShown: false, -}; - -function BottomTabNavigator() { - const activeRoute = useNavigationState | undefined>(getTopmostCentralPaneRoute); - return ( - - - - - - - - ); -} - -BottomTabNavigator.displayName = 'BottomTabNavigator'; - -export default BottomTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx deleted file mode 100644 index 86c9bce765b7..000000000000 --- a/src/libs/Navigation/AppNavigator/Navigators/FullScreenNavigator.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; -import createCustomFullScreenNavigator from '@libs/Navigation/AppNavigator/createCustomFullScreenNavigator'; -import useRootNavigatorOptions from '@libs/Navigation/AppNavigator/useRootNavigatorOptions'; -import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; -import SCREENS from '@src/SCREENS'; -import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; - -const loadWorkspaceInitialPage = () => require('../../../../pages/workspace/WorkspaceInitialPage').default; - -const RootStack = createCustomFullScreenNavigator(); - -type Screens = Partial React.ComponentType>>; - -const CENTRAL_PANE_WORKSPACE_SCREENS = { - [SCREENS.WORKSPACE.PROFILE]: () => require('../../../../pages/workspace/WorkspaceProfilePage').default, - [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default, - [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default, - [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../pages/workspace/WorkspaceMembersPage').default, - [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, - [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesPage').default, - [SCREENS.WORKSPACE.MORE_FEATURES]: () => require('../../../../pages/workspace/WorkspaceMoreFeaturesPage').default, - [SCREENS.WORKSPACE.TAGS]: () => require('../../../../pages/workspace/tags/WorkspaceTagsPage').default, - [SCREENS.WORKSPACE.TAXES]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesPage').default, - [SCREENS.WORKSPACE.REPORT_FIELDS]: () => require('../../../../pages/workspace/reportFields/WorkspaceReportFieldsPage').default, - [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, - [SCREENS.WORKSPACE.COMPANY_CARDS]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardsPage').default, - [SCREENS.WORKSPACE.PER_DIEM]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemPage').default, - [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, - [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPage').default, -} satisfies Screens; - -function FullScreenNavigator() { - const styles = useThemeStyles(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const rootNavigatorOptions = useRootNavigatorOptions(); - - return ( - - - - - {Object.entries(CENTRAL_PANE_WORKSPACE_SCREENS).map(([screenName, componentGetter]) => ( - - ))} - - - - ); -} - -FullScreenNavigator.displayName = 'FullScreenNavigator'; - -export {CENTRAL_PANE_WORKSPACE_SCREENS}; -export default FullScreenNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx new file mode 100644 index 000000000000..1e1672565ab3 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/ReportsSplitNavigator.tsx @@ -0,0 +1,68 @@ +import React, {useState} from 'react'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; +import usePermissions from '@hooks/usePermissions'; +import createSplitNavigator from '@libs/Navigation/AppNavigator/createSplitNavigator'; +import FreezeWrapper from '@libs/Navigation/AppNavigator/FreezeWrapper'; +import useSplitNavigatorScreenOptions from '@libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions'; +import getCurrentUrl from '@libs/Navigation/currentUrl'; +import shouldOpenOnAdminRoom from '@libs/Navigation/helpers/shouldOpenOnAdminRoom'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {AuthScreensParamList, ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; + +const loadReportScreen = () => require('@pages/home/ReportScreen').default; +const loadSidebarScreen = () => require('@pages/home/sidebar/BaseSidebarScreen').default; + +const Split = createSplitNavigator(); + +/** + * This SplitNavigator includes the HOME screen ( component) with a list of reports as a sidebar screen and the REPORT screen displayed as a central one. + * There can be multiple report screens in the stack with different report IDs. + */ +function ReportsSplitNavigator({route}: PlatformStackScreenProps) { + const {canUseDefaultRooms} = usePermissions(); + const {activeWorkspaceID} = useActiveWorkspace(); + const splitNavigatorScreenOptions = useSplitNavigatorScreenOptions(); + + const [initialReportID] = useState(() => { + const currentURL = getCurrentUrl(); + const reportIdFromPath = currentURL && new URL(currentURL).pathname.match(CONST.REGEX.REPORT_ID_FROM_PATH)?.at(1); + if (reportIdFromPath) { + return reportIdFromPath; + } + + const initialReport = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, shouldOpenOnAdminRoom(), activeWorkspaceID); + return initialReport?.reportID; + }); + + return ( + + + + + + + ); +} + +ReportsSplitNavigator.displayName = 'ReportsSplitNavigator'; + +export default ReportsSplitNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx new file mode 100644 index 000000000000..1a90cdb5313d --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/SettingsSplitNavigator.tsx @@ -0,0 +1,63 @@ +import {useRoute} from '@react-navigation/native'; +import React from 'react'; +import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen'; +import createSplitNavigator from '@libs/Navigation/AppNavigator/createSplitNavigator'; +import useSplitNavigatorScreenOptions from '@libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions'; +import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; +import SCREENS from '@src/SCREENS'; +import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; + +const loadInitialSettingsPage = () => require('../../../../pages/settings/InitialSettingsPage').default; + +type Screens = Partial React.ComponentType>>; + +const CENTRAL_PANE_SETTINGS_SCREENS = { + [SCREENS.SETTINGS.WORKSPACES]: () => require('../../../../pages/workspace/WorkspacesListPage').default, + [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../../pages/settings/Preferences/PreferencesPage').default, + [SCREENS.SETTINGS.SECURITY]: () => require('../../../../pages/settings/Security/SecuritySettingsPage').default, + [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../../pages/settings/Profile/ProfilePage').default, + [SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../../pages/settings/Wallet/WalletPage').default, + [SCREENS.SETTINGS.ABOUT]: () => require('../../../../pages/settings/AboutPage/AboutPage').default, + [SCREENS.SETTINGS.TROUBLESHOOT]: () => require('../../../../pages/settings/Troubleshoot/TroubleshootPage').default, + [SCREENS.SETTINGS.SAVE_THE_WORLD]: () => require('../../../../pages/TeachersUnite/SaveTheWorldPage').default, + [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: () => require('../../../../pages/settings/Subscription/SubscriptionSettingsPage').default, +} satisfies Screens; + +const Split = createSplitNavigator(); + +function SettingsSplitNavigator() { + const route = useRoute(); + const splitNavigatorScreenOptions = useSplitNavigatorScreenOptions(); + + return ( + + + + {Object.entries(CENTRAL_PANE_SETTINGS_SCREENS).map(([screenName, componentGetter]) => { + return ( + + ); + })} + + + ); +} + +SettingsSplitNavigator.displayName = 'SettingsSplitNavigator'; + +export {CENTRAL_PANE_SETTINGS_SCREENS}; +export default SettingsSplitNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx new file mode 100644 index 000000000000..c1b0fcc481f7 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx @@ -0,0 +1,86 @@ +import React, {useEffect} from 'react'; +import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen'; +import {workspaceSplitsWithoutEnteringAnimation} from '@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; +import createSplitNavigator from '@libs/Navigation/AppNavigator/createSplitNavigator'; +import useSplitNavigatorScreenOptions from '@libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions'; +import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {AuthScreensParamList, WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; +import type NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; + +type Screens = Partial React.ComponentType>>; + +const loadWorkspaceInitialPage = () => require('../../../../pages/workspace/WorkspaceInitialPage').default; + +const CENTRAL_PANE_WORKSPACE_SCREENS = { + [SCREENS.WORKSPACE.PROFILE]: () => require('../../../../pages/workspace/WorkspaceProfilePage').default, + [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPage').default, + [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default, + [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../pages/workspace/WorkspaceMembersPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, + [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesPage').default, + [SCREENS.WORKSPACE.MORE_FEATURES]: () => require('../../../../pages/workspace/WorkspaceMoreFeaturesPage').default, + [SCREENS.WORKSPACE.TAGS]: () => require('../../../../pages/workspace/tags/WorkspaceTagsPage').default, + [SCREENS.WORKSPACE.TAXES]: () => require('../../../../pages/workspace/taxes/WorkspaceTaxesPage').default, + [SCREENS.WORKSPACE.REPORT_FIELDS]: () => require('../../../../pages/workspace/reportFields/WorkspaceReportFieldsPage').default, + [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, + [SCREENS.WORKSPACE.COMPANY_CARDS]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardsPage').default, + [SCREENS.WORKSPACE.PER_DIEM]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemPage').default, + [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, + [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPage').default, +} satisfies Screens; + +const Split = createSplitNavigator(); + +function WorkspaceSplitNavigator({route, navigation}: PlatformStackScreenProps) { + const splitNavigatorScreenOptions = useSplitNavigatorScreenOptions(); + + useEffect(() => { + const unsubscribe = navigation.addListener('transitionEnd', () => { + // We want to call this function only once. + unsubscribe(); + + // If we open this screen from a different tab, then it won't have animation. + if (!workspaceSplitsWithoutEnteringAnimation.has(route.key)) { + return; + } + + // We want to set animation after mounting so it will animate on going UP to the settings split. + navigation.setOptions({animation: Animations.SLIDE_FROM_RIGHT}); + }); + + return unsubscribe; + }, [navigation, route.key]); + + return ( + + + + {Object.entries(CENTRAL_PANE_WORKSPACE_SCREENS).map(([screenName, componentGetter]) => ( + + ))} + + + ); +} + +WorkspaceSplitNavigator.displayName = 'WorkspaceSplitNavigator'; + +export {CENTRAL_PANE_WORKSPACE_SCREENS}; +export default WorkspaceSplitNavigator; diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.tsx b/src/libs/Navigation/AppNavigator/PublicScreens.tsx index 00b9a98e78bd..77e379e80293 100644 --- a/src/libs/Navigation/AppNavigator/PublicScreens.tsx +++ b/src/libs/Navigation/AppNavigator/PublicScreens.tsx @@ -20,9 +20,10 @@ const RootStack = createPlatformStackNavigator(); function PublicScreens() { return ( - {/* The structure for the HOME route has to be the same in public and auth screens. That's why the name for SignInPage is BOTTOM_TAB_NAVIGATOR. */} + {/* The structure for the HOME route has to be the same in public and auth screens. That's why the name for SignInPage is REPORTS_SPLIT_NAVIGATOR. */} ) { - if (!navigationRef.current) { - return; - } - - const state = navigationRef.current?.getRootState() as State; - - if (!state) { - return; - } - - const shouldPopHome = - state.routes?.length >= 3 && - state.routes.at(-1)?.name === SCREENS.REPORT && - state.routes.at(-2)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && - state.routes.at(-3)?.name === SCREENS.SEARCH.CENTRAL_PANE && - getTopmostBottomTabRoute(state)?.name === SCREENS.HOME; - - if (!shouldPopHome) { - return; - } - - event.preventDefault(); - const bottomTabState = state?.routes?.at(0)?.state; - navigationRef.dispatch({...StackActions.pop(), target: bottomTabState?.key}); - Navigation.goBack(); -} - -export default beforeRemoveReportOpenedFromSearchRHP; diff --git a/src/libs/Navigation/AppNavigator/beforeRemoveReportOpenedFromSearchRHP/index.ts b/src/libs/Navigation/AppNavigator/beforeRemoveReportOpenedFromSearchRHP/index.ts deleted file mode 100644 index b5d8f835ab43..000000000000 --- a/src/libs/Navigation/AppNavigator/beforeRemoveReportOpenedFromSearchRHP/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** The issue fixed by this handler is related to navigating back on native platforms. For more information, see the index.native.ts file in this folder */ -function beforeRemoveReportOpenedFromSearchRHP() {} - -export default beforeRemoveReportOpenedFromSearchRHP; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabNavigationContentWrapper.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabNavigationContentWrapper.tsx deleted file mode 100644 index dd93a6df7b1e..000000000000 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabNavigationContentWrapper.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useThemeStyles from '@hooks/useThemeStyles'; -import type {NavigationContentWrapperProps} from '@libs/Navigation/PlatformStackNavigation/types'; - -function BottomTabNavigationContentWrapper({children, displayName}: NavigationContentWrapperProps) { - const styles = useThemeStyles(); - - return ( - - {children} - - ); -} - -export default BottomTabNavigationContentWrapper; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx deleted file mode 100644 index 2461c542ec7d..000000000000 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type {ParamListBase} from '@react-navigation/native'; -import {createNavigatorFactory} from '@react-navigation/native'; -import React from 'react'; -import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent'; -import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; -import type {ExtraContentProps, PlatformStackNavigationEventMap, PlatformStackNavigationOptions, PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; -import BottomTabBar from './BottomTabBar'; -import BottomTabNavigationContentWrapper from './BottomTabNavigationContentWrapper'; -import useCustomState from './useCustomState'; - -const defaultScreenOptions: PlatformStackNavigationOptions = { - animation: Animations.NONE, -}; - -function ExtraContent({state}: ExtraContentProps) { - const selectedTab = state.routes.at(-1)?.name; - return ; -} - -const CustomBottomTabNavigatorComponent = createPlatformStackNavigatorComponent('CustomBottomTabNavigator', { - useCustomState, - defaultScreenOptions, - NavigationContentWrapper: BottomTabNavigationContentWrapper, - ExtraContent, -}); - -function createCustomBottomTabNavigator() { - return createNavigatorFactory, PlatformStackNavigationOptions, PlatformStackNavigationEventMap, typeof CustomBottomTabNavigatorComponent>( - CustomBottomTabNavigatorComponent, - )(); -} - -export default createCustomBottomTabNavigator; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/useCustomState.ts b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/useCustomState.ts deleted file mode 100644 index cf8ffd81840f..000000000000 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/useCustomState.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {useMemo} from 'react'; -import type {CustomStateHookProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {NavigationStateRoute} from '@libs/Navigation/types'; -import SCREENS from '@src/SCREENS'; - -function useCustomState({state}: CustomStateHookProps) { - return useMemo(() => { - const routesToRender = [state.routes.at(-1)] as NavigationStateRoute[]; - - // We need to render at least one HOME screen to make sure everything load properly. This may be not necessary after changing how IS_SIDEBAR_LOADED is handled. - // Currently this value will be switched only after the first HOME screen is rendered. - if (routesToRender.at(0)?.name !== SCREENS.HOME) { - const routeToRender = state.routes.find((route) => route.name === SCREENS.HOME); - if (routeToRender) { - routesToRender.unshift(routeToRender); - } - } - - return {stateToRender: {...state, routes: routesToRender, index: routesToRender.length - 1}}; - }, [state]); -} - -export default useCustomState; diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx deleted file mode 100644 index ba8de1f298bd..000000000000 --- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import type {ParamListBase, PartialState, RouterConfigOptions} from '@react-navigation/native'; -import {StackRouter} from '@react-navigation/native'; -import Onyx from 'react-native-onyx'; -import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import type {PlatformStackNavigationState, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; - -type StackState = PlatformStackNavigationState | PartialState>; - -const isAtLeastOneInState = (state: StackState, screenName: string): boolean => state.routes.some((route) => route.name === screenName); - -let isLoadingReportData = true; -Onyx.connect({ - key: ONYXKEYS.IS_LOADING_REPORT_DATA, - initWithStoredValues: false, - callback: (value) => (isLoadingReportData = value ?? false), -}); - -function adaptStateIfNecessary(state: StackState) { - const isNarrowLayout = getIsNarrowLayout(); - const workspaceCentralPane = state.routes.at(-1); - const policyID = - workspaceCentralPane?.params && 'policyID' in workspaceCentralPane.params && typeof workspaceCentralPane.params.policyID === 'string' - ? workspaceCentralPane.params.policyID - : undefined; - const policy = PolicyUtils.getPolicy(policyID ?? ''); - const isPolicyAccessible = PolicyUtils.isPolicyAccessible(policy); - - // There should always be WORKSPACE.INITIAL screen in the state to make sure go back works properly if we deeplinkg to a subpage of settings. - // The only exception is when the workspace is invalid or inaccessible. - if (!isAtLeastOneInState(state, SCREENS.WORKSPACE.INITIAL)) { - if (isNarrowLayout && !isLoadingReportData && !isPolicyAccessible) { - return; - } - // @ts-expect-error Updating read only property - // noinspection JSConstantReassignment - state.stale = true; // eslint-disable-line - - // This is necessary for ts to narrow type down to PartialState. - if (state.stale === true) { - // Unshift the root screen to fill left pane. - state.routes.unshift({ - name: SCREENS.WORKSPACE.INITIAL, - params: workspaceCentralPane?.params, - }); - } - } - - // If the screen is wide, there should be at least two screens inside: - // - WORKSPACE.INITIAL to cover left pane. - // - WORKSPACE.PROFILE (first workspace settings screen) to cover central pane. - if (!isNarrowLayout) { - if (state.routes.length === 1 && state.routes.at(0)?.name === SCREENS.WORKSPACE.INITIAL) { - // @ts-expect-error Updating read only property - // noinspection JSConstantReassignment - state.stale = true; // eslint-disable-line - // Push the default settings central pane screen. - if (state.stale === true) { - state.routes.push({ - name: SCREENS.WORKSPACE.PROFILE, - params: state.routes.at(0)?.params, - }); - } - } - // eslint-disable-next-line no-param-reassign, @typescript-eslint/non-nullable-type-assertion-style - (state.index as number) = state.routes.length - 1; - } -} - -function CustomFullScreenRouter(options: PlatformStackRouterOptions) { - const stackRouter = StackRouter(options); - - return { - ...stackRouter, - getInitialState({routeNames, routeParamList, routeGetIdList}: RouterConfigOptions) { - const initialState = stackRouter.getInitialState({routeNames, routeParamList, routeGetIdList}); - adaptStateIfNecessary(initialState); - - // If we needed to modify the state we need to rehydrate it to get keys for new routes. - if (initialState.stale) { - return stackRouter.getRehydratedState(initialState, {routeNames, routeParamList, routeGetIdList}); - } - - return initialState; - }, - getRehydratedState(partialState: StackState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): PlatformStackNavigationState { - adaptStateIfNecessary(partialState); - const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList}); - return state; - }, - }; -} - -export default CustomFullScreenRouter; diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx deleted file mode 100644 index f3d605e1824f..000000000000 --- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type {ParamListBase} from '@react-navigation/native'; -import {createNavigatorFactory} from '@react-navigation/native'; -import useNavigationResetOnLayoutChange from '@libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange'; -import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent'; -import defaultPlatformStackScreenOptions from '@libs/Navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions'; -import type {PlatformStackNavigationEventMap, PlatformStackNavigationOptions, PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; -import CustomFullScreenRouter from './CustomFullScreenRouter'; - -const CustomFullScreenNavigatorComponent = createPlatformStackNavigatorComponent('CustomFullScreenNavigator', { - createRouter: CustomFullScreenRouter, - useCustomEffects: useNavigationResetOnLayoutChange, - defaultScreenOptions: defaultPlatformStackScreenOptions, -}); - -function createCustomFullScreenNavigator() { - return createNavigatorFactory, PlatformStackNavigationOptions, PlatformStackNavigationEventMap, typeof CustomFullScreenNavigatorComponent>( - CustomFullScreenNavigatorComponent, - )(); -} - -export default createCustomFullScreenNavigator; diff --git a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/CustomRouter.ts deleted file mode 100644 index 3cbb5acb81e5..000000000000 --- a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/CustomRouter.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; -import {findFocusedRoute, getPathFromState, StackRouter} from '@react-navigation/native'; -import type {ParamListBase} from '@react-navigation/routers'; -import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import * as Localize from '@libs/Localize'; -import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; -import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; -import type {PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; -import {isCentralPaneName, isOnboardingFlowName} from '@libs/NavigationUtils'; -import * as Welcome from '@userActions/Welcome'; -import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; -import syncBrowserHistory from './syncBrowserHistory'; - -function insertRootRoute(state: State, routeToInsert: NavigationPartialRoute) { - const nonModalRoutes = state.routes.filter( - (route) => route.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR && route.name !== NAVIGATORS.LEFT_MODAL_NAVIGATOR && route.name !== NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR, - ); - const modalRoutes = state.routes.filter( - (route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || route.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR || route.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR, - ); - - // It's safe to modify this state before returning in getRehydratedState. - - // @ts-expect-error Updating read only property - // noinspection JSConstantReassignment - state.routes = [...nonModalRoutes, routeToInsert, ...modalRoutes]; // eslint-disable-line - - // @ts-expect-error Updating read only property - // noinspection JSConstantReassignment - state.index = state.routes.length - 1; // eslint-disable-line - - // @ts-expect-error Updating read only property - // noinspection JSConstantReassignment - state.stale = true; // eslint-disable-line -} - -function compareAndAdaptState(state: StackNavigationState) { - // If the state of the last path is not defined the getPathFromState won't work correctly. - if (!state?.routes.at(-1)?.state) { - return; - } - - // We need to be sure that the bottom tab state is defined. - const topmostBottomTabRoute = getTopmostBottomTabRoute(state); - const isNarrowLayout = getIsNarrowLayout(); - - // This solutions is heuristics and will work for our cases. We may need to improve it in the future if we will have more cases to handle. - if (topmostBottomTabRoute && !isNarrowLayout) { - const fullScreenRoute = state.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); - - // If there is fullScreenRoute we don't need to add anything. - if (fullScreenRoute) { - return; - } - - // We will generate a template state and compare the current state with it. - // If there is a difference in the screens that should be visible under the overlay, we will add the screen from templateState to the current state. - const pathFromCurrentState = getPathFromState(state, linkingConfig.config); - const {adaptedState: templateState} = getAdaptedStateFromPath(pathFromCurrentState, linkingConfig.config); - - if (!templateState) { - return; - } - - const templateFullScreenRoute = templateState.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); - - // If templateFullScreenRoute is defined, and full screen route is not in the state, we need to add it. - if (templateFullScreenRoute) { - insertRootRoute(state, templateFullScreenRoute); - return; - } - - const topmostCentralPaneRoute = state.routes.filter((route) => isCentralPaneName(route.name)).at(-1); - const templateCentralPaneRoute = templateState.routes.find((route) => isCentralPaneName(route.name)); - - const topmostCentralPaneRouteExtracted = getTopmostCentralPaneRoute(state); - const templateCentralPaneRouteExtracted = getTopmostCentralPaneRoute(templateState as State); - - // If there is no templateCentralPaneRoute, we don't have anything to add. - if (!templateCentralPaneRoute) { - return; - } - - // If there is no topmostCentralPaneRoute in the state and template state has one, we need to add it. - if (!topmostCentralPaneRoute) { - insertRootRoute(state, templateCentralPaneRoute); - return; - } - - // If there is central pane route in state and template state has one, we need to check if they are the same. - if (topmostCentralPaneRouteExtracted && templateCentralPaneRouteExtracted && topmostCentralPaneRouteExtracted.name !== templateCentralPaneRouteExtracted.name) { - // Not every RHP screen has matching central pane defined. In that case we use the REPORT screen as default for initial screen. - // But we don't want to override the central pane for those screens as they may be opened with different central panes under the overlay. - // e.g. i-know-a-teacher may be opened with different central panes under the overlay - if (templateCentralPaneRouteExtracted.name === SCREENS.REPORT) { - return; - } - insertRootRoute(state, templateCentralPaneRoute); - } - } -} - -function shouldPreventReset(state: StackNavigationState, action: CommonActions.Action | StackActionType) { - if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) { - return false; - } - const currentFocusedRoute = findFocusedRoute(state); - const targetFocusedRoute = findFocusedRoute(action?.payload); - - // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen - if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) { - Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton')); - return true; - } - - return false; -} - -function CustomRouter(options: PlatformStackRouterOptions) { - const stackRouter = StackRouter(options); - - return { - ...stackRouter, - getRehydratedState(partialState: StackNavigationState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): StackNavigationState { - compareAndAdaptState(partialState); - const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList}); - return state; - }, - getStateForAction(state: StackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) { - if (shouldPreventReset(state, action)) { - syncBrowserHistory(state); - return state; - } - return stackRouter.getStateForAction(state, action, configOptions); - }, - }; -} - -export default CustomRouter; diff --git a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/SearchRoute.tsx b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/SearchRoute.tsx deleted file mode 100644 index 2455587660ab..000000000000 --- a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/SearchRoute.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import useThemeStyles from '@hooks/useThemeStyles'; -import type {ExtraContentProps} from '@libs/Navigation/PlatformStackNavigation/types'; - -function SearchRoute({searchRoute, descriptors}: ExtraContentProps) { - const styles = useThemeStyles(); - - if (!searchRoute) { - return null; - } - - const key = searchRoute.key; - const descriptor = descriptors[key]; - - if (!descriptor) { - return null; - } - - return {descriptor.render()}; -} - -export default SearchRoute; diff --git a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/index.tsx deleted file mode 100644 index 9ac2ffd6c8f9..000000000000 --- a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type {ParamListBase} from '@react-navigation/native'; -import {createNavigatorFactory} from '@react-navigation/native'; -import useNavigationResetRootOnLayoutChange from '@libs/Navigation/AppNavigator/useNavigationResetRootOnLayoutChange'; -import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent'; -import defaultPlatformStackScreenOptions from '@libs/Navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions'; -import type {PlatformStackNavigationEventMap, PlatformStackNavigationOptions, PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; -import CustomRouter from './CustomRouter'; -import RenderSearchRoute from './SearchRoute'; -import useStateWithSearch from './useStateWithSearch'; - -const ResponsiveStackNavigatorComponent = createPlatformStackNavigatorComponent('ResponsiveStackNavigator', { - createRouter: CustomRouter, - defaultScreenOptions: defaultPlatformStackScreenOptions, - useCustomState: useStateWithSearch, - useCustomEffects: useNavigationResetRootOnLayoutChange, - ExtraContent: RenderSearchRoute, -}); - -function createResponsiveStackNavigator() { - return createNavigatorFactory, PlatformStackNavigationOptions, PlatformStackNavigationEventMap, typeof ResponsiveStackNavigatorComponent>( - ResponsiveStackNavigatorComponent, - )(); -} - -export default createResponsiveStackNavigator; diff --git a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/useStateWithSearch.ts b/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/useStateWithSearch.ts deleted file mode 100644 index 73984af34d2e..000000000000 --- a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/useStateWithSearch.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type {ParamListBase} from '@react-navigation/native'; -import {useMemo} from 'react'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; -import type {CustomStateHookProps, PlatformStackNavigationState, PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {RootStackParamList, State} from '@libs/Navigation/types'; -import {isCentralPaneName} from '@libs/NavigationUtils'; -import SCREENS from '@src/SCREENS'; - -type Routes = PlatformStackNavigationState['routes']; -function reduceCentralPaneRoutes(routes: Routes): Routes { - const result: Routes = []; - let count = 0; - const reverseRoutes = [...routes].reverse(); - - reverseRoutes.forEach((route) => { - if (isCentralPaneName(route.name)) { - // Remove all central pane routes except the last 3. This will improve performance. - if (count < 3) { - result.push(route); - count++; - } - } else { - result.push(route); - } - }); - - return result.reverse(); -} - -function useStateWithSearch({state}: CustomStateHookProps) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - return useMemo(() => { - const routes = reduceCentralPaneRoutes(state.routes); - - if (shouldUseNarrowLayout) { - const isSearchCentralPane = (route: PlatformStackRouteProp) => - getTopmostCentralPaneRoute({routes: [route]} as State)?.name === SCREENS.SEARCH.CENTRAL_PANE; - - const lastRoute = routes.at(-1); - const lastSearchCentralPane = lastRoute && isSearchCentralPane(lastRoute) ? lastRoute : undefined; - const filteredRoutes = routes.filter((route) => !isSearchCentralPane(route)); - - // On narrow layout, if we are on /search route we want to hide all central pane routes and show only the bottom tab navigator. - if (lastSearchCentralPane) { - const filteredRoute = filteredRoutes.at(0); - if (filteredRoute) { - return { - stateToRender: { - ...state, - index: 0, - routes: [filteredRoute], - }, - searchRoute: lastSearchCentralPane, - }; - } - } - - return { - stateToRender: { - ...state, - index: filteredRoutes.length - 1, - routes: filteredRoutes, - }, - searchRoute: undefined, - }; - } - - return { - stateToRender: { - ...state, - index: routes.length - 1, - routes: [...routes], - }, - searchRoute: undefined, - }; - }, [state, shouldUseNarrowLayout]); -} - -export default useStateWithSearch; diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts new file mode 100644 index 000000000000..a225301101c4 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -0,0 +1,260 @@ +import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; +import {StackActions} from '@react-navigation/native'; +import type {ParamListBase, Router} from '@react-navigation/routers'; +import Log from '@libs/Log'; +import getPolicyIDFromState from '@libs/Navigation/helpers/getPolicyIDFromState'; +import type {RootNavigatorParamList, State} from '@libs/Navigation/types'; +import * as SearchQueryUtils from '@libs/SearchQueryUtils'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import type {OpenWorkspaceSplitActionType, PushActionType, SwitchPolicyIdActionType} from './types'; + +const MODAL_ROUTES_TO_DISMISS: string[] = [ + NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, + NAVIGATORS.LEFT_MODAL_NAVIGATOR, + NAVIGATORS.RIGHT_MODAL_NAVIGATOR, + NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR, + NAVIGATORS.FEATURE_TRANING_MODAL_NAVIGATOR, + SCREENS.NOT_FOUND, + SCREENS.ATTACHMENTS, + SCREENS.TRANSACTION_RECEIPT, + SCREENS.PROFILE_AVATAR, + SCREENS.WORKSPACE_AVATAR, + SCREENS.REPORT_AVATAR, + SCREENS.CONCIERGE, +]; + +const workspaceSplitsWithoutEnteringAnimation = new Set(); +const reportsSplitsWithEnteringAnimation = new Set(); + +/** + * Handles the OPEN_WORKSPACE_SPLIT action. + * If the user is on other tab than settings and the workspace split is "remembered", this action will be called after pressing the settings tab. + * It will push the settings split navigator first and then push the workspace split navigator. + * This allows the user to swipe back on the iOS to the settings split navigator underneath. + */ +function handleOpenWorkspaceSplitAction( + state: StackNavigationState, + action: OpenWorkspaceSplitActionType, + configOptions: RouterConfigOptions, + stackRouter: Router, CommonActions.Action | StackActionType>, +) { + const actionToPushSettingsSplitNavigator = StackActions.push(NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, { + screen: SCREENS.SETTINGS.WORKSPACES, + }); + + const actionToPushWorkspaceSplitNavigator = StackActions.push(NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, { + screen: SCREENS.WORKSPACE.INITIAL, + params: { + policyID: action.payload.policyID, + }, + }); + + const stateWithSettingsSplitNavigator = stackRouter.getStateForAction(state, actionToPushSettingsSplitNavigator, configOptions); + + if (!stateWithSettingsSplitNavigator) { + Log.hmmm('[handleOpenWorkspaceSplitAction] SettingsSplitNavigator has not been found in the navigation state.'); + return null; + } + + const rehydratedStateWithSettingsSplitNavigator = stackRouter.getRehydratedState(stateWithSettingsSplitNavigator, configOptions); + const stateWithWorkspaceSplitNavigator = stackRouter.getStateForAction(rehydratedStateWithSettingsSplitNavigator, actionToPushWorkspaceSplitNavigator, configOptions); + + if (!stateWithWorkspaceSplitNavigator) { + Log.hmmm('[handleOpenWorkspaceSplitAction] WorkspaceSplitNavigator has not been found in the navigation state.'); + return null; + } + + const lastFullScreenRoute = stateWithWorkspaceSplitNavigator.routes.at(-1); + + if (lastFullScreenRoute?.key) { + // If the user opened the workspace split navigator from a different tab, we don't want to animate the entering transition. + // To make it feel like bottom tab navigator. + workspaceSplitsWithoutEnteringAnimation.add(lastFullScreenRoute.key); + } + + return stateWithWorkspaceSplitNavigator; +} + +/** + * Handles the SWITCH_POLICY_ID action. + * Information about the currently selected policy can be found in the last ReportsSplitNavigator or Search_Root. + * As the policy can only be changed from Search or Inbox Tab, after changing the policy a new ReportsSplitNavigator or Search_Root with the changed policy has to be pushed to the navigation state. + */ +function handleSwitchPolicyID( + state: StackNavigationState, + action: SwitchPolicyIdActionType, + configOptions: RouterConfigOptions, + stackRouter: Router, CommonActions.Action | StackActionType>, + setActiveWorkspaceID: (workspaceID: string | undefined) => void, +) { + const lastRoute = state.routes.at(-1); + if (lastRoute?.name === SCREENS.SEARCH.ROOT) { + const currentParams = lastRoute.params as RootNavigatorParamList[typeof SCREENS.SEARCH.ROOT]; + const queryJSON = SearchQueryUtils.buildSearchQueryJSON(currentParams.q); + if (!queryJSON) { + return null; + } + + if (action.payload.policyID) { + queryJSON.policyID = action.payload.policyID; + } else { + delete queryJSON.policyID; + } + + const newAction = StackActions.push(SCREENS.SEARCH.ROOT, { + ...currentParams, + q: SearchQueryUtils.buildSearchQueryString(queryJSON), + }); + + setActiveWorkspaceID(action.payload.policyID); + return stackRouter.getStateForAction(state, newAction, configOptions); + } + if (lastRoute?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { + const newAction = StackActions.push(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, {policyID: action.payload.policyID}); + + setActiveWorkspaceID(action.payload.policyID); + return stackRouter.getStateForAction(state, newAction, configOptions); + } + + // We don't have other navigators that should handle switch policy action. + return null; +} + +/** + * If a new ReportSplitNavigator is opened, it is necessary to check whether workspace is currently selected in the application. + * If so, the id of the current policy has to be passed to the new ReportSplitNavigator. + */ +function handlePushReportSplitAction( + state: StackNavigationState, + action: PushActionType, + configOptions: RouterConfigOptions, + stackRouter: Router, CommonActions.Action | StackActionType>, + setActiveWorkspaceID: (workspaceID: string | undefined) => void, +) { + const haveParamsPolicyID = action.payload.params && 'policyID' in action.payload.params; + let policyID; + + if (haveParamsPolicyID) { + policyID = (action.payload.params as Record)?.policyID; + setActiveWorkspaceID(policyID); + } else { + policyID = getPolicyIDFromState(state as State); + } + + const modifiedAction = { + ...action, + payload: { + ...action.payload, + params: { + ...action.payload.params, + policyID, + }, + }, + }; + + const stateWithReportsSplitNavigator = stackRouter.getStateForAction(state, modifiedAction, configOptions); + + if (!stateWithReportsSplitNavigator) { + Log.hmmm('[handlePushReportAction] ReportsSplitNavigator has not been found in the navigation state.'); + return null; + } + + const lastFullScreenRoute = stateWithReportsSplitNavigator.routes.at(-1); + const actionPayloadScreen = action.payload?.params && 'screen' in action.payload.params ? action.payload?.params?.screen : undefined; + + // ReportScreen should always be opened with an animation + if (actionPayloadScreen === SCREENS.REPORT && lastFullScreenRoute?.key) { + reportsSplitsWithEnteringAnimation.add(lastFullScreenRoute.key); + } + + return stateWithReportsSplitNavigator; +} + +/** + * If a new Search page is opened, it is necessary to check whether workspace is currently selected in the application. + * If so, the id of the current policy has to be passed to the new Search page + */ +function handlePushSearchPageAction( + state: StackNavigationState, + action: PushActionType, + configOptions: RouterConfigOptions, + stackRouter: Router, CommonActions.Action | StackActionType>, + setActiveWorkspaceID: (workspaceID: string | undefined) => void, +) { + const currentParams = action.payload.params as RootNavigatorParamList[typeof SCREENS.SEARCH.ROOT]; + const queryJSON = SearchQueryUtils.buildSearchQueryJSON(currentParams.q); + + if (!queryJSON) { + return null; + } + + if (!queryJSON.policyID) { + const policyID = getPolicyIDFromState(state as State); + + if (policyID) { + queryJSON.policyID = policyID; + } else { + delete queryJSON.policyID; + } + } else { + setActiveWorkspaceID(queryJSON.policyID); + } + + const modifiedAction = { + ...action, + payload: { + ...action.payload, + params: { + ...action.payload.params, + q: SearchQueryUtils.buildSearchQueryString(queryJSON), + }, + }, + }; + + return stackRouter.getStateForAction(state, modifiedAction, configOptions); +} + +/** + * Handles the DISMISS_MODAL action. + * If the last route is a modal route, it has to be popped from the navigation stack. + */ +function handleDismissModalAction( + state: StackNavigationState, + configOptions: RouterConfigOptions, + stackRouter: Router, CommonActions.Action | StackActionType>, +) { + const lastRoute = state.routes.at(-1); + const newAction = StackActions.pop(); + + if (!lastRoute?.name || !MODAL_ROUTES_TO_DISMISS.includes(lastRoute?.name)) { + Log.hmmm('[Navigation] dismissModal failed because there is no modal stack to dismiss'); + return null; + } + + return stackRouter.getStateForAction(state, newAction, configOptions); +} + +/** + * Handles opening a new modal navigator from an existing one. + */ +function handleNavigatingToModalFromModal( + state: StackNavigationState, + action: PushActionType, + configOptions: RouterConfigOptions, + stackRouter: Router, CommonActions.Action | StackActionType>, +) { + const modifiedState = {...state, routes: state.routes.slice(0, -1), index: state.index !== 0 ? state.index - 1 : 0}; + return stackRouter.getStateForAction(modifiedState, action, configOptions); +} + +export { + handleOpenWorkspaceSplitAction, + handleDismissModalAction, + handlePushReportSplitAction, + handlePushSearchPageAction, + handleSwitchPolicyID, + handleNavigatingToModalFromModal, + workspaceSplitsWithoutEnteringAnimation, + reportsSplitsWithEnteringAnimation, +}; diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts new file mode 100644 index 000000000000..7fd10aba7ba2 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts @@ -0,0 +1,104 @@ +import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; +import {findFocusedRoute, StackRouter} from '@react-navigation/native'; +import type {ParamListBase} from '@react-navigation/routers'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; +import * as Localize from '@libs/Localize'; +import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; +import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator'; +import * as Welcome from '@userActions/Welcome'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import * as GetStateForActionHandlers from './GetStateForActionHandlers'; +import syncBrowserHistory from './syncBrowserHistory'; +import type {DismissModalActionType, OpenWorkspaceSplitActionType, PushActionType, RootStackNavigatorAction, RootStackNavigatorRouterOptions, SwitchPolicyIdActionType} from './types'; + +function isOpenWorkspaceSplitAction(action: RootStackNavigatorAction): action is OpenWorkspaceSplitActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.OPEN_WORKSPACE_SPLIT; +} + +function isSwitchPolicyIdAction(action: RootStackNavigatorAction): action is SwitchPolicyIdActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.SWITCH_POLICY_ID; +} + +function isPushAction(action: RootStackNavigatorAction): action is PushActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.PUSH; +} + +function isDismissModalAction(action: RootStackNavigatorAction): action is DismissModalActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL; +} + +function shouldPreventReset(state: StackNavigationState, action: CommonActions.Action | StackActionType) { + if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) { + return false; + } + const currentFocusedRoute = findFocusedRoute(state); + const targetFocusedRoute = findFocusedRoute(action?.payload); + + // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen + if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) { + Welcome.setOnboardingErrorMessage(Localize.translateLocal('onboarding.purpose.errorBackButton')); + return true; + } + + return false; +} + +function isNavigatingToModalFromModal(state: StackNavigationState, action: CommonActions.Action | StackActionType): action is PushActionType { + if (action.type !== CONST.NAVIGATION.ACTION_TYPE.PUSH) { + return false; + } + + const lastRoute = state.routes.at(-1); + + // If the last route is a side modal navigator and the generated minimal action want's to push a new side modal navigator that means they are different ones. + // We want to dismiss the one that is currently on the top. + return isSideModalNavigator(lastRoute?.name) && isSideModalNavigator(action.payload.name); +} + +function RootStackRouter(options: RootStackNavigatorRouterOptions) { + const stackRouter = StackRouter(options); + const {setActiveWorkspaceID} = useActiveWorkspace(); + + return { + ...stackRouter, + getStateForAction(state: StackNavigationState, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) { + if (isOpenWorkspaceSplitAction(action)) { + return GetStateForActionHandlers.handleOpenWorkspaceSplitAction(state, action, configOptions, stackRouter); + } + + if (isSwitchPolicyIdAction(action)) { + return GetStateForActionHandlers.handleSwitchPolicyID(state, action, configOptions, stackRouter, setActiveWorkspaceID); + } + + if (isDismissModalAction(action)) { + return GetStateForActionHandlers.handleDismissModalAction(state, configOptions, stackRouter); + } + + if (isPushAction(action)) { + if (action.payload.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR) { + return GetStateForActionHandlers.handlePushReportSplitAction(state, action, configOptions, stackRouter, setActiveWorkspaceID); + } + + if (action.payload.name === SCREENS.SEARCH.ROOT) { + return GetStateForActionHandlers.handlePushSearchPageAction(state, action, configOptions, stackRouter, setActiveWorkspaceID); + } + } + + // Don't let the user navigate back to a non-onboarding screen if they are currently on an onboarding screen and it's not finished. + if (shouldPreventReset(state, action)) { + syncBrowserHistory(state); + return state; + } + + if (isNavigatingToModalFromModal(state, action)) { + return GetStateForActionHandlers.handleNavigatingToModalFromModal(state, action, configOptions, stackRouter); + } + + return stackRouter.getStateForAction(state, action, configOptions); + }, + }; +} + +export default RootStackRouter; diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx new file mode 100644 index 000000000000..3c4fe0471ef5 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/index.tsx @@ -0,0 +1,33 @@ +import {createNavigatorFactory} from '@react-navigation/native'; +import type {ParamListBase} from '@react-navigation/native'; +import TopLevelBottomTabBar from '@components/Navigation/TopLevelBottomTabBar'; +import useNavigationResetOnLayoutChange from '@libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; +import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent'; +import defaultPlatformStackScreenOptions from '@libs/Navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions'; +import type {CustomStateHookProps, PlatformStackNavigationEventMap, PlatformStackNavigationOptions, PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; +import RootStackRouter from './RootStackRouter'; + +// This is an optimization to keep mounted only last few screens in the stack. +function useCustomRootStackNavigatorState({state}: CustomStateHookProps) { + const lastSplitIndex = state.routes.findLastIndex((route) => isFullScreenName(route.name)); + const routesToRender = state.routes.slice(Math.max(0, lastSplitIndex - 1), state.routes.length); + + return {...state, routes: routesToRender, index: routesToRender.length - 1}; +} + +const RootStackNavigatorComponent = createPlatformStackNavigatorComponent('RootStackNavigator', { + createRouter: RootStackRouter, + defaultScreenOptions: defaultPlatformStackScreenOptions, + useCustomEffects: useNavigationResetOnLayoutChange, + useCustomState: useCustomRootStackNavigatorState, + ExtraContent: TopLevelBottomTabBar, +}); + +function createRootStackNavigator() { + return createNavigatorFactory, PlatformStackNavigationOptions, PlatformStackNavigationEventMap, typeof RootStackNavigatorComponent>( + RootStackNavigatorComponent, + )(); +} + +export default createRootStackNavigator; diff --git a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/syncBrowserHistory/index.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.ts similarity index 100% rename from src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/syncBrowserHistory/index.ts rename to src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.ts diff --git a/src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/syncBrowserHistory/index.web.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts similarity index 100% rename from src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/syncBrowserHistory/index.web.ts rename to src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts new file mode 100644 index 000000000000..6f6a0a878ab7 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/types.ts @@ -0,0 +1,56 @@ +import type {CommonActions, DefaultNavigatorOptions, ParamListBase, StackActionType, StackNavigationState, StackRouterOptions} from '@react-navigation/native'; +import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; +import type CONST from '@src/CONST'; + +type RootStackNavigatorActionType = + | { + type: typeof CONST.NAVIGATION.ACTION_TYPE.SWITCH_POLICY_ID; + payload: { + policyID: string | undefined; + }; + } + | { + type: typeof CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL; + } + | { + type: typeof CONST.NAVIGATION.ACTION_TYPE.OPEN_WORKSPACE_SPLIT; + payload: { + policyID: string; + }; + }; + +type OpenWorkspaceSplitActionType = RootStackNavigatorActionType & { + type: typeof CONST.NAVIGATION.ACTION_TYPE.OPEN_WORKSPACE_SPLIT; +}; + +type SwitchPolicyIdActionType = RootStackNavigatorActionType & { + type: typeof CONST.NAVIGATION.ACTION_TYPE.SWITCH_POLICY_ID; +}; + +type PushActionType = StackActionType & {type: typeof CONST.NAVIGATION.ACTION_TYPE.PUSH}; + +type DismissModalActionType = RootStackNavigatorActionType & { + type: typeof CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL; +}; + +type RootStackNavigatorConfig = { + isSmallScreenWidth: boolean; +}; + +type RootStackNavigatorRouterOptions = StackRouterOptions; + +type RootStackNavigatorProps = DefaultNavigatorOptions, StackNavigationOptions, StackNavigationEventMap> & RootStackNavigatorConfig; + +type RootStackNavigatorAction = CommonActions.Action | StackActionType | RootStackNavigatorActionType; + +export type { + OpenWorkspaceSplitActionType, + SwitchPolicyIdActionType, + PushActionType, + DismissModalActionType, + RootStackNavigatorAction, + RootStackNavigatorActionType, + RootStackNavigatorRouterOptions, + RootStackNavigatorProps, + RootStackNavigatorConfig, +}; diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SidebarSpacerWrapper.tsx b/src/libs/Navigation/AppNavigator/createSplitNavigator/SidebarSpacerWrapper.tsx new file mode 100644 index 000000000000..2d2bc583d59f --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SidebarSpacerWrapper.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import type {ReactNode} from 'react'; +import {View} from 'react-native'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type SidebarSpacerWrapperProps = { + children?: ReactNode; +}; + +function SidebarSpacerWrapper({children}: SidebarSpacerWrapperProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + return {children}; +} + +SidebarSpacerWrapper.displayName = 'SidebarSpacerWrapper'; + +export default SidebarSpacerWrapper; diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts new file mode 100644 index 000000000000..f4ecfe3700f1 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts @@ -0,0 +1,140 @@ +import type {CommonActions, ParamListBase, PartialState, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; +import {StackActions, StackRouter} from '@react-navigation/native'; +import pick from 'lodash/pick'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import getParamsFromRoute from '@libs/Navigation/helpers/getParamsFromRoute'; +import navigationRef from '@libs/Navigation/navigationRef'; +import type {NavigationPartialRoute} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {SplitNavigatorRouterOptions} from './types'; +import {getPreservedSplitNavigatorState} from './usePreserveSplitNavigatorState'; + +type StackState = StackNavigationState | PartialState>; + +const isAtLeastOneInState = (state: StackState, screenName: string): boolean => state.routes.some((route) => route.name === screenName); + +type AdaptStateIfNecessaryArgs = { + state: StackState; + options: SplitNavigatorRouterOptions; +}; + +function getRoutePolicyID(route: NavigationPartialRoute): string | undefined { + return (route?.params as Record | undefined)?.policyID; +} + +function adaptStateIfNecessary({state, options: {sidebarScreen, defaultCentralScreen, parentRoute}}: AdaptStateIfNecessaryArgs) { + const isNarrowLayout = getIsNarrowLayout(); + + const lastRoute = state.routes.at(-1) as NavigationPartialRoute; + const routePolicyID = getRoutePolicyID(lastRoute); + + // If invalid policy page is displayed on narrow layout, sidebar screen should not be pushed to the navigation state to avoid adding reduntant not found page + if (isNarrowLayout && !!routePolicyID) { + if (PolicyUtils.shouldDisplayPolicyNotFoundPage(routePolicyID)) { + return; + } + } + + // If the screen is wide, there should be at least two screens inside: + // - sidebarScreen to cover left pane. + // - defaultCentralScreen to cover central pane. + if (!isAtLeastOneInState(state, sidebarScreen)) { + const paramsFromRoute = getParamsFromRoute(sidebarScreen); + const copiedParams = pick(lastRoute?.params, paramsFromRoute); + + // We don't want to get an empty object as params because it breaks some navigation logic when comparing if routes are the same. + const params = isEmptyObject(copiedParams) ? undefined : copiedParams; + + // @ts-expect-error Updating read only property + // noinspection JSConstantReassignment + state.stale = true; // eslint-disable-line + + // @ts-expect-error Updating read only property + // Unshift the root screen to fill left pane. + state.routes.unshift({ + name: sidebarScreen, + // This handles the case where the sidebar should have params included in the central screen e.g. policyID for workspace initial. + params, + }); + } + + // If the screen is wide, there should be at least two screens inside: + // - sidebarScreen to cover left pane. + // - defaultCentralScreen to cover central pane. + if (!isNarrowLayout) { + if (state.routes.length === 1 && state.routes[0].name === sidebarScreen) { + const rootState = navigationRef.getRootState(); + + const previousSameNavigator = rootState?.routes.filter((route) => route.name === parentRoute.name).at(-2); + + // If we have optimization for not rendering all split navigators, then last selected option may not be in the state. In this case state has to be read from the preserved state. + const previousSameNavigatorState = previousSameNavigator?.state ?? (previousSameNavigator?.key ? getPreservedSplitNavigatorState(previousSameNavigator.key) : undefined); + const previousSelectedCentralScreen = + previousSameNavigatorState?.routes && previousSameNavigatorState.routes.length > 1 ? previousSameNavigatorState.routes.at(-1)?.name : undefined; + + // @ts-expect-error Updating read only property + // noinspection JSConstantReassignment + state.stale = true; // eslint-disable-line + + // @ts-expect-error Updating read only property + // Push the default settings central pane screen. + state.routes.push({ + name: previousSelectedCentralScreen ?? defaultCentralScreen, + params: state.routes.at(0)?.params, + }); + } + } + // eslint-disable-next-line no-param-reassign, @typescript-eslint/non-nullable-type-assertion-style + (state.index as number) = state.routes.length - 1; +} + +function isPushingSidebarOnCentralPane(state: StackState, action: CommonActions.Action | StackActionType, options: SplitNavigatorRouterOptions) { + return action.type === CONST.NAVIGATION.ACTION_TYPE.PUSH && action.payload.name === options.sidebarScreen && state.routes.length > 1; +} + +function SplitRouter(options: SplitNavigatorRouterOptions) { + const stackRouter = StackRouter(options); + return { + ...stackRouter, + getStateForAction(state: StackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) { + if (isPushingSidebarOnCentralPane(state, action, options)) { + if (getIsNarrowLayout()) { + const newAction = StackActions.popToTop(); + return stackRouter.getStateForAction(state, newAction, configOptions); + } + // On wide screen do nothing as we want to keep the central pane screen and the sidebar is visible. + return state; + } + return stackRouter.getStateForAction(state, action, configOptions); + }, + getInitialState({routeNames, routeParamList, routeGetIdList}: RouterConfigOptions) { + const preservedState = getPreservedSplitNavigatorState(options.parentRoute.key); + const initialState = preservedState ?? stackRouter.getInitialState({routeNames, routeParamList, routeGetIdList}); + + adaptStateIfNecessary({ + state: initialState, + options, + }); + + // If we needed to modify the state we need to rehydrate it to get keys for new routes. + if (initialState.stale) { + return stackRouter.getRehydratedState(initialState, {routeNames, routeParamList, routeGetIdList}); + } + + return initialState; + }, + getRehydratedState(partialState: StackState, {routeNames, routeParamList, routeGetIdList}: RouterConfigOptions): StackNavigationState { + adaptStateIfNecessary({ + state: partialState, + options, + }); + + const state = stackRouter.getRehydratedState(partialState, {routeNames, routeParamList, routeGetIdList}); + return state; + }, + }; +} + +export default SplitRouter; diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState.ts new file mode 100644 index 000000000000..06ac86f40b2d --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState.ts @@ -0,0 +1,29 @@ +import type {NavigationState, PartialState} from '@react-navigation/native'; +import {SIDEBAR_TO_SPLIT} from '@libs/Navigation/linkingConfig/RELATIONS'; +import type {NavigationPartialRoute, SplitNavigatorBySidebar, SplitNavigatorParamListType, SplitNavigatorSidebarScreen} from '@libs/Navigation/types'; + +type ExtractRouteType = Extract; + +// The function getPathFromState that we are using in some places isn't working correctly without defined index. +const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); + +function getInitialSplitNavigatorState( + splitNavigatorSidebarRoute: NavigationPartialRoute, + route?: NavigationPartialRoute>, + splitNavigatorParams?: Record, +): NavigationPartialRoute> { + const routes = []; + + routes.push(splitNavigatorSidebarRoute); + + if (route) { + routes.push(route); + } + return { + name: SIDEBAR_TO_SPLIT[splitNavigatorSidebarRoute.name], + state: getRoutesWithIndex(routes), + params: splitNavigatorParams, + }; +} + +export default getInitialSplitNavigatorState; diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx b/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx new file mode 100644 index 000000000000..8a943b40425a --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/index.tsx @@ -0,0 +1,52 @@ +import type {ParamListBase} from '@react-navigation/native'; +import {createNavigatorFactory} from '@react-navigation/native'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useNavigationResetOnLayoutChange from '@libs/Navigation/AppNavigator/useNavigationResetOnLayoutChange'; +import createPlatformStackNavigatorComponent from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent'; +import defaultPlatformStackScreenOptions from '@libs/Navigation/PlatformStackNavigation/defaultPlatformStackScreenOptions'; +import type { + CustomEffectsHookProps, + CustomStateHookProps, + PlatformStackNavigationEventMap, + PlatformStackNavigationOptions, + PlatformStackNavigationState, +} from '@libs/Navigation/PlatformStackNavigation/types'; +import SidebarSpacerWrapper from './SidebarSpacerWrapper'; +import SplitRouter from './SplitRouter'; +import usePreserveSplitNavigatorState from './usePreserveSplitNavigatorState'; + +function useCustomEffects(props: CustomEffectsHookProps) { + useNavigationResetOnLayoutChange(props); + usePreserveSplitNavigatorState(props.state, props.parentRoute); +} + +function useCustomSplitNavigatorState({state}: CustomStateHookProps) { + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const sidebarScreenRoute = state.routes.at(0); + + if (!sidebarScreenRoute) { + return state; + } + + const centralScreenRoutes = state.routes.slice(1); + const routesToRender = shouldUseNarrowLayout ? state.routes.slice(-2) : [sidebarScreenRoute, ...centralScreenRoutes.slice(-2)]; + + return {...state, routes: routesToRender, index: routesToRender.length - 1}; +} + +const SplitNavigatorComponent = createPlatformStackNavigatorComponent('SplitNavigator', { + createRouter: SplitRouter, + useCustomEffects, + defaultScreenOptions: defaultPlatformStackScreenOptions, + useCustomState: useCustomSplitNavigatorState, + NavigationContentWrapper: SidebarSpacerWrapper, +}); + +function createSplitNavigator() { + return createNavigatorFactory, PlatformStackNavigationOptions, PlatformStackNavigationEventMap, typeof SplitNavigatorComponent>( + SplitNavigatorComponent, + )(); +} + +export default createSplitNavigator; diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/types.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/types.ts new file mode 100644 index 000000000000..2396bf6e4b72 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/types.ts @@ -0,0 +1,12 @@ +import type {DefaultNavigatorOptions, ParamListBase, RouteProp, StackNavigationState, StackRouterOptions} from '@react-navigation/native'; +import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; + +type SplitNavigatorRouterOptions = StackRouterOptions & {defaultCentralScreen: string; sidebarScreen: string; parentRoute: RouteProp}; + +type SplitNavigatorProps = DefaultNavigatorOptions, StackNavigationOptions, StackNavigationEventMap> & { + persistentScreens?: Array>; + defaultCentralScreen: Extract; + sidebarScreen: Extract; +}; + +export type {SplitNavigatorProps, SplitNavigatorRouterOptions}; diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/usePreserveSplitNavigatorState.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/usePreserveSplitNavigatorState.ts new file mode 100644 index 000000000000..789fc27d81fe --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/usePreserveSplitNavigatorState.ts @@ -0,0 +1,29 @@ +import type {NavigationState, ParamListBase, RouteProp, StackNavigationState} from '@react-navigation/native'; +import {useEffect} from 'react'; + +const preservedSplitNavigatorStates: Record> = {}; + +const cleanPreservedSplitNavigatorStates = (state: NavigationState) => { + const currentSplitNavigatorKeys = state.routes.map((route) => route.key); + + for (const key of Object.keys(preservedSplitNavigatorStates)) { + if (!currentSplitNavigatorKeys.includes(key)) { + delete preservedSplitNavigatorStates[key]; + } + } +}; + +const getPreservedSplitNavigatorState = (key: string) => preservedSplitNavigatorStates[key]; + +function usePreserveSplitNavigatorState(state: StackNavigationState, route: RouteProp | undefined) { + useEffect(() => { + if (!route) { + return; + } + preservedSplitNavigatorStates[route.key] = state; + }, [route, state]); +} + +export default usePreserveSplitNavigatorState; + +export {getPreservedSplitNavigatorState, cleanPreservedSplitNavigatorStates}; diff --git a/src/libs/Navigation/AppNavigator/getActionsFromPartialDiff.ts b/src/libs/Navigation/AppNavigator/getActionsFromPartialDiff.ts deleted file mode 100644 index 86998ef4e308..000000000000 --- a/src/libs/Navigation/AppNavigator/getActionsFromPartialDiff.ts +++ /dev/null @@ -1,38 +0,0 @@ -import {getActionFromState, StackActions} from '@react-navigation/native'; -import type {NavigationAction} from '@react-navigation/native'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; -import NAVIGATORS from '@src/NAVIGATORS'; -import type {GetPartialStateDiffReturnType} from './getPartialStateDiff'; - -/** - * @param diff - Diff generated by getPartialDiff. - * @returns Array of actions to dispatch to apply diff. - */ -function getActionsFromPartialDiff(diff: GetPartialStateDiffReturnType): NavigationAction[] { - const actions: NavigationAction[] = []; - - const bottomTabDiff = diff[NAVIGATORS.BOTTOM_TAB_NAVIGATOR]; - const centralPaneDiff = diff[NAVIGATORS.CENTRAL_PANE_NAVIGATOR]; - const fullScreenDiff = diff[NAVIGATORS.FULL_SCREEN_NAVIGATOR]; - - // There is only one bottom tab navigator so we can just push this route. - if (bottomTabDiff) { - actions.push(StackActions.push(bottomTabDiff.name, bottomTabDiff.params)); - } - - if (centralPaneDiff) { - // In this case we have to wrap the inner central pane route with central pane navigator. - actions.push(StackActions.push(centralPaneDiff.name, centralPaneDiff.params)); - } - - if (fullScreenDiff) { - const action = getActionFromState({routes: [fullScreenDiff]}, linkingConfig.config); - if (action) { - actions.push(action); - } - } - - return actions; -} - -export default getActionsFromPartialDiff; diff --git a/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts b/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts deleted file mode 100644 index 17a8ee158219..000000000000 --- a/src/libs/Navigation/AppNavigator/getPartialStateDiff.ts +++ /dev/null @@ -1,86 +0,0 @@ -import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; -import getTopmostFullScreenRoute from '@libs/Navigation/getTopmostFullScreenRoute'; -import type {Metainfo} from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; -import type {NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; -import shallowCompare from '@libs/ObjectUtils'; -import NAVIGATORS from '@src/NAVIGATORS'; - -type GetPartialStateDiffReturnType = { - [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]?: NavigationPartialRoute; - [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]?: NavigationPartialRoute; - [NAVIGATORS.FULL_SCREEN_NAVIGATOR]?: NavigationPartialRoute; -}; - -/** - * This function returns partial additive diff between the two states. - * - * Example: Let's start with state A on route /r/123. If the screen is wide we will have a HOME opened on bottom tab and REPORT on central pane. - * Now let's say we want to navigate to /workspace/345/profile. We will generate state B from this path. - * State B will have WORKSPACE_INITIAL on the bottom tab and WORKSPACE_PROFILE on the central pane. - * Now we will generate partial diff between state A and state B. The diff will tell us that we need to push WORKSPACE_INITIAL on the bottom tab and WORKSPACE_PROFILE on the central pane. - * - * Then we can generate actions from this diff and dispatch them to the linkTo function. - * - * It's named partial diff because we don't cover RHP and LHP navigators yet. In the future we can improve this function to handle all navigators to help us clean and simplify the linkTo function. - * - * The partial diff has information which bottom tab, central pane and full screen screens we need to push to go from state to templateState. - * @param state - Current state. - * @param templateState - Desired state generated with getAdaptedStateFromPath. - * @param metainfo - Additional info from getAdaptedStateFromPath function. - * @returns The screen options object - */ -function getPartialStateDiff(state: State, templateState: State, metainfo: Metainfo): GetPartialStateDiffReturnType { - const diff: GetPartialStateDiffReturnType = {}; - - // If it is mandatory we need to compare both central pane and bottom tab of states. - if (metainfo.isCentralPaneAndBottomTabMandatory) { - const stateTopmostBottomTab = getTopmostBottomTabRoute(state); - const templateStateTopmostBottomTab = getTopmostBottomTabRoute(templateState); - - // Bottom tab navigator - if (stateTopmostBottomTab && templateStateTopmostBottomTab && stateTopmostBottomTab.name !== templateStateTopmostBottomTab.name) { - diff[NAVIGATORS.BOTTOM_TAB_NAVIGATOR] = templateStateTopmostBottomTab; - } - - const stateTopmostCentralPane = getTopmostCentralPaneRoute(state); - const templateStateTopmostCentralPane = getTopmostCentralPaneRoute(templateState); - - if ( - // If the central pane is only in the template state, it's diff. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (!stateTopmostCentralPane && templateStateTopmostCentralPane) || - (stateTopmostCentralPane && - templateStateTopmostCentralPane && - stateTopmostCentralPane.name !== templateStateTopmostCentralPane.name && - !shallowCompare(stateTopmostCentralPane.params as Record | undefined, templateStateTopmostCentralPane.params as Record | undefined)) - ) { - // We need to wrap central pane routes in the central pane navigator. - diff[NAVIGATORS.CENTRAL_PANE_NAVIGATOR] = templateStateTopmostCentralPane; - } - } - - // This one is heuristic and may need to be improved if we will be able to navigate from modal screen with full screen in background to another modal screen with full screen in background. - // For now this simple check is enough. - if (metainfo.isFullScreenNavigatorMandatory) { - const stateTopmostFullScreen = getTopmostFullScreenRoute(state); - const templateStateTopmostFullScreen = getTopmostFullScreenRoute(templateState); - const fullScreenDiff = templateState.routes.filter((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR).at(-1) as NavigationPartialRoute; - - if ( - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (!stateTopmostFullScreen && templateStateTopmostFullScreen) || - (stateTopmostFullScreen && - templateStateTopmostFullScreen && - (stateTopmostFullScreen.name !== templateStateTopmostFullScreen.name || - !shallowCompare(stateTopmostFullScreen.params as Record | undefined, templateStateTopmostFullScreen.params as Record | undefined))) - ) { - diff[NAVIGATORS.FULL_SCREEN_NAVIGATOR] = fullScreenDiff; - } - } - - return diff; -} - -export default getPartialStateDiff; -export type {GetPartialStateDiffReturnType}; diff --git a/src/libs/Navigation/AppNavigator/useNavigationResetRootOnLayoutChange.ts b/src/libs/Navigation/AppNavigator/useNavigationResetRootOnLayoutChange.ts deleted file mode 100644 index 03caac57410f..000000000000 --- a/src/libs/Navigation/AppNavigator/useNavigationResetRootOnLayoutChange.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {useEffect} from 'react'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import navigationRef from '@libs/Navigation/navigationRef'; - -/** - * This hook resets the navigation root state when changing the layout size, resetting the state calls the getRehydredState method in CustomRouter.ts. - * When the screen size is changed, it is necessary to check whether the application displays the content correctly. - * When the app is opened on a small layout and the user resizes it to wide, a second screen has to be present in the navigation state to fill the space. - */ -function useNavigationResetRootOnLayoutChange() { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - - useEffect(() => { - if (!navigationRef.isReady()) { - return; - } - navigationRef.resetRoot(navigationRef.getRootState()); - }, [shouldUseNarrowLayout]); -} - -export default useNavigationResetRootOnLayoutChange; diff --git a/src/libs/Navigation/AppNavigator/useRootNavigatorOptions.ts b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts similarity index 60% rename from src/libs/Navigation/AppNavigator/useRootNavigatorOptions.ts rename to src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts index 27b1c6d2fae1..15ca8ea2265a 100644 --- a/src/libs/Navigation/AppNavigator/useRootNavigatorOptions.ts +++ b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts @@ -1,23 +1,18 @@ import type {StackCardInterpolationProps} from '@react-navigation/stack'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation'; import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types'; -import variables from '@styles/variables'; -import CONFIG from '@src/CONFIG'; import hideKeyboardOnSwipe from './hideKeyboardOnSwipe'; import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; -type RootNavigatorOptions = { +type RootNavigatorScreenOptions = { rightModalNavigator: PlatformStackNavigationOptions; basicModalNavigator: PlatformStackNavigationOptions; leftModalNavigator: PlatformStackNavigationOptions; - homeScreen: PlatformStackNavigationOptions; + splitNavigator: PlatformStackNavigationOptions; fullScreen: PlatformStackNavigationOptions; - centralPaneNavigator: PlatformStackNavigationOptions; - bottomTab: PlatformStackNavigationOptions; }; const commonScreenOptions: PlatformStackNavigationOptions = { @@ -26,8 +21,7 @@ const commonScreenOptions: PlatformStackNavigationOptions = { }, }; -const useRootNavigatorOptions = () => { - const themeStyles = useThemeStyles(); +const useRootNavigatorScreenOptions = () => { const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const modalCardStyleInterpolator = useModalCardStyleInterpolator(); @@ -82,75 +76,32 @@ const useRootNavigatorOptions = () => { // This is necessary to cover translated sidebar with overlay. width: shouldUseNarrowLayout ? '100%' : '200%', - - // LHP should be displayed in place of the sidebar - left: shouldUseNarrowLayout ? 0 : -variables.sideBarWidth, - }, - }, - }, - homeScreen: { - ...commonScreenOptions, - title: CONFIG.SITE_TITLE, - headerShown: false, - web: { - // Note: The card* properties won't be applied on mobile platforms, as they use the native defaults. - cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props}), - cardStyle: { - ...StyleUtils.getNavigationModalCardStyle(), - width: shouldUseNarrowLayout ? '100%' : variables.sideBarWidth, - - // We need to shift the sidebar to not be covered by the StackNavigator so it can be clickable. - marginLeft: shouldUseNarrowLayout ? 0 : -variables.sideBarWidth, - ...(shouldUseNarrowLayout ? {} : themeStyles.borderRight), }, }, }, - - fullScreen: { + splitNavigator: { ...commonScreenOptions, // We need to turn off animation for the full screen to avoid delay when closing screens. - animation: shouldUseNarrowLayout ? Animations.SLIDE_FROM_RIGHT : Animations.NONE, + animation: Animations.NONE, web: { cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, isFullScreenModal: true}), cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), - - // This is necessary to cover whole screen. Including translated sidebar. - marginLeft: shouldUseNarrowLayout ? 0 : -variables.sideBarWidth, }, }, }, - - centralPaneNavigator: { + fullScreen: { ...commonScreenOptions, - ...hideKeyboardOnSwipe, - headerShown: false, - title: CONFIG.SITE_TITLE, - animation: shouldUseNarrowLayout ? undefined : Animations.NONE, + // We need to turn off animation for the full screen to avoid delay when closing screens. + animation: Animations.NONE, web: { cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, isFullScreenModal: true}), cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), - paddingRight: shouldUseNarrowLayout ? 0 : variables.sideBarWidth, - }, - }, - }, - - bottomTab: { - ...commonScreenOptions, - web: { - cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props}), - cardStyle: { - ...StyleUtils.getNavigationModalCardStyle(), - width: shouldUseNarrowLayout ? '100%' : variables.sideBarWidth, - - // We need to shift the sidebar to not be covered by the StackNavigator so it can be clickable. - marginLeft: shouldUseNarrowLayout ? 0 : -variables.sideBarWidth, - ...(shouldUseNarrowLayout ? {} : themeStyles.borderRight), }, }, }, - } satisfies RootNavigatorOptions; + } satisfies RootNavigatorScreenOptions; }; -export default useRootNavigatorOptions; +export default useRootNavigatorScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions.ts new file mode 100644 index 000000000000..28df27454ca0 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions.ts @@ -0,0 +1,65 @@ +import type {StackCardInterpolationProps} from '@react-navigation/stack'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; +import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types'; +import variables from '@styles/variables'; +import CONFIG from '@src/CONFIG'; +import hideKeyboardOnSwipe from './hideKeyboardOnSwipe'; +import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; + +type SplitNavigatorScreenOptions = { + sidebarScreen: PlatformStackNavigationOptions; + centralScreen: PlatformStackNavigationOptions; +}; + +const commonScreenOptions: PlatformStackNavigationOptions = { + web: { + cardOverlayEnabled: true, + }, +}; + +const useSplitNavigatorScreenOptions = () => { + const themeStyles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const modalCardStyleInterpolator = useModalCardStyleInterpolator(); + + return { + sidebarScreen: { + ...commonScreenOptions, + title: CONFIG.SITE_TITLE, + headerShown: false, + web: { + // Note: The card* properties won't be applied on mobile platforms, as they use the native defaults. + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props}), + cardStyle: { + ...StyleUtils.getNavigationModalCardStyle(), + width: shouldUseNarrowLayout ? '100%' : variables.sideBarWidth, + + // We need to shift the sidebar to not be covered by the StackNavigator so it can be clickable. + marginLeft: shouldUseNarrowLayout ? 0 : -variables.sideBarWidth, + ...(shouldUseNarrowLayout ? {} : themeStyles.borderRight), + }, + }, + }, + + centralScreen: { + ...commonScreenOptions, + ...hideKeyboardOnSwipe, + headerShown: false, + title: CONFIG.SITE_TITLE, + animation: shouldUseNarrowLayout ? undefined : Animations.NONE, + web: { + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, isFullScreenModal: true}), + cardStyle: { + ...StyleUtils.getNavigationModalCardStyle(), + paddingRight: shouldUseNarrowLayout ? 0 : variables.sideBarWidth, + }, + }, + }, + } satisfies SplitNavigatorScreenOptions; +}; + +export default useSplitNavigatorScreenOptions; diff --git a/src/libs/Navigation/FreezeWrapper/index.native.tsx b/src/libs/Navigation/FreezeWrapper/index.native.tsx deleted file mode 100644 index b071a065bd31..000000000000 --- a/src/libs/Navigation/FreezeWrapper/index.native.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; -import React, {useEffect, useRef, useState} from 'react'; -import {Freeze} from 'react-freeze'; -import shouldSetScreenBlurred from '@libs/Navigation/shouldSetScreenBlurred'; -import type ChildrenProps from '@src/types/utils/ChildrenProps'; - -type FreezeWrapperProps = ChildrenProps & { - /** Prop to disable freeze */ - keepVisible?: boolean; -}; - -function FreezeWrapper({keepVisible = false, children}: FreezeWrapperProps) { - const [isScreenBlurred, setIsScreenBlurred] = useState(false); - // we need to know the screen index to determine if the screen can be frozen - const screenIndexRef = useRef(null); - const isFocused = useIsFocused(); - const navigation = useNavigation(); - const currentRoute = useRoute(); - - useEffect(() => { - const index = navigation.getState()?.routes.findIndex((route) => route.key === currentRoute.key) ?? 0; - screenIndexRef.current = index; - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const unsubscribe = navigation.addListener('state', () => { - const navigationIndex = (navigation.getState()?.index ?? 0) - (screenIndexRef.current ?? 0); - setIsScreenBlurred(shouldSetScreenBlurred(navigationIndex)); - }); - return () => unsubscribe(); - }, [isFocused, isScreenBlurred, navigation]); - - return {children}; -} - -FreezeWrapper.displayName = 'FreezeWrapper'; - -export default FreezeWrapper; diff --git a/src/libs/Navigation/FreezeWrapper/index.tsx b/src/libs/Navigation/FreezeWrapper/index.tsx deleted file mode 100644 index 7219666b1b18..000000000000 --- a/src/libs/Navigation/FreezeWrapper/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; -import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; -import {Freeze} from 'react-freeze'; -import shouldSetScreenBlurred from '@libs/Navigation/shouldSetScreenBlurred'; -import type ChildrenProps from '@src/types/utils/ChildrenProps'; - -type FreezeWrapperProps = ChildrenProps & { - /** Prop to disable freeze */ - keepVisible?: boolean; -}; - -function FreezeWrapper({keepVisible = false, children}: FreezeWrapperProps) { - const [isScreenBlurred, setIsScreenBlurred] = useState(false); - const [freezed, setFreezed] = useState(false); - // we need to know the screen index to determine if the screen can be frozen - const screenIndexRef = useRef(null); - const isFocused = useIsFocused(); - const navigation = useNavigation(); - const currentRoute = useRoute(); - - useEffect(() => { - const index = navigation.getState()?.routes.findIndex((route) => route.key === currentRoute.key) ?? 0; - screenIndexRef.current = index; - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const unsubscribe = navigation.addListener('state', () => { - const navigationIndex = (navigation.getState()?.index ?? 0) - (screenIndexRef.current ?? 0); - setIsScreenBlurred(shouldSetScreenBlurred(navigationIndex)); - }); - return () => unsubscribe(); - }, [isFocused, isScreenBlurred, navigation]); - - // Decouple the Suspense render task so it won't be interuptted by React's concurrent mode - // and stuck in an infinite loop - useLayoutEffect(() => { - setFreezed(!isFocused && isScreenBlurred && !keepVisible); - }, [isFocused, isScreenBlurred, keepVisible]); - - return {children}; -} - -FreezeWrapper.displayName = 'FreezeWrapper'; - -export default FreezeWrapper; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 095ed2684a6d..a998b5e631c7 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1,34 +1,39 @@ -import {findFocusedRoute} from '@react-navigation/core'; -import type {EventArg, NavigationContainerEventMap} from '@react-navigation/native'; +import {getActionFromState} from '@react-navigation/core'; +import type {EventArg, NavigationAction, NavigationContainerEventMap} from '@react-navigation/native'; import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native'; +// eslint-disable-next-line you-dont-need-lodash-underscore/omit +import omit from 'lodash/omit'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {Writable} from 'type-fest'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Log from '@libs/Log'; -import {isCentralPaneName, removePolicyIDParamFromState} from '@libs/NavigationUtils'; -import {generateReportID} from '@libs/ReportUtils'; +import {shallowCompare} from '@libs/ObjectUtils'; +import getPolicyEmployeeAccountIDs from '@libs/PolicyEmployeeListUtils'; +import {doesReportBelongToWorkspace, generateReportID} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {HybridAppRoute, Route} from '@src/ROUTES'; import ROUTES, {HYBRID_APP_ROUTES} from '@src/ROUTES'; -import {PROTECTED_SCREENS} from '@src/SCREENS'; -import type {Screen} from '@src/SCREENS'; +import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS'; import type {Report} from '@src/types/onyx'; -import originalCloseRHPFlow from './closeRHPFlow'; -import originalDismissModal from './dismissModal'; -import {dismissModalWithReport as originalDismissModalWithReport} from './dismissModalWithReport'; -import getTopmostBottomTabRoute from './getTopmostBottomTabRoute'; -import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute'; -import originalGetTopmostReportActionId from './getTopmostReportActionID'; -import originalGetTopmostReportId from './getTopmostReportId'; -import isReportOpenInRHP from './isReportOpenInRHP'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; +import originalCloseRHPFlow from './helpers/closeRHPFlow'; +import getPolicyIDFromState from './helpers/getPolicyIDFromState'; +import getStateFromPath from './helpers/getStateFromPath'; +import getTopmostReportParams from './helpers/getTopmostReportParams'; +import isReportOpenInRHP from './helpers/isReportOpenInRHP'; +import linkTo from './helpers/linkTo'; +import getMinimalAction from './helpers/linkTo/getMinimalAction'; +import type {LinkToOptions} from './helpers/linkTo/types'; +import replaceWithSplitNavigator from './helpers/replaceWithSplitNavigator'; +import setNavigationActionToMicrotaskQueue from './helpers/setNavigationActionToMicrotaskQueue'; +import switchPolicyID from './helpers/switchPolicyID'; import {linkingConfig} from './linkingConfig'; -import getMatchingBottomTabRouteForState from './linkingConfig/getMatchingBottomTabRouteForState'; -import linkTo from './linkTo'; import navigationRef from './navigationRef'; -import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; -import switchPolicyID from './switchPolicyID'; -import type {NavigationStateRoute, RootStackParamList, State, StateOrRoute, SwitchPolicyIDParams} from './types'; +import type {NavigationPartialRoute, NavigationStateRoute, RootNavigatorParamList, State} from './types'; let allReports: OnyxCollection; Onyx.connect({ @@ -55,6 +60,9 @@ function setShouldPopAllStateOnUP(shouldPopAllStateFlag: boolean) { shouldPopAllStateOnUP = shouldPopAllStateFlag; } +/** + * Checks if the navigationRef is ready to perform a method. + */ function canNavigate(methodName: string, params: Record = {}): boolean { if (navigationRef.isReady()) { return true; @@ -63,53 +71,23 @@ function canNavigate(methodName: string, params: Record = {}): return false; } -// Re-exporting the getTopmostReportId here to fill in default value for state. The getTopmostReportId isn't defined in this file to avoid cyclic dependencies. -const getTopmostReportId = (state = navigationRef.getState()) => originalGetTopmostReportId(state); +/** + * Extracts from the topmost report its id. + */ +const getTopmostReportId = (state = navigationRef.getState()) => getTopmostReportParams(state)?.reportID; -// Re-exporting the getTopmostReportActionID here to fill in default value for state. The getTopmostReportActionID isn't defined in this file to avoid cyclic dependencies. -const getTopmostReportActionId = (state = navigationRef.getState()) => originalGetTopmostReportActionId(state); +/** + * Extracts from the topmost report its action id. + */ +const getTopmostReportActionId = (state = navigationRef.getState()) => getTopmostReportParams(state)?.reportActionID; -// Re-exporting the dismissModal here to fill in default value for navigationRef. The dismissModal isn't defined in this file to avoid cyclic dependencies. -const dismissModal = (reportID?: string, ref = navigationRef) => { - if (!reportID) { - originalDismissModal(ref); - return; - } - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - originalDismissModalWithReport({reportID, ...report}, ref); -}; -// Re-exporting the closeRHPFlow here to fill in default value for navigationRef. The closeRHPFlow isn't defined in this file to avoid cyclic dependencies. +/** + * Re-exporting the closeRHPFlow here to fill in default value for navigationRef. The closeRHPFlow isn't defined in this file to avoid cyclic dependencies. + */ const closeRHPFlow = (ref = navigationRef) => originalCloseRHPFlow(ref); -// Re-exporting the dismissModalWithReport here to fill in default value for navigationRef. The dismissModalWithReport isn't defined in this file to avoid cyclic dependencies. -// This method is needed because it allows to dismiss the modal and then open the report. Within this method is checked whether the report belongs to a specific workspace. Sometimes the report we want to check, hasn't been added to the Onyx yet. -// Then we can pass the report as a param without getting it from the Onyx. -const dismissModalWithReport = (report: OnyxEntry, ref = navigationRef) => originalDismissModalWithReport(report, ref); - -/** Method for finding on which index in stack we are. */ -function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number | undefined { - if ('routes' in stateOrRoute && stateOrRoute.routes) { - const childActiveRoute = stateOrRoute.routes[stateOrRoute.index ?? 0]; - return getActiveRouteIndex(childActiveRoute, stateOrRoute.index ?? 0); - } - - if ('state' in stateOrRoute && stateOrRoute.state?.routes) { - const childActiveRoute = stateOrRoute.state.routes[stateOrRoute.state.index ?? 0]; - return getActiveRouteIndex(childActiveRoute, stateOrRoute.state.index ?? 0); - } - - if ( - 'name' in stateOrRoute && - (stateOrRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || stateOrRoute.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR || stateOrRoute.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR) - ) { - return 0; - } - - return index; -} - /** - * Function that generates dynamic urls from paths passed from OldDot + * Function that generates dynamic urls from paths passed from OldDot. */ function parseHybridAppUrl(url: HybridAppRoute | Route): Route { switch (url) { @@ -126,33 +104,8 @@ function parseHybridAppUrl(url: HybridAppRoute | Route): Route { } /** - * Gets distance from the path in root navigator. In other words how much screen you have to pop to get to the route with this path. - * The search is limited to 5 screens from the top for performance reasons. - * @param path - Path that you are looking for. - * @return - Returns distance to path or -1 if the path is not found in root navigator. + * Returns the current active route. */ -function getDistanceFromPathInRootNavigator(path?: string): number { - let currentState = navigationRef.getRootState(); - - for (let index = 0; index < 5; index++) { - if (!currentState.routes.length) { - break; - } - - // When comparing path and pathFromState, the policyID parameter isn't included in the comparison - const currentStateWithoutPolicyID = removePolicyIDParamFromState(currentState as State); - const pathFromState = getPathFromState(currentStateWithoutPolicyID, linkingConfig.config); - if (path === pathFromState.substring(1)) { - return index; - } - - currentState = {...currentState, routes: currentState.routes.slice(0, -1), index: currentState.index - 1}; - } - - return -1; -} - -/** Returns the current active route */ function getActiveRoute(): string { const currentRoute = navigationRef.current && navigationRef.current.getCurrentRoute(); if (!currentRoute?.name) { @@ -167,7 +120,9 @@ function getActiveRoute(): string { return ''; } - +/** + * Returns the route of a report opened in RHP. + */ function getReportRHPActiveRoute(): string { if (isReportOpenInRHP(navigationRef.getRootState())) { return getActiveRoute(); @@ -193,10 +148,14 @@ function isActiveRoute(routePath: Route): boolean { } /** + * Navigates to a specified route. * Main navigation method for redirecting to a route. - * @param [type] - Type of action to perform. Currently UP is supported. + * + * @param route - The route to navigate to. + * @param options - Optional navigation options. + * @param options.forceReplace - If true, the navigation action will replace the current route instead of pushing a new one. */ -function navigate(route: Route = ROUTES.HOME, type?: string) { +function navigate(route: Route, options?: LinkToOptions) { if (!canNavigate('navigate', {route})) { // Store intended route if the navigator is not yet available, // we will try again after the NavigationContainer is ready @@ -204,119 +163,208 @@ function navigate(route: Route = ROUTES.HOME, type?: string) { pendingRoute = route; return; } - linkTo(navigationRef.current, route, type, isActiveRoute(route)); + + linkTo(navigationRef.current, route, options); } /** - * @param fallbackRoute - Fallback route if pop/goBack action should, but is not possible within RHP - * @param shouldEnforceFallback - Enforces navigation to fallback route - * @param shouldPopToTop - Should we navigate to LHN on back press + * When routes are compared to determine whether the fallback route passed to the goUp function is in the state, + * these parameters shouldn't be included in the comparison. */ -function goBack(fallbackRoute?: Route, shouldEnforceFallback = false, shouldPopToTop = false) { - if (!canNavigate('goBack')) { - return; +const routeParamsIgnore = ['path', 'initial', 'params', 'state', 'screen', 'policyID']; + +/** + * @private + * If we use destructuring, we will get an error if any of the ignored properties are not present in the object. + */ +function getRouteParamsToCompare(routeParams: Record) { + return omit(routeParams, routeParamsIgnore); +} + +/** + * @private + * Private method used in goUp to determine whether a target route is present in the navigation state. + */ +function doesRouteMatchToMinimalActionPayload(route: NavigationStateRoute | NavigationPartialRoute, minimalAction: Writable, compareParams: boolean) { + if (!minimalAction.payload) { + return false; } - if (shouldPopToTop) { - if (shouldPopAllStateOnUP) { - shouldPopAllStateOnUP = false; - navigationRef.current?.dispatch(StackActions.popToTop()); - return; - } + if (!('name' in minimalAction.payload)) { + return false; } - if (!navigationRef.current?.canGoBack()) { - Log.hmmm('[Navigation] Unable to go back'); - return; + const areRouteNamesEqual = route.name === minimalAction.payload.name; + + if (!areRouteNamesEqual) { + return false; } - const isFirstRouteInNavigator = !getActiveRouteIndex(navigationRef.current.getState()); - if (isFirstRouteInNavigator) { - const rootState = navigationRef.getRootState(); - const lastRoute = rootState.routes.at(-1); - // If the user comes from a different flow (there is more than one route in ModalNavigator) we should go back to the previous flow on UP button press instead of using the fallbackRoute. - if ((lastRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR || lastRoute?.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR) && (lastRoute.state?.index ?? 0) > 0) { - navigationRef.current.goBack(); - return; - } + if (!compareParams) { + return true; } - if (shouldEnforceFallback || (isFirstRouteInNavigator && fallbackRoute)) { - navigate(fallbackRoute, CONST.NAVIGATION.TYPE.UP); + if (!('params' in minimalAction.payload)) { + return false; + } + + const routeParams = getRouteParamsToCompare(route.params as Record); + const minimalActionParams = getRouteParamsToCompare(minimalAction.payload.params as Record); + + return shallowCompare(routeParams, minimalActionParams); +} + +/** + * @private + * Checks whether the given state is the root navigator state + */ +function isRootNavigatorState(state: State): state is State { + return state.key === navigationRef.current?.getRootState().key; +} + +type GoBackOptions = { + /** + * If we should compare params when searching for a route in state to go up to. + * There are situations where we want to compare params when going up e.g. goUp to a specific report. + * Sometimes we want to go up and update params of screen e.g. country picker. + * In that case we want to goUp to a country picker with any params so we don't compare them. + */ + compareParams?: boolean; + + /** + * Specifies whether goBack should pop to top when invoked. + * Additionaly, to execute popToTop, set the value of the global variable ShouldPopAllStateOnUP to true using the setShouldPopAllStateOnUP function. + */ + shouldPopToTop?: boolean; +}; + +const defaultGoBackOptions: Required = { + compareParams: true, + shouldPopToTop: false, +}; + +/** + * @private + * Navigate to the given backToRoute taking into account whether it is possible to go back to this screen. Within one nested navigator, we can go back by any number + * of screens, but if as a result of going back we would have to remove more than one screen from the rootState, + * replace is performed so as not to lose the visited pages. + * If backToRoute is not found in the state, replace is also called then. + * + * @param backToRoute - The route to go up. + * @param options - Optional configuration that affects navigation logic, such as parameter comparison. + */ +function goUp(backToRoute: Route, options?: GoBackOptions) { + if (!canNavigate('goUp') || !navigationRef.current) { + Log.hmmm(`[Navigation] Unable to go up. Can't navigate.`); return; } - const isCentralPaneFocused = isCentralPaneName(findFocusedRoute(navigationRef.current.getState())?.name); - const distanceFromPathInRootNavigator = getDistanceFromPathInRootNavigator(fallbackRoute ?? ''); + const compareParams = options?.compareParams ?? defaultGoBackOptions.compareParams; - if (isCentralPaneFocused && fallbackRoute) { - // Allow CentralPane to use UP with fallback route if the path is not found in root navigator. - if (distanceFromPathInRootNavigator === -1) { - navigate(fallbackRoute, CONST.NAVIGATION.TYPE.UP); - return; - } + const rootState = navigationRef.current.getRootState(); + const stateFromPath = getStateFromPath(backToRoute); - // Add possibility to go back more than one screen in root navigator if that screen is on the stack. - if (distanceFromPathInRootNavigator > 0) { - navigationRef.current.dispatch(StackActions.pop(distanceFromPathInRootNavigator)); - return; - } + const action = getActionFromState(stateFromPath, linkingConfig.config); + + if (!action) { + Log.hmmm(`[Navigation] Unable to go up. Action is undefined.`); + return; } - // If the central pane is focused, it's possible that we navigated from other central pane with different matching bottom tab. - if (isCentralPaneFocused) { - const rootState = navigationRef.getRootState(); - const stateAfterPop = {routes: rootState.routes.slice(0, -1)} as State; - const topmostCentralPaneRouteAfterPop = getTopmostCentralPaneRoute(stateAfterPop); + const {action: minimalAction, targetState} = getMinimalAction(action, rootState); - const topmostBottomTabRoute = getTopmostBottomTabRoute(rootState as State); - const matchingBottomTabRoute = getMatchingBottomTabRouteForState(stateAfterPop); + if (minimalAction.type !== CONST.NAVIGATION.ACTION_TYPE.NAVIGATE || !targetState) { + Log.hmmm('[Navigation] Unable to go up. Minimal action type is wrong.'); + return; + } - // If the central pane is defined after the pop action, we need to check if it's synced with the bottom tab screen. - // If not, we need to pop to the bottom tab screen/screens to sync it with the new central pane. - if (topmostCentralPaneRouteAfterPop && topmostBottomTabRoute?.name !== matchingBottomTabRoute.name) { - const bottomTabNavigator = rootState.routes.find((item: NavigationStateRoute) => item.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR)?.state; + const indexOfBackToRoute = targetState.routes.findLastIndex((route) => doesRouteMatchToMinimalActionPayload(route, minimalAction, compareParams)); + const distanceToPop = targetState.routes.length - indexOfBackToRoute - 1; - if (bottomTabNavigator && bottomTabNavigator.index) { - const matchingIndex = bottomTabNavigator.routes.findLastIndex((item) => item.name === matchingBottomTabRoute.name); - const indexToPop = matchingIndex !== -1 ? bottomTabNavigator.index - matchingIndex : undefined; - navigationRef.current.dispatch({...StackActions.pop(indexToPop), target: bottomTabNavigator?.key}); - } - } + // If we need to pop more than one route from rootState, we replace the current route to not lose visited routes from the navigation state + if (indexOfBackToRoute === -1 || (isRootNavigatorState(targetState) && distanceToPop > 1)) { + const replaceAction = {...minimalAction, type: CONST.NAVIGATION.ACTION_TYPE.REPLACE} as NavigationAction; + navigationRef.current.dispatch(replaceAction); + return; } - navigationRef.current.goBack(); + /** + * If we are not comparing params, we want to use navigate action because it will replace params in the route already existing in the state if necessary. + * This part will need refactor after migrating to react-navigation 7. We will use popTo instead. + */ + if (!compareParams) { + navigationRef.current.dispatch(minimalAction); + return; + } + + navigationRef.current.dispatch({...StackActions.pop(distanceToPop), target: targetState.key}); } /** - * Close the current screen and navigate to the route. - * If the current screen is the first screen in the navigator, we force using the fallback route to replace the current screen. - * It's useful in a case where we want to close an RHP and navigate to another RHP to prevent any blink effect. + * @param backToRoute - Fallback route if pop/goBack action should, but is not possible within RHP + * @param options - Optional configuration that affects navigation logic */ -function closeAndNavigate(route: Route) { - if (!navigationRef.current) { +function goBack(backToRoute?: Route, options?: GoBackOptions) { + if (!canNavigate('goBack')) { return; } - const isFirstRouteInNavigator = !getActiveRouteIndex(navigationRef.current.getState()); - if (isFirstRouteInNavigator) { - goBack(route, true); + if (options?.shouldPopToTop) { + if (shouldPopAllStateOnUP) { + shouldPopAllStateOnUP = false; + navigationRef.current?.dispatch(StackActions.popToTop()); + return; + } + } + + if (backToRoute) { + goUp(backToRoute, options); + return; + } + + if (!navigationRef.current?.canGoBack()) { + Log.hmmm('[Navigation] Unable to go back'); return; } - goBack(); - navigate(route); + + navigationRef.current?.goBack(); } /** - * Reset the navigation state to Home page + * Reset the navigation state to Home page. */ function resetToHome() { + const isNarrowLayout = getIsNarrowLayout(); const rootState = navigationRef.getRootState(); - const bottomTabKey = rootState.routes.find((item: NavigationStateRoute) => item.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR)?.state?.key; - if (bottomTabKey) { - navigationRef.dispatch({...StackActions.popToTop(), target: bottomTabKey}); - } navigationRef.dispatch({...StackActions.popToTop(), target: rootState.key}); + const splitNavigatorMainScreen = !isNarrowLayout + ? { + name: SCREENS.REPORT, + } + : undefined; + const payload = getInitialSplitNavigatorState({name: SCREENS.HOME}, splitNavigatorMainScreen); + navigationRef.dispatch({payload, type: CONST.NAVIGATION.ACTION_TYPE.REPLACE, target: rootState.key}); +} + +/** + * The goBack function doesn't support recursive pop e.g. pop route from root and then from nested navigator. + * There is only one case where recursive pop is needed which is going back to home. + * This function will cover this case. + * We will implement recursive pop if more use cases will appear. + */ +function goBackToHome() { + const isNarrowLayout = getIsNarrowLayout(); + + // This set the right split navigator. + goBack(ROUTES.HOME); + + // We want to keep the report screen in the split navigator on wide layout. + if (!isNarrowLayout) { + return; + } + + // This set the right route in this split navigator. + goBack(ROUTES.HOME); } /** @@ -330,13 +378,15 @@ function setParams(params: Record, routeKey = '') { } /** - * Returns the current active route without the URL params + * Returns the current active route without the URL params. */ function getActiveRouteWithoutParams(): string { return getActiveRoute().replace(/\?.*/, ''); } -/** Returns the active route name from a state event from the navigationRef */ +/** + * Returns the active route name from a state event from the navigationRef. + */ function getRouteNameFromStateEvent(event: EventArg<'state', false, NavigationContainerEventMap['state']['data']>): string | undefined { if (!event.data.state) { return; @@ -350,6 +400,7 @@ function getRouteNameFromStateEvent(event: EventArg<'state', false, NavigationCo } /** + * @private * Navigate to the route that we originally intended to go to * but the NavigationContainer was not ready when navigate() was called */ @@ -372,6 +423,7 @@ function setIsNavigationReady() { } /** + * @private * Checks if the navigation state contains routes that are protected (over the auth wall). * * @param state - react-navigation state object @@ -416,23 +468,84 @@ function waitForProtectedRoutes() { }); } -function navigateWithSwitchPolicyID(params: SwitchPolicyIDParams) { - if (!canNavigate('navigateWithSwitchPolicyID')) { +type NavigateToReportWithPolicyCheckPayload = {report?: OnyxEntry; reportID?: string; reportActionID?: string; referrer?: string; policyIDToCheck?: string}; + +/** + * Navigates to a report passed as a param (as an id or report object) and checks whether the target object belongs to the currently selected workspace. + * If not, the current workspace is set to global. + */ +function navigateToReportWithPolicyCheck({report, reportID, reportActionID, referrer, policyIDToCheck}: NavigateToReportWithPolicyCheckPayload, ref = navigationRef) { + const targetReport = reportID ? {reportID, ...allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]} : report; + const policyID = policyIDToCheck ?? getPolicyIDFromState(navigationRef.getRootState() as State); + const policyMemberAccountIDs = getPolicyEmployeeAccountIDs(policyID); + const shouldOpenAllWorkspace = isEmptyObject(targetReport) ? true : !doesReportBelongToWorkspace(targetReport, policyMemberAccountIDs, policyID); + + if ((shouldOpenAllWorkspace && !policyID) || !shouldOpenAllWorkspace) { + linkTo(ref.current, ROUTES.REPORT_WITH_ID.getRoute(targetReport?.reportID ?? '-1', reportActionID, referrer)); return; } - return switchPolicyID(navigationRef.current, params); + const params: Record = { + reportID: targetReport?.reportID ?? '-1', + }; + + if (reportActionID) { + params.reportActionID = reportActionID; + } + + if (referrer) { + params.referrer = referrer; + } + + ref.dispatch( + StackActions.push(NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, { + policyID: undefined, + screen: SCREENS.REPORT, + params, + }), + ); } -function getTopMostCentralPaneRouteFromRootState() { - return getTopmostCentralPaneRoute(navigationRef.getRootState() as State); +/** + * Closes the modal navigator (RHP, LHP, onboarding). + */ +const dismissModal = (reportID?: string, ref = navigationRef) => { + isNavigationReady().then(() => { + ref.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL}); + if (!reportID) { + return; + } + navigateToReportWithPolicyCheck({reportID}); + }); +}; + +/** + * Dismisses the modal and opens the given report. + */ +const dismissModalWithReport = (report: OnyxEntry, ref = navigationRef) => { + isNavigationReady().then(() => { + ref.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL}); + navigateToReportWithPolicyCheck({report}); + }); +}; + +/** + * Returns to the first screen in the stack, dismissing all the others, only if the global variable shouldPopAllStateOnUP is set to true. + */ +function popToTop() { + if (!shouldPopAllStateOnUP) { + goBack(); + return; + } + + shouldPopAllStateOnUP = false; + navigationRef.current?.dispatch(StackActions.popToTop()); } -function removeScreenFromNavigationState(screen: Screen) { +function removeScreenFromNavigationState(screen: string) { isNavigationReady().then(() => { - navigationRef.dispatch((state) => { + navigationRef.current?.dispatch((state) => { const routes = state.routes?.filter((item) => item.name !== screen); - return CommonActions.reset({ ...state, routes, @@ -452,7 +565,6 @@ export default { getActiveRoute, getActiveRouteWithoutParams, getReportRHPActiveRoute, - closeAndNavigate, goBack, isNavigationReady, setIsNavigationReady, @@ -461,12 +573,15 @@ export default { getTopmostReportActionId, waitForProtectedRoutes, parseHybridAppUrl, - navigateWithSwitchPolicyID, resetToHome, + goBackToHome, closeRHPFlow, setNavigationActionToMicrotaskQueue, - getTopMostCentralPaneRouteFromRootState, + navigateToReportWithPolicyCheck, + popToTop, removeScreenFromNavigationState, + switchPolicyID, + replaceWithSplitNavigator, }; export {navigationRef}; diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index df42aa04a12e..93c6fc355aea 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -4,8 +4,8 @@ import React, {useContext, useEffect, useMemo, useRef} from 'react'; import {NativeModules} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; -import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useCurrentReportID from '@hooks/useCurrentReportID'; +import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemePreference from '@hooks/useThemePreference'; @@ -19,17 +19,16 @@ import * as Session from '@userActions/Session'; import {updateOnboardingLastVisitedPath} from '@userActions/Welcome'; import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; 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 AppNavigator from './AppNavigator'; -import getPolicyIDFromState from './getPolicyIDFromState'; +import {cleanPreservedSplitNavigatorStates} from './AppNavigator/createSplitNavigator/usePreserveSplitNavigatorState'; +import customGetPathFromState from './helpers/customGetPathFromState'; +import getAdaptedStateFromPath from './helpers/getAdaptedStateFromPath'; 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 = { /** Whether the current user is logged in with an authToken */ @@ -91,7 +90,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh const currentReportIDValue = useCurrentReportID(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {setActiveWorkspaceID} = useActiveWorkspace(); const [user] = useOnyx(ONYXKEYS.USER); const isPrivateDomain = Session.isUserOnPrivateDomain(); @@ -103,6 +101,8 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh }); const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY); + const previousAuthenticated = usePrevious(authenticated); + const initialState = useMemo(() => { if (!user || user.isFromPublicDomain) { return; @@ -111,8 +111,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh // If the user haven't completed the flow, we want to always redirect them to the onboarding flow. // We also make sure that the user is authenticated, isn't part of a group workspace, & wasn't invited to NewDot. if (!NativeModules.HybridAppModule && !hasNonPersonalPolicy && !isOnboardingCompleted && !wasInvitedToNewDot && authenticated && !shouldShowRequire2FAModal) { - const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(isPrivateDomain), linkingConfig.config); - return adaptedState; + return getAdaptedStateFromPath(getOnboardingInitialPath(isPrivateDomain), linkingConfig.config); } // If there is no lastVisitedPath, we can do early return. We won't modify the default behavior. @@ -130,8 +129,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh } // Otherwise we want to redirect the user to the last visited path. - const {adaptedState} = getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); - return adaptedState; + return getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); // The initialState value is relevant only on the first render. // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps @@ -152,8 +150,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh 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 @@ -164,6 +160,36 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh Navigation.setShouldPopAllStateOnUP(!shouldUseNarrowLayout); }, [shouldUseNarrowLayout]); + useEffect(() => { + // Since the NAVIGATORS.REPORTS_SPLIT_NAVIGATOR url is "/" and it has to be used as an URL for SignInPage, + // this navigator should be the only one in the navigation state after logout. + const hasUserLoggedOut = !authenticated && !!previousAuthenticated; + if (!hasUserLoggedOut) { + return; + } + + const rootState = navigationRef.getRootState(); + const lastRoute = rootState.routes.at(-1); + if (!lastRoute) { + return; + } + + // REPORTS_SPLIT_NAVIGATOR will persist after user logout, because it is used both for logged-in and logged-out users + // That's why for ReportsSplit we need to explicitly clear params when resetting navigation state, + // However in case other routes (related to login/logout) appear in nav state, then we want to preserve params for those + const isReportSplitNavigatorMounted = lastRoute.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; + navigationRef.reset({ + ...rootState, + index: 0, + routes: [ + { + ...lastRoute, + params: isReportSplitNavigatorMounted ? undefined : lastRoute.params, + }, + ], + }); + }, [authenticated, previousAuthenticated]); + const handleStateChange = (state: NavigationState | undefined) => { if (!state) { return; @@ -171,16 +197,15 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh const currentRoute = navigationRef.getCurrentRoute(); Firebase.log(`[NAVIGATION] screen: ${currentRoute?.name}, params: ${JSON.stringify(currentRoute?.params ?? {})}`); - const activeWorkspaceID = getPolicyIDFromState(state as NavigationState); // Performance optimization to avoid context consumers to delay first render setTimeout(() => { currentReportIDValue?.updateCurrentReportID(state); - setActiveWorkspaceID(activeWorkspaceID); }, 0); parseAndLogRoute(state); // We want to clean saved scroll offsets for screens that aren't anymore in the state. cleanStaleScrollOffsets(state); + cleanPreservedSplitNavigatorStates(state); }; return ( diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx index 35076c8ca6b6..1f3b4a4c04ce 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx @@ -20,12 +20,22 @@ function createPlatformStackNavigatorComponent ({stateToRender: undefined, searchRoute: undefined})); + const useCustomState = options?.useCustomState ?? (() => undefined); const useCustomEffects = options?.useCustomEffects ?? (() => undefined); const ExtraContent = options?.ExtraContent; const NavigationContentWrapper = options?.NavigationContentWrapper; - function PlatformNavigator({id, initialRouteName, screenOptions, screenListeners, children, ...props}: PlatformStackNavigatorProps) { + function PlatformNavigator({ + id, + initialRouteName, + screenOptions, + screenListeners, + children, + sidebarScreen, + defaultCentralScreen, + parentRoute, + ...props + }: PlatformStackNavigatorProps) { const { navigation, state: originalState, @@ -47,6 +57,9 @@ function createPlatformStackNavigatorComponent, convertToNativeNavigationOptions, ); @@ -57,19 +70,19 @@ function createPlatformStackNavigatorComponent stateToRender ?? originalState, [originalState, stateToRender]); const customCodePropsWithCustomState = useMemo>>( () => ({ ...customCodeProps, state, - searchRoute, }), - [customCodeProps, state, searchRoute], + [customCodeProps, state], ); // Executes custom effects defined in "useCustomEffects" navigator option. diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index 2e3c99a6cb0d..fa45e5834aaa 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -19,13 +19,24 @@ function createPlatformStackNavigatorComponent, ) { const createRouter = options?.createRouter ?? StackRouter; - const useCustomState = options?.useCustomState ?? (() => ({stateToRender: undefined, searchRoute: undefined})); + const useCustomState = options?.useCustomState ?? (() => undefined); const defaultScreenOptions = options?.defaultScreenOptions; const ExtraContent = options?.ExtraContent; const NavigationContentWrapper = options?.NavigationContentWrapper; const useCustomEffects = options?.useCustomEffects ?? (() => undefined); - function PlatformNavigator({id, initialRouteName, screenOptions, screenListeners, children, ...props}: PlatformStackNavigatorProps) { + function PlatformNavigator({ + id, + initialRouteName, + screenOptions, + screenListeners, + children, + sidebarScreen, + defaultCentralScreen, + parentRoute, + persistentScreens, + ...props + }: PlatformStackNavigatorProps) { const { navigation, state: originalState, @@ -47,6 +58,10 @@ function createPlatformStackNavigatorComponent, convertToWebNavigationOptions, ); @@ -57,31 +72,41 @@ function createPlatformStackNavigatorComponent stateToRender ?? originalState, [originalState, stateToRender]); const customCodePropsWithCustomState = useMemo>>( () => ({ ...customCodeProps, state, - searchRoute, }), - [customCodeProps, state, searchRoute], + [customCodeProps, state], ); - // Executes custom effects defined in "useCustomEffects" navigator option. useCustomEffects(customCodePropsWithCustomState); + const mappedState = useMemo(() => { + return { + ...state, + routes: state.routes.map((route) => { + // eslint-disable-next-line rulesdir/no-negated-variables + const dontDetachScreen = persistentScreens?.includes(route.name) ? {dontDetachScreen: true} : {}; + return {...route, ...dontDetachScreen}; + }), + }; + }, [persistentScreens, state]); + const Content = useMemo( () => ( @@ -92,7 +117,7 @@ function createPlatformStackNavigatorComponent ), - [NavigationContent, customCodePropsWithCustomState, descriptors, navigation, props, state], + [NavigationContent, customCodePropsWithCustomState, descriptors, mappedState, navigation, props], ); // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/src/libs/Navigation/PlatformStackNavigation/types/NavigationBuilder.ts b/src/libs/Navigation/PlatformStackNavigation/types/NavigationBuilder.ts index 821584f58645..e65512895398 100644 --- a/src/libs/Navigation/PlatformStackNavigation/types/NavigationBuilder.ts +++ b/src/libs/Navigation/PlatformStackNavigation/types/NavigationBuilder.ts @@ -20,7 +20,14 @@ type PlatformNavigationBuilderOptions< EventMap extends PlatformSpecificEventMap & EventMapBase, ParamList extends ParamListBase = ParamListBase, RouterOptions extends PlatformStackRouterOptions = PlatformStackRouterOptions, -> = DefaultNavigatorOptions, NavigationOptions, EventMap> & NavigationBuilderOptions & RouterOptions; +> = DefaultNavigatorOptions, NavigationOptions, EventMap> & + NavigationBuilderOptions & + RouterOptions & { + persistentScreens?: Array>; + defaultCentralScreen?: Extract; + sidebarScreen?: Extract; + parentRoute?: RouteProp; + }; // Represents the return type of the useNavigationBuilder function using the types from PlatformStackNavigation. type PlatformNavigationBuilderResult< diff --git a/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts b/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts index 5a0dd8602bc0..2f170b202181 100644 --- a/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts +++ b/src/libs/Navigation/PlatformStackNavigation/types/NavigatorComponent.ts @@ -1,4 +1,4 @@ -import type {EventMapBase, ParamListBase, StackActionHelpers} from '@react-navigation/native'; +import type {EventMapBase, ParamListBase, RouteProp, StackActionHelpers} from '@react-navigation/native'; import type { PlatformSpecificEventMap, PlatformSpecificNavigationOptions, @@ -9,9 +9,6 @@ import type { } from '.'; import type {PlatformNavigationBuilderDescriptors, PlatformNavigationBuilderNavigation} from './NavigationBuilder'; -// Represents a route in the search context within the navigation state. -type SearchRoute = PlatformStackNavigationState['routes'][number]; - // Props that custom code receives when passed to the createPlatformStackNavigatorComponent generator function. // Custom logic like "transformState", "onWindowDimensionsChange" and custom components like "NavigationContentWrapper" and "ExtraContent" will receive these props type CustomCodeProps< @@ -24,17 +21,14 @@ type CustomCodeProps< navigation: PlatformNavigationBuilderNavigation; descriptors: PlatformNavigationBuilderDescriptors; displayName: string; - searchRoute?: SearchRoute; + parentRoute?: RouteProp; }; // Props for the custom state hook. type CustomStateHookProps = CustomCodeProps; -// Defines a hook function type for transforming the navigation state based on props, and returning the transformed state and search route. -type CustomStateHook = (props: CustomStateHookProps) => { - stateToRender?: PlatformStackNavigationState; - searchRoute?: SearchRoute; -}; +// Defines a hook function type for transforming the navigation state based on props, and returning the transformed state. +type CustomStateHook = (props: CustomStateHookProps) => PlatformStackNavigationState; // Props for the custom effects hook. type CustomEffectsHookProps = CustomCodeProps; diff --git a/src/libs/Navigation/PlatformStackNavigation/types/index.ts b/src/libs/Navigation/PlatformStackNavigation/types/index.ts index 04ed4e68d9a8..8171309427ed 100644 --- a/src/libs/Navigation/PlatformStackNavigation/types/index.ts +++ b/src/libs/Navigation/PlatformStackNavigation/types/index.ts @@ -27,7 +27,7 @@ type PlatformStackNavigationEventMap = CommonStackNavigationEventMap; type PlatformSpecificEventMap = StackNavigationOptions | NativeStackNavigationOptions; // Router options used in the PlatformStackNavigation -type PlatformStackRouterOptions = StackRouterOptions; +type PlatformStackRouterOptions = StackRouterOptions & {parentRoute?: RouteProp}; // Factory function type for creating a router specific to the PlatformStackNavigation type PlatformStackRouterFactory = RouterFactory< @@ -68,7 +68,11 @@ type PlatformStackNavigatorProps< RouterOptions extends PlatformStackRouterOptions = PlatformStackRouterOptions, > = DefaultNavigatorOptions, PlatformStackNavigationOptions, PlatformStackNavigationEventMap, RouteName> & RouterOptions & - StackNavigationConfig; + StackNavigationConfig & { + persistentScreens?: Array>; + defaultCentralScreen?: Extract; + sidebarScreen?: Extract; + }; // The "screenOptions" and "defaultScreenOptions" can either be an object of navigation options or // a factory function that returns the navigation options based on route and navigation props. diff --git a/src/libs/Navigation/dismissModal.ts b/src/libs/Navigation/dismissModal.ts deleted file mode 100644 index dd0e512ea33d..000000000000 --- a/src/libs/Navigation/dismissModal.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type {NavigationContainerRef} from '@react-navigation/native'; -import {StackActions} from '@react-navigation/native'; -import Log from '@libs/Log'; -import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; -import type {RootStackParamList} from './types'; - -// This function is in a separate file than Navigation.ts to avoid cyclic dependency. - -/** - * Dismisses the last modal stack if there is any - */ -function dismissModal(navigationRef: NavigationContainerRef) { - if (!navigationRef.isReady()) { - return; - } - - const state = navigationRef.getState(); - const lastRoute = state.routes.at(-1); - switch (lastRoute?.name) { - case NAVIGATORS.FULL_SCREEN_NAVIGATOR: - case NAVIGATORS.LEFT_MODAL_NAVIGATOR: - case NAVIGATORS.RIGHT_MODAL_NAVIGATOR: - case NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR: - case NAVIGATORS.FEATURE_TRANING_MODAL_NAVIGATOR: - case SCREENS.NOT_FOUND: - case SCREENS.ATTACHMENTS: - case SCREENS.TRANSACTION_RECEIPT: - case SCREENS.PROFILE_AVATAR: - case SCREENS.WORKSPACE_AVATAR: - case SCREENS.REPORT_AVATAR: - case SCREENS.CONCIERGE: - navigationRef.dispatch({...StackActions.pop(), target: state.key}); - break; - default: { - Log.hmmm('[Navigation] dismissModal failed because there is no modal stack to dismiss'); - } - } -} - -export default dismissModal; diff --git a/src/libs/Navigation/dismissModalWithReport.ts b/src/libs/Navigation/dismissModalWithReport.ts deleted file mode 100644 index 09f0070c59e4..000000000000 --- a/src/libs/Navigation/dismissModalWithReport.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {getActionFromState} from '@react-navigation/core'; -import type {NavigationContainerRef} from '@react-navigation/native'; -import {StackActions} from '@react-navigation/native'; -import findLastIndex from 'lodash/findLastIndex'; -import type {OnyxEntry} from 'react-native-onyx'; -import Log from '@libs/Log'; -import {isCentralPaneName} from '@libs/NavigationUtils'; -import getPolicyEmployeeAccountIDs from '@libs/PolicyEmployeeListUtils'; -import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; -import NAVIGATORS from '@src/NAVIGATORS'; -import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; -import type {Report} from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import getPolicyIDFromState from './getPolicyIDFromState'; -import getStateFromPath from './getStateFromPath'; -import getTopmostReportId from './getTopmostReportId'; -import {linkingConfig} from './linkingConfig'; -import switchPolicyID from './switchPolicyID'; -import type {RootStackParamList, StackNavigationAction, State} from './types'; - -// This function is in a separate file than Navigation.ts to avoid cyclic dependency. - -/** - * Dismisses the last modal stack if there is any - * - * @param targetReportID - The reportID to navigate to after dismissing the modal - */ -function dismissModalWithReport(targetReport: OnyxEntry, navigationRef: NavigationContainerRef) { - if (!navigationRef.isReady()) { - return; - } - - const state = navigationRef.getState(); - const lastRoute = state.routes.at(-1); - switch (lastRoute?.name) { - case NAVIGATORS.FULL_SCREEN_NAVIGATOR: - case NAVIGATORS.LEFT_MODAL_NAVIGATOR: - case NAVIGATORS.RIGHT_MODAL_NAVIGATOR: - case SCREENS.NOT_FOUND: - case SCREENS.ATTACHMENTS: - case SCREENS.TRANSACTION_RECEIPT: - case SCREENS.PROFILE_AVATAR: - case SCREENS.WORKSPACE_AVATAR: - case SCREENS.REPORT_AVATAR: - case SCREENS.CONCIERGE: - // If we are not in the target report, we need to navigate to it after dismissing the modal - if (targetReport?.reportID && targetReport?.reportID !== getTopmostReportId(state)) { - const reportState = getStateFromPath(ROUTES.REPORT_WITH_ID.getRoute(targetReport?.reportID)); - const policyID = getPolicyIDFromState(state as State); - const policyMemberAccountIDs = getPolicyEmployeeAccountIDs(policyID); - const shouldOpenAllWorkspace = isEmptyObject(targetReport) ? true : !doesReportBelongToWorkspace(targetReport, policyMemberAccountIDs, policyID); - - if (shouldOpenAllWorkspace) { - switchPolicyID(navigationRef, {route: ROUTES.HOME}); - } else { - switchPolicyID(navigationRef, {policyID, route: ROUTES.HOME}); - } - - const action: StackNavigationAction = getActionFromState(reportState, linkingConfig.config); - if (action) { - action.type = 'REPLACE'; - navigationRef.dispatch(action); - } - // If not-found page is in the route stack, we need to close it - } else if (state.routes.some((route) => route.name === SCREENS.NOT_FOUND)) { - const lastRouteIndex = state.routes.length - 1; - const centralRouteIndex = findLastIndex(state.routes, (route) => isCentralPaneName(route.name)); - navigationRef.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: state.key}); - } else { - navigationRef.dispatch({...StackActions.pop(), target: state.key}); - } - break; - default: { - Log.hmmm('[Navigation] dismissModalWithReport failed because there is no modal stack to dismiss'); - } - } -} - -// eslint-disable-next-line import/prefer-default-export -export {dismissModalWithReport}; diff --git a/src/libs/Navigation/getPolicyIDFromState.ts b/src/libs/Navigation/getPolicyIDFromState.ts deleted file mode 100644 index 702fb654780d..000000000000 --- a/src/libs/Navigation/getPolicyIDFromState.ts +++ /dev/null @@ -1,30 +0,0 @@ -import SCREENS from '@src/SCREENS'; -import extractPolicyIDFromQuery from './extractPolicyIDFromQuery'; -import getTopmostBottomTabRoute from './getTopmostBottomTabRoute'; -import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute'; -import type {RootStackParamList, State} from './types'; - -/** - * returns policyID value if one exists in navigation state - * - * PolicyID in this app can be stored in two ways: - * - on most screens but NOT Search as `policyID` param (on bottom tab screens) - * - on Search related screens as policyID filter inside `q` (SearchQuery) param (only for SEARCH_CENTRAL_PANE) - */ -const getPolicyIDFromState = (state: State): string | undefined => { - const topmostBottomTabRoute = getTopmostBottomTabRoute(state); - - if (!topmostBottomTabRoute) { - return; - } - - if (topmostBottomTabRoute.name === SCREENS.SEARCH.BOTTOM_TAB) { - const topmostCentralPaneRoute = getTopmostCentralPaneRoute(state); - return extractPolicyIDFromQuery(topmostCentralPaneRoute); - } - - const policyID = topmostBottomTabRoute && topmostBottomTabRoute.params && 'policyID' in topmostBottomTabRoute.params && topmostBottomTabRoute.params?.policyID; - return policyID ? (topmostBottomTabRoute.params?.policyID as string) : undefined; -}; - -export default getPolicyIDFromState; diff --git a/src/libs/Navigation/getTopmostBottomTabRoute.ts b/src/libs/Navigation/getTopmostBottomTabRoute.ts deleted file mode 100644 index 231e815a0016..000000000000 --- a/src/libs/Navigation/getTopmostBottomTabRoute.ts +++ /dev/null @@ -1,21 +0,0 @@ -import NAVIGATORS from '@src/NAVIGATORS'; -import type {BottomTabName, NavigationPartialRoute, RootStackParamList, State} from './types'; - -function getTopmostBottomTabRoute(state: State | undefined): NavigationPartialRoute | undefined { - const bottomTabNavigatorRoute = state?.routes.findLast((route) => route.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR); - - // The bottomTabNavigatorRoute state may be empty if we just logged in. - if (!bottomTabNavigatorRoute || bottomTabNavigatorRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR || bottomTabNavigatorRoute.state === undefined) { - return undefined; - } - - const topmostBottomTabRoute = bottomTabNavigatorRoute.state.routes.at(-1); - - if (!topmostBottomTabRoute) { - throw new Error('BottomTabNavigator route have no routes.'); - } - - return {name: topmostBottomTabRoute.name as BottomTabName, params: topmostBottomTabRoute.params, key: topmostBottomTabRoute.key}; -} - -export default getTopmostBottomTabRoute; diff --git a/src/libs/Navigation/getTopmostCentralPaneRoute.ts b/src/libs/Navigation/getTopmostCentralPaneRoute.ts deleted file mode 100644 index 5ac72281eaf6..000000000000 --- a/src/libs/Navigation/getTopmostCentralPaneRoute.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {isCentralPaneName} from '@libs/NavigationUtils'; -import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from './types'; - -// Get the name of topmost central pane route in the navigation stack. -function getTopmostCentralPaneRoute(state: State): NavigationPartialRoute | undefined { - if (!state) { - return; - } - - const topmostCentralPane = state.routes.filter((route) => isCentralPaneName(route.name)).at(-1); - - if (!topmostCentralPane) { - return; - } - - return topmostCentralPane as NavigationPartialRoute; -} - -export default getTopmostCentralPaneRoute; diff --git a/src/libs/Navigation/getTopmostFullScreenRoute.ts b/src/libs/Navigation/getTopmostFullScreenRoute.ts deleted file mode 100644 index fcc28ce76926..000000000000 --- a/src/libs/Navigation/getTopmostFullScreenRoute.ts +++ /dev/null @@ -1,28 +0,0 @@ -import NAVIGATORS from '@src/NAVIGATORS'; -import type {FullScreenName, NavigationPartialRoute, RootStackParamList, State} from './types'; - -// Get the name of topmost fullscreen route in the navigation stack. -function getTopmostFullScreenRoute(state: State): NavigationPartialRoute | undefined { - if (!state) { - return; - } - - const topmostFullScreenRoute = state.routes.filter((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR).at(-1); - - if (!topmostFullScreenRoute) { - return; - } - - if (topmostFullScreenRoute.state) { - // There will be at least one route in the fullscreen navigator. - const {name, params} = topmostFullScreenRoute.state.routes.at(-1) as NavigationPartialRoute; - - return {name, params}; - } - - if (!!topmostFullScreenRoute.params && 'screen' in topmostFullScreenRoute.params) { - return {name: topmostFullScreenRoute.params.screen as FullScreenName, params: topmostFullScreenRoute.params.params}; - } -} - -export default getTopmostFullScreenRoute; diff --git a/src/libs/Navigation/getTopmostReportActionID.ts b/src/libs/Navigation/getTopmostReportActionID.ts deleted file mode 100644 index d3c6e41887d8..000000000000 --- a/src/libs/Navigation/getTopmostReportActionID.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type {NavigationState, PartialState} from '@react-navigation/native'; -import {isCentralPaneName} from '@libs/NavigationUtils'; -import SCREENS from '@src/SCREENS'; -import type {RootStackParamList} from './types'; - -// This function is in a separate file than Navigation.ts to avoid cyclic dependency. - -/** - * Find the last visited report screen in the navigation state and get the linked reportActionID of it. - * - * @param state - The react-navigation state - * @returns - It's possible that there is no report screen - */ -function getTopmostReportActionID(state: NavigationState | NavigationState | PartialState): string | undefined { - if (!state) { - return; - } - - const topmostCentralPane = state.routes.filter((route) => isCentralPaneName(route.name)).at(-1); - if (!topmostCentralPane) { - return; - } - - const directReportParams = topmostCentralPane.params; - const directReportActionIDParam = directReportParams && 'reportActionID' in directReportParams && directReportParams?.reportActionID; - - if (!topmostCentralPane.state && !directReportActionIDParam) { - return; - } - - if (directReportActionIDParam) { - return directReportActionIDParam; - } - - const topmostReport = topmostCentralPane.state?.routes.filter((route) => route.name === SCREENS.REPORT).at(-1); - if (!topmostReport) { - return; - } - - const topmostReportActionID = topmostReport.params && 'reportActionID' in topmostReport.params && topmostReport.params?.reportActionID; - if (typeof topmostReportActionID !== 'string') { - return; - } - - return topmostReportActionID; -} - -export default getTopmostReportActionID; diff --git a/src/libs/Navigation/getTopmostReportId.ts b/src/libs/Navigation/getTopmostReportId.ts deleted file mode 100644 index dc53d040f087..000000000000 --- a/src/libs/Navigation/getTopmostReportId.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type {NavigationState, PartialState} from '@react-navigation/native'; -import {isCentralPaneName} from '@libs/NavigationUtils'; -import SCREENS from '@src/SCREENS'; -import type {RootStackParamList} from './types'; - -// This function is in a separate file than Navigation.ts to avoid cyclic dependency. - -/** - * Find the last visited report screen in the navigation state and get the id of it. - * - * @param state - The react-navigation state - * @returns - It's possible that there is no report screen - */ -function getTopmostReportId(state: NavigationState | NavigationState | PartialState): string | undefined { - if (!state) { - return; - } - - const topmostCentralPane = state.routes?.filter((route) => isCentralPaneName(route.name)).at(-1); - if (!topmostCentralPane) { - return; - } - - const directReportParams = topmostCentralPane.params; - const directReportIdParam = directReportParams && 'reportID' in directReportParams && directReportParams?.reportID; - - if (!topmostCentralPane.state && !directReportIdParam) { - return; - } - - if (directReportIdParam) { - return directReportIdParam; - } - - const topmostReport = topmostCentralPane.state?.routes.filter((route) => route.name === SCREENS.REPORT).at(-1); - if (!topmostReport) { - return; - } - - const topmostReportId = topmostReport.params && 'reportID' in topmostReport.params && topmostReport.params?.reportID; - if (typeof topmostReportId !== 'string') { - return; - } - - return topmostReportId; -} - -export default getTopmostReportId; diff --git a/src/libs/Navigation/getTopmostRouteName.ts b/src/libs/Navigation/getTopmostRouteName.ts deleted file mode 100644 index 7ae3afaf2cc9..000000000000 --- a/src/libs/Navigation/getTopmostRouteName.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type {NavigationState, PartialState} from '@react-navigation/native'; - -// Get the name of topmost route in the navigation stack. -function getTopmostRouteName(state: NavigationState | PartialState): string | undefined { - if (!state) { - return; - } - - return state.routes.at(-1)?.name; -} - -export default getTopmostRouteName; diff --git a/src/libs/Navigation/closeRHPFlow.ts b/src/libs/Navigation/helpers/closeRHPFlow.ts similarity index 90% rename from src/libs/Navigation/closeRHPFlow.ts rename to src/libs/Navigation/helpers/closeRHPFlow.ts index 9bc40f51f472..608fd7c855ea 100644 --- a/src/libs/Navigation/closeRHPFlow.ts +++ b/src/libs/Navigation/helpers/closeRHPFlow.ts @@ -1,13 +1,13 @@ import type {NavigationContainerRef} from '@react-navigation/native'; import {StackActions} from '@react-navigation/native'; import Log from '@libs/Log'; +import type {RootNavigatorParamList} from '@libs/Navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; -import type {RootStackParamList} from './types'; /** * Closes the last RHP flow, if there is only one, closes the entire RHP. */ -export default function closeRHPFlow(navigationRef: NavigationContainerRef) { +export default function closeRHPFlow(navigationRef: NavigationContainerRef) { if (!navigationRef.isReady()) { return; } diff --git a/src/libs/Navigation/linkingConfig/createNormalizedConfigs.ts b/src/libs/Navigation/helpers/createNormalizedConfigs.ts similarity index 100% rename from src/libs/Navigation/linkingConfig/createNormalizedConfigs.ts rename to src/libs/Navigation/helpers/createNormalizedConfigs.ts diff --git a/src/libs/Navigation/helpers/customGetPathFromState.ts b/src/libs/Navigation/helpers/customGetPathFromState.ts new file mode 100644 index 000000000000..24fa3dbe1321 --- /dev/null +++ b/src/libs/Navigation/helpers/customGetPathFromState.ts @@ -0,0 +1,21 @@ +import {getPathFromState} from '@react-navigation/native'; +import NAVIGATORS from '@src/NAVIGATORS'; +import {isFullScreenName} from './isNavigatorName'; + +// This function adds the policyID param to the url. +const customGetPathFromState: typeof getPathFromState = (state, options) => { + const path = getPathFromState(state, options); + const fullScreenRoute = state.routes.findLast((route) => isFullScreenName(route.name)); + + const shouldAddPolicyID = fullScreenRoute?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; + + if (!shouldAddPolicyID) { + return path; + } + + const policyID = fullScreenRoute.params && `policyID` in fullScreenRoute.params ? (fullScreenRoute.params.policyID as string) : undefined; + + return `${policyID ? `/w/${policyID}` : ''}${path}`; +}; + +export default customGetPathFromState; diff --git a/src/libs/Navigation/extractPolicyIDFromQuery.ts b/src/libs/Navigation/helpers/extractPolicyIDFromQuery.ts similarity index 88% rename from src/libs/Navigation/extractPolicyIDFromQuery.ts rename to src/libs/Navigation/helpers/extractPolicyIDFromQuery.ts index b0ef3d393983..822d71216b8b 100644 --- a/src/libs/Navigation/extractPolicyIDFromQuery.ts +++ b/src/libs/Navigation/helpers/extractPolicyIDFromQuery.ts @@ -1,5 +1,5 @@ +import type {NavigationPartialRoute} from '@libs/Navigation/types'; import {buildSearchQueryJSON, getPolicyIDFromSearchQuery} from '@libs/SearchQueryUtils'; -import type {NavigationPartialRoute} from './types'; function extractPolicyIDFromQuery(route?: NavigationPartialRoute) { if (!route?.params) { diff --git a/src/libs/Navigation/extrapolateStateFromParams.ts b/src/libs/Navigation/helpers/extrapolateStateFromParams.ts similarity index 100% rename from src/libs/Navigation/extrapolateStateFromParams.ts rename to src/libs/Navigation/helpers/extrapolateStateFromParams.ts diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts new file mode 100644 index 000000000000..08def7d5ab2b --- /dev/null +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -0,0 +1,253 @@ +import type {NavigationState, PartialState, Route} from '@react-navigation/native'; +import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; +import pick from 'lodash/pick'; +import Onyx from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; +import {isAnonymousUser} from '@libs/actions/Session'; +import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; +import {config} from '@libs/Navigation/linkingConfig/config'; +import {RHP_TO_SETTINGS, RHP_TO_SIDEBAR, RHP_TO_WORKSPACE, SEARCH_TO_RHP} from '@libs/Navigation/linkingConfig/RELATIONS'; +import type {NavigationPartialRoute, RootNavigatorParamList} from '@libs/Navigation/types'; +import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Report} from '@src/types/onyx'; +import extractPolicyIDFromQuery from './extractPolicyIDFromQuery'; +import getParamsFromRoute from './getParamsFromRoute'; +import {isFullScreenName} from './isNavigatorName'; +import replacePathInNestedState from './replacePathInNestedState'; + +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, +}); + +type GetAdaptedStateReturnType = ReturnType; + +type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; + +// The function getPathFromState that we are using in some places isn't working correctly without defined index. +const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); + +function isRouteWithBackToParam(route: NavigationPartialRoute): route is Route { + return route.params !== undefined && 'backTo' in route.params && typeof route.params.backTo === 'string'; +} + +function isRouteWithReportID(route: NavigationPartialRoute): route is Route { + return route.params !== undefined && 'reportID' in route.params && typeof route.params.reportID === 'string'; +} + +function getMatchingFullScreenRoute(route: NavigationPartialRoute, policyID?: string) { + // Check for backTo param. One screen with different backTo value may need different screens visible under the overlay. + if (isRouteWithBackToParam(route)) { + const stateForBackTo = getStateFromPath(route.params.backTo, config); + + // This may happen if the backTo url is invalid. + const lastRoute = stateForBackTo?.routes.at(-1); + if (!stateForBackTo || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { + return undefined; + } + + const isLastRouteFullScreen = isFullScreenName(lastRoute.name); + + // If the state for back to last route is a full screen route, we can use it + if (isLastRouteFullScreen) { + return lastRoute; + } + + const focusedStateForBackToRoute = findFocusedRoute(stateForBackTo); + + if (!focusedStateForBackToRoute) { + return undefined; + } + // If not, get the matching full screen route for the back to state. + return getMatchingFullScreenRoute(focusedStateForBackToRoute, policyID); + } + + if (SEARCH_TO_RHP.includes(route.name)) { + const paramsFromRoute = getParamsFromRoute(SCREENS.SEARCH.ROOT); + + return { + name: SCREENS.SEARCH.ROOT, + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }; + } + + if (RHP_TO_SIDEBAR[route.name]) { + return getInitialSplitNavigatorState( + { + name: RHP_TO_SIDEBAR[route.name], + }, + undefined, + policyID ? {policyID} : undefined, + ); + } + + if (RHP_TO_WORKSPACE[route.name]) { + const paramsFromRoute = getParamsFromRoute(RHP_TO_WORKSPACE[route.name]); + + return getInitialSplitNavigatorState( + { + name: SCREENS.WORKSPACE.INITIAL, + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + { + name: RHP_TO_WORKSPACE[route.name], + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + ); + } + + if (RHP_TO_SETTINGS[route.name]) { + const paramsFromRoute = getParamsFromRoute(RHP_TO_SETTINGS[route.name]); + + return getInitialSplitNavigatorState( + { + name: SCREENS.SETTINGS.ROOT, + }, + { + name: RHP_TO_SETTINGS[route.name], + params: paramsFromRoute.length > 0 ? pick(route.params, paramsFromRoute) : undefined, + }, + ); + } + + return undefined; +} + +// If there is no particular matching route defined, we want to get the default route. +// It is the reports split navigator with report. If the reportID is defined in the focused route, we want to use it for the default report. +// This is separated from getMatchingFullScreenRoute because we want to use it only for the initial state. +// We don't want to make this route mandatory e.g. after deep linking or opening a specific flow. +function getDefaultFullScreenRoute(route?: NavigationPartialRoute, policyID?: string) { + // We will use it if the reportID is not defined. Router of this navigator has logic to fill it with a report. + const fallbackRoute = { + name: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + }; + + if (route && isRouteWithReportID(route)) { + const reportID = route.params.reportID; + + if (!allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportID) { + return fallbackRoute; + } + + return getInitialSplitNavigatorState( + { + name: SCREENS.HOME, + }, + { + name: SCREENS.REPORT, + params: {reportID}, + }, + policyID ? {policyID} : undefined, + ); + } + + return fallbackRoute; +} + +function getOnboardingAdaptedState(state: PartialState): PartialState { + const onboardingRoute = state.routes.at(0); + if (!onboardingRoute || onboardingRoute.name === SCREENS.ONBOARDING.PURPOSE) { + return state; + } + + const routes = []; + routes.push({name: SCREENS.ONBOARDING.PURPOSE}); + if (onboardingRoute.name === SCREENS.ONBOARDING.ACCOUNTING) { + routes.push({name: SCREENS.ONBOARDING.EMPLOYEES}); + } + routes.push(onboardingRoute); + + return getRoutesWithIndex(routes); +} + +function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType { + const fullScreenRoute = state.routes.find((route) => isFullScreenName(route.name)); + const onboardingNavigator = state.routes.find((route) => route.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR); + const isReportSplitNavigator = fullScreenRoute?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; + const isWorkspaceSplitNavigator = fullScreenRoute?.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR; + + // If policyID is defined, it should be passed to the reportNavigator params. + if (isReportSplitNavigator && policyID) { + const routes = []; + const reportNavigatorWithPolicyID = {...fullScreenRoute}; + reportNavigatorWithPolicyID.params = {...reportNavigatorWithPolicyID.params, policyID}; + routes.push(reportNavigatorWithPolicyID); + + return getRoutesWithIndex(routes); + } + + if (isWorkspaceSplitNavigator) { + const settingsSplitRoute = getInitialSplitNavigatorState({name: SCREENS.SETTINGS.ROOT}, {name: SCREENS.SETTINGS.WORKSPACES}); + return getRoutesWithIndex([settingsSplitRoute, ...state.routes]); + } + + // If there is no full screen route in the root, we want to add it. + if (!fullScreenRoute) { + const focusedRoute = findFocusedRoute(state); + + if (focusedRoute) { + const matchingRootRoute = getMatchingFullScreenRoute(focusedRoute, policyID); + + // If there is a matching root route, add it to the state. + if (matchingRootRoute) { + const routes = [matchingRootRoute, ...state.routes]; + if (matchingRootRoute.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR) { + const settingsSplitRoute = getInitialSplitNavigatorState({name: SCREENS.SETTINGS.ROOT}, {name: SCREENS.SETTINGS.WORKSPACES}); + routes.unshift(settingsSplitRoute); + } + return getRoutesWithIndex(routes); + } + } + + const defaultFullScreenRoute = getDefaultFullScreenRoute(focusedRoute, policyID); + + // The onboarding flow consists of several screens. If we open any of the screens, the previous screens from that flow should be in the state. + if (onboardingNavigator?.state) { + const adaptedOnboardingNavigator = { + ...onboardingNavigator, + state: getOnboardingAdaptedState(onboardingNavigator.state), + }; + + return getRoutesWithIndex([defaultFullScreenRoute, adaptedOnboardingNavigator]); + } + + // If not, add the default full screen route. + return getRoutesWithIndex([defaultFullScreenRoute, ...state.routes]); + } + + return state; +} + +const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldReplacePathInNestedState = true) => { + const normalizedPath = !path.startsWith('/') ? `/${path}` : path; + const pathWithoutPolicyID = getPathWithoutPolicyID(normalizedPath); + const isAnonymous = isAnonymousUser(); + + // Anonymous users don't have access to workspaces + const policyID = isAnonymous ? undefined : extractPolicyIDFromPath(path); + + const state = getStateFromPath(pathWithoutPolicyID, options) as PartialState>; + if (shouldReplacePathInNestedState) { + replacePathInNestedState(state, normalizedPath); + } + + if (state === undefined) { + throw new Error(`[getAdaptedStateFromPath] Unable to get state from path: ${path}`); + } + + // On SCREENS.SEARCH.ROOT policyID is stored differently inside search query ("q" param), so we're handling this case + const focusedRoute = findFocusedRoute(state); + const policyIDFromQuery = extractPolicyIDFromQuery(focusedRoute); + return getAdaptedState(state, policyID ?? policyIDFromQuery); +}; + +export default getAdaptedStateFromPath; +export {getMatchingFullScreenRoute, isFullScreenName}; diff --git a/src/libs/Navigation/linkingConfig/getOnboardingAdaptedState.ts b/src/libs/Navigation/helpers/getOnboardingAdaptedState.ts similarity index 76% rename from src/libs/Navigation/linkingConfig/getOnboardingAdaptedState.ts rename to src/libs/Navigation/helpers/getOnboardingAdaptedState.ts index eee3f9f5e52d..97f02bd91509 100644 --- a/src/libs/Navigation/linkingConfig/getOnboardingAdaptedState.ts +++ b/src/libs/Navigation/helpers/getOnboardingAdaptedState.ts @@ -1,6 +1,10 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; import SCREENS from '@src/SCREENS'; +/** + * When we open the application via deeplink to a specific onboarding screen, we want the previous onboarding screens to be able to go back to them. + * Therefore, the routes of the previous screens are added here. + */ export default function getOnboardingAdaptedState(state: PartialState): PartialState { const onboardingRoute = state.routes.at(0); if (!onboardingRoute || onboardingRoute.name === SCREENS.ONBOARDING.PURPOSE) { diff --git a/src/libs/Navigation/helpers/getParamsFromRoute.ts b/src/libs/Navigation/helpers/getParamsFromRoute.ts new file mode 100644 index 000000000000..1dd815f65e9b --- /dev/null +++ b/src/libs/Navigation/helpers/getParamsFromRoute.ts @@ -0,0 +1,12 @@ +import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; +import type {Screen} from '@src/SCREENS'; + +function getParamsFromRoute(screenName: string): string[] { + const routeConfig = normalizedConfigs[screenName as Screen]; + + const route = routeConfig.pattern; + + return route.match(/(?<=[:?&])(\w+)(?=[/=?&]|$)/g) ?? []; +} + +export default getParamsFromRoute; diff --git a/src/libs/Navigation/helpers/getPolicyIDFromState.ts b/src/libs/Navigation/helpers/getPolicyIDFromState.ts new file mode 100644 index 000000000000..f5cd3ffdd081 --- /dev/null +++ b/src/libs/Navigation/helpers/getPolicyIDFromState.ts @@ -0,0 +1,26 @@ +import type {NavigationPartialRoute, RootNavigatorParamList, State} from '@libs/Navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import extractPolicyIDFromQuery from './extractPolicyIDFromQuery'; + +/** + * returns policyID value if one exists in navigation state + * + * PolicyID in this app can be stored in two ways: + * - on NAVIGATORS.REPORTS_SPLIT_NAVIGATOR as `policyID` param + * - on Search related screens as policyID filter inside `q` (SearchQuery) param (only for SEARCH_CENTRAL_PANE) + */ +const getPolicyIDFromState = (state: State): string | undefined => { + const lastPolicyRoute = state?.routes?.findLast((route) => route.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR || route.name === SCREENS.SEARCH.ROOT); + if (lastPolicyRoute?.params && 'policyID' in lastPolicyRoute.params) { + return lastPolicyRoute?.params?.policyID; + } + + if (lastPolicyRoute) { + return extractPolicyIDFromQuery(lastPolicyRoute as NavigationPartialRoute); + } + + return undefined; +}; + +export default getPolicyIDFromState; diff --git a/src/libs/Navigation/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts similarity index 92% rename from src/libs/Navigation/getStateFromPath.ts rename to src/libs/Navigation/helpers/getStateFromPath.ts index 58ec111575e8..b784b2322f74 100644 --- a/src/libs/Navigation/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -1,7 +1,7 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; import {getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; +import {linkingConfig} from '@libs/Navigation/linkingConfig'; import type {Route} from '@src/ROUTES'; -import {linkingConfig} from './linkingConfig'; /** * @param path - The path to parse diff --git a/src/libs/Navigation/helpers/getTopmostReportParams.ts b/src/libs/Navigation/helpers/getTopmostReportParams.ts new file mode 100644 index 000000000000..83844847bc1f --- /dev/null +++ b/src/libs/Navigation/helpers/getTopmostReportParams.ts @@ -0,0 +1,37 @@ +import type {NavigationState, PartialState} from '@react-navigation/native'; +import type {ReportsSplitNavigatorParamList, RootNavigatorParamList} from '@libs/Navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +// This function is in a separate file than Navigation.ts to avoid cyclic dependency. + +/** + * Find the last visited report screen in the navigation state and get its params. + * + * @param state - The react-navigation state + * @returns - It's possible that there is no report screen + */ + +type State = NavigationState | NavigationState | PartialState; + +function getTopmostReportParams(state: State): ReportsSplitNavigatorParamList[typeof SCREENS.REPORT] | undefined { + if (!state) { + return; + } + + const topmostReportsSplitNavigator = state.routes?.filter((route) => route.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR).at(-1); + + if (!topmostReportsSplitNavigator) { + return; + } + + const topmostReport = topmostReportsSplitNavigator.state?.routes.filter((route) => route.name === SCREENS.REPORT).at(-1); + + if (!topmostReport) { + return; + } + + return topmostReport?.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; +} + +export default getTopmostReportParams; diff --git a/src/libs/Navigation/helpers/getTopmostReportsSplitNavigator.ts b/src/libs/Navigation/helpers/getTopmostReportsSplitNavigator.ts new file mode 100644 index 000000000000..1b3258c2bcb5 --- /dev/null +++ b/src/libs/Navigation/helpers/getTopmostReportsSplitNavigator.ts @@ -0,0 +1,8 @@ +import navigationRef from '@libs/Navigation/navigationRef'; +import NAVIGATORS from '@src/NAVIGATORS'; + +function getTopmostReportsSplitNavigator() { + return navigationRef.getRootState()?.routes.findLast((route) => route.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR); +} + +export default getTopmostReportsSplitNavigator; diff --git a/src/libs/Navigation/helpers/isNavigatorName.ts b/src/libs/Navigation/helpers/isNavigatorName.ts new file mode 100644 index 000000000000..ceea8e5525d4 --- /dev/null +++ b/src/libs/Navigation/helpers/isNavigatorName.ts @@ -0,0 +1,48 @@ +import {SIDEBAR_TO_SPLIT, SPLIT_TO_SIDEBAR} from '@libs/Navigation/linkingConfig/RELATIONS'; +import type {FullScreenName, OnboardingFlowName, SplitNavigatorName, SplitNavigatorSidebarScreen} from '@libs/Navigation/types'; +import SCREENS from '@src/SCREENS'; + +const ONBOARDING_SCREENS = [ + SCREENS.ONBOARDING.PERSONAL_DETAILS, + SCREENS.ONBOARDING.PURPOSE, + SCREENS.ONBOARDING_MODAL.ONBOARDING, + SCREENS.ONBOARDING.EMPLOYEES, + SCREENS.ONBOARDING.ACCOUNTING, + SCREENS.ONBOARDING.PRIVATE_DOMAIN, + SCREENS.ONBOARDING.WORKSPACES, +]; + +const FULL_SCREENS_SET = new Set([...Object.values(SIDEBAR_TO_SPLIT), SCREENS.SEARCH.ROOT]); +const SIDEBARS_SET = new Set(Object.values(SPLIT_TO_SIDEBAR)); +const ONBOARDING_SCREENS_SET = new Set(ONBOARDING_SCREENS); +const SPLIT_NAVIGATORS_SET = new Set(Object.values(SIDEBAR_TO_SPLIT)); + +/** + * Functions defined below are used to check whether a screen belongs to a specific group. + * It is mainly used to filter routes in the navigation state. + */ +function checkIfScreenHasMatchingNameToSetValues(screen: string | undefined, set: Set): screen is T { + if (!screen) { + return false; + } + + return set.has(screen as T); +} + +function isOnboardingFlowName(screen: string | undefined) { + return checkIfScreenHasMatchingNameToSetValues(screen, ONBOARDING_SCREENS_SET); +} + +function isSplitNavigatorName(screen: string | undefined) { + return checkIfScreenHasMatchingNameToSetValues(screen, SPLIT_NAVIGATORS_SET); +} + +function isFullScreenName(screen: string | undefined) { + return checkIfScreenHasMatchingNameToSetValues(screen, FULL_SCREENS_SET); +} + +function isSidebarScreenName(screen: string | undefined) { + return checkIfScreenHasMatchingNameToSetValues(screen, SIDEBARS_SET); +} + +export {isFullScreenName, isOnboardingFlowName, isSidebarScreenName, isSplitNavigatorName}; diff --git a/src/libs/Navigation/isReportOpenInRHP.ts b/src/libs/Navigation/helpers/isReportOpenInRHP.ts similarity index 100% rename from src/libs/Navigation/isReportOpenInRHP.ts rename to src/libs/Navigation/helpers/isReportOpenInRHP.ts diff --git a/src/libs/Navigation/helpers/isReportTopmostSplitNavigator.ts b/src/libs/Navigation/helpers/isReportTopmostSplitNavigator.ts new file mode 100644 index 000000000000..fbffc8bba7b0 --- /dev/null +++ b/src/libs/Navigation/helpers/isReportTopmostSplitNavigator.ts @@ -0,0 +1,16 @@ +import {navigationRef} from '@libs/Navigation/Navigation'; +import type {RootNavigatorParamList, State} from '@libs/Navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; +import {isFullScreenName} from './isNavigatorName'; + +const isReportTopmostSplitNavigator = (): boolean => { + const rootState = navigationRef.getRootState() as State; + + if (!rootState) { + return false; + } + + return rootState.routes.findLast((route) => isFullScreenName(route.name))?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; +}; + +export default isReportTopmostSplitNavigator; diff --git a/src/libs/Navigation/helpers/isSearchTopmostFullScreenRoute.ts b/src/libs/Navigation/helpers/isSearchTopmostFullScreenRoute.ts new file mode 100644 index 000000000000..43077d44ab2a --- /dev/null +++ b/src/libs/Navigation/helpers/isSearchTopmostFullScreenRoute.ts @@ -0,0 +1,16 @@ +import {navigationRef} from '@libs/Navigation/Navigation'; +import type {RootNavigatorParamList, State} from '@libs/Navigation/types'; +import SCREENS from '@src/SCREENS'; +import {isFullScreenName} from './isNavigatorName'; + +const isSearchTopmostFullScreenRoute = (): boolean => { + const rootState = navigationRef.getRootState() as State; + + if (!rootState) { + return false; + } + + return rootState.routes.findLast((route) => isFullScreenName(route.name))?.name === SCREENS.SEARCH.ROOT; +}; + +export default isSearchTopmostFullScreenRoute; diff --git a/src/libs/Navigation/isSideModalNavigator.ts b/src/libs/Navigation/helpers/isSideModalNavigator.ts similarity index 100% rename from src/libs/Navigation/isSideModalNavigator.ts rename to src/libs/Navigation/helpers/isSideModalNavigator.ts diff --git a/src/libs/Navigation/linkTo/getMinimalAction.ts b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts similarity index 88% rename from src/libs/Navigation/linkTo/getMinimalAction.ts rename to src/libs/Navigation/helpers/linkTo/getMinimalAction.ts index ff01b3b8333b..9eab2f6f8717 100644 --- a/src/libs/Navigation/linkTo/getMinimalAction.ts +++ b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts @@ -3,6 +3,11 @@ import type {Writable} from 'type-fest'; import type {State} from '@navigation/types'; import type {ActionPayload} from './types'; +type MinimalAction = { + action: Writable; + targetState: State | undefined; +}; + /** * Motivation for this function is described in NAVIGATION.md * @@ -10,7 +15,7 @@ import type {ActionPayload} from './types'; * @param state The root state * @returns minimalAction minimal action is the action that we should dispatch */ -function getMinimalAction(action: NavigationAction, state: NavigationState): Writable { +function getMinimalAction(action: NavigationAction, state: NavigationState): MinimalAction { let currentAction: NavigationAction = action; let currentState: State | undefined = state; let currentTargetKey: string | undefined; @@ -36,7 +41,7 @@ function getMinimalAction(action: NavigationAction, state: NavigationState): Wri target: currentTargetKey, }; } - return currentAction; + return {action: currentAction, targetState: currentState}; } export default getMinimalAction; diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts new file mode 100644 index 000000000000..549a507458b2 --- /dev/null +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -0,0 +1,155 @@ +import {getActionFromState} from '@react-navigation/core'; +import type {NavigationContainerRef, NavigationState, PartialState, StackActionType} from '@react-navigation/native'; +import {findFocusedRoute, StackActions} from '@react-navigation/native'; +import {getMatchingFullScreenRoute, isFullScreenName} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; +import normalizePath from '@libs/Navigation/helpers/normalizePath'; +import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import {shallowCompare} from '@libs/ObjectUtils'; +import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; +import type {NavigationPartialRoute, ReportsSplitNavigatorParamList, RootNavigatorParamList, StackNavigationAction} from '@navigation/types'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import type {Route} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import getMinimalAction from './getMinimalAction'; +import type {LinkToOptions} from './types'; + +const defaultLinkToOptions: LinkToOptions = { + forceReplace: false, +}; + +function createActionWithPolicyID(action: StackActionType, policyID: string): StackActionType | undefined { + if (action.type !== 'PUSH' && action.type !== 'REPLACE') { + return; + } + + return { + ...action, + payload: { + ...action.payload, + params: { + ...action.payload.params, + policyID, + }, + }, + }; +} + +function areNamesAndParamsEqual(currentState: NavigationState, stateFromPath: PartialState>) { + const currentFocusedRoute = findFocusedRoute(currentState); + const targetFocusedRoute = findFocusedRoute(stateFromPath); + + const areNamesEqual = currentFocusedRoute?.name === targetFocusedRoute?.name; + const areParamsEqual = shallowCompare(currentFocusedRoute?.params as Record | undefined, targetFocusedRoute?.params as Record | undefined); + + return areNamesEqual && areParamsEqual; +} + +function shouldCheckFullScreenRouteMatching(action: StackNavigationAction): action is StackNavigationAction & {type: 'PUSH'; payload: {name: typeof NAVIGATORS.RIGHT_MODAL_NAVIGATOR}} { + return action !== undefined && action.type === 'PUSH' && action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; +} + +function isNavigatingToAttachmentScreen(focusedRouteName?: string) { + return focusedRouteName === SCREENS.ATTACHMENTS; +} + +function isNavigatingToReportWithSameReportID(currentRoute: NavigationPartialRoute, newRoute: NavigationPartialRoute) { + if (currentRoute.name !== SCREENS.REPORT || newRoute.name !== SCREENS.REPORT) { + return false; + } + + const currentParams = currentRoute.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; + const newParams = newRoute?.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]; + + return currentParams.reportID === newParams.reportID; +} + +export default function linkTo(navigation: NavigationContainerRef | null, path: Route, options?: LinkToOptions) { + if (!navigation) { + throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?"); + } + + // We know that the options are always defined because we have default options. + const {forceReplace} = {...defaultLinkToOptions, ...options} as Required; + + const normalizedPath = normalizePath(path); + const extractedPolicyID = extractPolicyIDFromPath(normalizedPath); + const pathWithoutPolicyID = getPathWithoutPolicyID(normalizedPath) as Route; + + // This is the state generated with the default getStateFromPath function. + // It won't include the whole state that will be generated for this path but the focused route will be correct. + // It is necessary because getActionFromState will generate RESET action for whole state generated with our custom getStateFromPath function. + const stateFromPath = getStateFromPath(pathWithoutPolicyID) as PartialState>; + const currentState = navigation.getRootState() as NavigationState; + + const focusedRouteFromPath = findFocusedRoute(stateFromPath); + const currentFocusedRoute = findFocusedRoute(currentState); + + // For type safety. It shouldn't ever happen. + if (!focusedRouteFromPath || !currentFocusedRoute) { + return; + } + + const action: StackNavigationAction = getActionFromState(stateFromPath, linkingConfig.config); + + // If there is no action, just reset the whole state. + if (!action) { + navigation.resetRoot(stateFromPath); + return; + } + + // We don't want to dispatch action to push/replace with exactly the same route that is already focused. + if (areNamesAndParamsEqual(currentState, stateFromPath)) { + return; + } + + if (forceReplace) { + action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE; + } + + // Attachment screen - This is a special case. We want to navigate to it instead of push. If there is no screen on the stack, it will be pushed. + // If not, it will be replaced. This way, navigating between one attachment screen and another won't be added to the browser history. + // Report screen - Also a special case. If we are navigating to the report with same reportID we want to replace it (navigate will do that). + // This covers the case when we open a specific message in report (reportActionID). + else if ( + action.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE && + !isNavigatingToAttachmentScreen(focusedRouteFromPath?.name) && + !isNavigatingToReportWithSameReportID(currentFocusedRoute, focusedRouteFromPath) + ) { + // We want to PUSH by default to add entries to the browser history. + action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH; + } + + // Handle deep links including policyID as /w/:policyID. + if (extractedPolicyID) { + const actionWithPolicyID = createActionWithPolicyID(action as StackActionType, extractedPolicyID); + if (!actionWithPolicyID) { + return; + } + + navigation.dispatch(actionWithPolicyID); + return; + } + + // If we deep link to a RHP page, we want to make sure we have the correct full screen route under the overlay. + if (shouldCheckFullScreenRouteMatching(action)) { + const newFocusedRoute = findFocusedRoute(stateFromPath); + if (newFocusedRoute) { + const matchingFullScreenRoute = getMatchingFullScreenRoute(newFocusedRoute); + + const lastFullScreenRoute = currentState.routes.findLast((route) => isFullScreenName(route.name)); + if (matchingFullScreenRoute && lastFullScreenRoute && matchingFullScreenRoute.name !== lastFullScreenRoute.name) { + const lastRouteInMatchingFullScreen = matchingFullScreenRoute.state?.routes?.at(-1); + const additionalAction = StackActions.push(matchingFullScreenRoute.name, { + screen: lastRouteInMatchingFullScreen?.name, + params: lastRouteInMatchingFullScreen?.params, + }); + navigation.dispatch(additionalAction); + } + } + } + + const {action: minimalAction} = getMinimalAction(action, navigation.getRootState()); + navigation.dispatch(minimalAction); +} diff --git a/src/libs/Navigation/helpers/linkTo/types.ts b/src/libs/Navigation/helpers/linkTo/types.ts new file mode 100644 index 000000000000..20719c54001d --- /dev/null +++ b/src/libs/Navigation/helpers/linkTo/types.ts @@ -0,0 +1,16 @@ +type ActionPayloadParams = { + screen?: string; + params?: unknown; + path?: string; +}; + +type ActionPayload = { + params?: ActionPayloadParams; +}; + +type LinkToOptions = { + // To explicitly set the action type to replace. + forceReplace: boolean; +}; + +export type {ActionPayload, ActionPayloadParams, LinkToOptions}; diff --git a/src/libs/Navigation/helpers/normalizePath.ts b/src/libs/Navigation/helpers/normalizePath.ts new file mode 100644 index 000000000000..9f15f95a540e --- /dev/null +++ b/src/libs/Navigation/helpers/normalizePath.ts @@ -0,0 +1,6 @@ +// Expensify uses path with leading '/' but react-navigation doesn't. This function normalizes the path to add the leading '/' for consistency. +function normalizePath(path: string) { + return !path.startsWith('/') ? `/${path}` : path; +} + +export default normalizePath; diff --git a/src/libs/Navigation/linkingConfig/replacePathInNestedState.ts b/src/libs/Navigation/helpers/replacePathInNestedState.ts similarity index 79% rename from src/libs/Navigation/linkingConfig/replacePathInNestedState.ts rename to src/libs/Navigation/helpers/replacePathInNestedState.ts index 6b50cd76446e..242632c83a55 100644 --- a/src/libs/Navigation/linkingConfig/replacePathInNestedState.ts +++ b/src/libs/Navigation/helpers/replacePathInNestedState.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {findFocusedRoute} from '@react-navigation/native'; import type {NavigationState, PartialState} from '@react-navigation/native'; -import type {RootStackParamList} from '@libs/Navigation/types'; +import type {RootNavigatorParamList} from '@libs/Navigation/types'; -function replacePathInNestedState(state: PartialState>, path: string) { +function replacePathInNestedState(state: PartialState>, path: string) { const found = findFocusedRoute(state); if (!found) { return; diff --git a/src/libs/Navigation/helpers/replaceWithSplitNavigator.ts b/src/libs/Navigation/helpers/replaceWithSplitNavigator.ts new file mode 100644 index 000000000000..40253c54e871 --- /dev/null +++ b/src/libs/Navigation/helpers/replaceWithSplitNavigator.ts @@ -0,0 +1,13 @@ +import navigationRef from '@libs/Navigation/navigationRef'; +import type {NavigationPartialRoute, SplitNavigatorBySidebar, SplitNavigatorSidebarScreen} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; + +function replaceWithSplitNavigator(splitNavigatorState: NavigationPartialRoute>) { + navigationRef.current?.dispatch({ + target: navigationRef.current.getRootState().key, + payload: splitNavigatorState, + type: CONST.NAVIGATION.ACTION_TYPE.REPLACE, + }); +} + +export default replaceWithSplitNavigator; diff --git a/src/libs/Navigation/helpers/resetPolicyIDInNavigationState.ts b/src/libs/Navigation/helpers/resetPolicyIDInNavigationState.ts new file mode 100644 index 000000000000..8a6235835e08 --- /dev/null +++ b/src/libs/Navigation/helpers/resetPolicyIDInNavigationState.ts @@ -0,0 +1,34 @@ +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import type {AuthScreensParamList} from '@libs/Navigation/types'; +import * as SearchQueryUtils from '@libs/SearchQueryUtils'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +/** + * Reset the policyID stored in the navigation state to undefined. + * It is necessary to reset this id after deleting the policy which is currently selected in the app. + */ +function resetPolicyIDInNavigationState() { + const rootState = navigationRef.getRootState(); + const lastPolicyRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR || route.name === SCREENS.SEARCH.ROOT); + + if (!lastPolicyRoute) { + return; + } + + if (lastPolicyRoute.params && 'policyID' in lastPolicyRoute.params) { + Navigation.setParams({policyID: undefined}, lastPolicyRoute.key); + return; + } + + const {q, ...rest} = lastPolicyRoute.params as AuthScreensParamList[typeof SCREENS.SEARCH.ROOT]; + const queryJSON = SearchQueryUtils.buildSearchQueryJSON(q); + if (!queryJSON || !queryJSON.policyID) { + return; + } + + delete queryJSON.policyID; + Navigation.setParams({q: SearchQueryUtils.buildSearchQueryString(queryJSON), ...rest}, lastPolicyRoute.key); +} + +export default resetPolicyIDInNavigationState; diff --git a/src/libs/Navigation/setNavigationActionToMicrotaskQueue.ts b/src/libs/Navigation/helpers/setNavigationActionToMicrotaskQueue.ts similarity index 100% rename from src/libs/Navigation/setNavigationActionToMicrotaskQueue.ts rename to src/libs/Navigation/helpers/setNavigationActionToMicrotaskQueue.ts diff --git a/src/libs/Navigation/shouldOpenOnAdminRoom.ts b/src/libs/Navigation/helpers/shouldOpenOnAdminRoom.ts similarity index 75% rename from src/libs/Navigation/shouldOpenOnAdminRoom.ts rename to src/libs/Navigation/helpers/shouldOpenOnAdminRoom.ts index a593e8c22768..ae316fa3fa44 100644 --- a/src/libs/Navigation/shouldOpenOnAdminRoom.ts +++ b/src/libs/Navigation/helpers/shouldOpenOnAdminRoom.ts @@ -1,4 +1,4 @@ -import getCurrentUrl from './currentUrl'; +import getCurrentUrl from '@libs/Navigation/currentUrl'; export default function shouldOpenOnAdminRoom() { const url = getCurrentUrl(); diff --git a/src/libs/Navigation/shouldPreventDeeplinkPrompt.ts b/src/libs/Navigation/helpers/shouldPreventDeeplinkPrompt.ts similarity index 100% rename from src/libs/Navigation/shouldPreventDeeplinkPrompt.ts rename to src/libs/Navigation/helpers/shouldPreventDeeplinkPrompt.ts diff --git a/src/libs/Navigation/helpers/switchPolicyID.ts b/src/libs/Navigation/helpers/switchPolicyID.ts new file mode 100644 index 000000000000..dafaa32485cc --- /dev/null +++ b/src/libs/Navigation/helpers/switchPolicyID.ts @@ -0,0 +1,6 @@ +import navigationRef from '@libs/Navigation/navigationRef'; +import CONST from '@src/CONST'; + +export default function switchPolicyID(newPolicyID: string | undefined) { + navigationRef.dispatch({type: CONST.NAVIGATION.ACTION_TYPE.SWITCH_POLICY_ID, payload: {policyID: newPolicyID}}); +} diff --git a/src/libs/Navigation/helpers/useIsHomeRouteActive.ts b/src/libs/Navigation/helpers/useIsHomeRouteActive.ts new file mode 100644 index 000000000000..9c7c3fb63b5c --- /dev/null +++ b/src/libs/Navigation/helpers/useIsHomeRouteActive.ts @@ -0,0 +1,23 @@ +import {findFocusedRoute, useNavigationState} from '@react-navigation/native'; +import useRootNavigationState from '@hooks/useRootNavigationState'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +/** + * Determines if the current route is the Home route/screen + */ +function useIsHomeRouteActive(isNarrowLayout: boolean) { + const focusedRoute = useNavigationState(findFocusedRoute); + const navigationState = useRootNavigationState((x) => x); + + if (isNarrowLayout) { + return focusedRoute?.name === SCREENS.HOME; + } + + // On full width screens HOME is always a sidebar to the Reports Screen + const isSplit = navigationState?.routes.at(-1)?.name === NAVIGATORS.REPORTS_SPLIT_NAVIGATOR; + const isReport = focusedRoute?.name === SCREENS.REPORT; + return isSplit && isReport; +} + +export default useIsHomeRouteActive; diff --git a/src/libs/Navigation/isReportScreenTopmostCentralPane.ts b/src/libs/Navigation/isReportScreenTopmostCentralPane.ts deleted file mode 100644 index 6cfc13886a56..000000000000 --- a/src/libs/Navigation/isReportScreenTopmostCentralPane.ts +++ /dev/null @@ -1,17 +0,0 @@ -import SCREENS from '@src/SCREENS'; -import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute'; -import {navigationRef} from './Navigation'; -import type {RootStackParamList, State} from './types'; - -const isReportScreenTopmostCentralPane = (): boolean => { - const rootState = navigationRef.getRootState() as State; - - if (!rootState) { - return false; - } - - const topmostCentralPaneRoute = getTopmostCentralPaneRoute(rootState); - return topmostCentralPaneRoute?.name === SCREENS.REPORT; -}; - -export default isReportScreenTopmostCentralPane; diff --git a/src/libs/Navigation/isSearchTopmostCentralPane.ts b/src/libs/Navigation/isSearchTopmostCentralPane.ts deleted file mode 100644 index 58eaf17a1be8..000000000000 --- a/src/libs/Navigation/isSearchTopmostCentralPane.ts +++ /dev/null @@ -1,17 +0,0 @@ -import SCREENS from '@src/SCREENS'; -import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute'; -import {navigationRef} from './Navigation'; -import type {RootStackParamList, State} from './types'; - -const isSearchTopmostCentralPane = (): boolean => { - const rootState = navigationRef.getRootState() as State; - - if (!rootState) { - return false; - } - - const topmostCentralPaneRoute = getTopmostCentralPaneRoute(rootState); - return topmostCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE; -}; - -export default isSearchTopmostCentralPane; diff --git a/src/libs/Navigation/linkTo/getActionForBottomTabNavigator.ts b/src/libs/Navigation/linkTo/getActionForBottomTabNavigator.ts deleted file mode 100644 index 85580d068ad7..000000000000 --- a/src/libs/Navigation/linkTo/getActionForBottomTabNavigator.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type {NavigationAction, NavigationState} from '@react-navigation/native'; -import type {Writable} from 'type-fest'; -import type {RootStackParamList, StackNavigationAction} from '@libs/Navigation/types'; -import getTopmostBottomTabRoute from '@navigation/getTopmostBottomTabRoute'; -import CONST from '@src/CONST'; -import type {ActionPayloadParams} from './types'; - -// Because we need to change the type to push, we also need to set target for this action to the bottom tab navigator. -function getActionForBottomTabNavigator( - action: StackNavigationAction, - state: NavigationState, - policyID?: string, - shouldNavigate?: boolean, -): Writable | undefined { - const bottomTabNavigatorRoute = state.routes.at(0); - if (!bottomTabNavigatorRoute || bottomTabNavigatorRoute.state === undefined || !action || action.type !== CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) { - return; - } - - const params = action.payload.params as ActionPayloadParams; - let payloadParams = params.params as Record; - const screen = params.screen; - - if (policyID && !payloadParams?.policyID) { - payloadParams = {...payloadParams, policyID}; - } else if (!policyID) { - delete payloadParams?.policyID; - } - - // Check if the current bottom tab is the same as the one we want to navigate to. If it is, we don't need to do anything. - const bottomTabCurrentTab = getTopmostBottomTabRoute(state); - const bottomTabParams = bottomTabCurrentTab?.params as Record; - - // Verify if the policyID is different than the one we are currently on. If it is, we need to navigate to the new policyID. - const isNewPolicy = bottomTabParams?.policyID !== payloadParams?.policyID; - if (bottomTabCurrentTab?.name === screen && !shouldNavigate && !isNewPolicy) { - return; - } - - return { - type: CONST.NAVIGATION.ACTION_TYPE.PUSH, - payload: { - name: screen, - params: payloadParams, - }, - target: bottomTabNavigatorRoute.state.key, - }; -} - -export default getActionForBottomTabNavigator; diff --git a/src/libs/Navigation/linkTo/index.ts b/src/libs/Navigation/linkTo/index.ts deleted file mode 100644 index 38068894ee67..000000000000 --- a/src/libs/Navigation/linkTo/index.ts +++ /dev/null @@ -1,228 +0,0 @@ -import {getActionFromState} from '@react-navigation/core'; -import type {NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; -import {findFocusedRoute} from '@react-navigation/native'; -import omitBy from 'lodash/omitBy'; -import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import isReportOpenInRHP from '@libs/Navigation/isReportOpenInRHP'; -import {isCentralPaneName} from '@libs/NavigationUtils'; -import shallowCompare from '@libs/ObjectUtils'; -import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; -import getActionsFromPartialDiff from '@navigation/AppNavigator/getActionsFromPartialDiff'; -import getPartialStateDiff from '@navigation/AppNavigator/getPartialStateDiff'; -import dismissModal from '@navigation/dismissModal'; -import extractPolicyIDFromQuery from '@navigation/extractPolicyIDFromQuery'; -import extrapolateStateFromParams from '@navigation/extrapolateStateFromParams'; -import getPolicyIDFromState from '@navigation/getPolicyIDFromState'; -import getStateFromPath from '@navigation/getStateFromPath'; -import getTopmostBottomTabRoute from '@navigation/getTopmostBottomTabRoute'; -import getTopmostCentralPaneRoute from '@navigation/getTopmostCentralPaneRoute'; -import getTopmostReportId from '@navigation/getTopmostReportId'; -import isSideModalNavigator from '@navigation/isSideModalNavigator'; -import {linkingConfig} from '@navigation/linkingConfig'; -import getAdaptedStateFromPath from '@navigation/linkingConfig/getAdaptedStateFromPath'; -import getMatchingBottomTabRouteForState from '@navigation/linkingConfig/getMatchingBottomTabRouteForState'; -import getMatchingCentralPaneRouteForState from '@navigation/linkingConfig/getMatchingCentralPaneRouteForState'; -import replacePathInNestedState from '@navigation/linkingConfig/replacePathInNestedState'; -import type {NavigationRoot, RootStackParamList, StackNavigationAction, State} from '@navigation/types'; -import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; -import type {Route} from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; -import getActionForBottomTabNavigator from './getActionForBottomTabNavigator'; -import getMinimalAction from './getMinimalAction'; -import type {ActionPayloadParams} from './types'; - -export default function linkTo(navigation: NavigationContainerRef | null, path: Route, type?: string, isActiveRoute?: boolean) { - if (!navigation) { - throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?"); - } - let root: NavigationRoot = navigation; - let current: NavigationRoot | undefined; - // Traverse up to get the root navigation - // eslint-disable-next-line no-cond-assign - while ((current = root.getParent())) { - root = current; - } - - const pathWithoutPolicyID = getPathWithoutPolicyID(`/${path}`) as Route; - const rootState = navigation.getRootState() as NavigationState; - const stateFromPath = getStateFromPath(pathWithoutPolicyID) as PartialState>; - // Creating path with /w/ included if necessary. - const topmostCentralPaneRoute = getTopmostCentralPaneRoute(rootState); - - const extractedPolicyID = extractPolicyIDFromPath(`/${path}`); - const policyIDFromState = getPolicyIDFromState(rootState); - const policyID = extractedPolicyID ?? policyIDFromState; - const lastRoute = rootState?.routes?.at(-1); - - const isNarrowLayout = getIsNarrowLayout(); - - const isFullScreenOnTop = lastRoute?.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR; - - // policyID on SCREENS.SEARCH.CENTRAL_PANE can be present only as part of SearchQuery, while on other pages it's stored in the url in the format: /w/:policyID/ - if (policyID && !isFullScreenOnTop && !policyIDFromState) { - // The stateFromPath doesn't include proper path if there is a policy passed with /w/id. - // We need to replace the path in the state with the proper one. - // To avoid this hacky solution we may want to create custom getActionFromState function in the future. - replacePathInNestedState(stateFromPath, `/w/${policyID}${pathWithoutPolicyID}`); - } - - const action: StackNavigationAction = getActionFromState(stateFromPath, linkingConfig.config); - - const isReportInRhpOpened = isReportOpenInRHP(rootState); - - // If action type is different than NAVIGATE we can't change it to the PUSH safely - if (action?.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) { - const actionPayloadParams = action.payload.params as ActionPayloadParams; - - const topRouteName = lastRoute?.name; - - // CentralPane screens aren't nested in any navigator, if actionPayloadParams?.screen is undefined, it means the screen name and parameters have to be read directly from action.payload - const targetName = actionPayloadParams?.screen ?? action.payload.name; - const targetParams = actionPayloadParams?.params ?? actionPayloadParams; - const isTargetNavigatorOnTop = topRouteName === action.payload.name; - - const isTargetScreenDifferentThanCurrent = !!(!topmostCentralPaneRoute || topmostCentralPaneRoute.name !== targetName); - const areParamsDifferent = - targetName === SCREENS.REPORT - ? getTopmostReportId(rootState) !== getTopmostReportId(stateFromPath) - : !shallowCompare( - omitBy(topmostCentralPaneRoute?.params as Record | undefined, (value) => value === undefined), - omitBy(targetParams as Record | undefined, (value) => value === undefined), - ); - - // If this action is navigating to the report screen and the top most navigator is different from the one we want to navigate - PUSH the new screen to the top of the stack by default - if (isCentralPaneName(action.payload.name) && (isTargetScreenDifferentThanCurrent || areParamsDifferent)) { - // We need to push a tab if the tab doesn't match the central pane route that we are going to push. - const topmostBottomTabRoute = getTopmostBottomTabRoute(rootState); - - const focusedRoute = findFocusedRoute(stateFromPath); - const policyIDFromQuery = extractPolicyIDFromQuery(focusedRoute); - const matchingBottomTabRoute = getMatchingBottomTabRouteForState(stateFromPath, policyID ?? policyIDFromQuery); - const isOpeningSearch = matchingBottomTabRoute.name === SCREENS.SEARCH.BOTTOM_TAB; - const 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, - }); - } - - if (type === CONST.NAVIGATION.TYPE.UP) { - action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE; - } else { - action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH; - } - - // If the type is UP, we deeplinked into one of the RHP flows and we want to replace the current screen with the previous one in the flow - // and at the same time we want the back button to go to the page we were before the deeplink - } else if (type === CONST.NAVIGATION.TYPE.UP) { - if (!areParamsDifferent && isSideModalNavigator(lastRoute?.name) && topmostCentralPaneRoute?.name === targetName) { - dismissModal(navigation); - return; - } - action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE; - - // If this action is navigating to ModalNavigator or FullScreenNavigator and the last route on the root navigator is not already opened Navigator then push - } else if ((action.payload.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR || isSideModalNavigator(action.payload.name)) && !isTargetNavigatorOnTop) { - if (isSideModalNavigator(topRouteName)) { - dismissModal(navigation); - } - - // If this RHP has mandatory central pane and bottom tab screens defined we need to push them. - const {adaptedState, metainfo} = getAdaptedStateFromPath(path, linkingConfig.config); - if (adaptedState && (metainfo.isCentralPaneAndBottomTabMandatory || metainfo.isFullScreenNavigatorMandatory)) { - const diff = getPartialStateDiff(rootState, adaptedState as State, metainfo); - const diffActions = getActionsFromPartialDiff(diff); - for (const diffAction of diffActions) { - root.dispatch(diffAction); - } - } - // All actions related to FullScreenNavigator on wide screen are pushed when comparing differences between rootState and adaptedState. - if (action.payload.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR) { - return; - } - action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH; - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - } else if (action.payload.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR) { - // If path contains a policyID, we should invoke the navigate function - const shouldNavigate = !!extractedPolicyID; - const actionForBottomTabNavigator = getActionForBottomTabNavigator(action, rootState, policyID, shouldNavigate); - - if (!actionForBottomTabNavigator) { - return; - } - - root.dispatch(actionForBottomTabNavigator); - - // If the layout is wide we need to push matching central pane route to the stack. - if (!isNarrowLayout) { - // stateFromPath should always include bottom tab navigator state, so getMatchingCentralPaneRouteForState will be always defined. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const matchingCentralPaneRoute = getMatchingCentralPaneRouteForState(stateFromPath, rootState)!; - if (matchingCentralPaneRoute && 'name' in matchingCentralPaneRoute) { - root.dispatch({ - type: CONST.NAVIGATION.ACTION_TYPE.PUSH, - payload: { - name: matchingCentralPaneRoute.name, - params: matchingCentralPaneRoute.params, - }, - }); - } - } else { - // If the layout is small we need to pop everything from the central pane so the bottom tab navigator is visible. - root.dispatch({ - type: 'POP_TO_TOP', - target: rootState.key, - }); - } - return; - } - } - - if ( - action && - 'payload' in action && - action.payload && - 'name' in action.payload && - (isSideModalNavigator(action.payload.name) || (action.payload.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR && type !== CONST.NAVIGATION.TYPE.UP)) - ) { - // Information about the state may be in the params. - const currentFocusedRoute = findFocusedRoute(extrapolateStateFromParams(rootState)); - const targetFocusedRoute = findFocusedRoute(stateFromPath); - - // If the current focused route is the same as the target focused route, we don't want to navigate. - if ( - currentFocusedRoute?.name === targetFocusedRoute?.name && - shallowCompare(currentFocusedRoute?.params as Record, targetFocusedRoute?.params as Record) - ) { - return; - } - - const minimalAction = getMinimalAction(action, navigation.getRootState()); - if (minimalAction) { - // There are situations where a route already exists on the current navigation stack - // But we want to push the same route instead of going back in the stack - // Which would break the user navigation history - if ((!isActiveRoute && type === CONST.NAVIGATION.ACTION_TYPE.PUSH) || action.payload.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR) { - minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.PUSH; - } - root.dispatch(minimalAction); - return; - } - } - - // When we navigate from the ReportScreen opened in RHP, this page shouldn't be removed from the navigation state to allow users to go back to it. - if (isReportInRhpOpened && action) { - action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH; - } - - if (action !== undefined) { - root.dispatch(action); - } else { - root.reset(stateFromPath); - } -} diff --git a/src/libs/Navigation/linkTo/types.ts b/src/libs/Navigation/linkTo/types.ts deleted file mode 100644 index 254a4cdef2a5..000000000000 --- a/src/libs/Navigation/linkTo/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -type ActionPayloadParams = { - screen?: string; - params?: unknown; - path?: string; -}; - -type ActionPayload = { - params?: ActionPayloadParams; -}; - -export type {ActionPayload, ActionPayloadParams}; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/FULLSCREEN_TO_TAB.ts b/src/libs/Navigation/linkingConfig/RELATIONS/FULLSCREEN_TO_TAB.ts new file mode 100644 index 000000000000..551d77fe8952 --- /dev/null +++ b/src/libs/Navigation/linkingConfig/RELATIONS/FULLSCREEN_TO_TAB.ts @@ -0,0 +1,14 @@ +import type {ValueOf} from 'type-fest'; +import BOTTOM_TABS from '@components/Navigation/BottomTabBar/BOTTOM_TABS'; +import type {FullScreenName} from '@libs/Navigation/types'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +const FULLSCREEN_TO_TAB: Record> = { + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: BOTTOM_TABS.HOME, + [SCREENS.SEARCH.ROOT]: BOTTOM_TABS.SEARCH, + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: BOTTOM_TABS.SETTINGS, + [NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR]: BOTTOM_TABS.SETTINGS, +}; + +export default FULLSCREEN_TO_TAB; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts new file mode 100644 index 000000000000..feee223c233c --- /dev/null +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts @@ -0,0 +1,31 @@ +import SCREENS from '@src/SCREENS'; + +// This file is used to define RHP screens that are in relation to the search screen. +const SEARCH_TO_RHP: string[] = [ + SCREENS.SEARCH.REPORT_RHP, + SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_CURRENCY_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_DATE_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_SUBMITTED_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_APPROVED_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_PAID_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_EXPORTED_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_POSTED_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_DESCRIPTION_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_MERCHANT_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_REPORT_ID_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_AMOUNT_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_CATEGORY_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_KEYWORD_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_TAX_RATE_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_EXPENSE_TYPE_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_TAG_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_FROM_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_IN_RHP, + SCREENS.SEARCH.ADVANCED_FILTERS_CARD_RHP, + SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP, +]; + +export default SEARCH_TO_RHP; diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts similarity index 66% rename from src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts rename to src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index 435b50eb2948..206953f074e4 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -1,7 +1,8 @@ -import type {CentralPaneName} from '@libs/Navigation/types'; +import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; import SCREENS from '@src/SCREENS'; -const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { +// This file is used to define relation between settings split navigator's central screens and RHP screens. +const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { [SCREENS.SETTINGS.PROFILE.ROOT]: [ SCREENS.SETTINGS.PROFILE.DISPLAY_NAME, SCREENS.SETTINGS.PROFILE.CONTACT_METHODS, @@ -46,37 +47,10 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE, SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE, SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM, - SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE, ], [SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS], [SCREENS.SETTINGS.SAVE_THE_WORLD]: [SCREENS.I_KNOW_A_TEACHER, SCREENS.INTRO_SCHOOL_PRINCIPAL, SCREENS.I_AM_A_TEACHER], [SCREENS.SETTINGS.TROUBLESHOOT]: [SCREENS.SETTINGS.CONSOLE], - [SCREENS.SEARCH.CENTRAL_PANE]: [ - SCREENS.SEARCH.REPORT_RHP, - SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_CURRENCY_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_DATE_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_SUBMITTED_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_APPROVED_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_PAID_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_EXPORTED_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_POSTED_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_DESCRIPTION_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_MERCHANT_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_REPORT_ID_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_AMOUNT_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_CATEGORY_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_KEYWORD_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_TAX_RATE_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_EXPENSE_TYPE_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_TAG_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_FROM_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_TO_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_IN_RHP, - SCREENS.SEARCH.ADVANCED_FILTERS_CARD_RHP, - SCREENS.SEARCH.SAVED_SEARCH_RENAME_RHP, - ], [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: [ SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD, SCREENS.SETTINGS.SUBSCRIPTION.SIZE, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_RHP.ts new file mode 100644 index 000000000000..4deffa6fd876 --- /dev/null +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_RHP.ts @@ -0,0 +1,19 @@ +import type {SplitNavigatorSidebarScreen} from '@libs/Navigation/types'; +import SCREENS from '@src/SCREENS'; + +/** + * This file is used to define the relationship between the sidebar and the right hand pane (RHP) screen. + * This means that going back from RHP will take the user directly to the sidebar. On wide layout the default central screen will be used to fill the space. + */ +const SIDEBAR_TO_RHP: Partial> = { + [SCREENS.SETTINGS.ROOT]: [ + SCREENS.SETTINGS.SHARE_CODE, + SCREENS.SETTINGS.PROFILE.STATUS, + SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE, + SCREENS.SETTINGS.EXIT_SURVEY.REASON, + SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE, + SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM, + ], +}; + +export default SIDEBAR_TO_RHP; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_SPLIT.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_SPLIT.ts new file mode 100644 index 000000000000..c4d18632ca68 --- /dev/null +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SIDEBAR_TO_SPLIT.ts @@ -0,0 +1,11 @@ +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +// This file is used to define the relationship between the sidebar (LHN) and the parent split navigator. +const SIDEBAR_TO_SPLIT = { + [SCREENS.SETTINGS.ROOT]: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR, + [SCREENS.HOME]: NAVIGATORS.REPORTS_SPLIT_NAVIGATOR, + [SCREENS.WORKSPACE.INITIAL]: NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR, +}; + +export default SIDEBAR_TO_SPLIT; diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts similarity index 97% rename from src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts rename to src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 614143c8d481..3a063ab1f3ee 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -1,7 +1,9 @@ -import type {FullScreenName} from '@libs/Navigation/types'; +import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import SCREENS from '@src/SCREENS'; -const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { +// This file is used to define relation between workspace split navigator's central screens and RHP screens. +const WORKSPACE_TO_RHP: Partial> = { + [SCREENS.WORKSPACE.INITIAL]: [], [SCREENS.WORKSPACE.PROFILE]: [ SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.ADDRESS, @@ -267,6 +269,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.PER_DIEM_EDIT_AMOUNT, SCREENS.WORKSPACE.PER_DIEM_EDIT_CURRENCY, ], + [SCREENS.WORKSPACE.MORE_FEATURES]: [], }; -export default FULL_SCREEN_TO_RHP_MAPPING; +export default WORKSPACE_TO_RHP; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/index.ts b/src/libs/Navigation/linkingConfig/RELATIONS/index.ts new file mode 100644 index 000000000000..3f779ba351a1 --- /dev/null +++ b/src/libs/Navigation/linkingConfig/RELATIONS/index.ts @@ -0,0 +1,25 @@ +import FULLSCREEN_TO_TAB from './FULLSCREEN_TO_TAB'; +import SEARCH_TO_RHP from './SEARCH_TO_RHP'; +import SETTINGS_TO_RHP from './SETTINGS_TO_RHP'; +import SIDEBAR_TO_RHP from './SIDEBAR_TO_RHP'; +import SIDEBAR_TO_SPLIT from './SIDEBAR_TO_SPLIT'; +import WORKSPACE_TO_RHP from './WORKSPACE_TO_RHP'; + +function createInverseRelation(relations: Partial>): Record { + const reversedRelations = {} as Record; + + Object.entries(relations).forEach(([key, values]) => { + const valuesWithType = (Array.isArray(values) ? values : [values]) as K[]; + valuesWithType.forEach((value: K) => { + reversedRelations[value] = key as T; + }); + }); + return reversedRelations; +} + +const RHP_TO_SETTINGS = createInverseRelation(SETTINGS_TO_RHP); +const RHP_TO_WORKSPACE = createInverseRelation(WORKSPACE_TO_RHP); +const RHP_TO_SIDEBAR = createInverseRelation(SIDEBAR_TO_RHP); +const SPLIT_TO_SIDEBAR = createInverseRelation(SIDEBAR_TO_SPLIT); + +export {SETTINGS_TO_RHP, RHP_TO_SETTINGS, RHP_TO_WORKSPACE, RHP_TO_SIDEBAR, SEARCH_TO_RHP, SIDEBAR_TO_RHP, WORKSPACE_TO_RHP, SIDEBAR_TO_SPLIT, SPLIT_TO_SIDEBAR, FULLSCREEN_TO_TAB}; diff --git a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts b/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts deleted file mode 100755 index a68959ae7d0f..000000000000 --- a/src/libs/Navigation/linkingConfig/TAB_TO_CENTRAL_PANE_MAPPING.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {BottomTabName, CentralPaneName} from '@navigation/types'; -import SCREENS from '@src/SCREENS'; - -const TAB_TO_CENTRAL_PANE_MAPPING: Record = { - [SCREENS.HOME]: [SCREENS.REPORT], - [SCREENS.SEARCH.BOTTOM_TAB]: [SCREENS.SEARCH.CENTRAL_PANE], - [SCREENS.SETTINGS.ROOT]: [ - SCREENS.SETTINGS.PROFILE.ROOT, - SCREENS.SETTINGS.PREFERENCES.ROOT, - SCREENS.SETTINGS.SECURITY, - SCREENS.SETTINGS.WALLET.ROOT, - SCREENS.SETTINGS.ABOUT, - SCREENS.SETTINGS.WORKSPACES, - SCREENS.SETTINGS.SAVE_THE_WORLD, - SCREENS.SETTINGS.TROUBLESHOOT, - SCREENS.SETTINGS.SUBSCRIPTION.ROOT, - ], -}; - -const generateCentralPaneToTabMapping = (): Record => { - const mapping: Record = {} as Record; - for (const [tabName, CentralPaneNames] of Object.entries(TAB_TO_CENTRAL_PANE_MAPPING)) { - for (const CentralPaneName of CentralPaneNames) { - mapping[CentralPaneName] = tabName as BottomTabName; - } - } - return mapping; -}; - -const CENTRAL_PANE_TO_TAB_MAPPING: Record = generateCentralPaneToTabMapping(); - -export {CENTRAL_PANE_TO_TAB_MAPPING}; -export default TAB_TO_CENTRAL_PANE_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 2f28db6fc99f..544f06748f4d 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1,15 +1,14 @@ import type {LinkingOptions} from '@react-navigation/native'; -import type {RootStackParamList} from '@navigation/types'; +import type {RouteConfig} from '@libs/Navigation/helpers/createNormalizedConfigs'; +import createNormalizedConfigs from '@libs/Navigation/helpers/createNormalizedConfigs'; +import type {RootNavigatorParamList} from '@navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; -import type {RouteConfig} from './createNormalizedConfigs'; -import createNormalizedConfigs from './createNormalizedConfigs'; // Moved to a separate file to avoid cyclic dependencies. -const config: LinkingOptions['config'] = { - initialRouteName: NAVIGATORS.BOTTOM_TAB_NAVIGATOR, +const config: LinkingOptions['config'] = { screens: { // Main Routes [SCREENS.VALIDATE_LOGIN]: ROUTES.VALIDATE_LOGIN, @@ -30,49 +29,9 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_AVATAR]: ROUTES.REPORT_AVATAR.route, [SCREENS.TRANSACTION_RECEIPT]: ROUTES.TRANSACTION_RECEIPT.route, [SCREENS.WORKSPACE_JOIN_USER]: ROUTES.WORKSPACE_JOIN_USER.route, - [SCREENS.REPORT]: ROUTES.REPORT_WITH_ID.route, - [SCREENS.SETTINGS.PROFILE.ROOT]: { - path: ROUTES.SETTINGS_PROFILE, - exact: true, - }, - [SCREENS.SETTINGS.PREFERENCES.ROOT]: { - path: ROUTES.SETTINGS_PREFERENCES, - exact: true, - }, - [SCREENS.SETTINGS.SECURITY]: { - path: ROUTES.SETTINGS_SECURITY, - exact: true, - }, - [SCREENS.SETTINGS.WALLET.ROOT]: { - path: ROUTES.SETTINGS_WALLET, - exact: true, - }, - [SCREENS.SETTINGS.ABOUT]: { - path: ROUTES.SETTINGS_ABOUT, - exact: true, - }, - [SCREENS.SETTINGS.TROUBLESHOOT]: { - path: ROUTES.SETTINGS_TROUBLESHOOT, - exact: true, - }, - [SCREENS.SETTINGS.WORKSPACES]: ROUTES.SETTINGS_WORKSPACES, - [SCREENS.SEARCH.CENTRAL_PANE]: { + [SCREENS.SEARCH.ROOT]: { path: ROUTES.SEARCH_CENTRAL_PANE.route, }, - [SCREENS.SETTINGS.SAVE_THE_WORLD]: ROUTES.SETTINGS_SAVE_THE_WORLD, - [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: ROUTES.SETTINGS_SUBSCRIPTION, - - // Sidebar - [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]: { - path: ROUTES.ROOT, - initialRouteName: SCREENS.HOME, - screens: { - [SCREENS.HOME]: ROUTES.HOME, - [SCREENS.SETTINGS.ROOT]: { - path: ROUTES.SETTINGS, - }, - }, - }, [SCREENS.NOT_FOUND]: '*', [NAVIGATORS.LEFT_MODAL_NAVIGATOR]: { @@ -338,12 +297,6 @@ const config: LinkingOptions['config'] = { login: (login: string) => decodeURIComponent(login), }, }, - [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE]: { - path: ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE_MAGIC_CODE.route, - parse: { - login: (login: string) => decodeURIComponent(login), - }, - }, [SCREENS.SETTINGS.PROFILE.STATUS]: { path: ROUTES.SETTINGS_STATUS, exact: true, @@ -1574,7 +1527,56 @@ const config: LinkingOptions['config'] = { }, }, - [NAVIGATORS.FULL_SCREEN_NAVIGATOR]: { + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: { + path: ROUTES.ROOT, + screens: { + [SCREENS.HOME]: { + path: ROUTES.HOME, + exact: true, + }, + [SCREENS.REPORT]: ROUTES.REPORT_WITH_ID.route, + }, + }, + + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: { + screens: { + [SCREENS.SETTINGS.ROOT]: ROUTES.SETTINGS, + [SCREENS.SETTINGS.WORKSPACES]: { + path: ROUTES.SETTINGS_WORKSPACES, + exact: true, + }, + [SCREENS.SETTINGS.PROFILE.ROOT]: { + path: ROUTES.SETTINGS_PROFILE, + exact: true, + }, + [SCREENS.SETTINGS.SECURITY]: { + path: ROUTES.SETTINGS_SECURITY, + exact: true, + }, + [SCREENS.SETTINGS.WALLET.ROOT]: { + path: ROUTES.SETTINGS_WALLET, + exact: true, + }, + [SCREENS.SETTINGS.ABOUT]: { + path: ROUTES.SETTINGS_ABOUT, + exact: true, + }, + [SCREENS.SETTINGS.TROUBLESHOOT]: { + path: ROUTES.SETTINGS_TROUBLESHOOT, + exact: true, + }, + [SCREENS.SETTINGS.SAVE_THE_WORLD]: ROUTES.SETTINGS_SAVE_THE_WORLD, + [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: ROUTES.SETTINGS_SUBSCRIPTION, + [SCREENS.SETTINGS.PREFERENCES.ROOT]: { + path: ROUTES.SETTINGS_PREFERENCES, + // exact: true, + }, + }, + }, + + [NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR]: { + // The path given as initialRouteName does not have route params. + // initialRouteName is not defined in this split navigator because in this case the initial route requires a policyID defined in its route params. screens: { [SCREENS.WORKSPACE.INITIAL]: { path: ROUTES.WORKSPACE_INITIAL.route, diff --git a/src/libs/Navigation/linkingConfig/customGetPathFromState.ts b/src/libs/Navigation/linkingConfig/customGetPathFromState.ts deleted file mode 100644 index a9c9b6f23b19..000000000000 --- a/src/libs/Navigation/linkingConfig/customGetPathFromState.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {getPathFromState} from '@react-navigation/native'; -import getPolicyIDFromState from '@libs/Navigation/getPolicyIDFromState'; -import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import type {BottomTabName, RootStackParamList, State} from '@libs/Navigation/types'; -import {removePolicyIDParamFromState} from '@libs/NavigationUtils'; -import SCREENS from '@src/SCREENS'; - -// The policy ID parameter should be included in the URL when any of these pages is opened in the bottom tab. -const SCREENS_WITH_POLICY_ID_IN_URL: BottomTabName[] = [SCREENS.HOME] as const; - -const customGetPathFromState: typeof getPathFromState = (state, options) => { - // For the Home and Settings pages we should remove policyID from the params, because on small screens it's displayed twice in the URL - const stateWithoutPolicyID = removePolicyIDParamFromState(state as State); - const path = getPathFromState(stateWithoutPolicyID, options); - const policyIDFromState = getPolicyIDFromState(state as State); - const topmostBottomTabRouteName = getTopmostBottomTabRoute(state as State)?.name; - const shouldAddPolicyID = !!topmostBottomTabRouteName && SCREENS_WITH_POLICY_ID_IN_URL.includes(topmostBottomTabRouteName); - return `${policyIDFromState && shouldAddPolicyID ? `/w/${policyIDFromState}` : ''}${path}`; -}; - -export default customGetPathFromState; diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts deleted file mode 100644 index 31b749d35106..000000000000 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ /dev/null @@ -1,421 +0,0 @@ -import type {NavigationState, PartialState, Route} from '@react-navigation/native'; -import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; -import pick from 'lodash/pick'; -import type {OnyxCollection} from 'react-native-onyx'; -import Onyx from 'react-native-onyx'; -import type {TupleToUnion} from 'type-fest'; -import type {TopTabScreen} from '@components/FocusTrap/TOP_TAB_SCREENS'; -import {isAnonymousUser} from '@libs/actions/Session'; -import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import type {BottomTabName, CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; -import {isCentralPaneName} from '@libs/NavigationUtils'; -import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; -import extractPolicyIDFromQuery from '@navigation/extractPolicyIDFromQuery'; -import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Screen} from '@src/SCREENS'; -import SCREENS from '@src/SCREENS'; -import type {Report} from '@src/types/onyx'; -import CENTRAL_PANE_TO_RHP_MAPPING from './CENTRAL_PANE_TO_RHP_MAPPING'; -import {config, normalizedConfigs} from './config'; -import FULL_SCREEN_TO_RHP_MAPPING from './FULL_SCREEN_TO_RHP_MAPPING'; -import getMatchingBottomTabRouteForState from './getMatchingBottomTabRouteForState'; -import getMatchingCentralPaneRouteForState from './getMatchingCentralPaneRouteForState'; -import getOnboardingAdaptedState from './getOnboardingAdaptedState'; -import replacePathInNestedState from './replacePathInNestedState'; - -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, -}); - -const RHP_SCREENS_OPENED_FROM_LHN = [ - SCREENS.SETTINGS.SHARE_CODE, - SCREENS.SETTINGS.PROFILE.STATUS, - SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE, - SCREENS.MONEY_REQUEST.CREATE, - SCREENS.SETTINGS.EXIT_SURVEY.REASON, - SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE, - SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM, - CONST.TAB_REQUEST.DISTANCE, - CONST.TAB_REQUEST.MANUAL, - CONST.TAB_REQUEST.SCAN, -] satisfies Array; - -type RHPScreenOpenedFromLHN = TupleToUnion; - -type Metainfo = { - // Sometimes modal screens don't have information about what should be visible under the overlay. - // That means such screen can have different screens under the overlay depending on what was already in the state. - // If the screens in the bottom tab and central pane are not mandatory for this state, we want to have this information. - // It will help us later with creating proper diff betwen current and desired state. - isCentralPaneAndBottomTabMandatory: boolean; - isFullScreenNavigatorMandatory: boolean; -}; - -type GetAdaptedStateReturnType = { - adaptedState: ReturnType; - metainfo: Metainfo; -}; - -type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; - -// The function getPathFromState that we are using in some places isn't working correctly without defined index. -const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); - -const addPolicyIDToRoute = (route: NavigationPartialRoute, policyID?: string) => { - const routeWithPolicyID = {...route}; - if (!routeWithPolicyID.params) { - routeWithPolicyID.params = {policyID}; - return routeWithPolicyID; - } - - if ('policyID' in routeWithPolicyID.params && !!routeWithPolicyID.params.policyID) { - return routeWithPolicyID; - } - - routeWithPolicyID.params = {...routeWithPolicyID.params, policyID}; - - return routeWithPolicyID; -}; - -function createBottomTabNavigator(route: NavigationPartialRoute, policyID?: string): NavigationPartialRoute { - const routesForBottomTabNavigator: Array> = []; - routesForBottomTabNavigator.push(addPolicyIDToRoute(route, policyID) as NavigationPartialRoute); - - return { - name: NAVIGATORS.BOTTOM_TAB_NAVIGATOR, - state: getRoutesWithIndex(routesForBottomTabNavigator), - }; -} - -function createFullScreenNavigator(route?: NavigationPartialRoute): NavigationPartialRoute { - const routes = []; - - const policyID = route?.params && 'policyID' in route.params ? route.params.policyID : undefined; - - // Both routes in FullScreenNavigator should store a policyID in params, so here this param is also passed to the screen displayed in LHN in FullScreenNavigator - routes.push({ - name: SCREENS.WORKSPACE.INITIAL, - params: { - policyID, - }, - }); - - if (route) { - routes.push(route); - } - return { - name: NAVIGATORS.FULL_SCREEN_NAVIGATOR, - state: getRoutesWithIndex(routes), - }; -} - -function getParamsFromRoute(screenName: string): string[] { - const routeConfig = normalizedConfigs[screenName as Screen]; - - const route = routeConfig.pattern; - - return route.match(/(?<=[:?&])(\w+)(?=[/=?&]|$)/g) ?? []; -} - -// This function will return CentralPaneNavigator route or FullScreenNavigator route. -function getMatchingRootRouteForRHPRoute(route: NavigationPartialRoute): NavigationPartialRoute | undefined { - // Check for backTo param. One screen with different backTo value may need diferent screens visible under the overlay. - if (route.params && 'backTo' in route.params && typeof route.params.backTo === 'string') { - const stateForBackTo = getStateFromPath(route.params.backTo, config); - if (stateForBackTo) { - // If there is rhpNavigator in the state generated for backTo url, we want to get root route matching to this rhp screen. - const rhpNavigator = stateForBackTo.routes.find((rt) => rt.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR); - if (rhpNavigator && rhpNavigator.state) { - return getMatchingRootRouteForRHPRoute(findFocusedRoute(stateForBackTo) as NavigationPartialRoute); - } - - // If we know that backTo targets the root route (full screen) we want to use it. - const fullScreenNavigator = stateForBackTo.routes.find((rt) => rt.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); - if (fullScreenNavigator && fullScreenNavigator.state) { - return fullScreenNavigator as NavigationPartialRoute; - } - - // If we know that backTo targets a central pane screen we want to use it. - const centralPaneScreen = stateForBackTo.routes.find((rt) => isCentralPaneName(rt.name)); - if (centralPaneScreen) { - return centralPaneScreen as NavigationPartialRoute; - } - } - } - - // Check for CentralPaneNavigator - for (const [centralPaneName, RHPNames] of Object.entries(CENTRAL_PANE_TO_RHP_MAPPING)) { - if (RHPNames.includes(route.name)) { - const paramsFromRoute = getParamsFromRoute(centralPaneName); - - return {name: centralPaneName as CentralPaneName, params: pick(route.params, paramsFromRoute)}; - } - } - - // Check for FullScreenNavigator - for (const [fullScreenName, RHPNames] of Object.entries(FULL_SCREEN_TO_RHP_MAPPING)) { - if (RHPNames.includes(route.name)) { - const paramsFromRoute = getParamsFromRoute(fullScreenName); - - return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: pick(route.params, paramsFromRoute)}); - } - } - - // check for valid reportID in the route params - // if the reportID is valid, we should navigate back to screen report in CPN - const reportID = (route.params as Record)?.reportID; - if (allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportID) { - return {name: SCREENS.REPORT, params: {reportID}}; - } -} - -function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType { - const isNarrowLayout = getIsNarrowLayout(); - const metainfo = { - isCentralPaneAndBottomTabMandatory: true, - isFullScreenNavigatorMandatory: true, - }; - - // We need to check what is defined to know what we need to add. - const bottomTabNavigator = state.routes.find((route) => route.name === NAVIGATORS.BOTTOM_TAB_NAVIGATOR); - const centralPaneNavigator = state.routes.find((route) => isCentralPaneName(route.name)); - const fullScreenNavigator = state.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); - const rhpNavigator = state.routes.find((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR); - const lhpNavigator = state.routes.find((route) => route.name === NAVIGATORS.LEFT_MODAL_NAVIGATOR); - const onboardingModalNavigator = state.routes.find((route) => route.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR); - const welcomeVideoModalNavigator = state.routes.find((route) => route.name === NAVIGATORS.WELCOME_VIDEO_MODAL_NAVIGATOR); - const migratedUserModalNavigator = state.routes.find((route) => route.name === NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR); - const attachmentsScreen = state.routes.find((route) => route.name === SCREENS.ATTACHMENTS); - const featureTrainingModalNavigator = state.routes.find((route) => route.name === NAVIGATORS.FEATURE_TRANING_MODAL_NAVIGATOR); - - if (rhpNavigator) { - // Routes - // - matching bottom tab - // - matching root route for rhp - // - found rhp - - // This one will be defined because rhpNavigator is defined. - const focusedRHPRoute = findFocusedRoute(state); - const routes = []; - - if (focusedRHPRoute) { - let matchingRootRoute = getMatchingRootRouteForRHPRoute(focusedRHPRoute); - const isRHPScreenOpenedFromLHN = focusedRHPRoute?.name && RHP_SCREENS_OPENED_FROM_LHN.includes(focusedRHPRoute?.name as RHPScreenOpenedFromLHN); - // This may happen if this RHP doesn't have a route that should be under the overlay defined. - if (!matchingRootRoute || isRHPScreenOpenedFromLHN) { - metainfo.isCentralPaneAndBottomTabMandatory = false; - metainfo.isFullScreenNavigatorMandatory = false; - // If matchingRootRoute is undefined and it's a narrow layout, don't add a report screen under the RHP. - matchingRootRoute = matchingRootRoute ?? (!isNarrowLayout ? {name: SCREENS.REPORT} : undefined); - } - - // If the root route is type of FullScreenNavigator, the default bottom tab will be added. - const matchingBottomTabRoute = getMatchingBottomTabRouteForState({routes: matchingRootRoute ? [matchingRootRoute] : []}); - routes.push(createBottomTabNavigator(matchingBottomTabRoute, policyID)); - // When we open a screen in RHP from FullScreenNavigator, we need to add the appropriate screen in CentralPane. - // Then, when we close FullScreenNavigator, we will be redirected to the correct page in CentralPane. - if (matchingRootRoute?.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR) { - routes.push({name: SCREENS.SETTINGS.WORKSPACES}); - } - - if (matchingRootRoute && (!isNarrowLayout || !isRHPScreenOpenedFromLHN)) { - routes.push(matchingRootRoute); - } - } - - routes.push(rhpNavigator); - return { - adaptedState: getRoutesWithIndex(routes), - metainfo, - }; - } - if (lhpNavigator ?? onboardingModalNavigator ?? welcomeVideoModalNavigator ?? featureTrainingModalNavigator ?? migratedUserModalNavigator) { - // Routes - // - default bottom tab - // - default central pane on desktop layout - // - found lhp / onboardingModalNavigator - - // There is no screen in these navigators that would have mandatory central pane, bottom tab or fullscreen navigator. - metainfo.isCentralPaneAndBottomTabMandatory = false; - metainfo.isFullScreenNavigatorMandatory = false; - const routes = []; - routes.push( - createBottomTabNavigator( - { - name: SCREENS.HOME, - }, - policyID, - ), - ); - if (!isNarrowLayout) { - routes.push({ - name: SCREENS.REPORT, - }); - } - - // Separate ifs are necessary for typescript to see that we are not pushing undefined to the array. - if (lhpNavigator) { - routes.push(lhpNavigator); - } - - if (onboardingModalNavigator) { - if (onboardingModalNavigator.state) { - // Build the routes list based on the current onboarding step, so going back will go to the previous step instead of closing the onboarding flow - routes.push({ - ...onboardingModalNavigator, - state: getOnboardingAdaptedState(onboardingModalNavigator.state), - }); - } else { - routes.push(onboardingModalNavigator); - } - } - - if (welcomeVideoModalNavigator) { - routes.push(welcomeVideoModalNavigator); - } - - if (migratedUserModalNavigator) { - routes.push(migratedUserModalNavigator); - } - - if (featureTrainingModalNavigator) { - routes.push(featureTrainingModalNavigator); - } - - return { - adaptedState: getRoutesWithIndex(routes), - metainfo, - }; - } - if (fullScreenNavigator) { - // Routes - // - default bottom tab - // - default central pane on desktop layout - // - found fullscreen - - const routes = []; - routes.push( - createBottomTabNavigator( - { - name: SCREENS.SETTINGS.ROOT, - }, - policyID, - ), - ); - - routes.push({ - name: SCREENS.SETTINGS.WORKSPACES, - }); - - routes.push(fullScreenNavigator); - - return { - adaptedState: getRoutesWithIndex(routes), - metainfo, - }; - } - if (centralPaneNavigator) { - // Routes - // - matching bottom tab - // - found central pane - const routes = []; - const matchingBottomTabRoute = getMatchingBottomTabRouteForState(state); - routes.push(createBottomTabNavigator(matchingBottomTabRoute, policyID)); - routes.push(centralPaneNavigator); - - return { - adaptedState: getRoutesWithIndex(routes), - metainfo, - }; - } - if (attachmentsScreen) { - // Routes - // - matching bottom tab - // - central pane (report screen) of the attachment - // - found report attachments - const routes = []; - const reportAttachments = attachmentsScreen as Route<'Attachments', RootStackParamList['Attachments']>; - - if (reportAttachments.params?.type === CONST.ATTACHMENT_TYPE.REPORT) { - const matchingBottomTabRoute = getMatchingBottomTabRouteForState(state); - routes.push(createBottomTabNavigator(matchingBottomTabRoute, policyID)); - if (!isNarrowLayout) { - routes.push({name: SCREENS.REPORT, params: {reportID: reportAttachments.params?.reportID}}); - } - routes.push(reportAttachments); - - return { - adaptedState: getRoutesWithIndex(routes), - metainfo, - }; - } - } - - // We need to make sure that this if only handles states where we deeplink to the bottom tab directly - if (bottomTabNavigator && bottomTabNavigator.state) { - // Routes - // - found bottom tab - // - matching central pane on desktop layout - - // We want to make sure that the bottom tab search page is always pushed with matching central pane page. Even on the narrow layout. - if (isNarrowLayout && bottomTabNavigator.state?.routes.at(0)?.name !== SCREENS.SEARCH.BOTTOM_TAB) { - return { - adaptedState: state, - metainfo, - }; - } - - const routes = [...state.routes]; - const matchingCentralPaneRoute = getMatchingCentralPaneRouteForState(state); - if (matchingCentralPaneRoute) { - routes.push(matchingCentralPaneRoute); - } else { - // If there is no matching central pane, we want to add the default one. - metainfo.isCentralPaneAndBottomTabMandatory = false; - routes.push({name: SCREENS.REPORT}); - } - - return { - adaptedState: getRoutesWithIndex(routes), - metainfo, - }; - } - - return { - adaptedState: state, - metainfo, - }; -} - -const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldReplacePathInNestedState = true) => { - const normalizedPath = !path.startsWith('/') ? `/${path}` : path; - const pathWithoutPolicyID = getPathWithoutPolicyID(normalizedPath); - const isAnonymous = isAnonymousUser(); - - // Anonymous users don't have access to workspaces - const policyID = isAnonymous ? undefined : extractPolicyIDFromPath(path); - - const state = getStateFromPath(pathWithoutPolicyID, options) as PartialState>; - if (shouldReplacePathInNestedState) { - replacePathInNestedState(state, normalizedPath); - } - if (state === undefined) { - throw new Error('Unable to parse path'); - } - - // On SCREENS.SEARCH.CENTRAL_PANE policyID is stored differently inside search query ("q" param), so we're handling this case - const focusedRoute = findFocusedRoute(state); - const policyIDFromQuery = extractPolicyIDFromQuery(focusedRoute); - - return getAdaptedState(state, policyID ?? policyIDFromQuery); -}; - -export default getAdaptedStateFromPath; -export type {Metainfo}; diff --git a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts deleted file mode 100644 index 7b213fdfeb6e..000000000000 --- a/src/libs/Navigation/linkingConfig/getMatchingBottomTabRouteForState.ts +++ /dev/null @@ -1,33 +0,0 @@ -import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; -import type {BottomTabName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; -import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; -import {CENTRAL_PANE_TO_TAB_MAPPING} from './TAB_TO_CENTRAL_PANE_MAPPING'; - -// Get the route that matches the topmost central pane route in the navigation stack. e.g REPORT -> HOME -function getMatchingBottomTabRouteForState(state: State, policyID?: string): NavigationPartialRoute { - const paramsWithPolicyID = policyID ? {policyID} : undefined; - const defaultRoute = {name: SCREENS.HOME, params: paramsWithPolicyID}; - const isFullScreenNavigatorOpened = state.routes.some((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); - - if (isFullScreenNavigatorOpened) { - return {name: SCREENS.SETTINGS.ROOT, params: paramsWithPolicyID}; - } - - const topmostCentralPaneRoute = getTopmostCentralPaneRoute(state); - - if (topmostCentralPaneRoute === undefined) { - return defaultRoute; - } - - const tabName = CENTRAL_PANE_TO_TAB_MAPPING[topmostCentralPaneRoute.name]; - - if (tabName === SCREENS.SEARCH.BOTTOM_TAB) { - const topmostCentralPaneRouteParams = {...topmostCentralPaneRoute.params} as Record; - return {name: tabName, params: topmostCentralPaneRouteParams}; - } - - return {name: tabName, params: paramsWithPolicyID}; -} - -export default getMatchingBottomTabRouteForState; diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts deleted file mode 100644 index cec00f705127..000000000000 --- a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts +++ /dev/null @@ -1,74 +0,0 @@ -import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import type {AuthScreensParamList, CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; -import NAVIGATORS from '@src/NAVIGATORS'; -import SCREENS from '@src/SCREENS'; -import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING'; - -/** - * @param state - react-navigation state - */ -const getTopMostReportIDFromRHP = (state: State): string => { - if (!state) { - return ''; - } - - const topmostRightPane = state.routes.filter((route) => route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR).at(-1); - - if (topmostRightPane?.state) { - return getTopMostReportIDFromRHP(topmostRightPane.state); - } - - const topmostRoute = state.routes.at(-1); - - if (topmostRoute?.state) { - return getTopMostReportIDFromRHP(topmostRoute.state); - } - - if (topmostRoute?.params && 'reportID' in topmostRoute.params && typeof topmostRoute.params.reportID === 'string') { - return topmostRoute.params.reportID; - } - - return ''; -}; - -// Get already opened settings screen within the policy -function getAlreadyOpenedSettingsScreen(rootState?: State): keyof AuthScreensParamList | undefined { - if (!rootState) { - return undefined; - } - - // If one of the screen from TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.SETTINGS.ROOT] is now in the navigation state, we can decide which screen we should display. - // A screen from the navigation state can be pushed to the navigation state again only if it has a matching policyID with the currently selected workspace. - // Otherwise, when we switch the workspace, we want to display the initial screen in the settings tab. - const alreadyOpenedSettingsScreen = rootState.routes.filter((item) => TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.SETTINGS.ROOT].includes(item.name as CentralPaneName)).at(-1); - - return alreadyOpenedSettingsScreen?.name as keyof AuthScreensParamList; -} - -// Get matching central pane route for bottom tab navigator. e.g HOME -> REPORT -function getMatchingCentralPaneRouteForState(state: State, rootState?: State): NavigationPartialRoute | undefined { - const topmostBottomTabRoute = getTopmostBottomTabRoute(state); - - if (!topmostBottomTabRoute) { - return; - } - - const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name].at(0); - if (!centralPaneName) { - return; - } - - if (topmostBottomTabRoute.name === SCREENS.SETTINGS.ROOT) { - // When we go back to the settings tab without switching the workspace id, we want to return to the previously opened screen - const screen = getAlreadyOpenedSettingsScreen(rootState) ?? centralPaneName; - return {name: screen as CentralPaneName, params: topmostBottomTabRoute.params}; - } - - if (topmostBottomTabRoute.name === SCREENS.HOME) { - return {name: centralPaneName, params: {reportID: getTopMostReportIDFromRHP(state)}}; - } - - return {name: centralPaneName}; -} - -export default getMatchingCentralPaneRouteForState; diff --git a/src/libs/Navigation/linkingConfig/index.ts b/src/libs/Navigation/linkingConfig/index.ts index f733e3a32d68..fe79a8a66e61 100644 --- a/src/libs/Navigation/linkingConfig/index.ts +++ b/src/libs/Navigation/linkingConfig/index.ts @@ -1,20 +1,13 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type {LinkingOptions} from '@react-navigation/native'; -import type {RootStackParamList} from '@navigation/types'; +import customGetPathFromState from '@libs/Navigation/helpers/customGetPathFromState'; +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import type {RootNavigatorParamList} from '@libs/Navigation/types'; import {config} from './config'; -import customGetPathFromState from './customGetPathFromState'; -import getAdaptedStateFromPath from './getAdaptedStateFromPath'; import prefixes from './prefixes'; -import {subscribe} from './subscribe'; -const linkingConfig: LinkingOptions = { - getStateFromPath: (...args) => { - const {adaptedState} = getAdaptedStateFromPath(...args); - - // ResultState | undefined is the type this function expect. - return adaptedState; - }, - subscribe, +const linkingConfig: LinkingOptions = { + getStateFromPath: getAdaptedStateFromPath, getPathFromState: customGetPathFromState, prefixes, config, diff --git a/src/libs/Navigation/linkingConfig/prefixes.ts b/src/libs/Navigation/linkingConfig/prefixes.ts index ca2da6f56b39..c3b52cef9852 100644 --- a/src/libs/Navigation/linkingConfig/prefixes.ts +++ b/src/libs/Navigation/linkingConfig/prefixes.ts @@ -1,8 +1,8 @@ import type {LinkingOptions} from '@react-navigation/native'; -import type {RootStackParamList} from '@libs/Navigation/types'; +import type {RootNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; -const prefixes: LinkingOptions['prefixes'] = [ +const prefixes: LinkingOptions['prefixes'] = [ 'app://-/', 'new-expensify://', 'https://www.expensify.cash', diff --git a/src/libs/Navigation/linkingConfig/subscribe/index.native.ts b/src/libs/Navigation/linkingConfig/subscribe/index.native.ts deleted file mode 100644 index 8f14032e7e33..000000000000 --- a/src/libs/Navigation/linkingConfig/subscribe/index.native.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import type {LinkingOptions} from '@react-navigation/native'; -import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; -import extractPathFromURL from '@react-navigation/native/src/extractPathFromURL'; -import {Linking} from 'react-native'; -import Navigation from '@libs/Navigation/Navigation'; -import {config} from '@navigation/linkingConfig/config'; -import prefixes from '@navigation/linkingConfig/prefixes'; -import type {RootStackParamList} from '@navigation/types'; -import type {Route} from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; - -// This field in linkingConfig is supported on native only. -const subscribe: LinkingOptions['subscribe'] = (listener) => { - // We need to override the default behaviour for the deep link to search screen. - // Even on mobile narrow layout, this screen need to push two screens on the stack to work (bottom tab and central pane). - // That's why we are going to handle it with our navigate function instead the default react-navigation one. - const linkingSubscription = Linking.addEventListener('url', ({url}) => { - const path = extractPathFromURL(prefixes, url); - - if (path) { - const stateFromPath = getStateFromPath(path, config); - if (stateFromPath) { - const focusedRoute = findFocusedRoute(stateFromPath); - if (focusedRoute && focusedRoute.name === SCREENS.SEARCH.CENTRAL_PANE) { - Navigation.navigate(path as Route); - return; - } - } - } - - listener(url); - }); - return () => { - // Clean up the event listeners - linkingSubscription.remove(); - }; -}; - -// eslint-disable-next-line import/prefer-default-export -export {subscribe}; diff --git a/src/libs/Navigation/linkingConfig/subscribe/index.ts b/src/libs/Navigation/linkingConfig/subscribe/index.ts deleted file mode 100644 index 7ccbb5252a2c..000000000000 --- a/src/libs/Navigation/linkingConfig/subscribe/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type {LinkingOptions} from '@react-navigation/native'; -import type {RootStackParamList} from '@libs/Navigation/types'; - -// This field in linkingConfig is supported on native only. -const subscribe: LinkingOptions['subscribe'] = undefined; - -// eslint-disable-next-line import/prefer-default-export -export {subscribe}; diff --git a/src/libs/Navigation/setupCustomAndroidBackHandler/index.android.ts b/src/libs/Navigation/setupCustomAndroidBackHandler/index.android.ts deleted file mode 100644 index bdea8c157425..000000000000 --- a/src/libs/Navigation/setupCustomAndroidBackHandler/index.android.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {findFocusedRoute, StackActions} from '@react-navigation/native'; -import {BackHandler} from 'react-native'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -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 = PlatformStackScreenProps; - -// 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); - - // Shouldn'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 from 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 searchParams = bottomTabRoutes[bottomTabRoutes.length - 2].params as SearchPageProps['route']['params']; - navigationRef.dispatch({...StackActions.push(SCREENS.SEARCH.CENTRAL_PANE, searchParams)}); - } - - 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 from 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 searchParams = bottomTabRoutes[bottomTabRoutes.length - 2].params as SearchPageProps['route']['params']; - navigationRef.dispatch({...StackActions.push(SCREENS.SEARCH.CENTRAL_PANE, searchParams)}); - 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 deleted file mode 100644 index aa9077e1220f..000000000000 --- a/src/libs/Navigation/setupCustomAndroidBackHandler/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Do nothing for platforms different than Android. -function setupCustomAndroidBackHandler() {} - -export default setupCustomAndroidBackHandler; diff --git a/src/libs/Navigation/shouldSetScreenBlurred/index.native.tsx b/src/libs/Navigation/shouldSetScreenBlurred/index.native.tsx deleted file mode 100644 index 4043fddb7372..000000000000 --- a/src/libs/Navigation/shouldSetScreenBlurred/index.native.tsx +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @param navigationIndex - * - * Decides whether to set screen to blurred state. - * - * If the screen is more than 1 screen away from the current screen, freeze it, - * we don't want to freeze the screen if it's the previous screen because the freeze placeholder - * would be visible at the beginning of the back animation then - */ -const shouldSetScreenBlurred = (navigationIndex: number) => navigationIndex > 1; - -export default shouldSetScreenBlurred; diff --git a/src/libs/Navigation/shouldSetScreenBlurred/index.tsx b/src/libs/Navigation/shouldSetScreenBlurred/index.tsx deleted file mode 100644 index 14b45921bdb2..000000000000 --- a/src/libs/Navigation/shouldSetScreenBlurred/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @param navigationIndex - * - * Decides whether to set screen to blurred state. - * - * Allow freezing the first screen and more in the stack only on - * web and desktop platforms. The reason is that in the case of - * LHN, we have FlashList rendering in the back while we are on - * Settings screen. - */ -const shouldSetScreenBlurred = (navigationIndex: number) => navigationIndex >= 1; - -export default shouldSetScreenBlurred; diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts deleted file mode 100644 index d3a51143eab7..000000000000 --- a/src/libs/Navigation/switchPolicyID.ts +++ /dev/null @@ -1,146 +0,0 @@ -import {getActionFromState} from '@react-navigation/core'; -import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; -import {getPathFromState} from '@react-navigation/native'; -import type {Writable} from 'type-fest'; -import getIsNarrowLayout from '@libs/getIsNarrowLayout'; -import {isCentralPaneName} from '@libs/NavigationUtils'; -import * as SearchQueryUtils from '@libs/SearchQueryUtils'; -import CONST from '@src/CONST'; -import type {Route} from '@src/ROUTES'; -import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; -import getStateFromPath from './getStateFromPath'; -import getTopmostCentralPaneRoute from './getTopmostCentralPaneRoute'; -import {linkingConfig} from './linkingConfig'; -import type {NavigationRoot, RootStackParamList, StackNavigationAction, State, SwitchPolicyIDParams} from './types'; - -type ActionPayloadParams = { - screen?: string; - params?: unknown; - path?: string; -}; - -type CentralPaneRouteParams = Record & {policyID?: string; q?: string; reportID?: string}; - -function checkIfActionPayloadNameIsEqual(action: Writable, screenName: string) { - return action?.payload && 'name' in action.payload && action?.payload?.name === screenName; -} - -function getActionForBottomTabNavigator(action: StackNavigationAction, state: NavigationState, policyID?: string): Writable | undefined { - const bottomTabNavigatorRoute = state.routes.at(0); - - if (!bottomTabNavigatorRoute || bottomTabNavigatorRoute.state === undefined || !action || action.type !== CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) { - return; - } - - let name: string | undefined; - let params: Record; - if (isCentralPaneName(action.payload.name)) { - name = action.payload.name; - params = action.payload.params as Record; - } else { - const actionPayloadParams = action.payload.params as ActionPayloadParams; - name = actionPayloadParams.screen; - params = actionPayloadParams?.params as Record; - } - - if (name === SCREENS.SEARCH.CENTRAL_PANE) { - name = SCREENS.SEARCH.BOTTOM_TAB; - } else if (!params) { - params = {policyID}; - } else { - params.policyID = policyID; - } - - return { - type: CONST.NAVIGATION.ACTION_TYPE.PUSH, - payload: { - name, - params, - }, - target: bottomTabNavigatorRoute.state.key, - }; -} - -export default function switchPolicyID(navigation: NavigationContainerRef | null, {policyID, route}: SwitchPolicyIDParams) { - if (!navigation) { - throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?"); - } - let root: NavigationRoot = navigation; - let current: NavigationRoot | undefined; - - // Traverse up to get the root navigation - // eslint-disable-next-line no-cond-assign - while ((current = root.getParent())) { - root = current; - } - - const rootState = navigation.getRootState() as NavigationState; - const topmostCentralPaneRoute = getTopmostCentralPaneRoute(rootState); - let newPath = route ?? getPathFromState({routes: rootState.routes} as State, linkingConfig.config); - - // Currently, the search page displayed in the bottom tab has the same URL as the page in the central pane, so we need to redirect to the correct search route. - // Here's the configuration: src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/index.tsx - const isOpeningSearchFromBottomTab = !route && topmostCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE; - if (isOpeningSearchFromBottomTab) { - newPath = ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchQueryUtils.buildCannedSearchQuery()}); - } - const stateFromPath = getStateFromPath(newPath as Route) as PartialState>; - const action: StackNavigationAction = getActionFromState(stateFromPath, linkingConfig.config); - - const actionForBottomTabNavigator = getActionForBottomTabNavigator(action, rootState, policyID); - - if (!actionForBottomTabNavigator) { - return; - } - - root.dispatch(actionForBottomTabNavigator); - - // If path is passed to this method, it means that screen is pushed to the Central Pane from another place in code - if (route) { - return; - } - - // The correct route for SearchPage is located in the CentralPane - const shouldAddToCentralPane = !getIsNarrowLayout() || isOpeningSearchFromBottomTab; - - // If the layout is wide we need to push matching central pane route to the stack. - if (shouldAddToCentralPane) { - const params: CentralPaneRouteParams = {...topmostCentralPaneRoute?.params}; - - if (isOpeningSearchFromBottomTab && params.q) { - delete params.policyID; - const queryJSON = SearchQueryUtils.buildSearchQueryJSON(params.q); - - if (policyID) { - if (queryJSON) { - queryJSON.policyID = policyID; - params.q = SearchQueryUtils.buildSearchQueryString(queryJSON); - } - } else if (queryJSON) { - delete queryJSON.policyID; - params.q = SearchQueryUtils.buildSearchQueryString(queryJSON); - } - } - - // If the user is on the home page and changes the current workspace, then should be displayed a report from the selected workspace. - // To achieve that, it's necessary to navigate without the reportID param. - if (checkIfActionPayloadNameIsEqual(actionForBottomTabNavigator, SCREENS.HOME)) { - delete params.reportID; - } - - root.dispatch({ - type: CONST.NAVIGATION.ACTION_TYPE.PUSH, - payload: { - name: topmostCentralPaneRoute?.name, - params, - }, - }); - } else { - // If the layout is small we need to pop everything from the central pane so the bottom tab navigator is visible. - root.dispatch({ - type: 'POP_TO_TOP', - target: rootState.key, - }); - } -} diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e8fed010ceb7..fa274d1ecf7e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -23,10 +23,11 @@ import type SCREENS from '@src/SCREENS'; import type EXIT_SURVEY_REASON_FORM_INPUT_IDS from '@src/types/form/ExitSurveyReasonForm'; import type {CompanyCardFeed} from '@src/types/onyx'; import type {ConnectionName, SageIntacctMappingName} from '@src/types/onyx/Policy'; +import type {SIDEBAR_TO_SPLIT} from './linkingConfig/RELATIONS'; -type NavigationRef = NavigationContainerRefWithCurrent; +type NavigationRef = NavigationContainerRefWithCurrent; -type NavigationRoot = NavigationHelpers; +type NavigationRoot = NavigationHelpers; type GoBackAction = Extract; type ResetAction = Extract; @@ -53,29 +54,16 @@ type NavigationPartialRoute = PartialRoute = NavigationState | PartialState>; -type CentralPaneScreensParamList = { - [SCREENS.REPORT]: { - reportActionID: string; - reportID: string; - openOnAdminRoom?: boolean; - referrer?: string; - }; - [SCREENS.SETTINGS.PROFILE.ROOT]: undefined; - [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; - [SCREENS.SETTINGS.SECURITY]: undefined; - [SCREENS.SETTINGS.WALLET.ROOT]: undefined; - [SCREENS.SETTINGS.ABOUT]: undefined; - [SCREENS.SETTINGS.TROUBLESHOOT]: undefined; - [SCREENS.SETTINGS.WORKSPACES]: undefined; +type SplitNavigatorSidebarScreen = keyof typeof SIDEBAR_TO_SPLIT; - [SCREENS.SEARCH.CENTRAL_PANE]: { - q: SearchQueryString; - name?: string; - }; - [SCREENS.SETTINGS.SAVE_THE_WORLD]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: undefined; +type SplitNavigatorParamListType = { + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: SettingsSplitNavigatorParamList; + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: ReportsSplitNavigatorParamList; + [NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR]: WorkspaceSplitNavigatorParamList; }; +type SplitNavigatorBySidebar = (typeof SIDEBAR_TO_SPLIT)[T]; + type BackToParams = { backTo?: Routes; }; @@ -87,7 +75,6 @@ type BackToAndForwardToParms = { type SettingsNavigatorParamList = { [SCREENS.SETTINGS.SHARE_CODE]: undefined; - [SCREENS.SETTINGS.PROFILE.ROOT]: undefined; [SCREENS.SETTINGS.PROFILE.PRONOUNS]: undefined; [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: undefined; [SCREENS.SETTINGS.PROFILE.TIMEZONE]: undefined; @@ -112,17 +99,11 @@ type SettingsNavigatorParamList = { backTo?: Routes; forwardTo?: Routes; }; - [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: undefined; [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined; [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined; [SCREENS.SETTINGS.PREFERENCES.THEME]: undefined; [SCREENS.SETTINGS.CLOSE]: undefined; - [SCREENS.SETTINGS.SECURITY]: undefined; - [SCREENS.SETTINGS.ABOUT]: undefined; - [SCREENS.SETTINGS.TROUBLESHOOT]: undefined; [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS]: undefined; - [SCREENS.SETTINGS.TROUBLESHOOT]: undefined; [SCREENS.SETTINGS.CONSOLE]: { backTo: Routes; }; @@ -131,7 +112,6 @@ type SettingsNavigatorParamList = { source: string; backTo: Routes; }; - [SCREENS.SETTINGS.WALLET.ROOT]: undefined; [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: { /** cardID of selected card */ @@ -152,16 +132,19 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME]: { /** domain of selected card */ domain: string; + backTo?: Routes; }; [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.PHONE]: { /** domain of selected card */ domain: string; + backTo?: Routes; }; [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.ADDRESS]: { /** Currently selected country */ country: string; /** domain of selected card */ domain: string; + backTo?: Routes; }; [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.CONFIRM]: { /** Currently selected country */ @@ -810,15 +793,12 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE]: { login: string; role?: string; + backTo?: Routes; }; [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE]: { login: string; currentRole: string; }; - [SCREENS.SETTINGS.DELEGATE.UPDATE_DELEGATE_ROLE_MAGIC_CODE]: { - login: string; - role: string; - }; [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: { login: string; role: string; @@ -932,6 +912,23 @@ type SettingsNavigatorParamList = { policyID: string; cardID: string; }; + [SCREENS.EXPENSIFY_CARD.EXPENSIFY_CARD_DETAILS]: { + policyID: string; + cardID: string; + backTo?: Routes; + }; + [SCREENS.EXPENSIFY_CARD.EXPENSIFY_CARD_NAME]: { + policyID: string; + cardID: string; + }; + [SCREENS.EXPENSIFY_CARD.EXPENSIFY_CARD_LIMIT]: { + policyID: string; + cardID: string; + }; + [SCREENS.EXPENSIFY_CARD.EXPENSIFY_CARD_LIMIT_TYPE]: { + policyID: string; + cardID: string; + }; [SCREENS.WORKSPACE.RULES_CUSTOM_NAME]: { policyID: string; }; @@ -1558,10 +1555,32 @@ type TravelNavigatorParamList = { }; }; -type FullScreenNavigatorParamList = { +type ReportsSplitNavigatorParamList = { + [SCREENS.HOME]: undefined; + [SCREENS.REPORT]: { + reportActionID: string; + reportID: string; + openOnAdminRoom?: boolean; + referrer?: string; + }; +}; + +type SettingsSplitNavigatorParamList = { + [SCREENS.SETTINGS.ROOT]: undefined; + [SCREENS.SETTINGS.WORKSPACES]: undefined; + [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; + [SCREENS.SETTINGS.SECURITY]: undefined; + [SCREENS.SETTINGS.PROFILE.ROOT]: undefined; + [SCREENS.SETTINGS.WALLET.ROOT]: undefined; + [SCREENS.SETTINGS.ABOUT]: undefined; + [SCREENS.SETTINGS.TROUBLESHOOT]: undefined; + [SCREENS.SETTINGS.SAVE_THE_WORLD]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: undefined; +}; + +type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.INITIAL]: { policyID: string; - backTo?: string; }; [SCREENS.WORKSPACE.PROFILE]: { policyID: string; @@ -1695,14 +1714,8 @@ type MigratedUserModalNavigatorParamList = { [SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT]: undefined; }; -type BottomTabNavigatorParamList = { - [SCREENS.HOME]: {policyID?: string}; - [SCREENS.SEARCH.BOTTOM_TAB]: undefined; - [SCREENS.SETTINGS.ROOT]: {policyID?: string}; -}; - type SharedScreensParamList = { - [NAVIGATORS.BOTTOM_TAB_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: NavigatorScreenParams; [SCREENS.TRANSITION_BETWEEN_APPS]: { email?: string; accountID?: number; @@ -1733,53 +1746,58 @@ type PublicScreensParamList = SharedScreensParamList & { [SCREENS.BANK_CONNECTION_COMPLETE]: undefined; }; -type AuthScreensParamList = CentralPaneScreensParamList & - SharedScreensParamList & { - [SCREENS.CONCIERGE]: undefined; - [SCREENS.TRACK_EXPENSE]: undefined; - [SCREENS.SUBMIT_EXPENSE]: undefined; - [SCREENS.ATTACHMENTS]: { - reportID: string; - source: string; - type: ValueOf; - accountID: string; - isAuthTokenRequired?: string; - fileName?: string; - attachmentLink?: string; - }; - [SCREENS.PROFILE_AVATAR]: { - accountID: string; - }; - [SCREENS.WORKSPACE_AVATAR]: { - policyID: string; - }; - [SCREENS.WORKSPACE_JOIN_USER]: { - policyID: string; - email: string; - }; - [SCREENS.REPORT_AVATAR]: { - reportID: string; - policyID?: string; - }; - [SCREENS.NOT_FOUND]: undefined; - [NAVIGATORS.LEFT_MODAL_NAVIGATOR]: NavigatorScreenParams; - [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: NavigatorScreenParams; - [NAVIGATORS.FULL_SCREEN_NAVIGATOR]: NavigatorScreenParams; - [NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: NavigatorScreenParams; - [NAVIGATORS.FEATURE_TRANING_MODAL_NAVIGATOR]: NavigatorScreenParams; - [NAVIGATORS.WELCOME_VIDEO_MODAL_NAVIGATOR]: NavigatorScreenParams; - [NAVIGATORS.EXPLANATION_MODAL_NAVIGATOR]: NavigatorScreenParams; - [NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR]: NavigatorScreenParams; - [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: undefined; - [SCREENS.TRANSACTION_RECEIPT]: { - reportID: string; - transactionID: string; - readonly?: string; - isFromReviewDuplicates?: string; - }; - [SCREENS.CONNECTION_COMPLETE]: undefined; - [SCREENS.BANK_CONNECTION_COMPLETE]: undefined; +type AuthScreensParamList = SharedScreensParamList & { + [SCREENS.CONCIERGE]: undefined; + [SCREENS.TRACK_EXPENSE]: undefined; + [SCREENS.SUBMIT_EXPENSE]: undefined; + [SCREENS.ATTACHMENTS]: { + reportID: string; + source: string; + type: ValueOf; + accountID: string; + isAuthTokenRequired?: string; + fileName?: string; + attachmentLink?: string; + }; + [SCREENS.PROFILE_AVATAR]: { + accountID: string; + }; + [SCREENS.WORKSPACE_AVATAR]: { + policyID: string; + }; + [SCREENS.WORKSPACE_JOIN_USER]: { + policyID: string; + email: string; + }; + [SCREENS.REPORT_AVATAR]: { + reportID: string; + policyID?: string; + }; + [SCREENS.NOT_FOUND]: undefined; + [NAVIGATORS.REPORTS_SPLIT_NAVIGATOR]: NavigatorScreenParams & {policyID?: string}; + [NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR]: NavigatorScreenParams & {policyID?: string}; + [NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.LEFT_MODAL_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.FEATURE_TRANING_MODAL_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.WELCOME_VIDEO_MODAL_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.EXPLANATION_MODAL_NAVIGATOR]: NavigatorScreenParams; + [NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR]: NavigatorScreenParams; + [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: undefined; + [SCREENS.TRANSACTION_RECEIPT]: { + reportID: string; + transactionID: string; + readonly?: string; + isFromReviewDuplicates?: string; + }; + [SCREENS.CONNECTION_COMPLETE]: undefined; + [SCREENS.BANK_CONNECTION_COMPLETE]: undefined; + [SCREENS.SEARCH.ROOT]: { + q: SearchQueryString; + name?: string; }; +}; type SearchReportParamList = { [SCREENS.SEARCH.REPORT_RHP]: { @@ -1854,39 +1872,41 @@ type DebugParamList = { }; }; -type RootStackParamList = PublicScreensParamList & AuthScreensParamList & LeftModalNavigatorParamList; +type RootNavigatorParamList = PublicScreensParamList & AuthScreensParamList & LeftModalNavigatorParamList; + +type WorkspaceScreenName = keyof WorkspaceSplitNavigatorParamList; -type BottomTabName = keyof BottomTabNavigatorParamList; +type OnboardingFlowName = keyof OnboardingModalNavigatorParamList; -type FullScreenName = keyof FullScreenNavigatorParamList; +type SplitNavigatorName = keyof SplitNavigatorParamListType; -type CentralPaneName = keyof CentralPaneScreensParamList; +type SplitNavigatorScreenName = keyof (WorkspaceSplitNavigatorParamList & SettingsSplitNavigatorParamList & ReportsSplitNavigatorParamList); -type OnboardingFlowName = keyof OnboardingModalNavigatorParamList; +type FullScreenName = SplitNavigatorName | typeof SCREENS.SEARCH.ROOT; -type SwitchPolicyIDParams = { - policyID?: string; - route?: Routes; - isPolicyAdmin?: boolean; -}; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace ReactNavigation { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-empty-interface + interface RootParamList extends RootNavigatorParamList {} + } +} export type { AddPersonalBankAccountNavigatorParamList, AuthScreensParamList, - CentralPaneScreensParamList, - CentralPaneName, - BackToParams, BackToAndForwardToParms, - BottomTabName, - BottomTabNavigatorParamList, + BackToParams, + DebugParamList, DetailsNavigatorParamList, EditRequestNavigatorParamList, EnablePaymentsNavigatorParamList, ExplanationModalNavigatorParamList, + FeatureTrainingNavigatorParamList, FlagCommentNavigatorParamList, FullScreenName, - FullScreenNavigatorParamList, LeftModalNavigatorParamList, + MissingPersonalDetailsParamList, MoneyRequestNavigatorParamList, NavigationPartialRoute, NavigationRef, @@ -1894,8 +1914,8 @@ export type { NavigationStateRoute, NewChatNavigatorParamList, NewTaskNavigatorParamList, - OnboardingModalNavigatorParamList, OnboardingFlowName, + OnboardingModalNavigatorParamList, ParticipantsNavigatorParamList, PrivateNotesNavigatorParamList, ProfileNavigatorParamList, @@ -1905,29 +1925,34 @@ export type { ReportDescriptionNavigatorParamList, ReportDetailsNavigatorParamList, ReportSettingsNavigatorParamList, + ReportsSplitNavigatorParamList, + RestrictedActionParamList, RightModalNavigatorParamList, RoomMembersNavigatorParamList, - RootStackParamList, + RootNavigatorParamList, + SearchAdvancedFiltersParamList, + SearchReportParamList, + SearchSavedSearchParamList, SettingsNavigatorParamList, + SettingsSplitNavigatorParamList, SignInNavigatorParamList, - FeatureTrainingNavigatorParamList, SplitDetailsNavigatorParamList, + SplitNavigatorBySidebar, + SplitNavigatorName, + SplitNavigatorParamListType, + SplitNavigatorScreenName, + SplitNavigatorSidebarScreen, StackNavigationAction, State, StateOrRoute, - SwitchPolicyIDParams, - TravelNavigatorParamList, TaskDetailsNavigatorParamList, TeachersUniteNavigatorParamList, + TransactionDuplicateNavigatorParamList, + TravelNavigatorParamList, WalletStatementNavigatorParamList, WelcomeVideoModalNavigatorParamList, - TransactionDuplicateNavigatorParamList, - SearchReportParamList, - SearchAdvancedFiltersParamList, - SearchSavedSearchParamList, - RestrictedActionParamList, - MissingPersonalDetailsParamList, - DebugParamList, + WorkspaceScreenName, + WorkspaceSplitNavigatorParamList, MigratedUserModalNavigatorParamList, WorkspaceConfirmationNavigatorParamList, }; diff --git a/src/libs/NavigationUtils.ts b/src/libs/NavigationUtils.ts deleted file mode 100644 index 0a352aa61b94..000000000000 --- a/src/libs/NavigationUtils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import cloneDeep from 'lodash/cloneDeep'; -import SCREENS from '@src/SCREENS'; -import getTopmostBottomTabRoute from './Navigation/getTopmostBottomTabRoute'; -import type {CentralPaneName, OnboardingFlowName, RootStackParamList, State} from './Navigation/types'; - -const CENTRAL_PANE_SCREEN_NAMES = new Set([ - SCREENS.SETTINGS.WORKSPACES, - SCREENS.SETTINGS.PREFERENCES.ROOT, - SCREENS.SETTINGS.SECURITY, - SCREENS.SETTINGS.PROFILE.ROOT, - SCREENS.SETTINGS.WALLET.ROOT, - SCREENS.SETTINGS.ABOUT, - SCREENS.SETTINGS.TROUBLESHOOT, - SCREENS.SETTINGS.SAVE_THE_WORLD, - SCREENS.SETTINGS.SUBSCRIPTION.ROOT, - SCREENS.SEARCH.CENTRAL_PANE, - SCREENS.REPORT, -]); - -const ONBOARDING_SCREEN_NAMES = new Set([ - SCREENS.ONBOARDING.PERSONAL_DETAILS, - SCREENS.ONBOARDING.PURPOSE, - SCREENS.ONBOARDING_MODAL.ONBOARDING, - SCREENS.ONBOARDING.EMPLOYEES, - SCREENS.ONBOARDING.ACCOUNTING, - SCREENS.ONBOARDING.PRIVATE_DOMAIN, - SCREENS.ONBOARDING.WORKSPACES, -]); - -const removePolicyIDParamFromState = (state: State) => { - const stateCopy = cloneDeep(state); - const bottomTabRoute = getTopmostBottomTabRoute(stateCopy); - if (bottomTabRoute?.params && 'policyID' in bottomTabRoute.params) { - delete bottomTabRoute.params.policyID; - } - return stateCopy; -}; - -function isCentralPaneName(screen: string | undefined): screen is CentralPaneName { - if (!screen) { - return false; - } - return CENTRAL_PANE_SCREEN_NAMES.has(screen as CentralPaneName); -} - -function isOnboardingFlowName(screen: string | undefined): screen is OnboardingFlowName { - if (!screen) { - return false; - } - - return ONBOARDING_SCREEN_NAMES.has(screen as OnboardingFlowName); -} - -export {isCentralPaneName, removePolicyIDParamFromState, isOnboardingFlowName}; diff --git a/src/libs/Notification/PushNotification/subscribePushNotification/index.ts b/src/libs/Notification/PushNotification/subscribePushNotification/index.ts index 59f1384c0ce9..b93eb51d53c9 100644 --- a/src/libs/Notification/PushNotification/subscribePushNotification/index.ts +++ b/src/libs/Notification/PushNotification/subscribePushNotification/index.ts @@ -1,22 +1,18 @@ import {NativeModules} from 'react-native'; import Onyx from 'react-native-onyx'; -import type {OnyxCollection} from 'react-native-onyx'; import applyOnyxUpdatesReliably from '@libs/actions/applyOnyxUpdatesReliably'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {ReportActionPushNotificationData} from '@libs/Notification/PushNotification/NotificationType'; -import getPolicyEmployeeAccountIDs from '@libs/PolicyEmployeeListUtils'; import {extractPolicyIDFromPath} from '@libs/PolicyUtils'; -import {doesReportBelongToWorkspace} from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; import {updateLastVisitedPath} from '@userActions/App'; import * as Modal from '@userActions/Modal'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {OnyxUpdatesFromServer, Report} from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; import PushNotification from '..'; let lastVisitedPath: string | undefined; @@ -30,15 +26,6 @@ Onyx.connect({ }, }); -let allReports: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (value) => { - allReports = value; - }, -}); - function getLastUpdateIDAppliedToClient(): Promise { return new Promise((resolve) => { Onyx.connect({ @@ -112,9 +99,6 @@ function navigateToReport({reportID, reportActionID}: ReportActionPushNotificati Log.info('[PushNotification] Navigating to report', false, {reportID, reportActionID}); const policyID = lastVisitedPath && extractPolicyIDFromPath(lastVisitedPath); - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const policyEmployeeAccountIDs = policyID ? getPolicyEmployeeAccountIDs(policyID) : []; - const reportBelongsToWorkspace = policyID && !isEmptyObject(report) && doesReportBelongToWorkspace(report, policyEmployeeAccountIDs, policyID); Navigation.waitForProtectedRoutes().then(() => { // The attachment modal remains open when navigating to the report so we need to close it @@ -130,10 +114,7 @@ function navigateToReport({reportID, reportActionID}: ReportActionPushNotificati } Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID}); - if (!reportBelongsToWorkspace) { - Navigation.navigateWithSwitchPolicyID({route: ROUTES.HOME}); - } - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(String(reportID))); + Navigation.navigateToReportWithPolicyCheck({reportID: String(reportID), policyIDToCheck: policyID}); updateLastVisitedPath(ROUTES.REPORT_WITH_ID.getRoute(String(reportID))); } catch (error) { let errorMessage = String(error); diff --git a/src/libs/ObjectUtils.ts b/src/libs/ObjectUtils.ts index 644fe1c7596e..9e5a4fc5d8d7 100644 --- a/src/libs/ObjectUtils.ts +++ b/src/libs/ObjectUtils.ts @@ -1,13 +1,20 @@ +const getDefinedKeys = (obj: Record): string[] => { + return Object.entries(obj) + .filter(([, value]) => value !== undefined) + .map(([key]) => key); +}; + const shallowCompare = (obj1?: Record, obj2?: Record): boolean => { if (!obj1 && !obj2) { return true; } if (obj1 && obj2) { - const keys1 = Object.keys(obj1); - const keys2 = Object.keys(obj2); + const keys1 = getDefinedKeys(obj1); + const keys2 = getDefinedKeys(obj2); return keys1.length === keys2.length && keys1.every((key) => obj1[key] === obj2[key]); } return false; }; -export default shallowCompare; +// eslint-disable-next-line import/prefer-default-export +export {shallowCompare}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index fb23b773e4f5..76986c7cad98 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -165,6 +165,7 @@ type PayeePersonalDetails = { login: string; accountID: number; keyForList: string; + isInteractive: boolean; }; type SectionBase = { @@ -1646,6 +1647,7 @@ function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: OnyxEn login: personalDetail?.login ?? '', accountID: personalDetail?.accountID ?? CONST.DEFAULT_NUMBER_ID, keyForList: String(personalDetail?.accountID ?? CONST.DEFAULT_NUMBER_ID), + isInteractive: false, }; } diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 304181158f4e..dc35a2532727 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -7,9 +7,9 @@ import type {OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, PrivatePers import type {Address} from '@src/types/onyx/PrivatePersonalDetails'; import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import * as LocalePhoneNumber from './LocalePhoneNumber'; -import * as Localize from './Localize'; -import * as UserUtils from './UserUtils'; +import {formatPhoneNumber} from './LocalePhoneNumber'; +import {translateLocal} from './Localize'; +import {generateAccountID} from './UserUtils'; type FirstAndLastName = { firstName: string; @@ -42,8 +42,20 @@ Onyx.connect({ if (!value) { return; } - hiddenTranslation = Localize.translateLocal('common.hidden'); - youTranslation = Localize.translateLocal('common.you').toLowerCase(); + hiddenTranslation = translateLocal('common.hidden'); + youTranslation = translateLocal('common.you').toLowerCase(); + }, +}); + +let defaultCountry = ''; + +Onyx.connect({ + key: ONYXKEYS.COUNTRY, + callback: (value) => { + if (!value) { + return; + } + defaultCountry = value; }, }); @@ -118,7 +130,7 @@ function getPersonalDetailsByIDs({ if (shouldChangeUserDisplayName && currentUserAccountID === detail.accountID) { return { ...detail, - displayName: Localize.translateLocal('common.you'), + displayName: translateLocal('common.you'), }; } @@ -143,7 +155,7 @@ function getAccountIDsByLogins(logins: string[]): number[] { const currentDetail = personalDetails.find((detail) => detail?.login === login?.toLowerCase()); if (!currentDetail) { // generate an account ID because in this case the detail is probably new, so we don't have a real accountID yet - foundAccountIDs.push(UserUtils.generateAccountID(login)); + foundAccountIDs.push(generateAccountID(login)); } else { foundAccountIDs.push(Number(currentDetail.accountID)); } @@ -197,7 +209,7 @@ function getPersonalDetailsOnyxDataForOptimisticUsers(newLogins: string[], newAc personalDetailsNew[accountID] = { login, accountID, - displayName: LocalePhoneNumber.formatPhoneNumber(login), + displayName: formatPhoneNumber(login), isOptimisticPersonalDetail: true, }; @@ -294,7 +306,7 @@ function getFormattedAddress(privatePersonalDetails: OnyxEntry | OnyxInputOrEntry): string { // If we have a number like +15857527441@expensify.sms then let's remove @expensify.sms and format it // so that the option looks cleaner in our UI. - const userLogin = LocalePhoneNumber.formatPhoneNumber(login); + const userLogin = formatPhoneNumber(login); if (!passedPersonalDetails) { return userLogin; @@ -370,6 +382,10 @@ function getUserNameByEmail(email: string, nameToDisplay: 'firstName' | 'display return email; } +function getDefaultCountry() { + return defaultCountry; +} + export { isPersonalDetailsEmpty, getDisplayNameOrDefault, @@ -388,4 +404,5 @@ export { getNewAccountIDsAndLogins, getPersonalDetailsLength, getUserNameByEmail, + getDefaultCountry, }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 0a2d4113564a..5798f45dde72 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -56,6 +56,7 @@ type ConnectionWithLastSyncData = { let allPolicies: OnyxCollection; let activePolicyId: OnyxEntry; +let isLoadingReportData = true; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, @@ -68,6 +69,12 @@ Onyx.connect({ callback: (value) => (activePolicyId = value), }); +Onyx.connect({ + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + initWithStoredValues: false, + callback: (value) => (isLoadingReportData = value ?? false), +}); + /** * Filter out the active policies, which will exclude policies with pending deletion * and policies the current user doesn't belong to. @@ -997,6 +1004,10 @@ function getIntegrationLastSuccessfulDate(connection?: Connections[keyof Connect return syncSuccessfulDate; } +function getNSQSCompanyID(policy: Policy) { + return policy.connections?.netsuiteQuickStart?.config?.credentials?.companyID; +} + function getCurrentSageIntacctEntityName(policy: Policy | undefined, defaultNameIfNoEntity: string): string | undefined { const currentEntityID = policy?.connections?.intacct?.config?.entity; if (!currentEntityID) { @@ -1080,6 +1091,12 @@ function goBackWhenEnableFeature(policyID: string) { }, CONST.WORKSPACE_ENABLE_FEATURE_REDIRECT_DELAY); } +function navigateToExpensifyCardPage(policyID: string) { + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policyID)); + }); +} + function getConnectedIntegration(policy: Policy | undefined, accountingIntegrations?: ConnectionName[]) { return (accountingIntegrations ?? Object.values(CONST.POLICY.CONNECTIONS.NAME)).find((integration) => !!policy?.connections?.[integration]); } @@ -1190,6 +1207,17 @@ function areAllGroupPoliciesExpenseChatDisabled(policies = allPolicies) { return !groupPolicies.some((policy) => !!policy?.isPolicyExpenseChatEnabled); } +// eslint-disable-next-line rulesdir/no-negated-variables +function shouldDisplayPolicyNotFoundPage(policyID: string): boolean { + const policy = getPolicy(policyID); + + if (!policy) { + return false; + } + + return !isPolicyAccessible(policy) && !isLoadingReportData; +} + function hasOtherControlWorkspaces(currentPolicyID: string) { const otherControlWorkspaces = Object.values(allPolicies ?? {}).filter((policy) => policy?.id !== currentPolicyID && isPolicyAdmin(policy) && isControlPolicy(policy)); return otherControlWorkspaces.length > 0; @@ -1389,6 +1417,7 @@ export { getNetSuiteReceivableAccountOptions, getNetSuiteInvoiceItemOptions, getNetSuiteTaxAccountOptions, + getNSQSCompanyID, getSageIntacctVendors, getSageIntacctNonReimbursableActiveDefaultVendor, getSageIntacctCreditCards, @@ -1399,6 +1428,7 @@ export { sortWorkspacesBySelected, removePendingFieldsFromCustomUnit, goBackWhenEnableFeature, + navigateToExpensifyCardPage, getIntegrationLastSuccessfulDate, getCurrentConnectionName, getCustomersOrJobsLabelNetSuite, @@ -1430,6 +1460,7 @@ export { getUserFriendlyWorkspaceType, isPolicyAccessible, areAllGroupPoliciesExpenseChatDisabled, + shouldDisplayPolicyNotFoundPage, hasOtherControlWorkspaces, getManagerAccountEmail, getRuleApprovers, diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts index 450a6d7f5481..b79249cd027f 100644 --- a/src/libs/ReportActionComposeFocusManager.ts +++ b/src/libs/ReportActionComposeFocusManager.ts @@ -1,9 +1,9 @@ +import {findFocusedRoute} from '@react-navigation/native'; import React from 'react'; import type {MutableRefObject} from 'react'; import type {TextInput} from 'react-native'; import SCREENS from '@src/SCREENS'; -import getTopmostRouteName from './Navigation/getTopmostRouteName'; -import isReportOpenInRHP from './Navigation/isReportOpenInRHP'; +import isReportOpenInRHP from './Navigation/helpers/isReportOpenInRHP'; import navigationRef from './Navigation/navigationRef'; type FocusCallback = (shouldFocusForNonBlurInputOnTapOutside?: boolean) => void; @@ -38,7 +38,8 @@ function onComposerFocus(callback: FocusCallback | null, isPriorityCallback = fa function focus(shouldFocusForNonBlurInputOnTapOutside?: boolean) { /** Do not trigger the refocusing when the active route is not the report screen */ const navigationState = navigationRef.getState(); - if (!navigationState || (!isReportOpenInRHP(navigationState) && getTopmostRouteName(navigationState) !== SCREENS.REPORT)) { + const focusedRoute = findFocusedRoute(navigationState); + if (!navigationState || (!isReportOpenInRHP(navigationState) && focusedRoute?.name !== SCREENS.REPORT)) { return; } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 71080593baae..607de0fee0e7 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1793,6 +1793,22 @@ function getPolicyChangeLogDeleteMemberMessage(reportAction: OnyxInputOrEntry, +): reportAction is ReportAction { + return isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY); +} + +function getPolicyChangeLogUpdateAutoReportingFrequencyMessage(reportAction: OnyxInputOrEntry): string { + if (!isPolicyChangeLogUpdateAutoReportingFrequencyMessage(reportAction)) { + return ''; + } + const originalMessage = getOriginalMessage(reportAction); + const oldFrequency = translateLocal(`workspace.common.frequency.${originalMessage?.oldFrequency}` as TranslationPaths); + const newFrequency = translateLocal(`workspace.common.frequency.${originalMessage?.newFrequency}` as TranslationPaths); + return translateLocal('report.actions.type.updateAutoReportingFrequency', {oldFrequency, newFrequency}); +} + function getRemovedConnectionMessage(reportAction: OnyxEntry): string { if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION)) { return ''; @@ -2026,6 +2042,7 @@ export { getPolicyChangeLogAddEmployeeMessage, getPolicyChangeLogChangeRoleMessage, getPolicyChangeLogDeleteMemberMessage, + getPolicyChangeLogUpdateAutoReportingFrequencyMessage, getPolicyChangeLogEmployeeLeftMessage, getRenamedAction, isCardIssuedAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 12f748a86d06..a7a67f6de26f 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -77,8 +77,9 @@ import Log from './Log'; import {isEmailPublicDomain} from './LoginUtils'; // eslint-disable-next-line import/no-cycle import ModifiedExpenseMessage from './ModifiedExpenseMessage'; +import {isFullScreenName} from './Navigation/helpers/isNavigatorName'; import {linkingConfig} from './Navigation/linkingConfig'; -import Navigation from './Navigation/Navigation'; +import Navigation, {navigationRef} from './Navigation/Navigation'; import {rand64} from './NumberUtils'; import Parser from './Parser'; import Permissions from './Permissions'; @@ -4672,8 +4673,10 @@ function navigateBackOnDeleteTransaction(backRoute: Route | undefined, isFromRHP if (!backRoute) { return; } - const topmostCentralPaneRoute = Navigation.getTopMostCentralPaneRouteFromRootState(); - if (topmostCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE) { + + const rootState = navigationRef.current?.getRootState(); + const lastFullScreenRoute = rootState?.routes.findLast((route) => isFullScreenName(route.name)); + if (lastFullScreenRoute?.name === SCREENS.SEARCH.ROOT) { Navigation.dismissModal(); return; } diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 9ff584e3a882..bb74310fd762 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -38,7 +38,18 @@ import { isMoneyRequestReport, isSettled, } from './ReportUtils'; -import {getAmount as getTransactionAmount, getCreated as getTransactionCreatedDate, getMerchant as getTransactionMerchant, isExpensifyCardTransaction, isPending} from './TransactionUtils'; +import { + getMerchant, + getAmount as getTransactionAmount, + getCreated as getTransactionCreatedDate, + getMerchant as getTransactionMerchant, + isAmountMissing, + isExpensifyCardTransaction, + isPartialMerchant, + isPending, + isReceiptBeingScanned, + isScanRequest, +} from './TransactionUtils'; const columnNamesToSortingProperty = { [CONST.SEARCH.TABLE_COLUMNS.TO]: 'formattedTo' as const, @@ -347,10 +358,14 @@ function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTr if (canIOUBePaid(report, chatReport, policy, allReportTransactions, false, chatReportRNVP, invoiceReceiverPolicy) && !hasOnlyHeldExpenses(report.reportID, allReportTransactions)) { return CONST.SEARCH.ACTION_TYPES.PAY; } - const hasOnlyPendingTransactions = allReportTransactions.length > 0 && allReportTransactions.every((t) => isExpensifyCardTransaction(t) && isPending(t)); + const hasOnlyPendingCardOrScanningTransactions = + allReportTransactions.length > 0 && + allReportTransactions.every( + (t) => (isExpensifyCardTransaction(t) && isPending(t)) || (isPartialMerchant(getMerchant(t)) && isAmountMissing(t)) || (isScanRequest(t) && isReceiptBeingScanned(t)), + ); const isAllowedToApproveExpenseReport = isAllowedToApproveExpenseReportUtils(report, undefined, policy); - if (canApproveIOU(report, policy) && isAllowedToApproveExpenseReport && !hasOnlyPendingTransactions) { + if (canApproveIOU(report, policy) && isAllowedToApproveExpenseReport && !hasOnlyPendingCardOrScanningTransactions) { return CONST.SEARCH.ACTION_TYPES.APPROVE; } @@ -637,7 +652,12 @@ function getOverflowMenu(itemName: string, hash: number, inputQuery: string, sho }, { text: translateLocal('common.delete'), - onSelected: () => showDeleteModal(hash), + onSelected: () => { + if (isMobileMenu && closeMenu) { + closeMenu(); + } + showDeleteModal(hash); + }, icon: Expensicons.Trashcan, shouldShowRightIcon: false, shouldShowRightComponent: false, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 5e21a43cea9f..fc9ba60be715 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -30,6 +30,7 @@ import { getPolicyChangeLogChangeRoleMessage, getPolicyChangeLogDeleteMemberMessage, getPolicyChangeLogEmployeeLeftMessage, + getPolicyChangeLogUpdateAutoReportingFrequencyMessage, getRemovedConnectionMessage, getRenamedAction, getReportAction, @@ -515,6 +516,9 @@ function getOptionData({ result.alternateText = getRenamedAction(lastAction); } else if (isTaskAction(lastAction)) { result.alternateText = formatReportLastMessageText(getTaskReportActionMessage(lastAction).text); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM) { + const actionMessage = getReportActionMessageText(lastAction); + result.alternateText = actionMessage ? `${lastActorDisplayName}: ${actionMessage}` : ''; } else if (isInviteOrRemovedAction(lastAction)) { const lastActionOriginalMessage = lastAction?.actionName ? getOriginalMessage(lastAction) : null; const targetAccountIDs = lastActionOriginalMessage?.targetAccountIDs ?? []; @@ -557,6 +561,8 @@ function getOptionData({ result.alternateText = getReportActionMessageText(lastAction) ?? ''; } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION) { result.alternateText = getRemovedConnectionMessage(lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) { + result.alternateText = getPolicyChangeLogUpdateAutoReportingFrequencyMessage(lastAction); } else { result.alternateText = lastMessageTextFromReport.length > 0 diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 581271d72c22..f5fa451c59a4 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -27,7 +27,7 @@ import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxData} from '@src/types/onyx/Request'; import {setShouldForceOffline} from './Network'; -import {getAll, save} from './PersistedRequests'; +import {getAll, rollbackOngoingRequest, save} from './PersistedRequests'; import {createDraftInitialWorkspace, createWorkspace, generatePolicyID} from './Policy/Policy'; import {resolveDuplicationConflictAction} from './RequestConflictUtils'; import {isAnonymousUser} from './Session'; @@ -411,6 +411,7 @@ function endSignOnTransition() { * @param [policyID] Optional, Policy id. * @param [currency] Optional, selected currency for the workspace * @param [file], avatar file for workspace + * @param [routeToNavigateAfterCreate], Optional, route to navigate after creating a workspace */ function createWorkspaceWithPolicyDraftAndNavigateToIt( policyOwnerEmail = '', @@ -421,18 +422,19 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt( policyID = '', currency?: string, file?: File, + routeToNavigateAfterCreate?: Route, ) { const policyIDWithDefault = policyID || generatePolicyID(); createDraftInitialWorkspace(policyOwnerEmail, policyName, policyIDWithDefault, makeMeAdmin, currency, file); - Navigation.isNavigationReady() .then(() => { if (transitionFromOldDot) { // We must call goBack() to remove the /transition route from history Navigation.goBack(); } + const routeToNavigate = routeToNavigateAfterCreate ?? ROUTES.WORKSPACE_INITIAL.getRoute(policyIDWithDefault, backTo); savePolicyDraftByNewWorkspace(policyIDWithDefault, policyName, policyOwnerEmail, makeMeAdmin, currency, file); - Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyIDWithDefault, backTo)); + Navigation.navigate(routeToNavigate, {forceReplace: !transitionFromOldDot}); }) .then(endSignOnTransition); } @@ -588,38 +590,42 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { const isStateImported = isUsingImportedState; const shouldUseStagingServer = preservedShouldUseStagingServer; const sequentialQueue = getAll(); - Onyx.clear(KEYS_TO_PRESERVE).then(() => { - // Network key is preserved, so when using imported state, we should stop forcing offline mode so that the app can re-fetch the network - if (isStateImported) { - setShouldForceOffline(false); - } - - if (shouldNavigateToHomepage) { - Navigation.navigate(ROUTES.HOME); - } - if (preservedUserSession) { - Onyx.set(ONYXKEYS.SESSION, preservedUserSession); - Onyx.set(ONYXKEYS.PRESERVED_USER_SESSION, null); - } + rollbackOngoingRequest(); + Onyx.clear(KEYS_TO_PRESERVE) + .then(() => { + // Network key is preserved, so when using imported state, we should stop forcing offline mode so that the app can re-fetch the network + if (isStateImported) { + setShouldForceOffline(false); + } - if (shouldUseStagingServer) { - Onyx.set(ONYXKEYS.USER, {shouldUseStagingServer}); - } + if (shouldNavigateToHomepage) { + Navigation.navigate(ROUTES.HOME); + } - // Requests in a sequential queue should be called even if the Onyx state is reset, so we do not lose any pending data. - // However, the OpenApp request must be called before any other request in a queue to ensure data consistency. - // To do that, sequential queue is cleared together with other keys, and then it's restored once the OpenApp request is resolved. - openApp().then(() => { - if (!sequentialQueue || isStateImported) { - return; + if (preservedUserSession) { + Onyx.set(ONYXKEYS.SESSION, preservedUserSession); + Onyx.set(ONYXKEYS.PRESERVED_USER_SESSION, null); } - sequentialQueue.forEach((request) => { - save(request); + if (shouldUseStagingServer) { + Onyx.set(ONYXKEYS.USER, {shouldUseStagingServer}); + } + }) + .then(() => { + // Requests in a sequential queue should be called even if the Onyx state is reset, so we do not lose any pending data. + // However, the OpenApp request must be called before any other request in a queue to ensure data consistency. + // To do that, sequential queue is cleared together with other keys, and then it's restored once the OpenApp request is resolved. + openApp().then(() => { + if (!sequentialQueue || isStateImported) { + return; + } + + sequentialQueue.forEach((request) => { + save(request); + }); }); }); - }); clearSoundAssetsCache(); } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 58c395ffbd28..33d3ef57fdf9 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -95,6 +95,7 @@ function openPersonalBankAccountSetupView(exitReportID?: string, policyID?: stri } if (!isUserValidated) { Navigation.navigate(ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT.getRoute(Navigation.getActiveRoute(), ROUTES.SETTINGS_ADD_BANK_ACCOUNT)); + return; } Navigation.navigate(ROUTES.SETTINGS_ADD_BANK_ACCOUNT); }); diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 7db91da05ce9..8e6d73053f10 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -532,10 +532,9 @@ function updateDelegateRole(email: string, role: DelegateRole, validateCode: str delegate.email === email ? { ...delegate, - role, + isLoading: true, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: {role: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, - isLoading: true, } : delegate, ), @@ -560,9 +559,9 @@ function updateDelegateRole(email: string, role: DelegateRole, validateCode: str ? { ...delegate, role, + isLoading: false, pendingAction: null, pendingFields: {role: null}, - isLoading: false, } : delegate, ), diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 3a93e929b681..008220df05cc 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -50,7 +50,7 @@ import isFileUploadable from '@libs/isFileUploadable'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import * as Localize from '@libs/Localize'; import Log from '@libs/Log'; -import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import {buildNextStep} from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; @@ -4581,7 +4581,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) { } InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : activeReportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : activeReportID); if (activeReportID) { notifyNewAction(activeReportID, payeeAccountID); } @@ -4659,7 +4659,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf API.write(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST, parameters, onyxData); InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : activeReportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : activeReportID); if (activeReportID) { notifyNewAction(activeReportID, payeeAccountID); } @@ -4717,7 +4717,7 @@ function sendInvoice( API.write(WRITE_COMMANDS.SEND_INVOICE, parameters, onyxData); InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - if (isSearchTopmostCentralPane()) { + if (isSearchTopmostFullScreenRoute()) { Navigation.dismissModal(); } else { Navigation.dismissModalWithReport(invoiceRoom); @@ -4936,10 +4936,10 @@ function trackExpense(params: CreateTrackExpenseParams) { } } InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : activeReportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : activeReportID); if (action === CONST.IOU.ACTION.SHARE) { - if (isSearchTopmostCentralPane() && activeReportID) { + if (isSearchTopmostFullScreenRoute() && activeReportID) { Navigation.goBack(); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(activeReportID)); } @@ -5511,7 +5511,7 @@ function splitBill({ API.write(WRITE_COMMANDS.SPLIT_BILL, parameters, onyxData); InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : existingSplitChatReportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : existingSplitChatReportID); notifyNewAction(splitData.chatReportID, currentUserAccountID); } @@ -5579,7 +5579,7 @@ function splitBillAndOpenReport({ API.write(WRITE_COMMANDS.SPLIT_BILL_AND_OPEN_REPORT, parameters, onyxData); InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : splitData.chatReportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : splitData.chatReportID); notifyNewAction(splitData.chatReportID, currentUserAccountID); } @@ -6153,7 +6153,7 @@ function completeSplitBill( API.write(WRITE_COMMANDS.COMPLETE_SPLIT_BILL, parameters, {optimisticData, successData, failureData}); InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : chatReportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : chatReportID); notifyNewAction(chatReportID, sessionAccountID); } @@ -6330,7 +6330,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData); InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); const activeReportID = isMoneyRequestReport && report?.reportID ? report.reportID : parameters.chatReportID; - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : activeReportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : activeReportID); notifyNewAction(activeReportID, userAccountID); } @@ -8000,7 +8000,7 @@ function sendMoneyElsewhere(report: OnyxEntry, amount: number, API.write(WRITE_COMMANDS.SEND_MONEY_ELSEWHERE, params, {optimisticData, successData, failureData}); - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : params.chatReportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : params.chatReportID); notifyNewAction(params.chatReportID, managerID); } @@ -8013,7 +8013,7 @@ function sendMoneyWithWallet(report: OnyxEntry, amount: number API.write(WRITE_COMMANDS.SEND_MONEY_WITH_WALLET, params, {optimisticData, successData, failureData}); - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : params.chatReportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : params.chatReportID); notifyNewAction(params.chatReportID, managerID); } @@ -8039,20 +8039,21 @@ function canApproveIOU( const iouSettled = isSettled(iouReport?.reportID); const reportNameValuePairs = chatReportRNVP ?? getReportNameValuePairs(iouReport?.reportID); const isArchivedExpenseReport = isArchivedReport(reportNameValuePairs); - let isTransactionBeingScanned = false; const reportTransactions = getReportTransactions(iouReport?.reportID); - for (const transaction of reportTransactions) { - const hasReceipt = hasReceiptTransactionUtils(transaction); - const isReceiptBeingScanned = isReceiptBeingScannedTransactionUtils(transaction); - - // If transaction has receipt (scan) and its receipt is being scanned, we shouldn't be able to Approve - if (hasReceipt && isReceiptBeingScanned) { - isTransactionBeingScanned = true; - } + const hasOnlyPendingCardOrScanningTransactions = + reportTransactions.length > 0 && + reportTransactions.every( + (transaction) => + (isExpensifyCardTransaction(transaction) && isPending(transaction)) || + (isPartialMerchant(getMerchant(transaction)) && isAmountMissing(transaction)) || + (isScanRequestTransactionUtils(transaction) && isReceiptBeingScannedTransactionUtils(transaction)), + ); + if (hasOnlyPendingCardOrScanningTransactions) { + return false; } const isPayAtEndExpenseReport = isPayAtEndExpenseReportReportUtils(iouReport?.reportID, reportTransactions); - return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedExpenseReport && !isTransactionBeingScanned && !isPayAtEndExpenseReport; + return reportTransactions.length > 0 && isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled && !isArchivedExpenseReport && !isPayAtEndExpenseReport; } function canIOUBePaid( diff --git a/src/libs/actions/ImportOnyxState.ts b/src/libs/actions/ImportOnyxState.ts new file mode 100644 index 000000000000..772b58283807 --- /dev/null +++ b/src/libs/actions/ImportOnyxState.ts @@ -0,0 +1,25 @@ +import type {OnyxEntry, OnyxKey} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import type {OnyxCollectionKey, OnyxCollectionValuesMapping, OnyxValues} from '@src/ONYXKEYS'; +import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; +import {KEYS_TO_PRESERVE} from './App'; + +function clearOnyxStateBeforeImport(): Promise { + return Onyx.clear(KEYS_TO_PRESERVE); +} + +function importOnyxCollectionState(collectionsMap: Map>): Promise { + const collectionPromises = Array.from(collectionsMap.entries()).map(([baseKey, items]) => { + return items ? Onyx.setCollection(baseKey, items) : Promise.resolve(); + }); + return Promise.all(collectionPromises); +} + +function importOnyxRegularState(state: Partial>>): Promise { + if (Object.keys(state).length > 0) { + return Onyx.multiSet(state as Partial); + } + return Promise.resolve(); +} + +export {clearOnyxStateBeforeImport, importOnyxCollectionState, importOnyxRegularState}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index d39b7bfe6649..a65b3493171e 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -73,9 +73,9 @@ import * as NumberUtils from '@libs/NumberUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; -import {goBackWhenEnableFeature} from '@libs/PolicyUtils'; +import {goBackWhenEnableFeature, navigateToExpensifyCardPage} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import type {PolicySelector} from '@pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover'; +import type {PolicySelector} from '@pages/home/sidebar/FloatingActionButtonAndPopover'; import * as PaymentMethods from '@userActions/PaymentMethods'; import * as PersistedRequests from '@userActions/PersistedRequests'; import type {OnboardingPurpose} from '@src/CONST'; @@ -346,6 +346,7 @@ function deleteWorkspace(policyID: string, policyName: string) { oldPolicyName: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name, policyName: '', }), + isPinned: false, }, }); @@ -417,6 +418,7 @@ function deleteWorkspace(policyID: string, policyName: string) { value: { oldPolicyName, policyName: report?.policyName, + isPinned: report?.isPinned, }, }); failureData.push({ @@ -2849,7 +2851,7 @@ function savePreferredExportMethod(policyID: string, exportMethod: ReportExportT Onyx.merge(`${ONYXKEYS.LAST_EXPORT_METHOD}`, {[policyID]: exportMethod}); } -function enableExpensifyCard(policyID: string, enabled: boolean) { +function enableExpensifyCard(policyID: string, enabled: boolean, shouldNavigateToExpensifyCardPage = false) { const authToken = NetworkStore.getAuthToken(); if (!authToken) { return; @@ -2896,6 +2898,11 @@ function enableExpensifyCard(policyID: string, enabled: boolean) { API.write(WRITE_COMMANDS.ENABLE_POLICY_EXPENSIFY_CARDS, parameters, onyxData); + if (enabled && shouldNavigateToExpensifyCardPage) { + navigateToExpensifyCardPage(policyID); + return; + } + if (enabled && getIsNarrowLayout()) { goBackWhenEnableFeature(policyID); } diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index bc051f1472bb..d9a5cf8f2bc6 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -67,16 +67,16 @@ import isPublicScreenRoute from '@libs/isPublicScreenRoute'; import * as Localize from '@libs/Localize'; import Log from '@libs/Log'; import {registerPaginationConfig} from '@libs/Middleware/Pagination'; +import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; +import type {LinkToOptions} from '@libs/Navigation/helpers/linkTo/types'; +import shouldOpenOnAdminRoom from '@libs/Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import shouldOpenOnAdminRoom from '@libs/Navigation/shouldOpenOnAdminRoom'; -import {isOnboardingFlowName} from '@libs/NavigationUtils'; import enhanceParameters from '@libs/Network/enhanceParameters'; import type {NetworkStatus} from '@libs/NetworkConnection'; import LocalNotification from '@libs/Notification/LocalNotification'; import Parser from '@libs/Parser'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; -import getPolicyEmployeeAccountIDs from '@libs/PolicyEmployeeListUtils'; import {extractPolicyIDFromPath, getPolicy} from '@libs/PolicyUtils'; import processReportIDDeeplink from '@libs/processReportIDDeeplink'; import * as Pusher from '@libs/Pusher/pusher'; @@ -97,7 +97,6 @@ import { buildOptimisticTaskReportAction, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, completeShortMention, - doesReportBelongToWorkspace, findLastAccessedReport, findSelfDMReportID, formatReportLastMessageText, @@ -1145,7 +1144,6 @@ function openReport( function navigateToAndOpenReport( userLogins: string[], shouldDismissModal = true, - actionType?: string, reportName?: string, avatarUri?: string, avatarFile?: File | CustomRNImageManipulatorResult | undefined, @@ -1164,7 +1162,7 @@ function navigateToAndOpenReport( if (isEmptyObject(chat)) { if (isGroupChat) { // If we are creating a group chat then participantAccountIDs is expected to contain currentUserAccountID - newChat = buildOptimisticGroupChatReport(participantAccountIDs, reportName ?? '', avatarUri ?? '', optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS); + newChat = buildOptimisticGroupChatReport(participantAccountIDs, reportName ?? '', avatarUri ?? '', optimisticReportID, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); } else { newChat = buildOptimisticChatReport( [...participantAccountIDs, currentUserAccountID], @@ -1176,7 +1174,7 @@ function navigateToAndOpenReport( undefined, undefined, undefined, - CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, ); } } @@ -1185,11 +1183,13 @@ function navigateToAndOpenReport( // We want to pass newChat here because if anything is passed in that param (even an existing chat), we will try to create a chat on the server openReport(report?.reportID, '', userLogins, newChat, undefined, undefined, undefined, avatarFile); if (shouldDismissModal) { - Navigation.dismissModalWithReport(report); - } else { - Navigation.navigateWithSwitchPolicyID({route: ROUTES.HOME}); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report?.reportID), actionType); + Navigation.dismissModal(); } + + // In some cases when RHP modal gets hidden and then we navigate to report Composer focus breaks, wrapping navigation in setTimeout fixes this + setTimeout(() => { + Navigation.isNavigationReady().then(() => Navigation.navigateToReportWithPolicyCheck({report})); + }, 0); } /** @@ -1545,7 +1545,7 @@ function handleReportChanged(report: OnyxEntry) { const currCallback = callback; callback = () => { currCallback(); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(preexistingReportID), CONST.NAVIGATION.TYPE.UP); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(preexistingReportID), {forceReplace: true}); }; // The report screen will listen to this event and transfer the draft comment to the existing report @@ -1712,7 +1712,7 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) { // if we are linking to the report action, and we are deleting it, and it's not a deleted parent action, // we should navigate to its report in order to not show not found page if (Navigation.isActiveRoute(ROUTES.REPORT_WITH_ID.getRoute(reportID, reportActionID)) && !isDeletedParentAction) { - Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(reportID), true); + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(reportID)); } } @@ -2369,7 +2369,7 @@ function updateWriteCapability(report: Report, newValue: WriteCapability) { /** * Navigates to the 1:1 report with Concierge */ -function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageActive = () => true, actionType?: string) { +function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageActive = () => true, linkToOptions?: LinkToOptions) { // If conciergeChatReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID. // Otherwise, we would find the concierge chat and navigate to it. if (!conciergeChatReportID) { @@ -2380,12 +2380,12 @@ function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageA if (!checkIfCurrentPageActive()) { return; } - navigateToAndOpenReport([CONST.EMAIL.CONCIERGE], shouldDismissModal, actionType); + navigateToAndOpenReport([CONST.EMAIL.CONCIERGE], shouldDismissModal); }); } else if (shouldDismissModal) { Navigation.dismissModal(conciergeChatReportID); } else { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeChatReportID), actionType); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeChatReportID), linkToOptions); } } @@ -2542,8 +2542,10 @@ function navigateToConciergeChatAndDeleteReport(reportID: string | undefined, sh // Dismiss the current report screen and replace it with Concierge Chat if (shouldPopToTop) { Navigation.setShouldPopAllStateOnUP(true); + Navigation.goBack(undefined, {shouldPopToTop: true}); + } else { + Navigation.goBack(); } - Navigation.goBack(undefined, undefined, shouldPopToTop); navigateToConciergeChat(); InteractionManager.runAfterInteractions(() => { deleteReport(reportID, shouldDeleteChildReports); @@ -2727,12 +2729,7 @@ function showReportActionNotification(reportID: string, reportAction: ReportActi const onClick = () => close(() => { const policyID = lastVisitedPath && extractPolicyIDFromPath(lastVisitedPath); - const policyEmployeeAccountIDs = policyID ? getPolicyEmployeeAccountIDs(policyID) : []; - const reportBelongsToWorkspace = policyID ? doesReportBelongToWorkspace(report, policyEmployeeAccountIDs, policyID) : false; - if (!reportBelongsToWorkspace) { - Navigation.navigateWithSwitchPolicyID({route: ROUTES.HOME}); - } - navigateFromNotification(reportID); + navigateFromNotification(reportID, policyID); }); if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE) { @@ -2953,14 +2950,14 @@ function openReportFromDeepLink(url: string) { const lastAccessedReportID = findLastAccessedReport(false, shouldOpenOnAdminRoom(), undefined, reportID)?.reportID; if (lastAccessedReportID) { const lastAccessedReportRoute = ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID); - Navigation.navigate(lastAccessedReportRoute, CONST.NAVIGATION.ACTION_TYPE.PUSH); + Navigation.navigate(lastAccessedReportRoute); return; } - navigateToConciergeChat(false, () => true, CONST.NAVIGATION.ACTION_TYPE.PUSH); + navigateToConciergeChat(false, () => true); return; } - Navigation.navigate(route as Route, CONST.NAVIGATION.ACTION_TYPE.PUSH); + Navigation.navigate(route as Route); }; if (isAnonymousUser()) { @@ -2998,7 +2995,7 @@ function navigateToMostRecentReport(currentReport: OnyxEntry) { Navigation.goBack(); } - navigateToConciergeChat(false, () => true, CONST.NAVIGATION.TYPE.UP); + navigateToConciergeChat(false, () => true, {forceReplace: true}); } } @@ -4677,6 +4674,7 @@ export { broadcastUserIsTyping, clearAddRoomMemberError, clearAvatarErrors, + clearDeleteTransactionNavigateBackUrl, clearGroupChat, clearIOUError, clearNewRoomFormError, @@ -4694,6 +4692,7 @@ export { exportReportToCSV, exportToIntegration, flagComment, + getConciergeReportID, getCurrentUserAccountID, getDraftPrivateNote, getMostRecentReportID, @@ -4729,6 +4728,7 @@ export { saveReportActionDraft, saveReportDraftComment, searchInServer, + setDeleteTransactionNavigateBackUrl, setGroupDraft, setIsComposerFullSize, setLastOpenedPublicRoom, @@ -4756,8 +4756,5 @@ export { updateReportName, updateRoomVisibility, updateWriteCapability, - getConciergeReportID, - setDeleteTransactionNavigateBackUrl, - clearDeleteTransactionNavigateBackUrl, prepareOnboardingOnyxData, }; diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index add7bf271795..096ae001cdef 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -1,10 +1,10 @@ import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; import type {NavigationState, PartialState} from '@react-navigation/native'; import Onyx from 'react-native-onyx'; +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; import {linkingConfig} from '@libs/Navigation/linkingConfig'; -import getAdaptedStateFromPath from '@libs/Navigation/linkingConfig/getAdaptedStateFromPath'; import {navigationRef} from '@libs/Navigation/Navigation'; -import type {RootStackParamList} from '@libs/Navigation/types'; +import type {RootNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -39,8 +39,8 @@ Onyx.connect({ */ function startOnboardingFlow(isPrivateDomain?: boolean) { const currentRoute = navigationRef.getCurrentRoute(); - const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(isPrivateDomain), linkingConfig.config, false); - const focusedRoute = findFocusedRoute(adaptedState as PartialState>); + const adaptedState = getAdaptedStateFromPath(getOnboardingInitialPath(isPrivateDomain), linkingConfig.config, false); + const focusedRoute = findFocusedRoute(adaptedState as PartialState>); if (focusedRoute?.name === currentRoute?.name) { return; } diff --git a/src/libs/actions/connections/index.ts b/src/libs/actions/connections/index.ts index 0008b22cd380..f15f0d6996d0 100644 --- a/src/libs/actions/connections/index.ts +++ b/src/libs/actions/connections/index.ts @@ -5,6 +5,7 @@ import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type { RemovePolicyConnectionParams, + SyncPolicyToNSQSParams, SyncPolicyToQuickbooksDesktopParams, UpdateManyPolicyConnectionConfigurationsParams, UpdatePolicyConnectionConfigParams, @@ -12,6 +13,7 @@ import type { import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; +import {getNSQSCompanyID} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; @@ -222,14 +224,15 @@ function getSyncConnectionParameters(connectionName: PolicyConnectionName) { /** * This method helps in syncing policy to the connected accounting integration. * - * @param policyID - ID of the policy for which the sync is needed + * @param policy - Policy for which the sync is needed * @param connectionName - Name of the connection, QBO/Xero * @param forceDataRefresh - If true, it will trigger a full data refresh */ -function syncConnection(policyID: string, connectionName: PolicyConnectionName | undefined, forceDataRefresh = false) { - if (!connectionName) { +function syncConnection(policy: Policy | undefined, connectionName: PolicyConnectionName | undefined, forceDataRefresh = false) { + if (!connectionName || !policy) { return; } + const policyID = policy.id; const syncConnectionData = getSyncConnectionParameters(connectionName); if (!syncConnectionData) { @@ -255,7 +258,7 @@ function syncConnection(policyID: string, connectionName: PolicyConnectionName | }, ]; - const parameters: SyncPolicyToQuickbooksDesktopParams = { + const parameters: SyncPolicyToQuickbooksDesktopParams | SyncPolicyToNSQSParams = { policyID, idempotencyKey: policyID, }; @@ -263,6 +266,9 @@ function syncConnection(policyID: string, connectionName: PolicyConnectionName | if (connectionName === CONST.POLICY.CONNECTIONS.NAME.QBD) { parameters.forceDataRefresh = forceDataRefresh; } + if (connectionName === CONST.POLICY.CONNECTIONS.NAME.NSQS) { + (parameters as SyncPolicyToNSQSParams).netSuiteAccountID = getNSQSCompanyID(policy) ?? ''; + } API.read(syncConnectionData.readCommand, parameters, { optimisticData, diff --git a/src/libs/actions/navigateFromNotification/index.native.ts b/src/libs/actions/navigateFromNotification/index.native.ts index 488ec8ac74e8..9e0a98e6b1c4 100644 --- a/src/libs/actions/navigateFromNotification/index.native.ts +++ b/src/libs/actions/navigateFromNotification/index.native.ts @@ -1,8 +1,7 @@ import Navigation from '@libs/Navigation/Navigation'; -import ROUTES from '@src/ROUTES'; -const navigateFromNotification = (reportID: string) => { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); +const navigateFromNotification = (reportID: string, policyIDToCheck?: string) => { + Navigation.navigateToReportWithPolicyCheck({reportID, policyIDToCheck}); }; export default navigateFromNotification; diff --git a/src/libs/actions/navigateFromNotification/index.ts b/src/libs/actions/navigateFromNotification/index.ts index f710a16a3e70..3a7d01947be8 100644 --- a/src/libs/actions/navigateFromNotification/index.ts +++ b/src/libs/actions/navigateFromNotification/index.ts @@ -1,9 +1,8 @@ import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; -const navigateFromNotification = (reportID: string) => { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, CONST.REFERRER.NOTIFICATION)); +const navigateFromNotification = (reportID: string, policyIDToCheck?: string) => { + Navigation.navigateToReportWithPolicyCheck({reportID, referrer: CONST.REFERRER.NOTIFICATION, policyIDToCheck}); }; export default navigateFromNotification; diff --git a/src/libs/freezeScreenWithLazyLoading.tsx b/src/libs/freezeScreenWithLazyLoading.tsx deleted file mode 100644 index eb3c8fa8bc63..000000000000 --- a/src/libs/freezeScreenWithLazyLoading.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import memoize from './memoize'; -import FreezeWrapper from './Navigation/FreezeWrapper'; - -function FrozenScreen(WrappedComponent: React.ComponentType) { - return (props: TProps) => ( - - - - ); -} - -export default function freezeScreenWithLazyLoading(lazyComponent: () => React.ComponentType) { - return memoize( - () => { - const Component = lazyComponent(); - return FrozenScreen(Component); - }, - {monitoringName: 'freezeScreenWithLazyLoading'}, - ); -} diff --git a/src/libs/navigateAfterJoinRequest/index.desktop.ts b/src/libs/navigateAfterJoinRequest/index.desktop.ts index cf6d009291c8..d63fcb25aaf7 100644 --- a/src/libs/navigateAfterJoinRequest/index.desktop.ts +++ b/src/libs/navigateAfterJoinRequest/index.desktop.ts @@ -3,7 +3,7 @@ import Navigation from '@navigation/Navigation'; import ROUTES from '@src/ROUTES'; const navigateAfterJoinRequest = () => { - Navigation.goBack(undefined, false, true); + Navigation.goBack(undefined, {shouldPopToTop: true}); if (getIsSmallScreenWidth()) { Navigation.navigate(ROUTES.SETTINGS); } diff --git a/src/libs/navigateAfterJoinRequest/index.ts b/src/libs/navigateAfterJoinRequest/index.ts index 42e91d18c6ba..60cbf64cda90 100644 --- a/src/libs/navigateAfterJoinRequest/index.ts +++ b/src/libs/navigateAfterJoinRequest/index.ts @@ -2,7 +2,7 @@ import Navigation from '@navigation/Navigation'; import ROUTES from '@src/ROUTES'; const navigateAfterJoinRequest = () => { - Navigation.goBack(undefined, false, true); + Navigation.goBack(undefined, {shouldPopToTop: true}); Navigation.navigate(ROUTES.SETTINGS); Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); }; diff --git a/src/libs/navigateAfterJoinRequest/index.web.ts b/src/libs/navigateAfterJoinRequest/index.web.ts index cf6d009291c8..d63fcb25aaf7 100644 --- a/src/libs/navigateAfterJoinRequest/index.web.ts +++ b/src/libs/navigateAfterJoinRequest/index.web.ts @@ -3,7 +3,7 @@ import Navigation from '@navigation/Navigation'; import ROUTES from '@src/ROUTES'; const navigateAfterJoinRequest = () => { - Navigation.goBack(undefined, false, true); + Navigation.goBack(undefined, {shouldPopToTop: true}); if (getIsSmallScreenWidth()) { Navigation.navigate(ROUTES.SETTINGS); } diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 8c74439fb00a..0938d44295c2 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -1,6 +1,6 @@ import ROUTES from '@src/ROUTES'; +import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; -import shouldOpenOnAdminRoom from './Navigation/shouldOpenOnAdminRoom'; import {findLastAccessedReport, isConciergeChatReport} from './ReportUtils'; const navigateAfterOnboarding = ( diff --git a/src/pages/AddPersonalBankAccountPage.tsx b/src/pages/AddPersonalBankAccountPage.tsx index 7e99a9427216..c441174a21e5 100644 --- a/src/pages/AddPersonalBankAccountPage.tsx +++ b/src/pages/AddPersonalBankAccountPage.tsx @@ -12,13 +12,14 @@ import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import getPlaidOAuthReceivedRedirectURI from '@libs/getPlaidOAuthReceivedRedirectURI'; -import Navigation from '@libs/Navigation/Navigation'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {addPersonalBankAccount, clearPersonalBankAccount, validatePlaidSelection} from '@userActions/BankAccounts'; import {continueSetup} from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; function AddPersonalBankAccountPage() { @@ -30,22 +31,21 @@ function AddPersonalBankAccountPage() { const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA); const {canUseInternationalBankAccount} = usePermissions(); const shouldShowSuccess = personalBankAccount?.shouldShowSuccess ?? false; - - const topMostCentralPane = Navigation.getTopMostCentralPaneRouteFromRootState(); + const topmostFullScreenRoute = navigationRef.current?.getRootState()?.routes.findLast((route) => isFullScreenName(route.name)); const goBack = useCallback(() => { - switch (topMostCentralPane?.name) { - case SCREENS.SETTINGS.WALLET.ROOT: - Navigation.goBack(ROUTES.SETTINGS_WALLET, true); + switch (topmostFullScreenRoute?.name) { + case NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR: + Navigation.goBack(ROUTES.SETTINGS_WALLET); break; - case SCREENS.REPORT: + case NAVIGATORS.REPORTS_SPLIT_NAVIGATOR: Navigation.closeRHPFlow(); break; default: Navigation.goBack(); break; } - }, [topMostCentralPane]); + }, [topmostFullScreenRoute?.name]); const submitBankAccountForm = useCallback(() => { const bankAccounts = plaidData?.bankAccounts ?? []; diff --git a/src/pages/AddressPage.tsx b/src/pages/AddressPage.tsx index a0793a958f49..8e3d0d373dc2 100644 --- a/src/pages/AddressPage.tsx +++ b/src/pages/AddressPage.tsx @@ -25,16 +25,18 @@ type AddressPageProps = { updateAddress: (values: FormOnyxValues) => void; /** Title of address page */ title: string; + + defaultCountry?: Country; } & BackToParams; -function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo}: AddressPageProps) { +function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo, defaultCountry}: AddressPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); // Check if country is valid const {street} = address ?? {}; const [street1, street2] = street ? street.split('\n') : [undefined, undefined]; - const [currentCountry, setCurrentCountry] = useState(address?.country); + const [currentCountry, setCurrentCountry] = useState(address?.country ?? defaultCountry); const [state, setState] = useState(address?.state); const [city, setCity] = useState(address?.city); const [zipcode, setZipcode] = useState(address?.zip); diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index c4de1e3b4062..191930ff749c 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -13,6 +13,7 @@ import * as Report from '@userActions/Report'; import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; /* * This is a "utility page", that does this: @@ -52,7 +53,7 @@ function ConciergePage() { Report.navigateToConciergeChat(true, () => !isUnmounted.current); }); } else { - Navigation.navigate(); + Navigation.navigate(ROUTES.HOME); } }, [session, isLoadingReportData, route.params, viewTourTaskReport]), ); diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index e429cc8681be..fd617998da98 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -11,7 +11,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {EditRequestNavigatorParamList} from '@libs/Navigation/types'; @@ -71,14 +71,14 @@ function EditReportFieldPage({route}: EditReportFieldPageProps) { if (value !== '') { ReportActions.updateReportField(report.reportID, {...reportField, value}, reportField); } - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : report?.reportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : report?.reportID); } }; const handleReportFieldDelete = () => { ReportActions.deleteReportField(report.reportID, reportField); setIsDeleteModalVisible(false); - Navigation.dismissModal(isSearchTopmostCentralPane() ? undefined : report?.reportID); + Navigation.dismissModal(isSearchTopmostFullScreenRoute() ? undefined : report?.reportID); }; const fieldValue = isReportFieldTitle ? report.reportName ?? '' : reportField.value ?? reportField.defaultValue; diff --git a/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx b/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx index 55f19f8c35b9..a273b210efa9 100644 --- a/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx +++ b/src/pages/EnablePayments/AddBankAccount/AddBankAccount.tsx @@ -52,7 +52,7 @@ function AddBankAccount() { PaymentMethods.continueSetup(onSuccessFallbackRoute); return; } - Navigation.goBack(ROUTES.SETTINGS_WALLET, true); + Navigation.goBack(ROUTES.SETTINGS_WALLET); }; const handleBackButtonPress = () => { @@ -63,7 +63,7 @@ function AddBankAccount() { if (screenIndex === 0) { BankAccounts.clearPersonalBankAccount(); Wallet.updateCurrentStep(null); - Navigation.goBack(ROUTES.SETTINGS_WALLET, true); + Navigation.goBack(ROUTES.SETTINGS_WALLET); return; } prevScreen(); diff --git a/src/pages/EnablePayments/EnablePayments.tsx b/src/pages/EnablePayments/EnablePayments.tsx index b55141fec299..acbafafeea59 100644 --- a/src/pages/EnablePayments/EnablePayments.tsx +++ b/src/pages/EnablePayments/EnablePayments.tsx @@ -60,7 +60,7 @@ function EnablePaymentsPage() { > Navigation.goBack(ROUTES.SETTINGS_WALLET, true)} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET)} /> diff --git a/src/pages/EnablePayments/EnablePaymentsPage.tsx b/src/pages/EnablePayments/EnablePaymentsPage.tsx index 5fdfcca02660..446976f41718 100644 --- a/src/pages/EnablePayments/EnablePaymentsPage.tsx +++ b/src/pages/EnablePayments/EnablePaymentsPage.tsx @@ -40,7 +40,7 @@ function EnablePaymentsPage({userWallet}: EnablePaymentsPageProps) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (isPendingOnfidoResult || hasFailedOnfido) { - Navigation.navigate(ROUTES.SETTINGS_WALLET, CONST.NAVIGATION.TYPE.UP); + Navigation.navigate(ROUTES.SETTINGS_WALLET, {forceReplace: true}); return; } diff --git a/src/pages/GroupChatNameEditPage.tsx b/src/pages/GroupChatNameEditPage.tsx index 7c4d8aa41d00..4f5336fdcad2 100644 --- a/src/pages/GroupChatNameEditPage.tsx +++ b/src/pages/GroupChatNameEditPage.tsx @@ -60,9 +60,7 @@ function GroupChatNameEditPage({report}: GroupChatNameEditPageProps) { if (values[INPUT_IDS.NEW_CHAT_NAME] !== currentChatName) { updateChatName(reportID, values[INPUT_IDS.NEW_CHAT_NAME] ?? '', CONST.REPORT.CHAT_TYPE.GROUP); } - - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID))); - + Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID))); return; } if (values[INPUT_IDS.NEW_CHAT_NAME] !== currentChatName) { diff --git a/src/pages/NewChatConfirmPage.tsx b/src/pages/NewChatConfirmPage.tsx index 1abccf25a947..e4e736ba1b99 100644 --- a/src/pages/NewChatConfirmPage.tsx +++ b/src/pages/NewChatConfirmPage.tsx @@ -96,7 +96,7 @@ function NewChatConfirmPage() { } const logins: string[] = (newGroupDraft.participants ?? []).map((participant) => participant.login).filter((login): login is string => !!login); - navigateToAndOpenReport(logins, true, undefined, newGroupDraft.reportName ?? '', newGroupDraft.avatarUri ?? '', avatarFile, optimisticReportID.current, true); + navigateToAndOpenReport(logins, true, newGroupDraft.reportName ?? '', newGroupDraft.avatarUri ?? '', avatarFile, optimisticReportID.current, true); }, [newGroupDraft, avatarFile]); const stashedLocalAvatarImage = newGroupDraft?.avatarUri; diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx index 42e83dbff6b0..18eaeba3e378 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -18,7 +18,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {PrivateNotesNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; -import {goBackFromPrivateNotes, navigateToDetailsPage} from '@libs/ReportUtils'; +import {goBackFromPrivateNotes, goBackToDetailsPage} from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import type {WithReportAndPrivateNotesOrNotFoundProps} from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; @@ -93,8 +93,10 @@ function PrivateNotesEditPage({route, report, accountID}: PrivateNotesEditPagePr debouncedSavePrivateNote(''); Keyboard.dismiss(); - if (!Object.values({...report.privateNotes, [route.params.accountID]: {note: editedNote}}).some((item) => item.note)) { - navigateToDetailsPage(report, backTo); + + const hasNewNoteBeenAdded = !originalNote && editedNote; + if (!Object.values({...report.privateNotes, [route.params.accountID]: {note: editedNote}}).some((item) => item.note) || hasNewNoteBeenAdded) { + goBackToDetailsPage(report, backTo); } else { Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID, backTo))); } diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 20f3a343b842..54ab7b812cd9 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -426,11 +426,11 @@ function AdvancedSearchFilters() { const applyFiltersAndNavigate = () => { clearAllFilters(); - Navigation.dismissModal(); Navigation.navigate( ROUTES.SEARCH_CENTRAL_PANE.getRoute({ query: queryString, }), + {forceReplace: true}, ); }; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 39d419141632..03a7f579a5a0 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,40 +1,66 @@ import React, {useMemo} from 'react'; +import {View} from 'react-native'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderGap from '@components/HeaderGap'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import BottomTabBar from '@components/Navigation/BottomTabBar'; +import BOTTOM_TABS from '@components/Navigation/BottomTabBar/BOTTOM_TABS'; +import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; +import {useSearchContext} from '@components/Search/SearchContext'; import SearchPageHeader from '@components/Search/SearchPageHeader'; import SearchStatusBar from '@components/Search/SearchStatusBar'; +import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import FreezeWrapper from '@libs/Navigation/AppNavigator/FreezeWrapper'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {AuthScreensParamList} from '@libs/Navigation/types'; -import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildCannedSearchQuery, buildSearchQueryJSON, getPolicyIDFromSearchQuery} from '@libs/SearchQueryUtils'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import SearchPageNarrow from './SearchPageNarrow'; +import SearchTypeMenu from './SearchTypeMenu'; -type SearchPageProps = PlatformStackScreenProps; +type SearchPageProps = PlatformStackScreenProps; function SearchPage({route}: SearchPageProps) { + const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); - const {q} = route.params; - const queryJSON = useMemo(() => buildSearchQueryJSON(q), [q]); + const {q, name} = route.params; + + const {queryJSON, policyID} = useMemo(() => { + const parsedQuery = buildSearchQueryJSON(q); + const extractedPolicyID = parsedQuery && getPolicyIDFromSearchQuery(parsedQuery); + + return {queryJSON: parsedQuery, policyID: extractedPolicyID}; + }, [q]); + const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: buildCannedSearchQuery()})); + const {clearSelectedTransactions} = useSearchContext(); + + const isSearchNameModified = name === q; + const searchName = isSearchNameModified ? undefined : name; - // On small screens this page is not displayed, the configuration is in the file: src/libs/Navigation/AppNavigator/createResponsiveStackNavigator/index.tsx - // To avoid calling hooks in the Search component when this page isn't visible, we return null here. if (shouldUseNarrowLayout) { - return null; + return ( + + + + ); } return ( - + {!!queryJSON && ( - <> - - - - + + + {queryJSON ? ( + + + + + + ) : ( + { + clearSelectedTransactions(); + turnOffMobileSelectionMode(); + }} + /> + )} + + + + + + + + )} - + ); } SearchPage.displayName = 'SearchPage'; +SearchPage.whyDidYouRender = true; export default SearchPage; diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageNarrow.tsx similarity index 69% rename from src/pages/Search/SearchPageBottomTab.tsx rename to src/pages/Search/SearchPageNarrow.tsx index 49b59ac59f13..e8ea684f9990 100644 --- a/src/pages/Search/SearchPageBottomTab.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -3,24 +3,24 @@ import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Animated, {clamp, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import BottomTabBar from '@components/Navigation/BottomTabBar'; +import BOTTOM_TABS from '@components/Navigation/BottomTabBar/BOTTOM_TABS'; +import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; import {useSearchContext} from '@components/Search/SearchContext'; import SearchStatusBar from '@components/Search/SearchStatusBar'; -import useActiveCentralPaneRoute from '@hooks/useActiveCentralPaneRoute'; +import type {SearchQueryJSON} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Navigation from '@libs/Navigation/Navigation'; -import type {AuthScreensParamList} from '@libs/Navigation/types'; -import {buildCannedSearchQuery, buildSearchQueryJSON, getPolicyIDFromSearchQuery, isCannedSearchQuery} from '@libs/SearchQueryUtils'; -import TopBar from '@navigation/AppNavigator/createCustomBottomTabNavigator/TopBar'; +import {buildCannedSearchQuery, isCannedSearchQuery} from '@libs/SearchQueryUtils'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import SearchSelectionModeHeader from './SearchSelectionModeHeader'; import SearchTypeMenu from './SearchTypeMenu'; import useHandleBackButton from './useHandleBackButton'; @@ -29,11 +29,17 @@ const TOO_CLOSE_TO_TOP_DISTANCE = 10; const TOO_CLOSE_TO_BOTTOM_DISTANCE = 10; const ANIMATION_DURATION_IN_MS = 300; -function SearchPageBottomTab() { +type SearchPageBottomTabProps = { + queryJSON?: SearchQueryJSON; + policyID?: string; + searchName?: string; +}; + +function SearchPageNarrow({queryJSON, policyID, searchName}: SearchPageBottomTabProps) { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); - const activeCentralPaneRoute = useActiveCentralPaneRoute(); + const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); @@ -83,21 +89,12 @@ function SearchPageBottomTab() { [windowHeight, topBarOffset, StyleUtils.searchHeaderHeight], ); - const searchParams = activeCentralPaneRoute?.params as AuthScreensParamList[typeof SCREENS.SEARCH.CENTRAL_PANE]; - const parsedQuery = buildSearchQueryJSON(searchParams?.q); - const isSearchNameModified = searchParams?.name === searchParams?.q; - const searchName = isSearchNameModified ? undefined : searchParams?.name; - const policyIDFromSearchQuery = parsedQuery && getPolicyIDFromSearchQuery(parsedQuery); - const isActiveCentralPaneRoute = activeCentralPaneRoute?.name === SCREENS.SEARCH.CENTRAL_PANE; - const queryJSON = isActiveCentralPaneRoute ? parsedQuery : undefined; - const policyID = isActiveCentralPaneRoute ? policyIDFromSearchQuery : undefined; - const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: buildCannedSearchQuery()})); if (!queryJSON) { return ( @@ -114,22 +111,22 @@ function SearchPageBottomTab() { return ( } headerGapStyles={styles.searchHeaderGap} > - {!selectionMode?.isEnabled ? ( - <> - - - - {shouldUseNarrowLayout ? ( + + {!selectionMode?.isEnabled ? ( + <> + + + - ) : ( - - )} - - ) : ( - - )} - {shouldUseNarrowLayout && ( + + ) : ( + + )} - )} + ); } -SearchPageBottomTab.displayName = 'SearchPageBottomTab'; +SearchPageNarrow.displayName = 'SearchPageNarrow'; -export default SearchPageBottomTab; +export default SearchPageNarrow; diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 30722677cdc8..868ae99290d7 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -86,7 +86,7 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title, useEffect(() => { const listener = (event: EventArg<'state', false, NavigationContainerEventMap['state']['data']>) => { - if (Navigation.getRouteNameFromStateEvent(event) === SCREENS.SEARCH.CENTRAL_PANE) { + if (Navigation.getRouteNameFromStateEvent(event) === SCREENS.SEARCH.ROOT) { setIsScreenFocused(true); return; } diff --git a/src/pages/TransactionReceiptPage.tsx b/src/pages/TransactionReceiptPage.tsx index 52ce46105e5d..132d1607fb52 100644 --- a/src/pages/TransactionReceiptPage.tsx +++ b/src/pages/TransactionReceiptPage.tsx @@ -4,7 +4,7 @@ import AttachmentModal from '@components/AttachmentModal'; import {openReport} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {AuthScreensParamList, RootStackParamList, State} from '@libs/Navigation/types'; +import type {AuthScreensParamList, RootNavigatorParamList, State} from '@libs/Navigation/types'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import {getReportAction, isTrackExpenseAction as isTrackExpenseReportReportActionsUtils} from '@libs/ReportActionsUtils'; import { @@ -53,7 +53,7 @@ function TransactionReceipt({route}: TransactionReceiptProps) { const onModalClose = () => { // Receipt Page can be opened either from Reports or from Search RHP view // We have to handle going back to correct screens, if it was opened from RHP just close the modal, otherwise go to Report Page - const rootState = navigationRef.getRootState() as State; + const rootState = navigationRef.getRootState() as State; const secondToLastRoute = rootState.routes.at(-2); if (secondToLastRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { Navigation.dismissModal(); diff --git a/src/pages/Travel/TravelTerms.tsx b/src/pages/Travel/TravelTerms.tsx index baf7b464c883..e4056c4bd6d8 100644 --- a/src/pages/Travel/TravelTerms.tsx +++ b/src/pages/Travel/TravelTerms.tsx @@ -92,11 +92,6 @@ function TravelTerms({route}: TravelTermsPageProps) { {`${translate('travel.termsAndConditions.subtitle')}`} {`${translate('travel.termsAndConditions.termsconditions')}.`} - - {`${translate('travel.termsAndConditions.helpDocIntro')}`} - {`${translate('travel.termsAndConditions.helpDoc')} `} - {`${translate('travel.termsAndConditions.helpDocOutro')}`} - { - if (!isFocused) { - return; - } const newPolicyID = policyID === activeWorkspaceID ? undefined : policyID; Navigation.goBack(); // On native platforms, we will see a blank screen if we navigate to a new HomeScreen route while navigating back at the same time. // Therefore we delay switching the workspace until after back navigation, using the InteractionManager. - switchPolicyAfterInteractions(newPolicyID, () => setActiveWorkspaceID(newPolicyID)); + switchPolicyAfterInteractions(newPolicyID); }, - [activeWorkspaceID, setActiveWorkspaceID, isFocused], + [activeWorkspaceID], ); const usersWorkspaces = useMemo(() => { diff --git a/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx index 26464269658a..e9c92cdabc35 100644 --- a/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx +++ b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx @@ -1,10 +1,9 @@ import {InteractionManager} from 'react-native'; import Navigation from '@libs/Navigation/Navigation'; -function switchPolicyAfterInteractions(newPolicyID: string | undefined, setActiveWorkspaceID: () => void) { +function switchPolicyAfterInteractions(newPolicyID: string | undefined) { InteractionManager.runAfterInteractions(() => { - setActiveWorkspaceID(); - Navigation.navigateWithSwitchPolicyID({policyID: newPolicyID}); + Navigation.switchPolicyID(newPolicyID); }); } diff --git a/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx index 4f8d9201d7b3..afdc04229c5f 100644 --- a/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx @@ -1,8 +1,7 @@ import Navigation from '@libs/Navigation/Navigation'; -function switchPolicyAfterInteractions(newPolicyID: string | undefined, setActiveWorkspaceID: () => void) { - setActiveWorkspaceID(); - Navigation.navigateWithSwitchPolicyID({policyID: newPolicyID}); +function switchPolicyAfterInteractions(newPolicyID: string | undefined) { + Navigation.switchPolicyID(newPolicyID); } export default switchPolicyAfterInteractions; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 68046fedc7d5..f133e4d8234a 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -28,6 +28,7 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; +import {hideEmojiPicker} from '@libs/actions/EmojiPickerAction'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -71,7 +72,7 @@ import { } from '@libs/ReportUtils'; import shouldFetchReport from '@libs/shouldFetchReport'; import {isNumeric} from '@libs/ValidationUtils'; -import type {AuthScreensParamList} from '@navigation/types'; +import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import {setShouldShowComposeInput} from '@userActions/Composer'; import { clearDeleteTransactionNavigateBackUrl, @@ -96,7 +97,7 @@ import ReportFooter from './report/ReportFooter'; import type {ActionListContextType, ReactionListRef, ScrollPosition} from './ReportScreenContext'; import {ActionListContext, ReactionListContext} from './ReportScreenContext'; -type ReportScreenNavigationProps = PlatformStackScreenProps; +type ReportScreenNavigationProps = PlatformStackScreenProps; type ReportScreenProps = CurrentReportIDContextValue & ReportScreenNavigationProps; @@ -313,6 +314,13 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const didSubscribeToReportLeavingEvents = useRef(false); const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false); + useEffect(() => { + if (!prevIsFocused || isFocused) { + return; + } + hideEmojiPicker(true); + }, [prevIsFocused, isFocused]); + useEffect(() => { if (!report?.reportID || shouldHideReport) { wasReportAccessibleRef.current = false; @@ -326,7 +334,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { Navigation.dismissModal(); return; } - Navigation.goBack(undefined, false, true); + Navigation.goBack(undefined, {shouldPopToTop: true}); }, [isInNarrowPaneModal]); let headerView = ( @@ -640,7 +648,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { Navigation.dismissModal(); if (Navigation.getTopmostReportId() === prevOnyxReportID) { Navigation.setShouldPopAllStateOnUP(true); - Navigation.goBack(undefined, false, true); + Navigation.goBack(undefined, {shouldPopToTop: true}); } if (prevReport?.parentReportID) { // Prevent navigation to the IOU/Expense Report if it is pending deletion. diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index 674bb028b02c..2ab9fca6d0d8 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -31,6 +31,7 @@ import { getPolicyChangeLogAddEmployeeMessage, getPolicyChangeLogChangeRoleMessage, getPolicyChangeLogDeleteMemberMessage, + getPolicyChangeLogUpdateAutoReportingFrequencyMessage, getRemovedConnectionMessage, getRenamedAction, getReportActionMessageText, @@ -544,6 +545,8 @@ const ContextMenuActions: ContextMenuAction[] = [ setClipboardMessage(getPolicyChangeLogChangeRoleMessage(reportAction)); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE) { setClipboardMessage(getPolicyChangeLogDeleteMemberMessage(reportAction)); + } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) { + setClipboardMessage(getPolicyChangeLogUpdateAutoReportingFrequencyMessage(reportAction)); } else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION) { setClipboardMessage(getDeletedTransactionMessage(reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.INTEGRATION_SYNC_FAILED)) { diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx index c36391ecec70..49a855fc690b 100644 --- a/src/pages/home/report/PureReportActionItem.tsx +++ b/src/pages/home/report/PureReportActionItem.tsx @@ -56,6 +56,7 @@ import { getPolicyChangeLogAddEmployeeMessage, getPolicyChangeLogChangeRoleMessage, getPolicyChangeLogDeleteMemberMessage, + getPolicyChangeLogUpdateAutoReportingFrequencyMessage, getRemovedConnectionMessage, getRemovedFromApprovalChainMessage, getRenamedAction, @@ -366,7 +367,7 @@ function PureReportActionItem({ const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); const reactionListRef = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(ReportAttachmentsContext); - const textInputRef = useRef(null); + const composerTextInputRef = useRef(null); const popoverAnchorRef = useRef>(null); const downloadedPreviews = useRef([]); const prevDraftMessage = usePrevious(draftMessage); @@ -443,7 +444,7 @@ function PureReportActionItem({ return; } - focusComposerWithDelay(textInputRef.current)(true); + focusComposerWithDelay(composerTextInputRef.current)(true); }, [prevDraftMessage, draftMessage]); useEffect(() => { @@ -877,6 +878,8 @@ function PureReportActionItem({ children = ; } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE) { children = ; + } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY) { + children = ; } else if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REMOVED_FROM_APPROVAL_CHAIN)) { children = ; } else if ( @@ -952,7 +955,7 @@ function PureReportActionItem({ reportID={reportID} policyID={report?.policyID} index={index} - ref={textInputRef} + ref={composerTextInputRef} shouldDisableEmojiPicker={ (chatIncludesConcierge(report) && isBlockedFromConcierge(blockedFromConcierge)) || isArchivedNonExpenseReport(report, reportNameValuePairs) } diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index cccef71987a4..a3ba63e7e5a3 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -647,7 +647,8 @@ function ComposerWithSuggestions( const prevIsFocused = usePrevious(isFocused); useEffect(() => { - if (modal?.isVisible && !prevIsModalVisible) { + const isModalVisible = modal?.isVisible; + if (isModalVisible && !prevIsModalVisible) { // eslint-disable-next-line react-compiler/react-compiler, no-param-reassign isNextModalWillOpenRef.current = false; } @@ -655,6 +656,7 @@ function ComposerWithSuggestions( // We want to blur the input immediately when a screen is out of focus. if (!isFocused) { textInputRef.current?.blur(); + return; } // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. @@ -664,8 +666,7 @@ function ComposerWithSuggestions( !( (willBlurTextInputOnTapOutside || (shouldAutoFocus && canFocusInputOnScreenFocus())) && !isNextModalWillOpenRef.current && - !modal?.isVisible && - isFocused && + !isModalVisible && (!!prevIsModalVisible || !prevIsFocused) ) ) { diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 5aa911785b14..fbd0973adbfe 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -480,7 +480,7 @@ function ReportActionCompose({ reportParticipantIDs={reportParticipantIDs} isComposerFullSize={isComposerFullSize} isBlockedFromConcierge={isBlockedFromConcierge} - disabled={!!disabled} + disabled={disabled} setMenuVisibility={setMenuVisibility} isMenuVisible={isMenuVisible} onTriggerAttachmentPicker={onTriggerAttachmentPicker} @@ -518,7 +518,7 @@ function ReportActionCompose({ displayFileInModal={displayFileInModal} onCleared={submitForm} isBlockedFromConcierge={isBlockedFromConcierge} - disabled={!!disabled} + disabled={disabled} setIsCommentEmpty={setIsCommentEmpty} handleSendMessage={handleSendMessage} shouldShowComposeInput={shouldShowComposeInput} diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 00f820bc57b9..337d5994deac 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -27,7 +27,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setShouldShowComposeInput} from '@libs/actions/Composer'; import {clearActive, isActive as isEmojiPickerActive, isEmojiPickerVisible} from '@libs/actions/EmojiPickerAction'; -import {composerFocusKeepFocusOn, callback as inputFocusCallback, inputFocusChange} from '@libs/actions/InputFocus'; +import {composerFocusKeepFocusOn} from '@libs/actions/InputFocus'; import {deleteReportActionDraft, editReportComment, saveReportActionDraft} from '@libs/actions/Report'; import {isMobileChrome} from '@libs/Browser/index.website'; import {canSkipTriggerHotkeys, insertText} from '@libs/ComposerUtils'; @@ -36,7 +36,6 @@ import {extractEmojis, replaceAndExtractEmojis} from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import type {Selection} from '@libs/focusComposerWithDelay/types'; import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; -import onyxSubscribe from '@libs/onyxSubscribe'; import Parser from '@libs/Parser'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import reportActionItemEventHandler from '@libs/ReportActionItemEventHandler'; @@ -99,7 +98,6 @@ function ReportActionItemMessageEdit( const mobileInputScrollPosition = useRef(0); const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); - const isInitialMount = useRef(true); const emojisPresentBefore = useRef([]); const [draft, setDraft] = useState(() => { if (draftMessage) { @@ -111,11 +109,14 @@ function ReportActionItemMessageEdit( const [isFocused, setIsFocused] = useState(false); const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); const debouncedValidateCommentMaxLength = useMemo(() => lodashDebounce(validateCommentMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME), [validateCommentMaxLength]); - const [modal, setModal] = useState({ - willAlertModalBecomeVisible: false, - isVisible: false, - }); - const [onyxFocused, setOnyxFocused] = useState(false); + + const [ + modal = { + willAlertModalBecomeVisible: false, + isVisible: false, + }, + ] = useOnyx(ONYXKEYS.MODAL); + const [onyxInputFocused = false] = useOnyx(ONYXKEYS.INPUT_FOCUSED); const textInputRef = useRef<(HTMLTextAreaElement & TextInput) | null>(null); const isFocusedRef = useRef(false); @@ -137,34 +138,8 @@ function ReportActionItemMessageEdit( }, [draftMessage, action, prevDraftMessage]); useEffect(() => { - composerFocusKeepFocusOn(textInputRef.current as HTMLElement, isFocused, modal, onyxFocused); - }, [isFocused, modal, onyxFocused]); - - useEffect(() => { - const unsubscribeOnyxModal = onyxSubscribe({ - key: ONYXKEYS.MODAL, - callback: (modalArg) => { - if (modalArg === undefined) { - return; - } - setModal(modalArg); - }, - }); - - const unsubscribeOnyxFocused = onyxSubscribe({ - key: ONYXKEYS.INPUT_FOCUSED, - callback: (modalArg) => { - if (modalArg === undefined) { - return; - } - setOnyxFocused(modalArg); - }, - }); - return () => { - unsubscribeOnyxModal(); - unsubscribeOnyxFocused(); - }; - }, []); + composerFocusKeepFocusOn(textInputRef.current as HTMLElement, isFocused, modal, onyxInputFocused); + }, [isFocused, modal, onyxInputFocused]); useEffect( // Remove focus callback on unmount to avoid stale callbacks @@ -203,38 +178,6 @@ function ReportActionItemMessageEdit( }, true); }, [focus]); - useEffect( - () => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - - return () => { - inputFocusCallback(() => setIsFocused(false)); - inputFocusChange(false); - - // Skip if the current report action is not active - if (!isActive()) { - return; - } - - if (isEmojiPickerActive(action.reportActionID)) { - clearActive(); - } - if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { - ReportActionContextMenu.clearActiveReportAction(); - } - - // Show the main composer when the focused message is deleted from another client - // to prevent the main composer stays hidden until we switch to another chat. - setShouldShowComposeInputKeyboardAware(true); - }; - }, - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- this cleanup needs to be called only on unmount - [action.reportActionID], - ); - // show the composer after editing is complete for devices that hide the composer during editing. useEffect(() => () => setShouldShowComposeInput(true), []); diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 92e315026007..c545ba590848 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -20,8 +20,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import DateUtils from '@libs/DateUtils'; import {getChatFSAttributes, parseFSAttributes} from '@libs/Fullstory'; -import isReportScreenTopmostCentralPane from '@libs/Navigation/isReportScreenTopmostCentralPane'; -import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import { @@ -50,7 +50,7 @@ import { isUnread, } from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; -import type {AuthScreensParamList} from '@navigation/types'; +import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import variables from '@styles/variables'; import {getCurrentUserAccountID, openReport, readNewestAction, subscribeToNewActionEvent} from '@userActions/Report'; import {PersonalDetailsContext} from '@src/components/OnyxProvider'; @@ -184,7 +184,7 @@ function ReportActionsList({ const {preferredLocale} = useLocalize(); const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); - const route = useRoute>(); + const route = useRoute>(); const reportScrollManager = useReportScrollManager(); const userActiveSince = useRef(DateUtils.getDBTime()); const lastMessageTime = useRef(null); @@ -461,7 +461,7 @@ function ReportActionsList({ setIsFloatingMessageCounterVisible(false); // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where // they are now in the list. - if (!isFromCurrentUser || (!isReportScreenTopmostCentralPane() && !Navigation.getReportRHPActiveRoute())) { + if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { return; } if (!hasNewestReportActionRef.current) { @@ -745,7 +745,7 @@ function ReportActionsList({ }, [isLoadingNewerReportActions, canShowHeader, hasLoadingNewerReportActionsError, retryLoadNewerChatsError]); const onStartReached = useCallback(() => { - if (!isSearchTopmostCentralPane()) { + if (!isSearchTopmostFullScreenRoute()) { loadNewerChats(false); return; } diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 99327cbef2d8..e5e715c16d86 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -13,7 +13,7 @@ import Timing from '@libs/actions/Timing'; import DateUtils from '@libs/DateUtils'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {AuthScreensParamList} from '@libs/Navigation/types'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {generateNewRandomInt, rand64} from '@libs/NumberUtils'; import Performance from '@libs/Performance'; import { @@ -93,7 +93,7 @@ function ReportActionsView({ }: ReportActionsViewProps) { useCopySelectionHelper(); const reactionListRef = useContext(ReactionListContext); - const route = useRoute>(); + const route = useRoute>(); const [session] = useOnyx(ONYXKEYS.SESSION); const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID ?? CONST.DEFAULT_NUMBER_ID}`, { selector: (reportActions: OnyxEntry) => getSortedReportActionsForDisplay(reportActions, canUserPerformWriteAction(report), true), diff --git a/src/pages/home/report/ReportDetailsExportPage.tsx b/src/pages/home/report/ReportDetailsExportPage.tsx index b7b66a356a86..4332ba764ff9 100644 --- a/src/pages/home/report/ReportDetailsExportPage.tsx +++ b/src/pages/home/report/ReportDetailsExportPage.tsx @@ -92,7 +92,7 @@ function ReportDetailsExportPage({route}: ReportDetailsExportPageProps) { description={translate('workspace.export.notReadyDescription')} shouldShowButton buttonText={translate('common.buttonConfirm')} - onButtonPress={Navigation.goBack} + onButtonPress={() => Navigation.goBack()} illustrationStyle={{width: 233, height: 162}} /> diff --git a/src/pages/home/report/UserTypingEventListener.tsx b/src/pages/home/report/UserTypingEventListener.tsx index 73062902f63e..6609e48161b2 100644 --- a/src/pages/home/report/UserTypingEventListener.tsx +++ b/src/pages/home/report/UserTypingEventListener.tsx @@ -4,7 +4,7 @@ import {InteractionManager} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {AuthScreensParamList} from '@libs/Navigation/types'; +import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -19,7 +19,7 @@ function UserTypingEventListener({report}: UserTypingEventListenerProps) { const didSubscribeToReportTypingEvents = useRef(false); const reportID = report.reportID; const isFocused = useIsFocused(); - const route = useRoute>(); + const route = useRoute>(); useEffect( () => () => { diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx b/src/pages/home/sidebar/BaseSidebarScreen.tsx similarity index 55% rename from src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx rename to src/pages/home/sidebar/BaseSidebarScreen.tsx index 580f94e0f2f4..369d1f234595 100644 --- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.tsx +++ b/src/pages/home/sidebar/BaseSidebarScreen.tsx @@ -1,28 +1,36 @@ +import {useRoute} from '@react-navigation/native'; import React, {useEffect} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import BottomTabBar from '@components/Navigation/BottomTabBar'; +import BOTTOM_TABS from '@components/Navigation/BottomTabBar/BOTTOM_TABS'; +import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; -import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateLastAccessedWorkspace} from '@libs/actions/Policy/Policy'; import * as Browser from '@libs/Browser'; -import TopBar from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar'; +import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; +import {getPreservedSplitNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveSplitNavigatorState'; +import getTopmostReportsSplitNavigator from '@libs/Navigation/helpers/getTopmostReportsSplitNavigator'; import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; -import SidebarLinksData from '@pages/home/sidebar/SidebarLinksData'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import SidebarLinksData from './SidebarLinksData'; function BaseSidebarScreen() { const styles = useThemeStyles(); - const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); + const {activeWorkspaceID} = useActiveWorkspace(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [activeWorkspace, activeWorkspaceResult] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID ?? CONST.DEFAULT_NUMBER_ID}`); + const currentRoute = useRoute(); const isLoading = isLoadingOnyxValue(activeWorkspaceResult); useEffect(() => { @@ -35,19 +43,34 @@ function BaseSidebarScreen() { return; } - Navigation.navigateWithSwitchPolicyID({policyID: undefined}); + // Otherwise, if the workspace is already loaded, we don't need to do anything + const topmostReport = getTopmostReportsSplitNavigator(); + + if (!topmostReport) { + return; + } + + // Switching workspace to global should only be performed from the currently opened sidebar screen + const topmostReportState = topmostReport?.state ?? getPreservedSplitNavigatorState(topmostReport?.key); + const isCurrentSidebar = topmostReportState?.routes.some((route) => currentRoute.key === route.key); + + if (!isCurrentSidebar) { + return; + } + + const reportsSplitNavigatorWithoutPolicyID = getInitialSplitNavigatorState({name: SCREENS.HOME}, {name: SCREENS.REPORT}); + Navigation.replaceWithSplitNavigator(reportsSplitNavigatorWithoutPolicyID); updateLastAccessedWorkspace(undefined); - }, [activeWorkspace, activeWorkspaceID, isLoading]); + }, [activeWorkspace, activeWorkspaceID, isLoading, currentRoute.key]); const shouldDisplaySearch = shouldUseNarrowLayout; return ( } > {({insets}) => ( <> diff --git a/src/pages/home/sidebar/BottomTabAvatar.tsx b/src/pages/home/sidebar/BottomTabAvatar.tsx index 88ae760531dc..095e1fdb40df 100644 --- a/src/pages/home/sidebar/BottomTabAvatar.tsx +++ b/src/pages/home/sidebar/BottomTabAvatar.tsx @@ -1,29 +1,25 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import {useOnyx} from 'react-native-onyx'; import {PressableWithFeedback} from '@components/Pressable'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import clearSelectedText from '@libs/clearSelectedText/clearSelectedText'; -import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import AvatarWithDelegateAvatar from './AvatarWithDelegateAvatar'; import AvatarWithOptionalStatus from './AvatarWithOptionalStatus'; import ProfileAvatarWithIndicator from './ProfileAvatarWithIndicator'; type BottomTabAvatarProps = { - /** Whether the create menu is open or not */ - isCreateMenuOpen?: boolean; - /** Whether the avatar is selected */ isSelected?: boolean; + + /** Function to call when the avatar is pressed */ + onPress: () => void; }; -function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomTabAvatarProps) { +function BottomTabAvatar({onPress, isSelected = false}: BottomTabAvatarProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); @@ -31,16 +27,6 @@ function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomT const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const emojiStatus = currentUserPersonalDetails?.status?.emojiCode ?? ''; - const showSettingsPage = useCallback(() => { - if (isCreateMenuOpen) { - // Prevent opening Settings page when click profile avatar quickly after clicking FAB icon - return; - } - - clearSelectedText(); - interceptAnonymousUser(() => Navigation.navigate(ROUTES.SETTINGS)); - }, [isCreateMenuOpen]); - let children; if (delegateEmail) { @@ -70,7 +56,7 @@ function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomT return ( (null); /** @@ -32,6 +36,7 @@ function BottomTabBarFloatingActionButton() { return ( diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx similarity index 96% rename from src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx rename to src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx index 0d78dd1950ed..38c084ce830b 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx @@ -1,4 +1,4 @@ -import {useIsFocused as useIsFocusedOriginal, useNavigationState} from '@react-navigation/native'; +import {useIsFocused} from '@react-navigation/native'; import type {ImageContentFit} from 'expo-image'; import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; @@ -31,9 +31,7 @@ import {canActionTask as canActionTaskUtils, canModifyTask as canModifyTaskUtils import {setSelfTourViewed} from '@libs/actions/Welcome'; import getIconForAction from '@libs/getIconForAction'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; -import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import Navigation from '@libs/Navigation/Navigation'; -import type {CentralPaneName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; import {hasSeenTourSelector} from '@libs/onboardingSelectors'; import {areAllGroupPoliciesExpenseChatDisabled, canSendInvoice as canSendInvoicePolicyUtils, shouldShowPolicy} from '@libs/PolicyUtils'; import {canCreateRequest, generateReportID, getDisplayNameForParticipant, getIcons, getReportName, getWorkspaceChats, isArchivedReport, isPolicyExpenseChat} from '@libs/ReportUtils'; @@ -44,21 +42,11 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {QuickActionName} from '@src/types/onyx/QuickAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import mapOnyxCollectionItems from '@src/utils/mapOnyxCollectionItems'; -// On small screen we hide the search page from central pane to show the search bottom tab page with bottom tab bar. -// We need to take this in consideration when checking if the screen is focused. -const useIsFocused = () => { - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const isFocused = useIsFocusedOriginal(); - const topmostCentralPane = useNavigationState | undefined>(getTopmostCentralPaneRoute); - return isFocused || (topmostCentralPane?.name === SCREENS.SEARCH.CENTRAL_PANE && shouldUseNarrowLayout); -}; - type PolicySelector = Pick; type FloatingActionButtonAndPopoverProps = { @@ -67,6 +55,9 @@ type FloatingActionButtonAndPopoverProps = { /* Callback function before the menu is hidden */ onHideCreateMenu?: () => void; + + /* If the tooltip is allowed to be shown */ + isTooltipAllowed: boolean; }; type FloatingActionButtonAndPopoverRef = { @@ -166,7 +157,7 @@ const getQuickActionTitle = (action: QuickActionName): TranslationPaths => { * Responsible for rendering the {@link PopoverMenu}, and the accompanying * FAB that can open or close the menu. */ -function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: FloatingActionButtonAndPopoverProps, ref: ForwardedRef) { +function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isTooltipAllowed}: FloatingActionButtonAndPopoverProps, ref: ForwardedRef) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -560,6 +551,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl cancelText={translate('common.cancel')} /> currentReportIDRef.current === reportID, []); - return ( - - - ); -} - -SidebarScreen.displayName = 'SidebarScreen'; - -export default SidebarScreen; diff --git a/src/pages/iou/HoldReasonPage.tsx b/src/pages/iou/HoldReasonPage.tsx index 61bf7399889a..8989e9c8ebca 100644 --- a/src/pages/iou/HoldReasonPage.tsx +++ b/src/pages/iou/HoldReasonPage.tsx @@ -41,7 +41,7 @@ function HoldReasonPage({route}: HoldReasonPageProps) { } IOU.putOnHold(transactionID, values.comment, reportID, searchHash); - Navigation.navigate(backTo); + Navigation.goBack(backTo); }; const validate = useCallback( diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index e5572bd34294..4456886f0ae8 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -1,4 +1,5 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; @@ -70,13 +71,15 @@ function IOURequestStartPage({ ); const isFromGlobalCreate = isEmptyObject(report?.reportID); - // Clear out the temporary expense if the reportID in the URL has changed from the transaction's reportID - useEffect(() => { - if (transaction?.reportID === reportID || isLoadingSelectedTab) { - return; - } - initMoneyRequest(reportID, policy, isFromGlobalCreate, transaction?.iouRequestType, transactionRequestType); - }, [transaction, policy, reportID, iouType, isFromGlobalCreate, transactionRequestType, isLoadingSelectedTab]); + // Clear out the temporary expense if the reportID in the URL has changed from the transaction's reportID. + useFocusEffect( + useCallback(() => { + if (transaction?.reportID === reportID || isLoadingSelectedTab) { + return; + } + initMoneyRequest(reportID, policy, isFromGlobalCreate, transaction?.iouRequestType, transactionRequestType); + }, [transaction, policy, reportID, isFromGlobalCreate, transactionRequestType, isLoadingSelectedTab]), + ); const navigateBack = () => { Navigation.closeRHPFlow(); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 5697a8626aa6..4ea550be00d0 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -23,7 +23,7 @@ import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpen import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; -import {getBankAccountRoute} from '@libs/ReportUtils'; +import {generateReportID, getBankAccountRoute} from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import {getDefaultTaxCode, getRateID, getRequestType, getValidWaypoints} from '@libs/TransactionUtils'; import type {GpsPoint} from '@userActions/IOU'; @@ -39,6 +39,7 @@ import { setMoneyRequestCategory, splitBill, splitBillAndOpenReport, + startMoneyRequest, startSplitBill, submitPerDiemExpense as submitPerDiemExpenseIOUActions, trackExpense as trackExpenseIOUActions, @@ -170,6 +171,19 @@ function IOURequestStepConfirmation({ setMoneyRequestBillable(transactionID, defaultBillable); }, [transactionID, defaultBillable]); + useEffect(() => { + if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) { + return; + } + startMoneyRequest( + CONST.IOU.TYPE.CREATE, + // When starting to create an expense from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used + // for all of the routes in the creation flow. + generateReportID(), + ); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run again + }, [isLoadingTransaction]); + useEffect(() => { if (!transaction?.category) { return; @@ -206,7 +220,7 @@ function IOURequestStepConfirmation({ // back to the participants step if (!transaction?.participantsAutoAssigned && participantsAutoAssignedFromRoute !== 'true') { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, transaction?.reportID || reportID, undefined, action)); + Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, transaction?.reportID || reportID, undefined, action), {compareParams: false}); return; } navigateToStartMoneyRequestStep(requestType, iouType, transactionID, reportID, action); diff --git a/src/pages/iou/request/step/IOURequestStepCurrency.tsx b/src/pages/iou/request/step/IOURequestStepCurrency.tsx index a648b3579357..813055c7b1c0 100644 --- a/src/pages/iou/request/step/IOURequestStepCurrency.tsx +++ b/src/pages/iou/request/step/IOURequestStepCurrency.tsx @@ -45,7 +45,7 @@ function IOURequestStepCurrency({ // to the confirmation page if (pageIndex === CONST.IOU.PAGE_INDEX.CONFIRM) { if (selectedCurrencyValue) { - Navigation.navigate(appendParam(backTo as string, 'currency', selectedCurrencyValue)); + Navigation.goBack(appendParam(backTo as string, 'currency', selectedCurrencyValue), {compareParams: false}); } else { Navigation.goBack(backTo); } diff --git a/src/pages/iou/request/step/IOURequestStepDestination.tsx b/src/pages/iou/request/step/IOURequestStepDestination.tsx index cc6928079061..fa08e7d0d5e7 100644 --- a/src/pages/iou/request/step/IOURequestStepDestination.tsx +++ b/src/pages/iou/request/step/IOURequestStepDestination.tsx @@ -93,9 +93,9 @@ function IOURequestStepDestination({ if (backTo) { navigateBack(); } else if (explicitPolicyID) { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME.getRoute(action, iouType, transactionID, policyExpenseReport?.reportID ?? reportID)); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME.getRoute(action, iouType, transactionID, policyExpenseReport?.reportID ?? reportID, Navigation.getActiveRoute())); } else { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME.getRoute(action, iouType, transactionID, reportID)); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TIME.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute())); } }; diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 1156a4ebee91..2ff6258842c3 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -24,6 +24,7 @@ import { createDistanceRequest, getIOURequestPolicyID, resetSplitShares, + setCustomUnitRateID, setMoneyRequestAmount, setMoneyRequestMerchant, setMoneyRequestParticipantsFromReport, @@ -379,6 +380,8 @@ function IOURequestStepDistance({ if (iouType === CONST.IOU.TYPE.CREATE && isPaidGroupPolicy(activePolicy) && activePolicy?.isPolicyExpenseChatEnabled) { const activePolicyExpenseChat = getPolicyExpenseChat(currentUserPersonalDetails.accountID, activePolicy?.id); setMoneyRequestParticipantsFromReport(transactionID, activePolicyExpenseChat); + const rateID = DistanceRequestUtils.getCustomUnitRateID(activePolicyExpenseChat?.reportID); + setCustomUnitRateID(transactionID, rateID); Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( CONST.IOU.ACTION.CREATE, diff --git a/src/pages/iou/request/step/IOURequestStepSubrate.tsx b/src/pages/iou/request/step/IOURequestStepSubrate.tsx index 43801d8ec642..a91f867e7f9e 100644 --- a/src/pages/iou/request/step/IOURequestStepSubrate.tsx +++ b/src/pages/iou/request/step/IOURequestStepSubrate.tsx @@ -1,6 +1,6 @@ import {useNavigation} from '@react-navigation/native'; -import React, {useCallback, useEffect, useState} from 'react'; -import {View} from 'react-native'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ConfirmModal from '@components/ConfirmModal'; @@ -10,11 +10,11 @@ import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import type BaseModalProps from '@components/Modal/types'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import ValuePicker from '@components/ValuePicker'; -import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -76,7 +76,7 @@ function IOURequestStepSubrate({ const navigation = useNavigation(); const isFocused = navigation.isFocused(); const {translate} = useLocalize(); - const {inputCallbackRef} = useAutoFocusInput(); + const textInputRef = useRef(null); const parsedIndex = parseInt(pageIndex, 10); const selectedDestination = transaction?.comment?.customUnit?.customUnitRateID; const allSubrates = transaction?.comment?.customUnit?.subRates ?? []; @@ -229,13 +229,18 @@ function IOURequestStepSubrate({ value={subrateValue} defaultValue={currentSubrate?.id} items={validOptions} - onValueChange={(value) => setSubrateValue(value as string)} + onValueChange={(value) => { + setSubrateValue(value as string); + InteractionManager.runAfterInteractions(() => { + textInputRef.current?.focus(); + }); + }} /> ; - action?: () => void; + action: () => void; link?: string | (() => Promise); iconType?: typeof CONST.ICON_TYPE_ICON | typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE; iconStyles?: StyleProp; @@ -94,10 +97,9 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const theme = useTheme(); const styles = useThemeStyles(); const {isExecuting, singleExecution} = useSingleExecution(); - const waitForNavigate = useWaitForNavigation(); const popoverAnchor = useRef(null); const {translate} = useLocalize(); - const activeCentralPaneRoute = useActiveCentralPaneRoute(); + const focusedRouteName = useNavigationState((state) => findFocusedRoute(state)?.name); const emojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; const [allConnectionSyncProgresses] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}`); const {setInitialURL} = useContext(InitialURLContext); @@ -147,25 +149,29 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr { translationKey: 'common.profile', icon: Expensicons.Profile, - routeName: ROUTES.SETTINGS_PROFILE, + screenName: SCREENS.SETTINGS.PROFILE.ROOT, brickRoadIndicator: profileBrickRoadIndicator, + action: () => Navigation.navigate(ROUTES.SETTINGS_PROFILE), }, { translationKey: 'common.wallet', icon: Expensicons.Wallet, - routeName: ROUTES.SETTINGS_WALLET, + screenName: SCREENS.SETTINGS.WALLET.ROOT, brickRoadIndicator: hasPaymentMethodError(bankAccountList, paymentCardList) || !isEmptyObject(userWallet?.errors) || !isEmptyObject(walletTerms?.errors) ? 'error' : undefined, + action: () => Navigation.navigate(ROUTES.SETTINGS_WALLET), }, { translationKey: 'common.preferences', icon: Expensicons.Gear, - routeName: ROUTES.SETTINGS_PREFERENCES, + screenName: SCREENS.SETTINGS.PREFERENCES.ROOT, + action: () => Navigation.navigate(ROUTES.SETTINGS_PREFERENCES), }, { translationKey: 'initialSettingsPage.security', icon: Expensicons.Lock, - routeName: ROUTES.SETTINGS_SECURITY, + screenName: SCREENS.SETTINGS.SECURITY, + action: () => Navigation.navigate(ROUTES.SETTINGS_SECURITY), }, ], }; @@ -182,18 +188,19 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr { translationKey: 'common.workspaces', icon: Expensicons.Buildings, - routeName: ROUTES.SETTINGS_WORKSPACES, + screenName: SCREENS.SETTINGS.WORKSPACES, brickRoadIndicator: hasGlobalWorkspaceSettingsRBR(policies, allConnectionSyncProgresses) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + action: () => Navigation.navigate(ROUTES.SETTINGS_WORKSPACES), }, { translationKey: 'allSettingsScreen.domains', icon: Expensicons.Globe, - action: () => { - openOldDotLink(CONST.OLDDOT_URLS.ADMIN_DOMAINS_URL); - }, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, link: () => buildOldDotURL(CONST.OLDDOT_URLS.ADMIN_DOMAINS_URL), + action: () => { + openOldDotLink(CONST.OLDDOT_URLS.ADMIN_DOMAINS_URL); + }, }, ]; @@ -201,10 +208,11 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr items.splice(1, 0, { translationKey: 'allSettingsScreen.subscription', icon: Expensicons.CreditCard, - routeName: ROUTES.SETTINGS_SUBSCRIPTION, + screenName: SCREENS.SETTINGS.SUBSCRIPTION.ROOT, brickRoadIndicator: !!privateSubscription?.errors || hasSubscriptionRedDotError() ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, badgeText: freeTrialText, badgeStyle: freeTrialText ? styles.badgeSuccess : undefined, + action: () => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION), }); } @@ -230,12 +238,12 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr { translationKey: 'initialSettingsPage.help', icon: Expensicons.QuestionMark, - action: () => { - openExternalLink(CONST.NEWHELP_URL); - }, iconRight: Expensicons.NewWindow, shouldShowRightIcon: true, link: CONST.NEWHELP_URL, + action: () => { + openExternalLink(CONST.NEWHELP_URL); + }, }, { translationKey: 'exitSurvey.goToExpensifyClassic', @@ -267,17 +275,20 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr { translationKey: 'initialSettingsPage.about', icon: Expensicons.Info, - routeName: ROUTES.SETTINGS_ABOUT, + screenName: SCREENS.SETTINGS.ABOUT, + action: () => Navigation.navigate(ROUTES.SETTINGS_ABOUT), }, { translationKey: 'initialSettingsPage.aboutPage.troubleshoot', icon: Expensicons.Lightbulb, - routeName: ROUTES.SETTINGS_TROUBLESHOOT, + screenName: SCREENS.SETTINGS.TROUBLESHOOT, + action: () => Navigation.navigate(ROUTES.SETTINGS_TROUBLESHOOT), }, { translationKey: 'sidebarScreen.saveTheWorld', icon: Expensicons.Heart, - routeName: ROUTES.SETTINGS_SAVE_THE_WORLD, + screenName: SCREENS.SETTINGS.SAVE_THE_WORLD, + action: () => Navigation.navigate(ROUTES.SETTINGS_SAVE_THE_WORLD), }, { translationKey: signOutTranslationKey, @@ -317,6 +328,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr {menuItemsData.items.map((item) => { const keyTitle = item.translationKey ? translate(item.translationKey) : item.title; const isPaymentItem = item.translationKey === 'common.wallet'; + const isFocused = focusedRouteName ? focusedRouteName === item.screenName : false; return ( { - if (item.action) { - item.action(); - } else { - waitForNavigate(() => { - Navigation.navigate(item.routeName); - })(); - } + item.action(); })} iconStyles={item.iconStyles} badgeText={item.badgeText ?? getWalletBalance(isPaymentItem)} @@ -346,11 +352,8 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr ref={popoverAnchor} shouldBlockSelection={!!item.link} onSecondaryInteraction={item.link ? (event) => openPopover(item.link, event) : undefined} - focused={ - !!activeCentralPaneRoute && - !!item.routeName && - !!(activeCentralPaneRoute.name.toLowerCase().replaceAll('_', '') === item.routeName.toLowerCase().replaceAll('/', '')) - } + focused={isFocused} + isPaneMenu iconRight={item.iconRight} shouldShowRightIcon={item.shouldShowRightIcon} shouldIconUseAutoWidthStyle @@ -360,7 +363,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr ); }, - [styles.pb4, styles.mh3, styles.sectionTitle, styles.sectionMenuItem, translate, userWallet?.currentBalance, isExecuting, singleExecution, activeCentralPaneRoute, waitForNavigate], + [styles.pb4, styles.mh3, styles.sectionTitle, styles.sectionMenuItem, translate, userWallet?.currentBalance, focusedRouteName, isExecuting, singleExecution], ); const accountMenuItems = useMemo(() => getMenuItemsSection(accountMenuItemsData), [accountMenuItemsData, getMenuItemsSection]); @@ -426,10 +429,9 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr return ( } shouldEnableKeyboardAvoidingView={false} > {headerContent} diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx index a2475ca3ec70..62e75f6895cd 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx @@ -70,7 +70,7 @@ function NewContactMethodPage({route}: NewContactMethodPageProps) { return; } - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS.route); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); User.clearUnvalidatedNewContactMethodAction(); }, [pendingContactAction?.actionVerified]); diff --git a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx index e0bf0e781e88..a6353bffb12b 100644 --- a/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/CountrySelectionPage.tsx @@ -18,7 +18,7 @@ import type SCREENS from '@src/SCREENS'; type CountrySelectionPageProps = PlatformStackScreenProps; -function CountrySelectionPage({route, navigation}: CountrySelectionPageProps) { +function CountrySelectionPage({route}: CountrySelectionPageProps) { const [searchValue, setSearchValue] = useState(''); const {translate} = useLocalize(); const currentCountry = route.params.country; @@ -44,19 +44,16 @@ function CountrySelectionPage({route, navigation}: CountrySelectionPageProps) { const selectCountry = useCallback( (option: Option) => { const backTo = route.params.backTo ?? ''; - // Check the navigation state and "backTo" parameter to decide navigation behavior - if (navigation.getState().routes.length === 1 && !backTo) { - // If there is only one route and "backTo" is empty, go back in navigation + + // Check the "backTo" parameter to decide navigation behavior + if (!backTo) { Navigation.goBack(); - } else if (!!backTo && navigation.getState().routes.length === 1) { - // If "backTo" is not empty and there is only one route, go back to the specific route defined in "backTo" with a country parameter - Navigation.goBack(appendParam(backTo, 'country', option.value)); } else { - // Otherwise, navigate to the specific route defined in "backTo" with a country parameter - Navigation.navigate(appendParam(backTo, 'country', option.value)); + // Set compareParams to false because we want to go back to this particular screen and update params (country). + Navigation.goBack(appendParam(backTo, 'country', option.value), {compareParams: false}); } }, - [route, navigation], + [route], ); return ( @@ -70,7 +67,7 @@ function CountrySelectionPage({route, navigation}: CountrySelectionPageProps) { onBackButtonPress={() => { const backTo = route.params.backTo ?? ''; const backToRoute = backTo ? `${backTo}?country=${currentCountry}` : ''; - Navigation.goBack(backToRoute as Route); + Navigation.goBack(backToRoute as Route, {compareParams: false}); }} /> diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx index 668081242996..cc873235d328 100644 --- a/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx @@ -1,32 +1,19 @@ import React, {useMemo} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import {getCurrentAddress, getDefaultCountry} from '@libs/PersonalDetailsUtils'; import AddressPage from '@pages/AddressPage'; -import * as PersonalDetails from '@userActions/PersonalDetails'; import type {FormOnyxValues} from '@src/components/Form/types'; +import type {Country} from '@src/CONST'; +import {updateAddress as updateAddressPersonalDetails} from '@src/libs/actions/PersonalDetails'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import type {PrivatePersonalDetails} from '@src/types/onyx'; - -type PersonalAddressPageOnyxProps = { - /** User's private personal details */ - privatePersonalDetails: OnyxEntry; - /** Whether app is loading */ - isLoadingApp: OnyxEntry; -}; - -type PersonalAddressPageProps = PlatformStackScreenProps & PersonalAddressPageOnyxProps; /** * Submit form to update user's first and last legal name * @param values - form input values */ function updateAddress(values: FormOnyxValues) { - PersonalDetails.updateAddress( + updateAddressPersonalDetails( values.addressLine1?.trim() ?? '', values.addressLine2?.trim() ?? '', values.city.trim(), @@ -36,12 +23,16 @@ function updateAddress(values: FormOnyxValues PersonalDetailsUtils.getCurrentAddress(privatePersonalDetails), [privatePersonalDetails]); + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const address = useMemo(() => getCurrentAddress(privatePersonalDetails), [privatePersonalDetails]); + const defaultCountry = getDefaultCountry(); return ( ({ - privatePersonalDetails: { - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - }, - isLoadingApp: { - key: ONYXKEYS.IS_LOADING_APP, - }, -})(PersonalAddressPage); +export default PersonalAddressPage; diff --git a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx index 85bf9333588d..14a3248d2c80 100644 --- a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx @@ -1,6 +1,5 @@ -import {useNavigation, useRoute} from '@react-navigation/native'; +import {useRoute} from '@react-navigation/native'; import {CONST as COMMON_CONST} from 'expensify-common'; -import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -25,7 +24,6 @@ type RouteParams = { function StateSelectionPage() { const route = useRoute(); - const navigation = useNavigation(); const {translate} = useLocalize(); const [searchValue, setSearchValue] = useState(''); @@ -57,26 +55,15 @@ function StateSelectionPage() { (option: Option) => { const backTo = params?.backTo ?? ''; - // Determine navigation action based on "backTo" presence and route stack length. - if (navigation.getState()?.routes.length === 1) { - // If this is the only page in the navigation stack (examples include direct navigation to this page via URL or page reload). - if (isEmpty(backTo)) { - // No "backTo": default back navigation. - Navigation.goBack(); - } else { - // "backTo" provided: navigate back to "backTo" with state parameter. - Navigation.goBack(appendParam(backTo, 'state', option.value)); - } - } else if (!isEmpty(backTo)) { - // Most common case: Navigation stack has multiple routes and "backTo" is defined: navigate to "backTo" with state parameter. - Navigation.navigate(appendParam(backTo, 'state', option.value)); - } else { - // This is a fallback block and should never execute if StateSelector is correctly appending the "backTo" route. - // Navigation stack has multiple routes but no "backTo" defined: default back navigation. + // Check the "backTo" parameter to decide navigation behavior + if (!backTo) { Navigation.goBack(); + } else { + // Set compareParams to false because we want to goUp to this particular screen and update params (state). + Navigation.goBack(appendParam(backTo, 'state', option.value), {compareParams: false}); } }, - [navigation, params?.backTo], + [params?.backTo], ); return ( diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx index 2add4009bb56..18556ddc72d3 100644 --- a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx +++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx @@ -76,7 +76,7 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { title={translate('delegate.role', {role})} description={translate('delegate.accessLevel')} helperText={translate('delegate.roleDescription', {role})} - onPress={() => Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(login, role), CONST.NAVIGATION.ACTION_TYPE.PUSH)} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(login, role, ROUTES.SETTINGS_DELEGATE_CONFIRM.getRoute(login, role)))} shouldShowRightIcon /> { diff --git a/src/pages/settings/Security/AddDelegate/SelectDelegateRolePage.tsx b/src/pages/settings/Security/AddDelegate/SelectDelegateRolePage.tsx index b5b7a3bd566b..d93665c8e668 100644 --- a/src/pages/settings/Security/AddDelegate/SelectDelegateRolePage.tsx +++ b/src/pages/settings/Security/AddDelegate/SelectDelegateRolePage.tsx @@ -38,7 +38,7 @@ function SelectDelegateRolePage({route}: SelectDelegateRolePageProps) { Navigation.goBack(ROUTES.SETTINGS_ADD_DELEGATE)} + onBackButtonPress={() => Navigation.goBack(route.params?.backTo ?? ROUTES.SETTINGS_ADD_DELEGATE)} /> ; + isValidateCodeActionModalVisible: boolean; + onClose?: () => void; +}; +function UpdateDelegateMagicCodeModal({login, role, isValidateCodeActionModalVisible, onClose}: UpdateDelegateMagicCodeModalProps) { + const {translate} = useLocalize(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [validateCodeAction] = useOnyx(ONYXKEYS.VALIDATE_ACTION_CODE); + const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login); + const updateDelegateErrors = account?.delegatedAccess?.errorFields?.updateDelegateRole?.[login]; + + useEffect(() => { + if (currentDelegate?.role !== role || !!currentDelegate.pendingFields?.role || !!updateDelegateErrors) { + return; + } + + // Dismiss modal on successful magic code verification + Navigation.navigate(ROUTES.SETTINGS_SECURITY); + }, [login, currentDelegate, role, updateDelegateErrors]); + + const onBackButtonPress = () => { + onClose?.(); + }; + + const clearError = () => { + if (!updateDelegateErrors) { + return; + } + clearDelegateErrorsByField(currentDelegate?.email ?? '', 'updateDelegateRole'); + }; + + return ( + requestValidateCodeAction()} + hasMagicCodeBeenSent={validateCodeAction?.validateCodeSent} + handleSubmitForm={(validateCode) => updateDelegateRole(login, role, validateCode)} + descriptionPrimary={translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + /> + ); +} + +UpdateDelegateMagicCodeModal.displayName = 'UpdateDelegateMagicCodeModal'; + +export default UpdateDelegateMagicCodeModal; diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx deleted file mode 100644 index 38663d0f93e0..000000000000 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, {useEffect, useRef} from 'react'; -import {useOnyx} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; -import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import ValidateCodeForm from './ValidateCodeForm'; -import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; - -type UpdateDelegateMagicCodePageProps = PlatformStackScreenProps; - -function UpdateDelegateMagicCodePage({route}: UpdateDelegateMagicCodePageProps) { - const {translate} = useLocalize(); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const login = route.params.login; - const role = route.params.role as ValueOf; - - const styles = useThemeStyles(); - const validateCodeFormRef = useRef(null); - - const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login); - const updateDelegateErrors = account?.delegatedAccess?.errorFields?.addDelegate?.[login]; - - useEffect(() => { - if (!currentDelegate || !!currentDelegate.pendingFields?.role || !!updateDelegateErrors) { - return; - } - - // Dismiss modal on successful magic code verification - Navigation.dismissModal(); - }, [login, currentDelegate, role, updateDelegateErrors]); - - const onBackButtonPress = () => { - Navigation.goBack(ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE.getRoute(login, role)); - }; - - return ( - - {({safeAreaPaddingBottomStyle}) => ( - - - {translate('delegate.enterMagicCodeUpdate', {contactMethod: account?.primaryLogin ?? ''})} - - - )} - - ); -} - -UpdateDelegateMagicCodePage.displayName = 'UpdateDelegateMagicCodePage'; - -export default UpdateDelegateMagicCodePage; diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx index 097b1ce679af..1bc3e312391b 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx @@ -1,4 +1,5 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useState} from 'react'; +import type {ValueOf} from 'type-fest'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -6,16 +7,17 @@ import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useBeforeRemove from '@hooks/useBeforeRemove'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearDelegateRolePendingAction, requestValidationCode, updateDelegateRoleOptimistically} from '@libs/actions/Delegate'; +import {clearDelegateRolePendingAction, updateDelegateRoleOptimistically} from '@libs/actions/Delegate'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {DelegateRole} from '@src/types/onyx/Account'; +import UpdateDelegateMagicCodeModal from './UpdateDelegateMagicCodeModal'; type UpdateDelegateRolePageProps = PlatformStackScreenProps; @@ -23,6 +25,8 @@ function UpdateDelegateRolePage({route}: UpdateDelegateRolePageProps) { const {translate} = useLocalize(); const login = route.params.login; const currentRole = route.params.currentRole; + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false); + const [newRole, setNewRole] = useState | null>(); const styles = useThemeStyles(); const roleOptions = Object.values(CONST.DELEGATE_ROLE).map((role) => ({ @@ -33,6 +37,7 @@ function UpdateDelegateRolePage({route}: UpdateDelegateRolePageProps) { isSelected: role === currentRole, })); + useBeforeRemove(() => setIsValidateCodeActionModalVisible(false)); useEffect(() => { updateDelegateRoleOptimistically(login ?? '', currentRole as DelegateRole); return () => clearDelegateRolePendingAction(login); @@ -72,13 +77,22 @@ function UpdateDelegateRolePage({route}: UpdateDelegateRolePageProps) { Navigation.dismissModal(); return; } - - requestValidationCode(); - Navigation.navigate(ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE_MAGIC_CODE.getRoute(login, option.value)); + setNewRole(option?.value); + setIsValidateCodeActionModalVisible(true); }} sections={[{data: roleOptions}]} ListItem={RadioListItem} /> + {!!newRole && ( + { + setIsValidateCodeActionModalVisible(false); + }} + /> + )} ); diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/SuccessStep.tsx b/src/pages/settings/Security/TwoFactorAuth/Steps/SuccessStep.tsx index 9604a7d1627f..ad68d7cf7a0c 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/SuccessStep.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/SuccessStep.tsx @@ -40,7 +40,7 @@ function SuccessStep({backTo, forwardTo}: SuccessStepParams) { TwoFactorAuthActions.clearTwoFactorAuthData(); setStep(CONST.TWO_FACTOR_AUTH_STEPS.ENABLED); if (backTo) { - Navigation.navigate(backTo); + Navigation.goBack(backTo); } if (forwardTo) { Link.openLink(forwardTo, environmentURL); diff --git a/src/pages/settings/Subscription/CardSection/CardSection.tsx b/src/pages/settings/Subscription/CardSection/CardSection.tsx index 5f51be73c4a5..30a66ee22ae4 100644 --- a/src/pages/settings/Subscription/CardSection/CardSection.tsx +++ b/src/pages/settings/Subscription/CardSection/CardSection.tsx @@ -58,7 +58,7 @@ function CardSection() { const requestRefund = useCallback(() => { requestRefundByUser(); setIsRequestRefundModalVisible(false); - Navigation.resetToHome(); + Navigation.goBackToHome(); }, []); const viewPurchases = useCallback(() => { diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx index be49a6014f98..1ae4f619e0f8 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.tsx @@ -20,6 +20,7 @@ import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -60,6 +61,9 @@ type BaseGetPhysicalCardProps = { /** Callback executed when validating get physical card form data */ onValidate?: OnValidate; + + /** In case we want to prioritize a backTo route(eg. navigating from GetPhysicalCardConfirm to GetPhysicalCardAddress) instead of the default ROUTES.SETTINGS_WALLET_DOMAINCARD */ + backTo?: Route; }; function DefaultRenderContent({onSubmit, submitButtonText, children, onValidate}: RenderContentProps) { @@ -89,6 +93,7 @@ function BaseGetPhysicalCard({ submitButtonText, title, onValidate = () => ({}), + backTo, }: BaseGetPhysicalCardProps) { const styles = useThemeStyles(); const isRouteSet = useRef(false); @@ -174,12 +179,16 @@ function BaseGetPhysicalCard({ ); const handleBackButtonPress = useCallback(() => { + if (backTo) { + Navigation.goBack(backTo); + return; + } if (currentCardID) { Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(currentCardID)); return; } Navigation.goBack(); - }, [currentCardID]); + }, [backTo, currentCardID]); return ( ); } diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.tsx b/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.tsx index 3e2935e626cb..781a34084a17 100644 --- a/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.tsx +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.tsx @@ -9,22 +9,21 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import BaseGetPhysicalCard from './BaseGetPhysicalCard'; const goToGetPhysicalCardName = (domain: string) => { - Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain), CONST.NAVIGATION.ACTION_TYPE.PUSH); + Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain, Navigation.getActiveRoute())); }; const goToGetPhysicalCardPhone = (domain: string) => { - Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain), CONST.NAVIGATION.ACTION_TYPE.PUSH); + Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain, Navigation.getActiveRoute())); }; const goToGetPhysicalCardAddress = (domain: string) => { - Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.getRoute(domain), CONST.NAVIGATION.ACTION_TYPE.PUSH); + Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.getRoute(domain, Navigation.getActiveRoute())); }; type GetPhysicalCardConfirmProps = PlatformStackScreenProps; diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardName.tsx b/src/pages/settings/Wallet/Card/GetPhysicalCardName.tsx index a150e4c25f80..a86f74e1c4bd 100644 --- a/src/pages/settings/Wallet/Card/GetPhysicalCardName.tsx +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardName.tsx @@ -26,7 +26,7 @@ type GetPhysicalCardNameProps = PlatformStackScreenProps state.routes.findLast((route) => isFullScreenName(route.name))); const goBack = useCallback(() => { - switch (topMostCentralPane?.name) { - case SCREENS.SETTINGS.WALLET.ROOT: - Navigation.goBack(ROUTES.SETTINGS_WALLET, true); + switch (topmostFullScreenRoute?.name) { + case NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR: + Navigation.goBack(ROUTES.SETTINGS_WALLET); break; - case SCREENS.REPORT: + case NAVIGATORS.REPORTS_SPLIT_NAVIGATOR: Navigation.closeRHPFlow(); break; default: Navigation.goBack(); break; } - }, [topMostCentralPane]); + }, [topmostFullScreenRoute?.name]); const handleFinishStep = useCallback(() => { clearDraftValues(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM); diff --git a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx index a988c947b3eb..4227c4119cc5 100644 --- a/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx +++ b/src/pages/settings/Wallet/ReportVirtualCardFraudPage.tsx @@ -63,7 +63,7 @@ function ReportVirtualCardFraudPage({ if (latestIssuedVirtualCardID) { Navigation.removeScreenFromNavigationState(SCREENS.SETTINGS.WALLET.DOMAIN_CARD); - Navigation.closeAndNavigate(ROUTES.SETTINGS_REPORT_FRAUD_CONFIRMATION.getRoute(latestIssuedVirtualCardID)); + Navigation.goBack(ROUTES.SETTINGS_REPORT_FRAUD_CONFIRMATION.getRoute(latestIssuedVirtualCardID)); setIsValidateCodeActionModalVisible(false); } }, [formData?.isLoading, latestIssuedVirtualCardID, prevIsLoading, virtualCard?.errors]); diff --git a/src/pages/settings/Wallet/VerifyAccountPage.tsx b/src/pages/settings/Wallet/VerifyAccountPage.tsx index f9fc3ff27ba6..2dece9c74df0 100644 --- a/src/pages/settings/Wallet/VerifyAccountPage.tsx +++ b/src/pages/settings/Wallet/VerifyAccountPage.tsx @@ -12,7 +12,6 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import * as User from '@userActions/User'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -63,7 +62,7 @@ function VerifyAccountPage({route}: VerifyAccountPageProps) { setIsValidateCodeActionModalVisible(false); if (navigateForwardTo) { - Navigation.navigate(navigateForwardTo, CONST.NAVIGATION.TYPE.UP); + Navigation.navigate(navigateForwardTo, {forceReplace: true}); } else { Navigation.goBack(backTo); } diff --git a/src/pages/workspace/AccessOrNotFoundWrapper.tsx b/src/pages/workspace/AccessOrNotFoundWrapper.tsx index db33902c2d88..378d75732d76 100644 --- a/src/pages/workspace/AccessOrNotFoundWrapper.tsx +++ b/src/pages/workspace/AccessOrNotFoundWrapper.tsx @@ -92,7 +92,7 @@ function PageNotFoundFallback({policyID, fullPageNotFoundViewProps, isFeatureEna shouldShowOfflineIndicator={false} onBackButtonPress={() => { if (isPolicyNotAccessible) { - Navigation.dismissModal(); + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES); return; } Navigation.goBack(policyID && !isMoneyRequest ? ROUTES.WORKSPACE_PROFILE.getRoute(policyID) : undefined); diff --git a/src/pages/workspace/WorkspaceConfirmationPage.tsx b/src/pages/workspace/WorkspaceConfirmationPage.tsx index ae3356ddb215..ea3638b0c84d 100644 --- a/src/pages/workspace/WorkspaceConfirmationPage.tsx +++ b/src/pages/workspace/WorkspaceConfirmationPage.tsx @@ -2,12 +2,22 @@ import React from 'react'; import ScreenWrapper from '@components/ScreenWrapper'; import WorkspaceConfirmationForm from '@components/WorkspaceConfirmationForm'; import type {WorkspaceConfirmationSubmitFunctionParams} from '@components/WorkspaceConfirmationForm'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {createWorkspaceWithPolicyDraftAndNavigateToIt} from '@libs/actions/App'; +import {generatePolicyID} from '@libs/actions/Policy/Policy'; import getCurrentUrl from '@libs/Navigation/currentUrl'; +import ROUTES from '@src/ROUTES'; function WorkspaceConfirmationPage() { + // It is necessary to use here isSmallScreenWidth because on a wide layout we should always navigate to ROUTES.WORKSPACE_PROFILE. + // shouldUseNarrowLayout cannot be used to determine that as this screen is displayed in RHP and shouldUseNarrowLayout always returns true. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const onSubmit = (params: WorkspaceConfirmationSubmitFunctionParams) => { - createWorkspaceWithPolicyDraftAndNavigateToIt('', params.name, false, false, '', params.policyID, params.currency, params.avatarFile as File); + const policyID = params.policyID || generatePolicyID(); + const routeToNavigate = isSmallScreenWidth ? ROUTES.WORKSPACE_INITIAL.getRoute(policyID) : ROUTES.WORKSPACE_PROFILE.getRoute(policyID); + createWorkspaceWithPolicyDraftAndNavigateToIt('', params.name, false, false, '', policyID, params.currency, params.avatarFile as File, routeToNavigate); }; const currentUrl = getCurrentUrl(); // Approved Accountants and Guides can enter a flow where they make a workspace for other users, diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 85efdb764ee8..af7a1c9d9189 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -1,4 +1,4 @@ -import {useFocusEffect, useNavigationState} from '@react-navigation/native'; +import {findFocusedRoute, useFocusEffect, useNavigationState} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -26,6 +26,8 @@ import { Workflows, } from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; +import BottomTabBar from '@components/Navigation/BottomTabBar'; +import BOTTOM_TABS from '@components/Navigation/BottomTabBar/BOTTOM_TABS'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -43,7 +45,6 @@ import {clearErrors, openPolicyInitialPage, removeWorkspace, updateGeneralSettin import {navigateToBankAccountRoute} from '@libs/actions/ReimbursementAccount'; import {checkIfFeedConnectionIsBroken, flatAllCardsList} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; -import getTopmostRouteName from '@libs/Navigation/getTopmostRouteName'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import { @@ -60,11 +61,11 @@ import { shouldShowTaxRateError, } from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar, getIcons, getPolicyExpenseChat, getReportName, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; -import type {FullScreenNavigatorParamList} from '@navigation/types'; +import type WORKSPACE_TO_RHP from '@navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP'; +import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -74,33 +75,19 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; +type WorkspaceTopLevelScreens = keyof typeof WORKSPACE_TO_RHP; + type WorkspaceMenuItem = { translationKey: TranslationPaths; icon: IconAsset; action: () => void; brickRoadIndicator?: ValueOf; - routeName?: - | typeof SCREENS.WORKSPACE.ACCOUNTING.ROOT - | typeof SCREENS.WORKSPACE.INITIAL - | typeof SCREENS.WORKSPACE.INVOICES - | typeof SCREENS.WORKSPACE.DISTANCE_RATES - | typeof SCREENS.WORKSPACE.WORKFLOWS - | typeof SCREENS.WORKSPACE.CATEGORIES - | typeof SCREENS.WORKSPACE.TAGS - | typeof SCREENS.WORKSPACE.TAXES - | typeof SCREENS.WORKSPACE.MORE_FEATURES - | typeof SCREENS.WORKSPACE.PROFILE - | typeof SCREENS.WORKSPACE.MEMBERS - | typeof SCREENS.WORKSPACE.EXPENSIFY_CARD - | typeof SCREENS.WORKSPACE.COMPANY_CARDS - | typeof SCREENS.WORKSPACE.REPORT_FIELDS - | typeof SCREENS.WORKSPACE.RULES - | typeof SCREENS.WORKSPACE.PER_DIEM; + screenName: WorkspaceTopLevelScreens; badgeText?: string; highlighted?: boolean; }; -type WorkspaceInitialPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; +type WorkspaceInitialPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; type PolicyFeatureStates = Record; @@ -130,7 +117,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress(connectionSyncProgress, policy)); const waitForNavigate = useWaitForNavigation(); const {singleExecution, isExecuting} = useSingleExecution(); - const activeRoute = useNavigationState(getTopmostRouteName); + const activeRoute = useNavigationState((state) => findFocusedRoute(state)?.name); const {translate} = useLocalize(); const {isOffline} = useNetwork(); const wasRendered = useRef(false); @@ -208,7 +195,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.distanceRates', icon: Car, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.DISTANCE_RATES, + screenName: SCREENS.WORKSPACE.DISTANCE_RATES, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_DISTANCE_RATES_ENABLED, }); } @@ -218,7 +205,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.expensifyCard', icon: ExpensifyCard, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_EXPENSIFY_CARD.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.EXPENSIFY_CARD, + screenName: SCREENS.WORKSPACE.EXPENSIFY_CARD, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED, }); } @@ -230,7 +217,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.companyCards', icon: CreditCard, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.COMPANY_CARDS, + screenName: SCREENS.WORKSPACE.COMPANY_CARDS, brickRoadIndicator: hasBrokenFeedConnection ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED, }); @@ -241,7 +228,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'common.perDiem', icon: CalendarSolid, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.PER_DIEM, + screenName: SCREENS.WORKSPACE.PER_DIEM, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED, }); } @@ -251,7 +238,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.workflows', icon: Workflows, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.WORKFLOWS, + screenName: SCREENS.WORKSPACE.WORKFLOWS, brickRoadIndicator: !isEmptyObject(policy?.errorFields?.reimburser ?? {}) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED, }); @@ -262,7 +249,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.rules', icon: Feed, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_RULES.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.RULES, + screenName: SCREENS.WORKSPACE.RULES, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED, }); } @@ -274,7 +261,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.invoices', icon: InvoiceGeneric, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.INVOICES, + screenName: SCREENS.WORKSPACE.INVOICES, badgeText: convertToDisplayString(policy?.invoice?.bankAccount?.stripeConnectAccountBalance ?? 0, currencyCode), highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_INVOICES_ENABLED, }); @@ -286,7 +273,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac icon: Folder, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES.getRoute(policyID)))), brickRoadIndicator: hasPolicyCategoryError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - routeName: SCREENS.WORKSPACE.CATEGORIES, + screenName: SCREENS.WORKSPACE.CATEGORIES, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED, }); } @@ -296,7 +283,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.tags', icon: Tag, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TAGS.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.TAGS, + screenName: SCREENS.WORKSPACE.TAGS, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED, }); } @@ -306,7 +293,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.taxes', icon: Coins, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TAXES.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.TAXES, + screenName: SCREENS.WORKSPACE.TAXES, brickRoadIndicator: shouldShowTaxRateError(policy) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED, }); @@ -317,7 +304,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.reportFields', icon: Pencil, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_REPORT_FIELDS.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.REPORT_FIELDS, + screenName: SCREENS.WORKSPACE.REPORT_FIELDS, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED, }); } @@ -328,7 +315,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac icon: Sync, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)))), brickRoadIndicator: hasSyncError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - routeName: SCREENS.WORKSPACE.ACCOUNTING.ROOT, + screenName: SCREENS.WORKSPACE.ACCOUNTING.ROOT, highlighted: highlightedFeature === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED, }); } @@ -337,7 +324,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac translationKey: 'workspace.common.moreFeatures', icon: Gear, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)))), - routeName: SCREENS.WORKSPACE.MORE_FEATURES, + screenName: SCREENS.WORKSPACE.MORE_FEATURES, }); const menuItems: WorkspaceMenuItem[] = [ @@ -346,14 +333,14 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac icon: Building, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE.getRoute(policyID)))), brickRoadIndicator: hasGeneralSettingsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - routeName: SCREENS.WORKSPACE.PROFILE, + screenName: SCREENS.WORKSPACE.PROFILE, }, { translationKey: 'workspace.common.members', icon: Users, action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)))), brickRoadIndicator: hasMembersError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - routeName: SCREENS.WORKSPACE.MEMBERS, + screenName: SCREENS.WORKSPACE.MEMBERS, }, ...(isPaidGroupPolicy(policy) && shouldShowProtectedItems ? protectedMenuItems : []), ]; @@ -416,7 +403,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac // We are checking if the user can access the route. // If user can't access the route, we are dismissing any modals that are open when the NotFound view is shown - const canAccessRoute = activeRoute && (workspaceMenuItems.some((item) => item.routeName === activeRoute) || activeRoute === SCREENS.WORKSPACE.INITIAL); + const canAccessRoute = activeRoute && (workspaceMenuItems.some((item) => item.screenName === activeRoute) || activeRoute === SCREENS.WORKSPACE.INITIAL); useEffect(() => { if (!shouldShowNotFoundPage && canAccessRoute) { @@ -446,27 +433,23 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac }; }, [policy]); + const shouldShowBottomTab = !shouldShowNotFoundPage; + return ( : null} > { - if (route.params?.backTo) { - Navigation.resetToHome(); - Navigation.isNavigationReady().then(() => Navigation.navigate(route.params?.backTo as Route)); - } else { - Navigation.dismissModal(); - } - }} + onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} policyAvatar={policyAvatar} style={styles.headerBarDesktopHeight} /> @@ -496,7 +479,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac brickRoadIndicator={item.brickRoadIndicator} wrapperStyle={styles.sectionMenuItem} highlighted={!!item?.highlighted} - focused={!!(item.routeName && activeRoute?.startsWith(item.routeName))} + focused={!!(item.screenName && activeRoute?.startsWith(item.screenName))} badgeText={item.badgeText} shouldIconUseAutoWidthStyle /> @@ -511,7 +494,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac title={getReportName(currentUserPolicyExpenseChat)} description={translate('workspace.common.workspace')} icon={getIcons(currentUserPolicyExpenseChat, personalDetails)} - onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(currentUserPolicyExpenseChat?.reportID), CONST.NAVIGATION.TYPE.UP)} + onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(currentUserPolicyExpenseChat?.reportID))} shouldShowRightIcon wrapperStyle={[styles.br2, styles.pl2, styles.pr0, styles.pv3, styles.mt1, styles.alignItemsCenter]} shouldShowSubscriptAvatar diff --git a/src/pages/workspace/WorkspaceInviteMessagePage.tsx b/src/pages/workspace/WorkspaceInviteMessagePage.tsx index f949269e173a..ba074cef7d1b 100644 --- a/src/pages/workspace/WorkspaceInviteMessagePage.tsx +++ b/src/pages/workspace/WorkspaceInviteMessagePage.tsx @@ -97,7 +97,7 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}: if (isEmptyObject(policy)) { return; } - Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID, route.params.backTo), true); + Navigation.goBack(route.params.backTo); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isOnyxLoading]); @@ -110,10 +110,12 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}: clearDraftValues(ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM); if ((route.params?.backTo as string)?.endsWith('members')) { Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.dismissModal()); - return; } - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(route.params.policyID))); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.dismissModal(); + Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(route.params.policyID)); + }); }; /** Opens privacy url as an external link */ @@ -183,7 +185,7 @@ function WorkspaceInviteMessagePage({policy, route, currentUserPersonalDetails}: guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_MEMBERS} shouldShowBackButton onCloseButtonPress={() => Navigation.dismissModal()} - onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID, route.params.backTo))} + onBackButtonPress={() => Navigation.goBack(route.params.backTo)} /> { - Navigation.goBack(undefined, false, true); + Navigation.goBack(undefined, {shouldPopToTop: true}); Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID ?? '-1')); }); return; diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 152796028d6a..c2e34e1ceb9e 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -47,7 +47,7 @@ import {formatPhoneNumber as formatPhoneNumberUtil} from '@libs/LocalePhoneNumbe import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; +import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {isPersonalDetailsReady, sortAlphabetically} from '@libs/OptionsListUtils'; import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils'; import {getMemberAccountIDsForWorkspace, isDeletedPolicyEmployee, isExpensifyTeam, isPaidGroupPolicy, isPolicyAdmin as isPolicyAdminUtils} from '@libs/PolicyUtils'; @@ -67,7 +67,7 @@ import WorkspacePageWithSections from './WorkspacePageWithSections'; type WorkspaceMembersPageProps = WithPolicyAndFullscreenLoadingProps & WithCurrentUserPersonalDetailsProps & - PlatformStackScreenProps; + PlatformStackScreenProps; /** * Inverts an object, equivalent of _.invert diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index 6cdf4ad676c3..6075d58353f9 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -20,7 +20,7 @@ import {getCompanyFeeds} from '@libs/CardUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; +import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {getPerDiemCustomUnit, isControlPolicy} from '@libs/PolicyUtils'; import {enablePolicyCategories} from '@userActions/Policy/Category'; import {enablePolicyDistanceRates} from '@userActions/Policy/DistanceRate'; @@ -52,7 +52,7 @@ import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscree import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import ToggleSettingOptionRow from './workflows/ToggleSettingsOptionRow'; -type WorkspaceMoreFeaturesPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; +type WorkspaceMoreFeaturesPageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps; type Item = { icon: IconAsset; diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx index 0ce996d1a2e6..000f61510acd 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.tsx +++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx @@ -104,7 +104,7 @@ function WorkspaceNewRoomPage() { '', visibility, writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL, - CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + CONST.REPORT.NOTIFICATION_PREFERENCE.DAILY, '', '', parsedDescription, diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index c30ac3c57381..c818180a6c96 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -21,6 +21,7 @@ import {isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy as shouldShowPoli import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -175,8 +176,8 @@ function WorkspacePageWithSections({ shouldShowOfflineIndicatorInWideScreen={shouldShowOfflineIndicatorInWideScreen && !shouldShow} > Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} + onLinkPress={Navigation.goBackToHome} shouldShow={shouldShow} subtitleKey={shouldShowPolicy ? 'workspace.common.notAuthorized' : undefined} shouldForceFullScreen diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx index 8379f3e94caa..b8fc7e6256b8 100644 --- a/src/pages/workspace/WorkspaceProfilePage.tsx +++ b/src/pages/workspace/WorkspaceProfilePage.tsx @@ -22,33 +22,33 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearInviteDraft} from '@libs/actions/Policy/Member'; import {clearAvatarErrors, clearPolicyErrorField, deleteWorkspace, deleteWorkspaceAvatar, openPolicyProfilePage, updateWorkspaceAvatar} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import resetPolicyIDInNavigationState from '@libs/Navigation/helpers/resetPolicyIDInNavigationState'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {FullScreenNavigatorParamList, RootStackParamList, State} from '@libs/Navigation/types'; +import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import Parser from '@libs/Parser'; -import {getUserFriendlyWorkspaceType, getWorkspaceAccountID, goBackFromInvalidPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; +import {getUserFriendlyWorkspaceType, getWorkspaceAccountID, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; import {getFullSizeAvatar} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; +import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WithPolicyProps} from './withPolicy'; import withPolicy from './withPolicy'; import WorkspacePageWithSections from './WorkspacePageWithSections'; -type WorkspaceProfilePageProps = WithPolicyProps & PlatformStackScreenProps; +type WorkspaceProfilePageProps = WithPolicyProps & PlatformStackScreenProps; function WorkspaceProfilePage({policyDraft, policy: policyProp, route}: WorkspaceProfilePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const illustrations = useThemeIllustrations(); - const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); const {canUseSpotnanaTravel} = usePermissions(); + const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID}); @@ -167,14 +167,8 @@ function WorkspaceProfilePage({policyDraft, policy: policyProp, route}: Workspac // If the workspace being deleted is the active workspace, switch to the "All Workspaces" view if (activeWorkspaceID === policy.id) { setActiveWorkspaceID(undefined); - Navigation.dismissModal(); - const rootState = navigationRef.current?.getRootState() as State; - const topmostBottomTabRoute = getTopmostBottomTabRoute(rootState); - if (topmostBottomTabRoute?.name === SCREENS.SETTINGS.ROOT) { - Navigation.setParams({policyID: undefined}, topmostBottomTabRoute?.key); - } + resetPolicyIDInNavigationState(); } - goBackFromInvalidPolicy(); }, [policy?.id, policyName, activeWorkspaceID, setActiveWorkspaceID]); return ( diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index cdf6a798aa11..219637583e70 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -4,16 +4,18 @@ import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; -import FeatureList from '@components/FeatureList'; import type {FeatureListItem} from '@components/FeatureList'; +import FeatureList from '@components/FeatureList'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import LottieAnimations from '@components/LottieAnimations'; import type {MenuItemProps} from '@components/MenuItem'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import BottomTabBar from '@components/Navigation/BottomTabBar'; +import BOTTOM_TABS from '@components/Navigation/BottomTabBar/BOTTOM_TABS'; import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {PressableWithoutFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -31,16 +33,14 @@ import {clearDeleteWorkspaceError, clearErrors, deleteWorkspace, leaveWorkspace, import {callFunctionIfActionIsAllowed, isSupportAuthToken} from '@libs/actions/Session'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import localeCompare from '@libs/LocaleCompare'; -import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; -import type {RootStackParamList, State} from '@libs/Navigation/types'; +import resetPolicyIDInNavigationState from '@libs/Navigation/helpers/resetPolicyIDInNavigationState'; +import Navigation from '@libs/Navigation/Navigation'; import {getPolicy, getPolicyBrickRoadIndicatorStatus, getWorkspaceAccountID, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import type {AvatarSource} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; import type {Policy as PolicyType} from '@src/types/onyx'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {PolicyDetailsForNonMembers} from '@src/types/onyx/Policy'; @@ -110,6 +110,7 @@ function WorkspacesListPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -120,8 +121,6 @@ function WorkspacesListPage() { const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; - const {activeWorkspaceID, setActiveWorkspaceID} = useActiveWorkspace(); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [policyIDToDelete, setPolicyIDToDelete] = useState(); const [policyNameToDelete, setPolicyNameToDelete] = useState(); @@ -152,14 +151,9 @@ function WorkspacesListPage() { deleteWorkspace(policyIDToDelete, policyNameToDelete); setIsDeleteModalOpen(false); - // If the workspace being deleted is the active workspace, switch to the "All Workspaces" view - if (activeWorkspaceID === policyIDToDelete) { + if (policyIDToDelete === activeWorkspaceID) { setActiveWorkspaceID(undefined); - const rootState = navigationRef.current?.getRootState() as State; - const topmostBottomTabRoute = getTopmostBottomTabRoute(rootState); - if (topmostBottomTabRoute?.name === SCREENS.SETTINGS.ROOT) { - Navigation.setParams({policyID: undefined}, topmostBottomTabRoute?.key); - } + resetPolicyIDInNavigationState(); } }; @@ -351,6 +345,18 @@ function WorkspacesListPage() { }, {}); }, [reports]); + const navigateToWorkspace = useCallback( + (policyID: string) => { + // On the wide layout, we always want to open the Profile page when opening workpsace settings from the list + if (shouldUseNarrowLayout) { + Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID)); + return; + } + Navigation.navigate(ROUTES.WORKSPACE_PROFILE.getRoute(policyID)); + }, + [shouldUseNarrowLayout], + ); + /** * Add free policies (workspaces) to the list of menu items and returns the list of menu items */ @@ -386,7 +392,7 @@ function WorkspacesListPage() { return { title: policy.name, icon: policy.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy.name), - action: () => Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policy.id)), + action: () => navigateToWorkspace(policy.id), brickRoadIndicator: !isPolicyAdmin(policy) ? undefined : reimbursementAccountBrickRoadIndicator ?? @@ -411,7 +417,7 @@ function WorkspacesListPage() { }; }) .sort((a, b) => localeCompare(a.title, b.title)); - }, [reimbursementAccount?.errors, policies, isOffline, theme.textLight, policyRooms, session?.email, allConnectionSyncProgresses]); + }, [reimbursementAccount?.errors, policies, isOffline, session?.email, allConnectionSyncProgresses, theme.textLight, policyRooms, navigateToWorkspace]); const getHeaderButton = () => (