diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 31bfdf963525..04fdb7e80d6a 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -200,26 +200,66 @@ jobs: if: failure() run: | echo ${{ steps.schedule-awsdf-main.outputs.data }} - cat "./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/Test spec output.txt" unzip "Customer Artifacts.zip" -d mainResults - cat ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/debug.log + cat "./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/logcat.txt" || true + cat ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/debug.log || true + cat "./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/Test spec output.txt" || true + + - name: Announce failed workflow in Slack + if: failure() + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#expensify-margelo', + attachments: [{ + color: 'danger', + text: `💥 ${process.env.AS_REPO} E2E Test run failed failed on workflow 💥`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - name: Unzip AWS Device Farm results - if: ${{ always() }} + if: always() run: unzip "Customer Artifacts.zip" - name: Print AWS Device Farm run results - if: ${{ always() }} + if: always() run: cat "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md" - name: Check if test failed, if so post the results and add the DeployBlocker label + id: checkIfRegressionDetected run: | if grep -q '🔴' ./output.md; then + # Create an output to the GH action that the test failed: + echo "performanceRegressionDetected=true" >> "$GITHUB_OUTPUT" + gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash gh pr comment ${{ inputs.PR_NUMBER }} -F ./output.md gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers 📣 Please look into this performance regression as it's a deploy blocker." else + echo "performanceRegressionDetected=false" >> "$GITHUB_OUTPUT" echo '✅ no performance regression detected' fi env: GITHUB_TOKEN: ${{ github.token }} + + - name: 'Announce regression in Slack' + if: ${{ steps.checkIfRegressionDetected.outputs.performanceRegressionDetected == 'true' }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#newdot-performance', + attachments: [{ + color: 'danger', + text: `🔴 Performance regression detected in PR ${{ inputs.PR_NUMBER }}\nDetected in workflow.`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml new file mode 100644 index 000000000000..d2e0ec4f38e5 --- /dev/null +++ b/.github/workflows/failureNotifier.yml @@ -0,0 +1,96 @@ +name: Notify on Workflow Failure + +on: + workflow_run: + workflows: ["Process new code merged to main"] + types: + - completed + +permissions: + issues: write + +jobs: + notifyFailure: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + steps: + - name: Fetch Workflow Run Jobs + id: fetch-workflow-jobs + uses: actions/github-script@v7 + with: + script: | + const runId = "${{ github.event.workflow_run.id }}"; + const jobsData = await github.rest.actions.listJobsForWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + }); + return jobsData.data; + + - name: Process Each Failed Job + uses: actions/github-script@v7 + with: + script: | + const jobs = ${{ steps.fetch-workflow-jobs.outputs.result }}; + + const headCommit = "${{ github.event.workflow_run.head_commit.id }}"; + const prData = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: headCommit, + }); + + const pr = prData.data[0]; + const prLink = pr.html_url; + const prAuthor = pr.user.login; + const prMerger = "${{ github.event.workflow_run.actor.login }}"; + + const failureLabel = 'Workflow Failure'; + for (let i = 0; i < jobs.total_count; i++) { + if (jobs.jobs[i].conclusion == 'failure') { + const jobName = jobs.jobs[i].name; + const jobLink = jobs.jobs[i].html_url; + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: failureLabel, + state: 'open' + }); + const existingIssue = issues.data.find(issue => issue.title.includes(jobName)); + if (!existingIssue) { + const annotations = await github.rest.checks.listAnnotations({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: jobs.jobs[i].id, + }); + let errorMessage = ""; + for(let j = 0; j < annotations.data.length; j++) { + errorMessage += annotations.data[j].annotation_level + ": "; + errorMessage += annotations.data[j].message + "\n"; + } + const issueTitle = `Investigate workflow job failing on main: ${ jobName }`; + const issueBody = `🚨 **Failure Summary** 🚨:\n\n` + + `- **📋 Job Name**: [${ jobName }](${ jobLink })\n` + + `- **🔧 Failure in Workflow**: Process new code merged to main\n` + + `- **🔗 Triggered by PR**: [PR Link](${ prLink })\n` + + `- **👤 PR Author**: @${ prAuthor }\n` + + `- **🤝 Merged by**: @${ prMerger }\n` + + `- **🐛 Error Message**: \n ${errorMessage}\n\n` + + `⚠️ **Action Required** ⚠️:\n\n` + + `🛠️ A recent merge appears to have caused a failure in the job named [${ jobName }](${ jobLink }).\n` + + `This issue has been automatically created and labeled with \`${ failureLabel }\` for investigation. \n\n` + + `👀 **Please look into the following**:\n` + + `1. **Why the PR caused the job to fail?**\n` + + `2. **Address any underlying issues.**\n\n` + + `🐛 We appreciate your help in squashing this bug!`; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + labels: [failureLabel, 'Daily'], + assignees: [prMerger] + }); + } + } + } diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index 8f9512062e9d..f09865de0194 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -1,3 +1,4 @@ +# Reminder: If this workflow's name changes, update the name in the dependent workflow at .github/workflows/failureNotifier.yml. name: Process new code merged to main on: diff --git a/__mocks__/@react-native-community/push-notification-ios.js b/__mocks__/@react-native-community/push-notification-ios.js deleted file mode 100644 index 0fe8354b9e08..000000000000 --- a/__mocks__/@react-native-community/push-notification-ios.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - addEventListener: jest.fn(), - requestPermissions: jest.fn(() => Promise.resolve()), - getInitialNotification: jest.fn(() => Promise.resolve()), -}; diff --git a/__mocks__/@react-native-firebase/crashlytics.js b/__mocks__/@react-native-firebase/crashlytics.js deleted file mode 100644 index cc7ff3f55e4a..000000000000 --- a/__mocks__/@react-native-firebase/crashlytics.js +++ /dev/null @@ -1,7 +0,0 @@ -// uses and we need to mock the imported crashlytics module -// due to an error that happens otherwise https://github.com/invertase/react-native-firebase/issues/2475 -export default () => ({ - log: jest.fn(), - recordError: jest.fn(), - setCrashlyticsCollectionEnabled: jest.fn(), -}); diff --git a/__mocks__/@react-native-firebase/crashlytics.ts b/__mocks__/@react-native-firebase/crashlytics.ts new file mode 100644 index 000000000000..2df845ba0c69 --- /dev/null +++ b/__mocks__/@react-native-firebase/crashlytics.ts @@ -0,0 +1,15 @@ +import type {FirebaseCrashlyticsTypes} from '@react-native-firebase/crashlytics'; + +type CrashlyticsModule = Pick; + +type CrashlyticsMock = () => CrashlyticsModule; + +// uses and we need to mock the imported crashlytics module +// due to an error that happens otherwise https://github.com/invertase/react-native-firebase/issues/2475 +const crashlyticsMock: CrashlyticsMock = () => ({ + log: jest.fn(), + recordError: jest.fn(), + setCrashlyticsCollectionEnabled: jest.fn(), +}); + +export default crashlyticsMock; diff --git a/__mocks__/@react-native-firebase/perf.ts b/__mocks__/@react-native-firebase/perf.ts new file mode 100644 index 000000000000..e304b1a1f007 --- /dev/null +++ b/__mocks__/@react-native-firebase/perf.ts @@ -0,0 +1,5 @@ +type PerfMock = () => void; + +const perfMock: PerfMock = () => {}; + +export default perfMock; diff --git a/__mocks__/push-notification-ios.js b/__mocks__/push-notification-ios.js deleted file mode 100644 index 0fe8354b9e08..000000000000 --- a/__mocks__/push-notification-ios.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - addEventListener: jest.fn(), - requestPermissions: jest.fn(() => Promise.resolve()), - getInitialNotification: jest.fn(() => Promise.resolve()), -}; diff --git a/__mocks__/react-freeze.js b/__mocks__/react-freeze.js deleted file mode 100644 index 51294f40f9ca..000000000000 --- a/__mocks__/react-freeze.js +++ /dev/null @@ -1,6 +0,0 @@ -const Freeze = (props) => props.children; - -export { - // eslint-disable-next-line import/prefer-default-export - Freeze, -}; diff --git a/__mocks__/react-freeze.ts b/__mocks__/react-freeze.ts new file mode 100644 index 000000000000..d87abe01acfb --- /dev/null +++ b/__mocks__/react-freeze.ts @@ -0,0 +1,8 @@ +import type {Freeze as FreezeComponent} from 'react-freeze'; + +const Freeze: typeof FreezeComponent = (props) => props.children as JSX.Element; + +export { + // eslint-disable-next-line import/prefer-default-export + Freeze, +}; diff --git a/__mocks__/react-native-dev-menu.js b/__mocks__/react-native-dev-menu.js deleted file mode 100644 index 49cb4c61a209..000000000000 --- a/__mocks__/react-native-dev-menu.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - addItem: jest.fn(), -}; diff --git a/__mocks__/react-native-dev-menu.ts b/__mocks__/react-native-dev-menu.ts new file mode 100644 index 000000000000..0d35d5c32723 --- /dev/null +++ b/__mocks__/react-native-dev-menu.ts @@ -0,0 +1,11 @@ +import type {addItem} from 'react-native-dev-menu'; + +type ReactNativeDevMenuMock = { + addItem: typeof addItem; +}; + +const reactNativeDevMenuMock: ReactNativeDevMenuMock = { + addItem: jest.fn(), +}; + +export default reactNativeDevMenuMock; diff --git a/__mocks__/react-native-localize.js b/__mocks__/react-native-localize.js deleted file mode 100644 index 8d302b7d598b..000000000000 --- a/__mocks__/react-native-localize.js +++ /dev/null @@ -1,3 +0,0 @@ -const mockRNLocalize = require('react-native-localize/mock'); - -module.exports = mockRNLocalize; diff --git a/__mocks__/react-native-localize.ts b/__mocks__/react-native-localize.ts new file mode 100644 index 000000000000..aa0322d6714c --- /dev/null +++ b/__mocks__/react-native-localize.ts @@ -0,0 +1,3 @@ +import mockRNLocalize from 'react-native-localize/mock'; + +module.exports = mockRNLocalize; diff --git a/__mocks__/react-native-pdf.js b/__mocks__/react-native-pdf.js deleted file mode 100644 index 4d179e730903..000000000000 --- a/__mocks__/react-native-pdf.js +++ /dev/null @@ -1,4 +0,0 @@ -export default { - DocumentDir: jest.fn(), - ImageCache: jest.fn(), -}; diff --git a/__mocks__/react-native-reanimated.js b/__mocks__/react-native-reanimated.js deleted file mode 100644 index dfd96838caa7..000000000000 --- a/__mocks__/react-native-reanimated.js +++ /dev/null @@ -1,7 +0,0 @@ -// This mock is required as per setup instructions for react-navigation testing -// https://reactnavigation.org/docs/testing/#mocking-native-modules - -const Reanimated = require('react-native-reanimated/mock'); - -Reanimated.default.call = () => {}; -module.exports = Reanimated; diff --git a/android/app/build.gradle b/android/app/build.gradle index 1301f18d7e8f..c0d157da2afd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043707 - versionName "1.4.37-7" + versionCode 1001043900 + versionName "1.4.39-0" } flavorDimensions "default" diff --git a/assets/images/google-meet.svg b/assets/images/google-meet.svg deleted file mode 100644 index 8def88aa6edc..000000000000 --- a/assets/images/google-meet.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/images/olddot-wireframe.svg b/assets/images/olddot-wireframe.svg new file mode 100644 index 000000000000..ee9aa93be255 --- /dev/null +++ b/assets/images/olddot-wireframe.svg @@ -0,0 +1,3422 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__gears.svg b/assets/images/simple-illustrations/simple-illustration__gears.svg new file mode 100644 index 000000000000..3b4cbc001e3b --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__gears.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__lockclosed.svg b/assets/images/simple-illustrations/simple-illustration__lockclosed.svg new file mode 100644 index 000000000000..3779b92b0b0f --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__lockclosed.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__palmtree.svg b/assets/images/simple-illustrations/simple-illustration__palmtree.svg new file mode 100644 index 000000000000..2aef4956cde9 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__palmtree.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__profile.svg b/assets/images/simple-illustrations/simple-illustration__profile.svg new file mode 100644 index 000000000000..85312f26e186 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__profile.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/simple-illustrations/simple-illustration__qr-code.svg b/assets/images/simple-illustrations/simple-illustration__qr-code.svg new file mode 100644 index 000000000000..10268d747588 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__qr-code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/zoom-icon.svg b/assets/images/zoom-icon.svg deleted file mode 100644 index 81f025aedf79..000000000000 --- a/assets/images/zoom-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index 0a17f2b0f01c..d3bcecdae8cb 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,5 +1,7 @@ require('dotenv').config(); +const IS_E2E_TESTING = process.env.E2E_TESTING === 'true'; + const defaultPresets = ['@babel/preset-react', '@babel/preset-env', '@babel/preset-flow', '@babel/preset-typescript']; const defaultPlugins = [ // Adding the commonjs: true option to react-native-web plugin can cause styling conflicts @@ -72,7 +74,8 @@ const metro = { ], env: { production: { - plugins: [['transform-remove-console', {exclude: ['error', 'warn']}]], + // Keep console logs for e2e tests + plugins: IS_E2E_TESTING ? [] : [['transform-remove-console', {exclude: ['error', 'warn']}]], }, }, }; diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 543b133fe62b..5bb6dfb85851 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -30,7 +30,7 @@ When creating RHP flows, you have to remember a couple things: - Since you can deeplink to different pages inside the RHP navigator, it is important to provide the possibility for the user to properly navigate back from any page with UP press (`HeaderWithBackButton` component). -- An example can be deeplinking to `/settings/profile/personal-details`. From there, when pressing the UP button, you should navigate to `/settings/profile`, so in order for it to work, you should provide the correct route in `onBackButtonPress` prop of `HeaderWithBackButton` (`Navigation.goBack(ROUTES.SETTINGS_PROFILE)` in this example). +- An example can be deeplinking to `/settings/profile/timezone/select`. From there, when pressing the UP button, you should navigate to `/settings/profile/timezone`, so in order for it to work, you should provide the correct route in `onBackButtonPress` prop of `HeaderWithBackButton` (`Navigation.goBack(ROUTES.SETTINGS_PROFILE)` in this example). - We use a custom `goBack` function to handle the browser and the `react-navigation` history stack. Under the hood, it resolves to either replacing the current screen with the one we navigate to (deeplinking scenario) or just going back if we reached the current page by navigating in App (pops the screen). It ensures the requested behaviors on web, which is navigating back to the place from where you deeplinked when going into the RHP flow by it. diff --git a/docs/articles/expensify-classic/getting-started/Using-The-App.md b/docs/articles/expensify-classic/getting-started/Using-The-App.md deleted file mode 100644 index f1bc31793ba8..000000000000 --- a/docs/articles/expensify-classic/getting-started/Using-The-App.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Using the app -description: Streamline expense management effortlessly with the Expensify mobile app. Learn how to install, enable push notifications, and use SmartScan to capture, categorize, and track expenses. Versatile for personal and business use, Expensify is a secure and automated solution for managing your finances on the go. ---- - -
-# Overview -The Expensify mobile app is the ultimate expense management solution that makes it effortless to track and submit your receipts and expenses. Use the app to snap a picture of your receipts, categorize and submit expenses, and even review and approve expense reports. -# How to install the Expensify app -To get started with Expensify on your mobile device, you need to download the app: -1. Visit the App Store (iOS) or Google Play Store (Android). -2. Search for "Expensify" and select the official Expensify app. -3. Tap "Download" or "Install." - -Once the app is installed, open it and log in with your Expensify credentials. If you don't have an Expensify account, you can create one during the sign-up process. -# How to enable on push notifications -Push notifications keep you informed about expense approvals, reimbursements, and more. To enable push notifications: -1. Open the Expensify app. -2. Go to "Settings" or "Preferences." -3. Find the "Receive realtime alerts" toggle -4. Toggle realtime alerts on to begin receiving notifications - -# Deep dive -## Using SmartScan on the App -### Capture receipts -1. Open the Expensify mobile app. -2. Tap the green camera button to take a photo of a receipt. -3. The receipt will be SmartScanned automatically. - -If you have multiple receipts tap the Rapid Fire Mode button in the bottom right hand corner to snap multiple pictures. You can also upload an existing photo from your gallery. -### SmartScan analysis -After capturing or uploading a receipt, Expensify's SmartScan technology goes to work. It analyzes the receipt to extract key details such as the merchant's name, transaction date, transaction currency, and total amount spent. SmartScan inputs all the data for you, so you don’t have to type a thing. -### Review and edit -Once SmartScan is finished, you can further categorize and code your expense based on your company’s policy. Review this data to ensure accuracy. If necessary, you can edit or add additional details, such as expense categories, tags, attendees, tax rates, or descriptions. -### Multi-Currency support -For businesses dealing with international expenses, SmartScan can handle multiple currencies and provide accurate exchange rate conversion based on your policies reporting currency. It's essential to set up and configure currency preferences for these scenarios. -### Custom expense categories -SmartScan can automatically categorize expenses based on vendor or merchant. Users can customize these categories to suit their specific accounting needs. This can be particularly useful for tracking expenses across different departments or projects. -### SmartScan outcomes -SmartScan's performance can vary depending on factors such as receipt quality, language, and handwriting. It's important to keep the following variables in mind: -**Receipt quality**: The clarity and condition of a receipt can impact SmartScan's accuracy. For best results, ensure your environment is well-lit and the receipt is straight and free of obstructions. -**Language support**: While SmartScan supports multiple languages, its accuracy may differ from one language to another. Users dealing with non-English receipts should be aware of potential variations in data extraction. -**Handwriting recognition**: Handwritten receipts might pose challenges for SmartScan. In such cases, manual verification may be necessary to ensure accurate data entry. - -{% include faq-begin.md %} - -## Can I use the mobile app for both personal and business expenses? -Yes, you can use Expensify for personal and business expenses. It's versatile and suitable for both individual and corporate use. Check out our personal and business plans [here](https://www.expensify.com/pricing) to see what might be right for you. -## Is it possible to categorize and tag expenses on the mobile app? -Yes, you can categorize and tag expenses on the mobile app. The app allows you to customize categories and tags to help organize and track your spending. -## What should I do if I encounter issues with the mobile app, such as login problems or crashes? -If you experience issues, first make sure you’re using the most recent version of the app. You can also try to restarting the app. If the issue persists, you can start a chat with Concierge in the app or write to [concierge@expensify.com](mailto:concierge@expensify.com). -## Is the mobile app secure for managing sensitive financial information? -Expensify takes security seriously and employs encryption and other security measures to protect your data. It's important to use strong, unique passwords and enable device security features like biometric authentication. -## Can I use the mobile app offline, and will my data sync when I'm back online? -Yes, you can use the mobile app offline to capture receipts and create expenses. The app will sync your data once you have an internet connection. - -{% include faq-end.md %} -
-
- -# Coming soon - - -
\ No newline at end of file diff --git a/docs/redirects.csv b/docs/redirects.csv index 2609f6665c8d..648f3ad6612f 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -50,3 +50,4 @@ https://community.expensify.com/discussion/4826/how-to-set-your-annual-subscript https://community.expensify.com/discussion/5667/deep-dive-how-does-the-annual-subscription-billing-work,https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Annual-Subscription https://help.expensify.com/expensify-classic/hubs/getting-started/plan-types,https://use.expensify.com/ https://help.expensify.com/articles/expensify-classic/getting-started/Employees,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace +https://help.expensify.com/articles/expensify-classic/getting-started/Using-The-App,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index 454857981834..643c81bd0b9c 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index ba9258840638..8a5170cfe697 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index a2effb8de0b1..c23bcfef9657 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.37 + 1.4.39 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.37.7 + 1.4.39.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 02b01e7153d0..61cf39e17bc7 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.37 + 1.4.39 CFBundleSignature ???? CFBundleVersion - 1.4.37.7 + 1.4.39.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 69e1e7d6e9d9..27ca75150241 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.37 + 1.4.39 CFBundleVersion - 1.4.37.7 + 1.4.39.0 NSExtension NSExtensionPointIdentifier diff --git a/jest.config.js b/jest.config.js index de7ed4b1f974..b347db593d83 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,8 +22,8 @@ module.exports = { doNotFake: ['nextTick'], }, testEnvironment: 'jsdom', - setupFiles: ['/jest/setup.js', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], - setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js', '/tests/perf-test/setupAfterEnv.js'], + setupFiles: ['/jest/setup.ts', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], + setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.js'], cacheDirectory: '/.jest-cache', moduleNameMapper: { '\\.(lottie)$': '/__mocks__/fileMock.js', diff --git a/jest/setup.js b/jest/setup.ts similarity index 90% rename from jest/setup.js rename to jest/setup.ts index e82bf678941d..68d904fac5be 100644 --- a/jest/setup.js +++ b/jest/setup.ts @@ -1,6 +1,7 @@ import mockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock'; import '@shopify/flash-list/jestSetup'; import 'react-native-gesture-handler/jestSetup'; +import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import * as reanimatedJestUtils from 'react-native-reanimated/src/reanimated2/jestUtils'; import 'setimmediate'; import setupMockImages from './setupMockImages'; @@ -19,7 +20,7 @@ jest.mock('@react-native-clipboard/clipboard', () => mockClipboard); // Mock react-native-onyx storage layer because the SQLite storage layer doesn't work in jest. // Mocking this file in __mocks__ does not work because jest doesn't support mocking files that are not directly used in the testing project, // and we only want to mock the storage layer, not the whole Onyx module. -jest.mock('react-native-onyx/dist/storage', () => require('react-native-onyx/dist/storage/__mocks__')); +jest.mock('react-native-onyx/dist/storage', () => mockStorage); // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise jest.spyOn(console, 'debug').mockImplementation((...params) => { @@ -34,6 +35,6 @@ jest.spyOn(console, 'debug').mockImplementation((...params) => { // This mock is required for mocking file systems when running tests jest.mock('react-native-fs', () => ({ - unlink: jest.fn(() => new Promise((res) => res())), + unlink: jest.fn(() => new Promise((res) => res())), CachesDirectoryPath: jest.fn(), })); diff --git a/jest/setupAfterEnv.js b/jest/setupAfterEnv.ts similarity index 100% rename from jest/setupAfterEnv.js rename to jest/setupAfterEnv.ts diff --git a/jest/setupMockImages.js b/jest/setupMockImages.ts similarity index 87% rename from jest/setupMockImages.js rename to jest/setupMockImages.ts index 10925aca8736..c48797b3c07b 100644 --- a/jest/setupMockImages.js +++ b/jest/setupMockImages.ts @@ -1,14 +1,10 @@ import fs from 'fs'; import path from 'path'; -import _ from 'underscore'; -/** - * @param {String} imagePath - */ -function mockImages(imagePath) { +function mockImages(imagePath: string) { const imageFilenames = fs.readdirSync(path.resolve(__dirname, `../assets/${imagePath}/`)); // eslint-disable-next-line rulesdir/prefer-early-return - _.each(imageFilenames, (fileName) => { + imageFilenames.forEach((fileName) => { if (/\.svg/.test(fileName)) { jest.mock(`../assets/${imagePath}/${fileName}`, () => () => ''); } diff --git a/package-lock.json b/package-lock.json index 11099886bfb0..eaf3aba67c85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "1.4.37-7", + "version": "1.4.39-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.37-7", + "version": "1.4.39-0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#2ed4240336e50abb4a7fa9ff6a3c180f8bc9ce5b", + "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#77f85a5265043c6100f1fa65edd58901724faf08", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -96,12 +96,12 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.1", + "react-native-onyx": "2.0.2", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", @@ -199,7 +199,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^25.9.4", + "electron": "^26.6.8", "electron-builder": "24.6.4", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", @@ -232,6 +232,7 @@ "shellcheck": "^1.1.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", + "ts-node": "^10.9.2", "type-fest": "^3.12.0", "typescript": "^5.3.2", "wait-port": "^0.2.9", @@ -2887,6 +2888,28 @@ "node": ">=0.1.90" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -3352,8 +3375,8 @@ }, "node_modules/@expensify/react-native-live-markdown": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#2ed4240336e50abb4a7fa9ff6a3c180f8bc9ce5b", - "integrity": "sha512-i+HIsCFL9cdma+saH/KN2llTGqEb2DQttEJKozdm4fvcie9Ce2/q7XNDZo6nIYTbIVXPDLKPDmWLXqXTgLBKDQ==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#77f85a5265043c6100f1fa65edd58901724faf08", + "integrity": "sha512-EpXjQ+JBR3pRuYuT5iFzQw45hrCcr5ZmX/lji4i3Un/BOQ14JbTkQjjwo4hYX3EdOvfrAUSJs0ZVqeCEIMo3YQ==", "license": "MIT", "workspaces": [ "example" @@ -20161,6 +20184,30 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "devOptional": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true + }, "node_modules/@turf/along": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@turf/along/-/along-6.5.0.tgz", @@ -22780,7 +22827,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -27256,6 +27303,12 @@ "sha.js": "^2.4.8" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true + }, "node_modules/cross-fetch": { "version": "3.1.5", "license": "MIT", @@ -28151,6 +28204,15 @@ "detect-port": "bin/detect-port.js" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "devOptional": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.4.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz", @@ -28529,9 +28591,9 @@ } }, "node_modules/electron": { - "version": "25.9.4", - "resolved": "https://registry.npmjs.org/electron/-/electron-25.9.4.tgz", - "integrity": "sha512-5pDU8a7o7ZIPTZHAqjflGMq764Favdsc271KXrAT3oWvFTHs5Ve9+IOt5EUVPrwvC2qRWKpCIEM47WzwkTlENQ==", + "version": "26.6.8", + "resolved": "https://registry.npmjs.org/electron/-/electron-26.6.8.tgz", + "integrity": "sha512-nuzJ5nVButL1jErc97IVb+A6jbContMg5Uuz5fhmZ4NLcygLkSW8FZpnOT7A4k8Saa95xDJOvqGZyQdI/OPNFw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -39902,6 +39964,12 @@ "semver": "bin/semver" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true + }, "node_modules/make-event-props": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.6.1.tgz", @@ -45044,9 +45112,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.1.tgz", - "integrity": "sha512-o6QNvq91qg8hFXIhmHjBqlNXD/YZxBZSRN8Vkq7xD2NYskzxK2mLqhBdhB8yMMwe6Cd8sVUK4vlZax/JU79xYw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.2.tgz", + "integrity": "sha512-24kcG3ChBXp+uSSCXudFvZTdCnKLRHQRgvTcnh2eA7COtKvbL8ggEJNkglSYmcf5WoDzLgYyWiKvcjcXQnmBvw==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -45138,8 +45206,8 @@ }, "node_modules/react-native-picker-select": { "version": "8.1.0", - "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", - "integrity": "sha512-NpXXyK+UuANYOysjUb9pCoq9SookRYPfpOcM4shxOD4+2Fkh7TYt2LBUpAdBicMHmtaR43RWXVQk9pMimOhg2w==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", + "integrity": "sha512-e8TAWVR4AEw2PFGFxlevCBFr1RwvwTqq1M2w9Yi6xNz+d4SbG6tDIcJDNIqt0gyBqvxlL7BuK0G5BjbiZDLKsg==", "license": "MIT", "dependencies": { "lodash.isequal": "^4.5.0" @@ -50519,6 +50587,70 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "devOptional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ts-node/node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "devOptional": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ts-object-utils": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/ts-object-utils/-/ts-object-utils-0.0.5.tgz", @@ -50760,7 +50892,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -51510,6 +51642,12 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true + }, "node_modules/v8-to-istanbul": { "version": "9.0.1", "license": "ISC", @@ -53360,6 +53498,15 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 71983e0e1679..568917c4b8f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.37-7", + "version": "1.4.39-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.", @@ -19,15 +19,15 @@ "ipad-sm": "concurrently \"npx react-native run-ios --simulator=\\\"iPad Pro (11-inch) (4th generation)\\\" --mode=\\\"DebugDevelopment\\\" --scheme=\\\"New Expensify Dev\\\"\"", "start": "npx react-native start", "web": "scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server", - "web-proxy": "node web/proxy.js", + "web-proxy": "ts-node web/proxy.js", "web-server": "webpack-dev-server --open --config config/webpack/webpack.dev.js", "build": "webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "build-staging": "webpack --config config/webpack/webpack.common.js --env envFile=.env.staging", "build-adhoc": "webpack --config config/webpack/webpack.common.js --env envFile=.env.adhoc", - "desktop": "scripts/set-pusher-suffix.sh && node desktop/start.js", + "desktop": "scripts/set-pusher-suffix.sh && ts-node desktop/start.js", "desktop-build": "scripts/build-desktop.sh production", "desktop-build-staging": "scripts/build-desktop.sh staging", - "createDocsRoutes": "node .github/scripts/createDocsRoutes.js", + "createDocsRoutes": "ts-node .github/scripts/createDocsRoutes.js", "desktop-build-adhoc": "scripts/build-desktop.sh adhoc", "ios-build": "fastlane ios build", "android-build": "fastlane android build", @@ -50,16 +50,16 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "test:e2e": "node tests/e2e/testRunner.js --development --skipCheckout --skipInstallDeps --buildMode none", - "test:e2e:dev": "node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps", + "test:e2e": "ts-node tests/e2e/testRunner.js --development --skipCheckout --skipInstallDeps --buildMode none", + "test:e2e:dev": "ts-node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", - "workflow-test:generate": "node workflow_tests/utils/preGenerateTest.js", + "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.js", "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1" }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#2ed4240336e50abb4a7fa9ff6a3c180f8bc9ce5b", + "@expensify/react-native-live-markdown": "git+ssh://git@github.com/Expensify/react-native-live-markdown.git#77f85a5265043c6100f1fa65edd58901724faf08", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -144,12 +144,12 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.1", + "react-native-onyx": "2.0.2", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#7a407cd4174d9838a944c1c2e1cb4a9737ac69c5", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", @@ -247,7 +247,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^25.9.4", + "electron": "^26.6.8", "electron-builder": "24.6.4", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", @@ -280,6 +280,7 @@ "shellcheck": "^1.1.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", + "ts-node": "^10.9.2", "type-fest": "^3.12.0", "typescript": "^5.3.2", "wait-port": "^0.2.9", diff --git a/patches/@react-native+virtualized-lists+0.73.4+001+onStartReched.patch b/patches/@react-native+virtualized-lists+0.73.4+001+onStartReched.patch new file mode 100644 index 000000000000..b183124964f6 --- /dev/null +++ b/patches/@react-native+virtualized-lists+0.73.4+001+onStartReched.patch @@ -0,0 +1,32 @@ +diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js +index 0516679..e338d90 100644 +--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js ++++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js +@@ -1546,7 +1546,7 @@ class VirtualizedList extends StateSafePureComponent { + // Next check if the user just scrolled within the start threshold + // and call onStartReached only once for a given content length, + // and only if onEndReached is not being executed +- else if ( ++ if ( + onStartReached != null && + this.state.cellsAroundViewport.first === 0 && + isWithinStartThreshold && +@@ -1558,13 +1558,11 @@ class VirtualizedList extends StateSafePureComponent { + + // If the user scrolls away from the start or end and back again, + // cause onStartReached or onEndReached to be triggered again +- else { +- this._sentStartForContentLength = isWithinStartThreshold +- ? this._sentStartForContentLength +- : 0; +- this._sentEndForContentLength = isWithinEndThreshold +- ? this._sentEndForContentLength +- : 0; ++ if (!isWithinStartThreshold) { ++ this._sentStartForContentLength = 0; ++ } ++ if (!isWithinEndThreshold) { ++ this._sentEndForContentLength = 0; + } + } + diff --git a/src/App.js b/src/App.tsx similarity index 93% rename from src/App.js rename to src/App.tsx index b750d12e8c28..7c1ead1d86d3 100644 --- a/src/App.js +++ b/src/App.tsx @@ -1,5 +1,4 @@ import {PortalProvider} from '@gorhom/portal'; -import PropTypes from 'prop-types'; import React from 'react'; import {LogBox} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; @@ -32,14 +31,11 @@ import * as Session from './libs/actions/Session'; import * as Environment from './libs/Environment/Environment'; import InitialUrlContext from './libs/InitialUrlContext'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; +import type {Route} from './ROUTES'; -const propTypes = { - /** Initial url that may be passed as deeplink from Hybrid App */ - url: PropTypes.string, -}; - -const defaultProps = { - url: undefined, +type AppProps = { + /** If we have an authToken this is true */ + url?: Route; }; // For easier debugging and development, when we are in web we expose Onyx to the window, so you can more easily set data into Onyx @@ -57,7 +53,7 @@ LogBox.ignoreLogs([ const fill = {flex: 1}; -function App({url}) { +function App({url}: AppProps) { useDefaultDragAndDrop(); OnyxUpdateManager(); return ( @@ -88,6 +84,7 @@ function App({url}) { + {/* @ts-expect-error TODO: Remove this once Expensify (https://github.com/Expensify/App/issues/25231) is migrated to TypeScript. */} @@ -97,8 +94,6 @@ function App({url}) { ); } -App.propTypes = propTypes; -App.defaultProps = defaultProps; App.displayName = 'App'; export default App; diff --git a/src/CONST.ts b/src/CONST.ts index 6c726cde12f7..73375043bc50 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -462,8 +462,6 @@ const CONST = { EMPTY_ARRAY, EMPTY_OBJECT, USE_EXPENSIFY_URL, - NEW_ZOOM_MEETING_URL: 'https://zoom.us/start/videomeeting', - NEW_GOOGLE_MEET_MEETING_URL: 'https://meet.google.com/new', GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com', GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com', IMAGE_BASE64_MATCH: 'base64', @@ -500,6 +498,7 @@ const CONST = { ADMIN_POLICIES_URL: 'admin_policies', ADMIN_DOMAINS_URL: 'admin_domains', INBOX: 'inbox', + DISMMISSED_REASON: '?dismissedReason=missingFeatures', }, SIGN_IN_FORM_WIDTH: 300, @@ -3174,6 +3173,14 @@ const CONST = { CHAT_SPLIT: 'newDotSplitChat', }, + MANAGE_TEAMS_CHOICE: { + MULTI_LEVEL: 'multiLevelApproval', + CUSTOM_EXPENSE: 'customExpenseCoding', + CARD_TRACKING: 'companyCardTracking', + ACCOUNTING: 'accountingIntegrations', + RULE: 'ruleEnforcement', + }, + MINI_CONTEXT_MENU_MAX_ITEMS: 4, WORKSPACE_SWITCHER: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e987c5b94d7d..e3a78cbff39d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -125,13 +125,12 @@ const ROUTES = { route: 'settings/wallet/card/:domain/activate', getRoute: (domain: string) => `settings/wallet/card/${domain}/activate` as const, }, - SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details', - SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name', - SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: 'settings/profile/personal-details/date-of-birth', - SETTINGS_PERSONAL_DETAILS_ADDRESS: 'settings/profile/personal-details/address', - SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY: { - route: 'settings/profile/personal-details/address/country', - getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/personal-details/address/country?country=${country}`, backTo), + SETTINGS_LEGAL_NAME: 'settings/profile/legal-name', + SETTINGS_DATE_OF_BIRTH: 'settings/profile/date-of-birth', + SETTINGS_ADDRESS: 'settings/profile/address', + SETTINGS_ADDRESS_COUNTRY: { + route: 'settings/profile/address/country', + getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/address/country?country=${country}`, backTo), }, SETTINGS_CONTACT_METHODS: { route: 'settings/profile/contact-methods', @@ -281,10 +280,6 @@ const ROUTES = { route: ':iouType/new/currency/:reportID?', getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const, }, - MONEY_REQUEST_DESCRIPTION: { - route: ':iouType/new/description/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}` as const, - }, MONEY_REQUEST_CATEGORY: { route: ':iouType/new/category/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const, @@ -347,9 +342,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/date/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DESCRIPTION: { - route: 'create/:iouType/description/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/description/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/description/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/description/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DISTANCE: { route: 'create/:iouType/distance/:transactionID/:reportID', @@ -413,6 +408,10 @@ const ROUTES = { NEW_TASK_TITLE: 'new/task/title', NEW_TASK_DESCRIPTION: 'new/task/description', + ONBOARD: 'onboard', + ONBOARD_MANAGE_EXPENSES: 'onboard/manage-expenses', + ONBOARD_EXPENSIFY_CLASSIC: 'onboard/expensify-classic', + TEACHERS_UNITE: 'teachersunite', I_KNOW_A_TEACHER: 'teachersunite/i-know-a-teacher', I_AM_A_TEACHER: 'teachersunite/i-am-a-teacher', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index e2f0e9745561..cd80937a3864 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -49,14 +49,10 @@ const SCREENS = { PRONOUNS: 'Settings_Pronouns', TIMEZONE: 'Settings_Timezone', TIMEZONE_SELECT: 'Settings_Timezone_Select', - - PERSONAL_DETAILS: { - INITIAL: 'Settings_PersonalDetails_Initial', - LEGAL_NAME: 'Settings_PersonalDetails_LegalName', - DATE_OF_BIRTH: 'Settings_PersonalDetails_DateOfBirth', - ADDRESS: 'Settings_PersonalDetails_Address', - ADDRESS_COUNTRY: 'Settings_PersonalDetails_Address_Country', - }, + LEGAL_NAME: 'Settings_LegalName', + DATE_OF_BIRTH: 'Settings_DateOfBirth', + ADDRESS: 'Settings_Address', + ADDRESS_COUNTRY: 'Settings_Address_Country', }, PREFERENCES: { @@ -104,6 +100,7 @@ const SCREENS = { PARTICIPANTS: 'Participants', MONEY_REQUEST: 'MoneyRequest', NEW_TASK: 'NewTask', + ONBOARD_ENGAGEMENT: 'Onboard_Engagement', TEACHERS_UNITE: 'TeachersUnite', TASK_DETAILS: 'Task_Details', ENABLE_PAYMENTS: 'EnablePayments', @@ -150,7 +147,6 @@ const SCREENS = { CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', DATE: 'Money_Request_Date', - DESCRIPTION: 'Money_Request_Description', CATEGORY: 'Money_Request_Category', MERCHANT: 'Money_Request_Merchant', WAYPOINT: 'Money_Request_Waypoint', @@ -230,6 +226,12 @@ const SCREENS = { EDIT_CURRENCY: 'SplitDetails_Edit_Currency', }, + ONBOARD_ENGAGEMENT: { + ROOT: 'Onboard_Engagement_Root', + MANAGE_TEAMS_EXPENSES: 'Manage_Teams_Expenses', + EXPENSIFY_CLASSIC: 'Expenisfy_Classic', + }, + I_KNOW_A_TEACHER: 'I_Know_A_Teacher', INTRO_SCHOOL_PRINCIPAL: 'Intro_School_Principal', I_AM_A_TEACHER: 'I_Am_A_Teacher', diff --git a/src/components/AddressForm.js b/src/components/AddressForm.js index 68d451e5c7c8..aee1b652b22c 100644 --- a/src/components/AddressForm.js +++ b/src/components/AddressForm.js @@ -67,7 +67,7 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS const styles = useThemeStyles(); const {translate} = useLocalize(); const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [country, 'samples'], ''); - const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat}); + const zipFormat = ['common.zipCodeExampleFormat', {zipSampleFormat}]; const isUSAForm = country === CONST.COUNTRY.US; /** @@ -103,7 +103,7 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS if (countrySpecificZipRegex) { if (!countrySpecificZipRegex.test(values.zipPostCode.trim().toUpperCase())) { if (ValidationUtils.isRequiredFulfilled(values.zipPostCode.trim())) { - errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}]; + errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', countryZipFormat]; } else { errors.zipPostCode = 'common.error.fieldRequired'; } diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index 8016f1b2ea39..9b4254a9bc45 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -1,6 +1,7 @@ import type {RefObject} from 'react'; import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, View, ViewStyle} from 'react-native'; import type {Place} from 'react-native-google-places-autocomplete'; +import type {MaybePhraseKey} from '@libs/Localize'; import type Locale from '@src/types/onyx/Locale'; type CurrentLocationButtonProps = { @@ -43,7 +44,7 @@ type AddressSearchProps = { onBlur?: () => void; /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** Hint text to display */ hint?: string; diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 05080fcdd21c..245aa2126d08 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -43,6 +43,7 @@ function AmountTextInput( disableKeyboard autoGrow hideFocusedState + shouldInterceptSwipe inputStyle={[styles.iouAmountTextInput, styles.p0, styles.noLeftBorderRadius, styles.noRightBorderRadius, style]} textInputContainerStyles={[styles.borderNone, styles.noLeftBorderRadius, styles.noRightBorderRadius]} onChangeText={onChangeAmount} diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 78ef72ac3536..ab39e5379230 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -436,7 +436,7 @@ function AttachmentModal({ }, }); } - if (!isOffline) { + if (!isOffline && allowDownload) { menuItems.push({ icon: Expensicons.Download, text: translate('common.download'), diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 010d074d1da6..f55db3dd0620 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -421,7 +421,7 @@ function AvatarWithImagePicker({ {errorData.validationError && ( )} diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx index 6af3a4c6d477..34bc3f7e30c8 100644 --- a/src/components/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs.tsx @@ -38,7 +38,7 @@ function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) { const [primaryBreadcrumb, secondaryBreadcrumb] = breadcrumbs; return ( - + {primaryBreadcrumb.type === CONST.BREADCRUMB_TYPE.ROOT ? (
; /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** Value for checkbox. This prop is intended to be set by FormProvider only */ value?: boolean; diff --git a/src/components/CommunicationsLink.js b/src/components/CommunicationsLink.js deleted file mode 100644 index 01ae0354a66d..000000000000 --- a/src/components/CommunicationsLink.js +++ /dev/null @@ -1,51 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Clipboard from '@libs/Clipboard'; -import ContextMenuItem from './ContextMenuItem'; -import * as Expensicons from './Icon/Expensicons'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; - -const propTypes = { - /** Children to wrap in CommunicationsLink. */ - children: PropTypes.node.isRequired, - - /** Styles to be assigned to Container */ - // eslint-disable-next-line react/forbid-prop-types - containerStyles: PropTypes.arrayOf(PropTypes.object), - - /** Value to be copied or passed via tap. */ - value: PropTypes.string.isRequired, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - containerStyles: [], -}; - -function CommunicationsLink(props) { - const styles = useThemeStyles(); - return ( - - - {props.children} - Clipboard.setString(props.value)} - /> - - - ); -} - -CommunicationsLink.propTypes = propTypes; -CommunicationsLink.defaultProps = defaultProps; -CommunicationsLink.displayName = 'CommunicationsLink'; - -export default withLocalize(CommunicationsLink); diff --git a/src/components/CommunicationsLink.tsx b/src/components/CommunicationsLink.tsx new file mode 100644 index 000000000000..646326e0a632 --- /dev/null +++ b/src/components/CommunicationsLink.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Clipboard from '@libs/Clipboard'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import ContextMenuItem from './ContextMenuItem'; +import * as Expensicons from './Icon/Expensicons'; + +type CommunicationsLinkProps = ChildrenProps & { + /** Styles to be assigned to Container */ + containerStyles?: StyleProp; + + /** Value to be copied or passed via tap. */ + value: string; +}; + +function CommunicationsLink({value, containerStyles, children}: CommunicationsLinkProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + {children} + Clipboard.setString(value)} + /> + + + ); +} + +CommunicationsLink.displayName = 'CommunicationsLink'; + +export default CommunicationsLink; diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 516de55c73ba..a5c9e8952905 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,3 +1,4 @@ +import {MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {flushSync} from 'react-dom'; @@ -5,10 +6,10 @@ import {flushSync} from 'react-dom'; import type {DimensionValue, NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; import {StyleSheet, View} from 'react-native'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; -import RNTextInput from '@components/RNTextInput'; import Text from '@components/Text'; import useHtmlPaste from '@hooks/useHtmlPaste'; import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; +import useMarkdownStyle from '@hooks/useMarkdownStyle'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -80,6 +81,7 @@ function Composer( const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const markdownStyle = useMarkdownStyle(); const {windowWidth} = useWindowDimensions(); const textRef = useRef(null); const textInput = useRef(null); @@ -168,7 +170,7 @@ function Composer( // To make sure the composer does not capture paste events from other inputs, we check where the event originated // If it did originate in another input, we return early to prevent the composer from handling the paste - const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; + const isTargetInput = ['INPUT', 'TEXTAREA', 'SPAN'].includes(eventTarget?.nodeName ?? '') || eventTarget?.contentEditable === 'true'; if (isTargetInput) { return true; } @@ -327,13 +329,14 @@ function Composer( return ( <> - (textInput.current = el)} + ref={(el) => (textInput.current = el as AnimatedTextInputRef)} selection={selection} style={inputStyleMemo} + markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} autoFocus={autoFocus} diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 589530cd7879..25dc99459064 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -3,6 +3,7 @@ import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; import type {Country} from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -11,7 +12,7 @@ import MenuItemWithTopDescription from './MenuItemWithTopDescription'; type CountrySelectorProps = { /** Form error text. e.g when no country is selected */ - errorText?: string; + errorText?: MaybePhraseKey; /** Callback called when the country changes. */ onInputChange: (value?: string) => void; @@ -47,7 +48,7 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange}: Co description={translate('common.country')} onPress={() => { const activeRoute = Navigation.getActiveRouteWithoutParams(); - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); + Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); }} /> diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx index f66a0204ac5e..42ea96fe41bb 100644 --- a/src/components/CustomStatusBarAndBackground/index.tsx +++ b/src/components/CustomStatusBarAndBackground/index.tsx @@ -10,7 +10,7 @@ import updateStatusBarAppearance from './updateStatusBarAppearance'; type CustomStatusBarAndBackgroundProps = { /** Whether the CustomStatusBar is nested within another CustomStatusBar. * A nested CustomStatusBar will disable the "root" CustomStatusBar. */ - isNested: boolean; + isNested?: boolean; }; function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBackgroundProps) { diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx index 86fa8f475664..91b8b0fc4483 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -53,6 +53,7 @@ function DisplayNamesWithToolTip({shouldUseFullTitle, fullTitle, displayNamesWit style={[textStyles, styles.pRelative]} numberOfLines={numberOfLines || undefined} ref={containerRef} + testID={DisplayNamesWithToolTip.displayName} > {shouldUseFullTitle ? ReportUtils.formatReportLastMessageText(fullTitle) diff --git a/src/components/DistanceEReceipt.js b/src/components/DistanceEReceipt.js index 3418aa55e22d..794c1c9aa53f 100644 --- a/src/components/DistanceEReceipt.js +++ b/src/components/DistanceEReceipt.js @@ -4,7 +4,6 @@ import {ScrollView, View} from 'react-native'; import _ from 'underscore'; import EReceiptBackground from '@assets/images/eReceipt_background.svg'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; @@ -33,10 +32,9 @@ function DistanceEReceipt({transaction}) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {isOffline} = useNetwork(); const {thumbnail} = TransactionUtils.hasReceipt(transaction) ? ReceiptUtils.getThumbnailAndImageURIs(transaction) : {}; const {amount: transactionAmount, currency: transactionCurrency, merchant: transactionMerchant, created: transactionDate} = ReportUtils.getTransactionDetails(transaction); - const formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : translate('common.tbd'); + const formattedTransactionAmount = CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); const thumbnailSource = tryResolveUrlFromApiRoot(thumbnail || ''); const waypoints = lodashGet(transaction, 'comment.waypoints', {}); const sortedWaypoints = useMemo( @@ -64,7 +62,7 @@ function DistanceEReceipt({transaction}) { /> - {isOffline || !thumbnailSource ? ( + {TransactionUtils.isFetchingWaypointsFromServer(transaction) || !thumbnailSource ? ( ) : ( - {formattedTransactionAmount} + {!!transactionAmount && {formattedTransactionAmount}} {transactionMerchant} diff --git a/src/components/DistanceRequest/index.js b/src/components/DistanceRequest/index.js index b63ce337a1d9..eafc36a57927 100644 --- a/src/components/DistanceRequest/index.js +++ b/src/components/DistanceRequest/index.js @@ -183,7 +183,7 @@ function DistanceRequest({transactionID, report, transaction, route, isEditingRe } if (_.size(validatedWaypoints) < 2) { - return {0: translate('iou.error.atLeastTwoDifferentWaypoints')}; + return {0: 'iou.error.atLeastTwoDifferentWaypoints'}; } }; diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index d2143f5b48da..3765d1e3b168 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -35,8 +35,8 @@ type DotIndicatorMessageProps = { }; /** Check if the error includes a receipt. */ -function isReceiptError(message: string | ReceiptError): message is ReceiptError { - if (typeof message === 'string') { +function isReceiptError(message: Localize.MaybePhraseKey | ReceiptError): message is ReceiptError { + if (typeof message === 'string' || Array.isArray(message)) { return false; } return (message?.error ?? '') === CONST.IOU.RECEIPT_ERROR; @@ -57,7 +57,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles}: DotIndica .map((key) => messages[key]); // Removing duplicates using Set and transforming the result into an array - const uniqueMessages = [...new Set(sortedMessages)].map((message) => Localize.translateIfPhraseKey(message)); + const uniqueMessages = [...new Set(sortedMessages)].map((message) => (isReceiptError(message) ? message : Localize.translateIfPhraseKey(message))); const isErrorMessage = type === 'error'; diff --git a/src/components/FormAlertWithSubmitButton.tsx b/src/components/FormAlertWithSubmitButton.tsx index 789f1cd2466d..9968bb0e0772 100644 --- a/src/components/FormAlertWithSubmitButton.tsx +++ b/src/components/FormAlertWithSubmitButton.tsx @@ -2,12 +2,13 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import Button from './Button'; import FormAlertWrapper from './FormAlertWrapper'; type FormAlertWithSubmitButtonProps = { /** Error message to display above button */ - message?: string | null; + message?: MaybePhraseKey; /** Whether the button is disabled */ isDisabled?: boolean; diff --git a/src/components/FormAlertWrapper.tsx b/src/components/FormAlertWrapper.tsx index bdd5622f7aeb..d8b379208a29 100644 --- a/src/components/FormAlertWrapper.tsx +++ b/src/components/FormAlertWrapper.tsx @@ -4,6 +4,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import type Network from '@src/types/onyx/Network'; import FormHelpMessage from './FormHelpMessage'; import {withNetwork} from './OnyxProvider'; @@ -28,7 +29,7 @@ type FormAlertWrapperProps = { isMessageHtml?: boolean; /** Error message to display above button */ - message?: string | null; + message?: MaybePhraseKey; /** Props to detect online status */ network: Network; @@ -68,7 +69,7 @@ function FormAlertWrapper({ {` ${translate('common.inTheFormBeforeContinuing')}.`} ); - } else if (isMessageHtml) { + } else if (isMessageHtml && typeof message === 'string') { content = ${message}`} />; } diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 690f2fc6883a..bd4f72c63ec3 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -93,7 +93,6 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim & { + /** Key of the element */ + key?: string; +}; + +function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { const styles = useThemeStyles(); - const htmlAttribs = props.tnode.attributes; + const htmlAttribs = tnode.attributes; const {environmentURL} = useEnvironment(); // An auth token is needed to download Expensify chat attachments const isAttachment = Boolean(htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]); - const displayName = lodashGet(props.tnode, 'domNode.children[0].data', ''); - const parentStyle = lodashGet(props.tnode, 'parent.styles.nativeTextRet', {}); + const tNodeChild = tnode?.domNode?.children?.[0]; + const displayName = tNodeChild && 'data' in tNodeChild && typeof tNodeChild.data === 'string' ? tNodeChild.data : ''; + const parentStyle = tnode.parent?.styles?.nativeTextRet ?? {}; const attrHref = htmlAttribs.href || htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || ''; const internalNewExpensifyPath = Link.getInternalNewExpensifyPath(attrHref); const internalExpensifyPath = Link.getInternalExpensifyPath(attrHref); - if (!HTMLEngineUtils.isChildOfComment(props.tnode)) { + if (!HTMLEngineUtils.isChildOfComment(tnode)) { // This is not a comment from a chat, the AnchorForCommentsOnly uses a Pressable to create a context menu on right click. // We don't have this behaviour in other links in NewDot // TODO: We should use TextLink, but I'm leaving it as Text for now because TextLink breaks the alignment in Android. @@ -34,7 +39,7 @@ function AnchorRenderer(props) { onPress={() => Link.openLink(attrHref, environmentURL, isAttachment)} suppressHighlighting > - + ); } @@ -58,18 +63,16 @@ function AnchorRenderer(props) { // eslint-disable-next-line react/jsx-props-no-multi-spaces target={htmlAttribs.target || '_blank'} rel={htmlAttribs.rel || 'noopener noreferrer'} - style={{...props.style, ...parentStyle, ...styles.textUnderlinePositionUnder, ...styles.textDecorationSkipInkNone}} - key={props.key} - displayName={displayName} + style={[parentStyle, styles.textUnderlinePositionUnder, styles.textDecorationSkipInkNone, style]} + key={key} // Only pass the press handler for internal links. For public links or whitelisted internal links fallback to default link handling onPress={internalNewExpensifyPath || internalExpensifyPath ? () => Link.openLink(attrHref, environmentURL, isAttachment) : undefined} > - + ); } -AnchorRenderer.propTypes = htmlRendererPropTypes; AnchorRenderer.displayName = 'AnchorRenderer'; export default AnchorRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx similarity index 65% rename from src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx index 1932eaaf8a4f..d1c11dc12ed5 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx @@ -1,25 +1,30 @@ import React from 'react'; +import type {TextStyle} from 'react-native'; import {splitBoxModelStyle} from 'react-native-render-html'; -import _ from 'underscore'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import * as HTMLEngineUtils from '@components/HTMLEngineProvider/htmlEngineUtils'; import InlineCodeBlock from '@components/InlineCodeBlock'; import useStyleUtils from '@hooks/useStyleUtils'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -function CodeRenderer(props) { +type CodeRendererProps = CustomRendererProps & { + /** Key of the element */ + key?: string; +}; + +function CodeRenderer({TDefaultRenderer, key, style, ...defaultRendererProps}: CodeRendererProps) { const StyleUtils = useStyleUtils(); // We split wrapper and inner styles // "boxModelStyle" corresponds to border, margin, padding and backgroundColor - const {boxModelStyle, otherStyle: textStyle} = splitBoxModelStyle(props.style); + const {boxModelStyle, otherStyle: textStyle} = splitBoxModelStyle(style ?? {}); - // Get the correct fontFamily variant based in the fontStyle and fontWeight + /** Get the default fontFamily variant */ const font = StyleUtils.getFontFamilyMonospace({ - fontStyle: textStyle.fontStyle, - fontWeight: textStyle.fontWeight, + fontStyle: undefined, + fontWeight: undefined, }); // Determine the font size for the code based on whether it's inside an H1 element. - const isInsideH1 = HTMLEngineUtils.isChildOfH1(props.tnode); + const isInsideH1 = HTMLEngineUtils.isChildOfH1(defaultRendererProps.tnode); const fontSize = StyleUtils.getCodeFontSize(isInsideH1); @@ -34,20 +39,17 @@ function CodeRenderer(props) { fontStyle: undefined, }; - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style']); - return ( ); } -CodeRenderer.propTypes = htmlRendererPropTypes; CodeRenderer.displayName = 'CodeRenderer'; export default CodeRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx similarity index 61% rename from src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx index 9ff5fdecae13..03f7a5dbedf7 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx @@ -1,23 +1,17 @@ import React from 'react'; -import _ from 'underscore'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -const propTypes = { - ...htmlRendererPropTypes, - ...withLocalizePropTypes, -}; - -function EditedRenderer(props) { +function EditedRenderer({tnode, TDefaultRenderer, style, ...defaultRendererProps}: CustomRendererProps) { const theme = useTheme(); const styles = useThemeStyles(); - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style', 'tnode']); - const isPendingDelete = Boolean(props.tnode.attributes.deleted !== undefined); + const {translate} = useLocalize(); + const isPendingDelete = Boolean(tnode.attributes.deleted !== undefined); return ( - {props.translate('reportActionCompose.edited')} + {translate('reportActionCompose.edited')} ); } -EditedRenderer.propTypes = propTypes; EditedRenderer.displayName = 'EditedRenderer'; -export default withLocalize(EditedRenderer); +export default EditedRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx similarity index 77% rename from src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index f60377c842ea..3e6119ff279f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -1,6 +1,7 @@ -import lodashGet from 'lodash/get'; import React, {memo} from 'react'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus'; import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; import ThumbnailImage from '@components/ThumbnailImage'; @@ -12,15 +13,22 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; +import type {User} from '@src/types/onyx'; -const propTypes = {...htmlRendererPropTypes}; +type ImageRendererWithOnyxProps = { + /** Current user */ + // Following line is disabled because the onyx prop is only being used on the memo HOC + // eslint-disable-next-line react/no-unused-prop-types + user: OnyxEntry; +}; -function ImageRenderer(props) { +type ImageRendererProps = ImageRendererWithOnyxProps & CustomRendererProps; + +function ImageRenderer({tnode}: ImageRendererProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const htmlAttribs = props.tnode.attributes; + const htmlAttribs = tnode.attributes; // There are two kinds of images that need to be displayed: // @@ -63,20 +71,10 @@ function ImageRenderer(props) { { - const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report.reportID, source); + const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report?.reportID ?? '', source); Navigation.navigate(route); }} - onLongPress={(event) => - showContextMenuForReport( - // Imitate the web event for native renderers - {nativeEvent: {...(event.nativeEvent || {}), target: {tagName: 'IMG'}}}, - anchor, - report.reportID, - action, - checkIfContextMenuActive, - ReportUtils.isArchivedRoom(report), - ) - } + onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} > @@ -93,18 +91,15 @@ function ImageRenderer(props) { ); } -ImageRenderer.propTypes = propTypes; ImageRenderer.displayName = 'ImageRenderer'; -export default withOnyx({ +export default withOnyx({ user: { key: ONYXKEYS.USER, }, })( memo( ImageRenderer, - (prevProps, nextProps) => - lodashGet(prevProps, 'tnode.attributes') === lodashGet(nextProps, 'tnode.attributes') && - lodashGet(prevProps, 'user.shouldUseStagingServer') === lodashGet(nextProps, 'user.shouldUseStagingServer'), + (prevProps, nextProps) => prevProps.tnode.attributes === nextProps.tnode.attributes && prevProps.user?.shouldUseStagingServer === nextProps.user?.shouldUseStagingServer, ), ); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx similarity index 53% rename from src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx index 93ede229876d..09dc8cf9f641 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx @@ -1,26 +1,30 @@ import React from 'react'; +import type {TextStyle} from 'react-native'; +import {StyleSheet} from 'react-native'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import {TNodeChildrenRenderer} from 'react-native-render-html'; -import _ from 'underscore'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -function MentionHereRenderer(props) { +function MentionHereRenderer({style, tnode}: CustomRendererProps) { const StyleUtils = useStyleUtils(); + + const flattenStyle = StyleSheet.flatten(style as TextStyle); + const {color, ...styleWithoutColor} = flattenStyle; + return ( - + ); } -MentionHereRenderer.propTypes = htmlRendererPropTypes; MentionHereRenderer.displayName = 'HereMentionRenderer'; export default MentionHereRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js deleted file mode 100644 index cd6ada90b58b..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js +++ /dev/null @@ -1,120 +0,0 @@ -import {cloneDeep} from 'lodash'; -import lodashGet from 'lodash/get'; -import React from 'react'; -import {TNodeChildrenRenderer} from 'react-native-render-html'; -import _ from 'underscore'; -import {usePersonalDetails} from '@components/OnyxProvider'; -import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; -import Text from '@components/Text'; -import UserDetailsTooltip from '@components/UserDetailsTooltip'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; -import Navigation from '@libs/Navigation/Navigation'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; -import CONST from '@src/CONST'; -import * as LoginUtils from '@src/libs/LoginUtils'; -import ROUTES from '@src/ROUTES'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; - -const propTypes = { - ...htmlRendererPropTypes, - - /** Current user personal details */ - currentUserPersonalDetails: personalDetailsPropType.isRequired, -}; - -function MentionUserRenderer(props) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style']); - const htmlAttributeAccountID = lodashGet(props.tnode.attributes, 'accountid'); - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; - - let accountID; - let displayNameOrLogin; - let navigationRoute; - const tnode = cloneDeep(props.tnode); - - const getMentionDisplayText = (displayText, userAccountID, userLogin = '') => { - // If the userAccountID does not exist, this is an email-based mention so the displayText must be an email. - // If the userAccountID exists but userLogin is different from displayText, this means the displayText is either user display name, Hidden, or phone number, in which case we should return it as is. - if (userAccountID && userLogin !== displayText) { - return displayText; - } - - // If the emails are not in the same private domain, we also return the displayText - if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, props.currentUserPersonalDetails.login)) { - return displayText; - } - - // Otherwise, the emails must be of the same private domain, so we should remove the domain part - return displayText.split('@')[0]; - }; - - if (!_.isEmpty(htmlAttributeAccountID)) { - const user = lodashGet(personalDetails, htmlAttributeAccountID); - accountID = parseInt(htmlAttributeAccountID, 10); - displayNameOrLogin = lodashGet(user, 'displayName', '') || LocalePhoneNumber.formatPhoneNumber(lodashGet(user, 'login', '')) || translate('common.hidden'); - displayNameOrLogin = getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID, lodashGet(user, 'login', '')); - navigationRoute = ROUTES.PROFILE.getRoute(htmlAttributeAccountID); - } else if (!_.isEmpty(tnode.data)) { - // We need to remove the LTR unicode and leading @ from data as it is not part of the login - displayNameOrLogin = tnode.data.replace(CONST.UNICODE.LTR, '').slice(1); - // We need to replace tnode.data here because we will pass it to TNodeChildrenRenderer below - tnode.data = tnode.data.replace(displayNameOrLogin, getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID)); - - accountID = _.first(PersonalDetailsUtils.getAccountIDsByLogins([displayNameOrLogin])); - navigationRoute = ROUTES.DETAILS.getRoute(displayNameOrLogin); - } else { - // If neither an account ID or email is provided, don't render anything - return null; - } - - const isOurMention = accountID === props.currentUserPersonalDetails.accountID; - - return ( - - {({anchor, report, action, checkIfContextMenuActive}) => ( - showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} - onPress={(event) => { - event.preventDefault(); - Navigation.navigate(navigationRoute); - }} - role={CONST.ROLE.LINK} - accessibilityLabel={`/${navigationRoute}`} - > - - - {!_.isEmpty(htmlAttributeAccountID) ? `@${displayNameOrLogin}` : } - - - - )} - - ); -} - -MentionUserRenderer.propTypes = propTypes; -MentionUserRenderer.displayName = 'MentionUserRenderer'; - -export default withCurrentUserPersonalDetails(MentionUserRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx new file mode 100644 index 000000000000..ad9cfb4e6384 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -0,0 +1,98 @@ +import isEmpty from 'lodash/isEmpty'; +import React from 'react'; +import {StyleSheet} from 'react-native'; +import type {TextStyle} from 'react-native'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; +import {TNodeChildrenRenderer} from 'react-native-render-html'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; +import Text from '@components/Text'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type MentionUserRendererProps = WithCurrentUserPersonalDetailsProps & CustomRendererProps; + +function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersonalDetails, ...defaultRendererProps}: MentionUserRendererProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const htmlAttribAccountID = tnode.attributes.accountid; + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; + + let accountID: number; + let displayNameOrLogin: string; + let navigationRoute: Route; + + if (!isEmpty(htmlAttribAccountID)) { + const user = personalDetails.htmlAttribAccountID; + accountID = parseInt(htmlAttribAccountID, 10); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || user?.displayName || translate('common.hidden'); + navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID); + } else if ('data' in tnode && !isEmptyObject(tnode.data)) { + // We need to remove the LTR unicode and leading @ from data as it is not part of the login + displayNameOrLogin = tnode.data.replace(CONST.UNICODE.LTR, '').slice(1); + + accountID = PersonalDetailsUtils.getAccountIDsByLogins([displayNameOrLogin])?.[0]; + navigationRoute = ROUTES.DETAILS.getRoute(displayNameOrLogin); + } else { + // If neither an account ID or email is provided, don't render anything + return null; + } + + const isOurMention = accountID === currentUserPersonalDetails.accountID; + + const flattenStyle = StyleSheet.flatten(style as TextStyle); + const {color, ...styleWithoutColor} = flattenStyle; + + return ( + + {({anchor, report, action, checkIfContextMenuActive}) => ( + showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onPress={(event) => { + event.preventDefault(); + Navigation.navigate(navigationRoute); + }} + role={CONST.ROLE.LINK} + accessibilityLabel={`/${navigationRoute}`} + > + + + {htmlAttribAccountID ? `@${displayNameOrLogin}` : } + + + + )} + + ); +} + +MentionUserRenderer.displayName = 'MentionUserRenderer'; + +export default withCurrentUserPersonalDetails(MentionUserRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx index 775bf75294eb..6124b59dfd49 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx @@ -1,14 +1,9 @@ import React from 'react'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; -type NextStepEmailRendererProps = { - tnode: { - data: string; - }; -}; - -function NextStepEmailRenderer({tnode}: NextStepEmailRendererProps) { +function NextStepEmailRenderer({tnode}: CustomRendererProps) { const styles = useThemeStyles(); return ( @@ -16,7 +11,7 @@ function NextStepEmailRenderer({tnode}: NextStepEmailRendererProps) { nativeID="email-with-break-opportunities" style={[styles.breakWord, styles.textLabelSupporting, styles.textStrong]} > - {tnode.data} + {'data' in tnode ? tnode.data : ''} ); } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx similarity index 53% rename from src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 27eff02d63ea..798ec8f64194 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -1,52 +1,47 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {GestureResponderEvent} from 'react-native'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; -import withLocalize from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -const propTypes = { +type PreRendererProps = CustomRendererProps & { /** Press in handler for the code block */ - onPressIn: PropTypes.func, + onPressIn?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** Press out handler for the code block */ - onPressOut: PropTypes.func, + onPressOut?: (event?: GestureResponderEvent | KeyboardEvent) => void; + + /** Long press handler for the code block */ + onLongPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** The position of this React element relative to the parent React element, starting at 0 */ - renderIndex: PropTypes.number.isRequired, + renderIndex: number; /** The total number of elements children of this React element parent */ - renderLength: PropTypes.number.isRequired, - - ...htmlRendererPropTypes, -}; - -const defaultProps = { - onPressIn: undefined, - onPressOut: undefined, + renderLength: number; }; -function PreRenderer(props) { +function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...defaultRendererProps}: PreRendererProps) { const styles = useThemeStyles(); - const TDefaultRenderer = props.TDefaultRenderer; - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'onPressIn', 'onPressOut', 'onLongPress']); - const isLast = props.renderIndex === props.renderLength - 1; + const {translate} = useLocalize(); + const isLast = defaultRendererProps.renderIndex === defaultRendererProps.renderLength - 1; return ( - + {({anchor, report, action, checkIfContextMenuActive}) => ( showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onPress={onPressIn ?? (() => {})} + onPressIn={onPressIn} + onPressOut={onPressOut} + onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} role={CONST.ROLE.PRESENTATION} - accessibilityLabel={props.translate('accessibilityHints.prestyledText')} + accessibilityLabel={translate('accessibilityHints.prestyledText')} > {/* eslint-disable-next-line react/jsx-props-no-spreading */} @@ -60,7 +55,5 @@ function PreRenderer(props) { } PreRenderer.displayName = 'PreRenderer'; -PreRenderer.propTypes = propTypes; -PreRenderer.defaultProps = defaultProps; -export default withLocalize(PreRenderer); +export default PreRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js b/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js deleted file mode 100644 index f26806482e48..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js +++ /dev/null @@ -1,8 +0,0 @@ -import PropTypes from 'prop-types'; - -export default { - tnode: PropTypes.object, - TDefaultRenderer: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), - key: PropTypes.string, - style: PropTypes.object, -}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts similarity index 74% rename from src/components/HTMLEngineProvider/HTMLRenderers/index.js rename to src/components/HTMLEngineProvider/HTMLRenderers/index.ts index 9d0dab731792..f2c8cbe89a98 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/index.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts @@ -1,3 +1,4 @@ +import type {CustomTagRendererRecord} from 'react-native-render-html'; import AnchorRenderer from './AnchorRenderer'; import CodeRenderer from './CodeRenderer'; import EditedRenderer from './EditedRenderer'; @@ -10,7 +11,7 @@ import PreRenderer from './PreRenderer'; /** * This collection defines our custom renderers. It is a mapping from HTML tag type to the corresponding component. */ -export default { +const HTMLEngineProviderComponentList: CustomTagRendererRecord = { // Standard HTML tag renderers a: AnchorRenderer, code: CodeRenderer, @@ -20,7 +21,11 @@ export default { // Custom tag renderers edited: EditedRenderer, pre: PreRenderer, + /* eslint-disable @typescript-eslint/naming-convention */ 'mention-user': MentionUserRenderer, 'mention-here': MentionHereRenderer, 'next-step-email': NextStepEmailRenderer, + /* eslint-enable @typescript-eslint/naming-convention */ }; + +export default HTMLEngineProviderComponentList; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 64fe512eaff2..ece6295fc7f8 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -17,11 +17,13 @@ import useThrottledButtonState from '@hooks/useThrottledButtonState'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type HeaderWithBackButtonProps from './types'; function HeaderWithBackButton({ + icon, iconFill, guidesCallTaskID = '', onBackButtonPress = () => Navigation.goBack(ROUTES.HOME), @@ -65,12 +67,21 @@ function HeaderWithBackButton({ const {isKeyboardShown} = useKeyboardState(); const waitForNavigate = useWaitForNavigation(); + // If the icon is present, the header bar should be taller and use different font. + const isCentralPaneSettings = !!icon; + return ( {shouldShowBackButton && ( @@ -99,6 +110,14 @@ function HeaderWithBackButton({ )} + {icon && ( + + )} {shouldShowAvatarWithDisplay ? ( )} diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 88f7e717a44d..83afbad8636b 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -28,6 +28,13 @@ type HeaderWithBackButtonProps = Partial & { /** Title color */ titleColor?: string; + /** + * Icon displayed on the left of the title. + * If it is passed, the new styling is applied to the component: + * taller header on desktop and different font of the title. + * */ + icon?: IconAsset; + /** Method to trigger when pressing download button of the header */ onDownloadButtonPress?: () => void; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index a6e7cc2882d6..252d13259aea 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -96,6 +96,7 @@ import NewWindow from '@assets/images/new-window.svg'; import NewWorkspace from '@assets/images/new-workspace.svg'; import OfflineCloud from '@assets/images/offline-cloud.svg'; import Offline from '@assets/images/offline.svg'; +import OldDotWireframe from '@assets/images/olddot-wireframe.svg'; import Paperclip from '@assets/images/paperclip.svg'; import Paycheck from '@assets/images/paycheck.svg'; import Pencil from '@assets/images/pencil.svg'; @@ -238,6 +239,7 @@ export { NewWorkspace, Offline, OfflineCloud, + OldDotWireframe, Paperclip, Paycheck, Pencil, diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 954c8d0392fc..51422e7b4a49 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -37,11 +37,13 @@ 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 EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; +import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg'; import HandEarth from '@assets/images/simple-illustrations/simple-illustration__handearth.svg'; import HotDogStand from '@assets/images/simple-illustrations/simple-illustration__hotdogstand.svg'; import Hourglass from '@assets/images/simple-illustrations/simple-illustration__hourglass.svg'; import InvoiceBlue from '@assets/images/simple-illustrations/simple-illustration__invoice.svg'; +import LockClosed from '@assets/images/simple-illustrations/simple-illustration__lockclosed.svg'; import LockOpen from '@assets/images/simple-illustrations/simple-illustration__lockopen.svg'; import Luggage from '@assets/images/simple-illustrations/simple-illustration__luggage.svg'; import Mailbox from '@assets/images/simple-illustrations/simple-illustration__mailbox.svg'; @@ -50,6 +52,9 @@ import MoneyBadge from '@assets/images/simple-illustrations/simple-illustration_ import MoneyIntoWallet from '@assets/images/simple-illustrations/simple-illustration__moneyintowallet.svg'; import MoneyWings from '@assets/images/simple-illustrations/simple-illustration__moneywings.svg'; import OpenSafe from '@assets/images/simple-illustrations/simple-illustration__opensafe.svg'; +import PalmTree from '@assets/images/simple-illustrations/simple-illustration__palmtree.svg'; +import Profile from '@assets/images/simple-illustrations/simple-illustration__profile.svg'; +import QrCode from '@assets/images/simple-illustrations/simple-illustration__qr-code.svg'; import SanFrancisco from '@assets/images/simple-illustrations/simple-illustration__sanfrancisco.svg'; import ShieldYellow from '@assets/images/simple-illustrations/simple-illustration__shield.svg'; import SmallRocket from '@assets/images/simple-illustrations/simple-illustration__smallrocket.svg'; @@ -118,4 +123,9 @@ export { CommentBubbles, TrashCan, TeleScope, + Profile, + PalmTree, + LockClosed, + Gears, + QrCode, }; diff --git a/src/components/InlineCodeBlock/index.native.tsx b/src/components/InlineCodeBlock/index.native.tsx index 3a70308fa0cc..85d02b7239ca 100644 --- a/src/components/InlineCodeBlock/index.native.tsx +++ b/src/components/InlineCodeBlock/index.native.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import type {TText} from 'react-native-render-html'; import useThemeStyles from '@hooks/useThemeStyles'; import type InlineCodeBlockProps from './types'; +import type {TTextOrTPhrasing} from './types'; import WrappedText from './WrappedText'; -function InlineCodeBlock({TDefaultRenderer, defaultRendererProps, textStyle, boxModelStyle}: InlineCodeBlockProps) { +function InlineCodeBlock({TDefaultRenderer, defaultRendererProps, textStyle, boxModelStyle}: InlineCodeBlockProps) { const styles = useThemeStyles(); return ( @@ -16,7 +16,7 @@ function InlineCodeBlock({TDefaultRenderer, defaultRen textStyles={textStyle} wordStyles={[boxModelStyle, styles.codeWordStyle]} > - {defaultRendererProps.tnode.data} + {'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data} ); diff --git a/src/components/InlineCodeBlock/index.tsx b/src/components/InlineCodeBlock/index.tsx index 0802d4752661..593a08aaad5e 100644 --- a/src/components/InlineCodeBlock/index.tsx +++ b/src/components/InlineCodeBlock/index.tsx @@ -1,10 +1,10 @@ import React from 'react'; import {StyleSheet} from 'react-native'; -import type {TText} from 'react-native-render-html'; import Text from '@components/Text'; import type InlineCodeBlockProps from './types'; +import type {TTextOrTPhrasing} from './types'; -function InlineCodeBlock({TDefaultRenderer, textStyle, defaultRendererProps, boxModelStyle}: InlineCodeBlockProps) { +function InlineCodeBlock({TDefaultRenderer, textStyle, defaultRendererProps, boxModelStyle}: InlineCodeBlockProps) { const flattenTextStyle = StyleSheet.flatten(textStyle); const {textDecorationLine, ...textStyles} = flattenTextStyle; @@ -13,7 +13,7 @@ function InlineCodeBlock({TDefaultRenderer, textStyle, // eslint-disable-next-line react/jsx-props-no-spreading {...defaultRendererProps} > - {defaultRendererProps.tnode.data} + {'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data} ); } diff --git a/src/components/InlineCodeBlock/types.ts b/src/components/InlineCodeBlock/types.ts index ae847b293a60..cc05f36a20cf 100644 --- a/src/components/InlineCodeBlock/types.ts +++ b/src/components/InlineCodeBlock/types.ts @@ -1,7 +1,9 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import type {TDefaultRenderer, TDefaultRendererProps, TText} from 'react-native-render-html'; +import type {TDefaultRenderer, TDefaultRendererProps, TPhrasing, TText} from 'react-native-render-html'; -type InlineCodeBlockProps = { +type TTextOrTPhrasing = TText | TPhrasing; + +type InlineCodeBlockProps = { TDefaultRenderer: TDefaultRenderer; textStyle: StyleProp; defaultRendererProps: TDefaultRendererProps; @@ -9,3 +11,4 @@ type InlineCodeBlockProps = { }; export default InlineCodeBlockProps; +export type {TTextOrTPhrasing}; diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 1e2e57a0b3fb..46c96fd706a9 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -7,6 +7,7 @@ import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; +import type {MaybePhraseKey} from '@libs/Localize'; import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import FormHelpMessage from './FormHelpMessage'; @@ -32,7 +33,7 @@ type MagicCodeInputProps = { shouldDelayFocus?: boolean; /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** Specifies autocomplete hints for the system, so it can provide autofill */ autoComplete: AutoCompleteVariant; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 4d7089fb24bd..6163fa116561 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -14,6 +14,7 @@ import ControlSelection from '@libs/ControlSelection'; import convertToLTR from '@libs/convertToLTR'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getButtonState from '@libs/getButtonState'; +import type {MaybePhraseKey} from '@libs/Localize'; import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import * as Session from '@userActions/Session'; @@ -136,7 +137,7 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { error?: string; /** Error to display at the bottom of the component */ - errorText?: string; + errorText?: MaybePhraseKey; /** A boolean flag that gives the icon a green fill if true */ success?: boolean; @@ -565,7 +566,7 @@ function MenuItem( /> )} {/* Since subtitle can be of type number, we should allow 0 to be shown */} - {(subtitle ?? subtitle === 0) && ( + {(subtitle === 0 || subtitle) && ( {subtitle} diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index c1f4df8d4c99..7fc5013f58a0 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -176,6 +176,8 @@ function BaseModal( return ( e.stopPropagation()} onBackdropPress={handleBackdropPress} // Note: Escape key on web/desktop will trigger onBackButtonPress callback // eslint-disable-next-line react/jsx-props-no-multi-spaces diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 3f5a3c50f6cc..a5b0d6707421 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -2,18 +2,15 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import GoogleMeetIcon from '@assets/images/google-meet.svg'; -import ZoomIcon from '@assets/images/zoom-icon.svg'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as IOU from '@userActions/IOU'; -import * as Link from '@userActions/Link'; -import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -65,6 +62,8 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money isPolicyAdmin && (isApproved || isManager) : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager); const isDraft = ReportUtils.isDraftExpenseReport(moneyRequestReport); + const isOnInstantSubmitPolicy = PolicyUtils.isInstantSubmitEnabled(policy); + const isOnSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); const shouldShowPayButton = useMemo( () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableSpend !== 0 && !ReportUtils.isArchivedRoom(chatReport) && !isAutoReimbursable, [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableSpend, chatReport, isAutoReimbursable], @@ -73,8 +72,11 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money if (!isPaidGroupPolicy) { return false; } + if (isOnInstantSubmitPolicy && isOnSubmitAndClosePolicy) { + return false; + } return isManager && !isDraft && !isApproved && !isSettled; - }, [isPaidGroupPolicy, isManager, isDraft, isApproved, isSettled]); + }, [isPaidGroupPolicy, isManager, isDraft, isApproved, isSettled, isOnInstantSubmitPolicy, isOnSubmitAndClosePolicy]); const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0; const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; @@ -91,22 +93,6 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money ); const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(moneyRequestReport)]; - if (!ReportUtils.isArchivedRoom(chatReport)) { - threeDotsMenuItems.push({ - icon: ZoomIcon, - text: translate('videoChatButtonAndMenu.zoom'), - onSelected: Session.checkIfActionIsAllowed(() => { - Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL); - }), - }); - threeDotsMenuItems.push({ - icon: GoogleMeetIcon, - text: translate('videoChatButtonAndMenu.googleMeet'), - onSelected: Session.checkIfActionIsAllowed(() => { - Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL); - }), - }); - } return ( diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index b6a91cf7a9c8..92656a7ad225 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -255,9 +255,9 @@ function MoneyRequestConfirmationList(props) { const shouldShowBillable = !lodashGet(props.policy, 'disabledFields.defaultBillable', true); const hasRoute = TransactionUtils.hasRoute(transaction); - const isDistanceRequestWithoutRoute = props.isDistanceRequest && !hasRoute; - const formattedAmount = isDistanceRequestWithoutRoute - ? translate('common.tbd') + const isDistanceRequestWithPendingRoute = props.isDistanceRequest && (!hasRoute || !rate); + const formattedAmount = isDistanceRequestWithPendingRoute + ? '' : CurrencyUtils.convertToDisplayString( shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : props.iouAmount, props.isDistanceRequest ? currency : props.iouCurrencyCode, @@ -332,7 +332,7 @@ function MoneyRequestConfirmationList(props) { let text; if (isSplitBill && props.iouAmount === 0) { text = translate('iou.split'); - } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithoutRoute) { + } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { text = translate('iou.request'); if (props.iouAmount !== 0) { text = translate('iou.requestAmount', {amount: formattedAmount}); @@ -347,7 +347,7 @@ function MoneyRequestConfirmationList(props) { value: props.iouType, }, ]; - }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithoutRoute, translate]); + }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]); const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]); @@ -426,9 +426,17 @@ function MoneyRequestConfirmationList(props) { if (!props.isDistanceRequest) { return; } + + /* + Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as: + When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page. + In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending. + */ + IOU.setMoneyRequestPendingFields(props.transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); + const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate, currency, translate, toLocaleDigit); IOU.setMoneyRequestMerchant_temporaryForRefactor(props.transactionID, distanceMerchant); - }, [hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, props.isDistanceRequest, props.transactionID]); + }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, props.isDistanceRequest, props.transactionID]); /** * @param {Object} option @@ -482,7 +490,7 @@ function MoneyRequestConfirmationList(props) { } else { // validate the amount for distance requests const decimals = CurrencyUtils.getCurrencyDecimals(props.iouCurrencyCode); - if (props.isDistanceRequest && !isDistanceRequestWithoutRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) { + if (props.isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) { setFormError('common.error.invalidAmount'); return; } @@ -505,7 +513,7 @@ function MoneyRequestConfirmationList(props) { props.iouType, props.isDistanceRequest, props.iouCategory, - isDistanceRequestWithoutRoute, + isDistanceRequestWithPendingRoute, props.iouCurrencyCode, props.iouAmount, transaction, @@ -559,7 +567,7 @@ function MoneyRequestConfirmationList(props) { )} {button} @@ -578,7 +586,6 @@ function MoneyRequestConfirmationList(props) { formError, styles.ph1, styles.mb2, - translate, ]); const {image: receiptImage, thumbnail: receiptThumbnail} = @@ -665,11 +672,15 @@ function MoneyRequestConfirmationList(props) { title={props.iouComment} description={translate('common.description')} onPress={() => { - if (props.isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION.getRoute(props.iouType, props.reportID)); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( + CONST.IOU.ACTION.EDIT, + props.iouType, + transaction.transactionID, + props.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ); }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.tsx similarity index 63% rename from src/components/MoneyRequestHeader.js rename to src/components/MoneyRequestHeader.tsx index e907f798051b..97f967a32d29 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.tsx @@ -1,97 +1,89 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import compose from '@libs/compose'; import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; -import iouReportPropTypes from '@pages/iouReportPropTypes'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Policy, Report, ReportAction, ReportActions, Session, Transaction} from '@src/types/onyx'; +import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import * as Expensicons from './Icon/Expensicons'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; import {usePersonalDetails} from './OnyxProvider'; -import transactionPropTypes from './transactionPropTypes'; -const propTypes = { - /** The report currently being looked at */ - report: iouReportPropTypes.isRequired, - - /** The policy which the report is tied to */ - policy: PropTypes.shape({ - /** Name of the policy */ - name: PropTypes.string, - }), - - /* Onyx Props */ +type MoneyRequestHeaderOnyxProps = { /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user email */ - email: PropTypes.string, - }), + session: OnyxEntry; /** The expense report or iou report (only will have a value if this is a transaction thread) */ - parentReport: iouReportPropTypes, - - /** The report action the transaction is tied to from the parent report */ - parentReportAction: PropTypes.shape(reportActionPropTypes), + parentReport: OnyxEntry; /** All the data for the transaction */ - transaction: transactionPropTypes, + transaction: OnyxEntry; + + /** All report actions */ + // eslint-disable-next-line react/no-unused-prop-types + parentReportActions: OnyxEntry; }; -const defaultProps = { - session: { - email: null, - }, - parentReport: {}, - parentReportAction: {}, - transaction: {}, - policy: {}, +type MoneyRequestHeaderProps = MoneyRequestHeaderOnyxProps & { + /** The report currently being looked at */ + report: Report; + + /** The policy which the report is tied to */ + policy: Policy; + + /** The report action the transaction is tied to from the parent report */ + parentReportAction: ReportAction & OriginalMessageIOU; }; -function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy}) { +function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy}: MoneyRequestHeaderProps) { const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); const {translate} = useLocalize(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const moneyRequestReport = parentReport; - const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); + const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID); const isApproved = ReportUtils.isReportApproved(moneyRequestReport); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); // Only the requestor can take delete the request, admins can only edit it. - const isActionOwner = lodashGet(parentReportAction, 'actorAccountID') === lodashGet(session, 'accountID', null); + const isActionOwner = typeof parentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && parentReportAction.actorAccountID === session?.accountID; const deleteTransaction = useCallback(() => { - IOU.deleteMoneyRequest(lodashGet(parentReportAction, 'originalMessage.IOUTransactionID'), parentReportAction, true); + IOU.deleteMoneyRequest(parentReportAction?.originalMessage?.IOUTransactionID ?? '', parentReportAction, true); setIsDeleteModalVisible(false); }, [parentReportAction, setIsDeleteModalVisible]); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isPending = TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction); - const canModifyRequest = isActionOwner && !isSettled && !isApproved && !ReportActionsUtils.isDeletedAction(parentReportAction); + let canDeleteRequest = canModifyRequest; + + if (ReportUtils.isPaidGroupPolicyExpenseReport(moneyRequestReport)) { + // If it's a paid policy expense report, only allow deleting the request if it's not submitted or the user is the policy admin + canDeleteRequest = canDeleteRequest && (ReportUtils.isDraftExpenseReport(moneyRequestReport) || PolicyUtils.isPolicyAdmin(policy)); + } useEffect(() => { - if (canModifyRequest) { + if (canDeleteRequest) { return; } setIsDeleteModalVisible(false); - }, [canModifyRequest]); + }, [canDeleteRequest]); const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(report)]; if (canModifyRequest) { if (!TransactionUtils.hasReceipt(transaction)) { @@ -103,18 +95,20 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, - transaction.transactionID, + transaction?.transactionID ?? '', report.reportID, Navigation.getActiveRouteWithoutParams(), ), ), }); } - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('reportActionContextMenu.deleteAction', {action: parentReportAction}), - onSelected: () => setIsDeleteModalVisible(true), - }); + if (canDeleteRequest) { + threeDotsMenuItems.push({ + icon: Expensicons.Trashcan, + text: translate('reportActionContextMenu.deleteAction', {action: parentReportAction}), + onSelected: () => setIsDeleteModalVisible(true), + }); + } } return ( @@ -129,7 +123,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)} report={{ ...report, - ownerAccountID: lodashGet(parentReport, 'ownerAccountID', null), + ownerAccountID: parentReport?.ownerAccountID, }} policy={policy} personalDetails={personalDetails} @@ -166,29 +160,25 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, } MoneyRequestHeader.displayName = 'MoneyRequestHeader'; -MoneyRequestHeader.propTypes = propTypes; -MoneyRequestHeader.defaultProps = defaultProps; -export default compose( - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - parentReport: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, - }, - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, - canEvict: false, +const MoneyRequestHeaderWithTransaction = withOnyx>({ + transaction: { + key: ({report, parentReportActions}) => { + const parentReportAction = (report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : {}) as ReportAction & OriginalMessageIOU; + return `${ONYXKEYS.COLLECTION.TRANSACTION}${parentReportAction.originalMessage.IOUTransactionID ?? 0}`; }, - }), - // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file - withOnyx({ - transaction: { - key: ({report, parentReportActions}) => { - const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]); - return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportAction, 'originalMessage.IOUTransactionID', 0)}`; - }, - }, - }), -)(MoneyRequestHeader); + }, +})(MoneyRequestHeader); + +export default withOnyx, Omit>({ + session: { + key: ONYXKEYS.SESSION, + }, + parentReport: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, + }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID ?? '0'}`, + canEvict: false, + }, +})(MoneyRequestHeaderWithTransaction); diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index be5cec7a2c0d..a2f79d2696b8 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -284,9 +284,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const shouldShowBillable = !lodashGet(policy, 'disabledFields.defaultBillable', true); const hasRoute = TransactionUtils.hasRoute(transaction); - const isDistanceRequestWithoutRoute = isDistanceRequest && !hasRoute; - const formattedAmount = isDistanceRequestWithoutRoute - ? translate('common.tbd') + const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate); + const formattedAmount = isDistanceRequestWithPendingRoute + ? '' : CurrencyUtils.convertToDisplayString( shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : iouAmount, isDistanceRequest ? currency : iouCurrencyCode, @@ -376,7 +376,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ let text; if (isTypeSplit && iouAmount === 0) { text = translate('iou.split'); - } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithoutRoute) { + } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { text = translate('iou.request'); if (iouAmount !== 0) { text = translate('iou.requestAmount', {amount: formattedAmount}); @@ -391,7 +391,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ value: iouType, }, ]; - }, [isTypeSplit, isTypeRequest, iouType, iouAmount, receiptPath, formattedAmount, isDistanceRequestWithoutRoute, translate]); + }, [isTypeSplit, isTypeRequest, iouType, iouAmount, receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); const selectedParticipants = useMemo(() => _.filter(pickedParticipants, (participant) => participant.selected), [pickedParticipants]); const personalDetailsOfPayee = useMemo(() => payeePersonalDetails || currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]); @@ -473,9 +473,17 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ if (!isDistanceRequest) { return; } + + /* + Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as: + When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page. + In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending. + */ + IOU.setMoneyRequestPendingFields(transaction.transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null}); + const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate, currency, translate, toLocaleDigit); IOU.setMoneyRequestMerchant_temporaryForRefactor(transaction.transactionID, distanceMerchant); - }, [hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, isDistanceRequest, transaction]); + }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, isDistanceRequest, transaction]); /** * @param {Object} option @@ -530,7 +538,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ } else { // validate the amount for distance requests const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode); - if (isDistanceRequest && !isDistanceRequestWithoutRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) { + if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) { setFormError('common.error.invalidAmount'); return; } @@ -555,7 +563,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ onSendMoney, iouCurrencyCode, isDistanceRequest, - isDistanceRequestWithoutRoute, + isDistanceRequestWithPendingRoute, iouAmount, isEditingSplitBill, onConfirm, @@ -608,13 +616,13 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ )} {button} ); - }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2, translate]); + }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2]); const {image: receiptImage, thumbnail: receiptThumbnail} = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : {}; return ( @@ -693,11 +701,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ title={iouComment} description={translate('common.description')} onPress={() => { - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ); }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 975d154b885b..2c41864564a3 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -1,9 +1,12 @@ +import {mapValues} from 'lodash'; import React, {useCallback} from 'react'; import type {ImageStyle, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import type {MaybePhraseKey} from '@libs/Localize'; import mapChildrenFlat from '@libs/mapChildrenFlat'; import shouldRenderOffscreen from '@libs/shouldRenderOffscreen'; import CONST from '@src/CONST'; @@ -59,6 +62,10 @@ type OfflineWithFeedbackProps = ChildrenProps & { type StrikethroughProps = Partial & {style: Array}; +function isMaybePhraseKeyType(message: unknown): message is MaybePhraseKey { + return typeof message === 'string' || Array.isArray(message); +} + function OfflineWithFeedback({ pendingAction, canDismissError = true, @@ -82,8 +89,8 @@ function OfflineWithFeedback({ // Some errors have a null message. This is used to apply opacity only and to avoid showing redundant messages. const errorEntries = Object.entries(errors ?? {}); - const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError] => errorEntry[1] !== null); - const errorMessages = Object.fromEntries(filteredErrorEntries); + const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, MaybePhraseKey | ReceiptError] => errorEntry[1] !== null); + const errorMessages = mapValues(Object.fromEntries(filteredErrorEntries), (error) => (isMaybePhraseKeyType(error) ? ErrorUtils.getErrorMessageWithTranslationData(error) : error)); const hasErrorMessages = !isEmptyObject(errorMessages); const isOfflinePendingAction = !!isOffline && !!pendingAction; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 345680e809f3..cb6a2dcbe722 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -41,9 +41,6 @@ const propTypes = { /** Whether referral CTA should be displayed */ shouldShowReferralCTA: PropTypes.bool, - /** A method triggered when the user closes the call to action banner */ - onCallToActionClosed: PropTypes.func, - /** Referral content type */ referralContentType: PropTypes.string, @@ -56,7 +53,6 @@ const propTypes = { const defaultProps = { shouldDelayFocus: false, shouldShowReferralCTA: false, - onCallToActionClosed: () => {}, referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND, safeAreaPaddingBottomStyle: {}, contentContainerStyles: [], @@ -72,7 +68,6 @@ class BaseOptionsSelector extends Component { this.updateFocusedIndex = this.updateFocusedIndex.bind(this); this.scrollToIndex = this.scrollToIndex.bind(this); this.selectRow = this.selectRow.bind(this); - this.closeReferralModal = this.closeReferralModal.bind(this); this.selectFocusedOption = this.selectFocusedOption.bind(this); this.addToSelection = this.addToSelection.bind(this); this.updateSearchValue = this.updateSearchValue.bind(this); @@ -95,7 +90,6 @@ class BaseOptionsSelector extends Component { allOptions, focusedIndex, shouldDisableRowSelection: false, - shouldShowReferralModal: this.props.shouldShowReferralCTA, errorMessage: '', paginationPage: 1, disableEnterShortCut: false, @@ -259,18 +253,13 @@ class BaseOptionsSelector extends Component { updateSearchValue(value) { this.setState({ paginationPage: 1, - errorMessage: value.length > this.props.maxLength ? this.props.translate('common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}) : '', + errorMessage: value.length > this.props.maxLength ? ['common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}] : '', value, }); this.props.onChangeText(value); } - closeReferralModal() { - this.setState((prevState) => ({shouldShowReferralModal: !prevState.shouldShowReferralModal})); - this.props.onCallToActionClosed(this.props.referralContentType); - } - handleFocusIn() { const activeElement = document.activeElement; this.setState({ @@ -653,12 +642,9 @@ class BaseOptionsSelector extends Component { )} - {this.props.shouldShowReferralCTA && this.state.shouldShowReferralModal && ( + {this.props.shouldShowReferralCTA && ( - + )} diff --git a/src/components/PDFView/PDFPasswordForm.js b/src/components/PDFView/PDFPasswordForm.js index 42d2ebbb771e..10596bb9faf9 100644 --- a/src/components/PDFView/PDFPasswordForm.js +++ b/src/components/PDFView/PDFPasswordForm.js @@ -55,13 +55,13 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat const errorText = useMemo(() => { if (isPasswordInvalid) { - return translate('attachmentView.passwordIncorrect'); + return 'attachmentView.passwordIncorrect'; } if (!_.isEmpty(validationErrorText)) { - return translate(validationErrorText); + return validationErrorText; } return ''; - }, [isPasswordInvalid, translate, validationErrorText]); + }, [isPasswordInvalid, validationErrorText]); useEffect(() => { if (!isFocused) { diff --git a/src/components/Picker/types.ts b/src/components/Picker/types.ts index edf39a59c9d8..a12f4cbe683a 100644 --- a/src/components/Picker/types.ts +++ b/src/components/Picker/types.ts @@ -1,5 +1,6 @@ import type {ChangeEvent, Component, ReactElement} from 'react'; import type {MeasureLayoutOnSuccessCallback, NativeMethods, StyleProp, ViewStyle} from 'react-native'; +import type {MaybePhraseKey} from '@libs/Localize'; type MeasureLayoutOnFailCallback = () => void; @@ -58,7 +59,7 @@ type BasePickerProps = { placeholder?: PickerPlaceholder; /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** Customize the BasePicker container */ containerStyles?: StyleProp; diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index 58d022ef9d65..a87e1f1f0412 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -121,6 +121,8 @@ function PopoverWithoutOverlay( e.stopPropagation()} > {}}: ReceiptEmptyS > diff --git a/src/components/ReferralProgramCTA.tsx b/src/components/ReferralProgramCTA.tsx index 4e0ed1f573f9..83bdfd67fef1 100644 --- a/src/components/ReferralProgramCTA.tsx +++ b/src/components/ReferralProgramCTA.tsx @@ -33,7 +33,7 @@ function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: Ref const theme = useTheme(); const handleDismissCallToAction = () => { - User.dismissReferralBanner(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND); + User.dismissReferralBanner(referralContentType); }; if (!referralContentType || dismissedReferralBanners[referralContentType]) { diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index e2021360c11a..133996fde41c 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -49,7 +49,7 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont const {totalDisplaySpend, nonReimbursableSpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(report); const shouldShowBreakdown = nonReimbursableSpend && reimbursableSpend; - const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, report.currency, ReportUtils.hasOnlyDistanceRequestTransactions(report.reportID)); + const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, report.currency); const formattedOutOfPocketAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, report.currency); const formattedCompanySpendAmount = CurrencyUtils.convertToDisplayString(nonReimbursableSpend, report.currency); diff --git a/src/components/ReportActionItem/MoneyRequestPreview.tsx b/src/components/ReportActionItem/MoneyRequestPreview.tsx index 70a313c77e9e..e89193108d24 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview.tsx @@ -155,20 +155,27 @@ function MoneyRequestPreview({ const isScanning = hasReceipt && TransactionUtils.isReceiptBeingScanned(transaction); const hasFieldErrors = TransactionUtils.hasMissingSmartscanFields(transaction); const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); + const isFetchingWaypointsFromServer = TransactionUtils.isFetchingWaypointsFromServer(transaction); const isCardTransaction = TransactionUtils.isCardTransaction(transaction); const isSettled = ReportUtils.isSettled(iouReport?.reportID); const isDeleted = action?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - // Show the merchant for IOUs and expenses only if they are custom or not related to scanning smartscan - const shouldShowMerchant = !!requestMerchant && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + /* + Show the merchant for IOUs and expenses only if: + - the merchant is not empty, is custom, or is not related to scanning smartscan; + - the request is not a distance request with a pending route and amount = 0 - in this case, + the merchant says: "Route pending...", which is already shown in the amount field; + */ + const shouldShowMerchant = + !!requestMerchant && + requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && + requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT && + !(isFetchingWaypointsFromServer && !requestAmount); const shouldShowDescription = !!description && !shouldShowMerchant && !isScanning; - const hasPendingWaypoints = transaction?.pendingFields?.waypoints; let merchantOrDescription = requestMerchant; if (!shouldShowMerchant) { merchantOrDescription = description || ''; - } else if (hasPendingWaypoints) { - merchantOrDescription = requestMerchant.replace(CONST.REGEX.FIRST_SPACE, translate('common.tbd')); } const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(transaction)] : []; @@ -217,14 +224,14 @@ function MoneyRequestPreview({ }; const getDisplayAmountText = (): string => { - if (isDistanceRequest) { - return requestAmount && !hasPendingWaypoints ? CurrencyUtils.convertToDisplayString(requestAmount, requestCurrency) : translate('common.tbd'); - } - if (isScanning) { return translate('iou.receiptScanning'); } + if (isFetchingWaypointsFromServer && !requestAmount) { + return translate('iou.routePending'); + } + if (!isSettled && TransactionUtils.hasMissingSmartscanFields(transaction)) { return Localize.translateLocal('iou.receiptMissingDetails'); } diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3a3aef6cabcd..df5be02ca64f 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -106,11 +106,7 @@ function MoneyRequestView({ } = ReportUtils.getTransactionDetails(transaction) ?? {}; const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); - let formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : ''; - const hasPendingWaypoints = transaction?.pendingFields?.waypoints; - if (isDistanceRequest && (!formattedTransactionAmount || hasPendingWaypoints)) { - formattedTransactionAmount = translate('common.tbd'); - } + const formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : ''; const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); const isCardTransaction = TransactionUtils.isCardTransaction(transaction); const cardProgramName = isCardTransaction && transactionCardID !== undefined ? CardUtils.getCardDescription(transactionCardID) : ''; @@ -268,7 +264,17 @@ function MoneyRequestView({ interactive={canEdit} shouldShowRightIcon={canEdit} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))} + onPress={() => + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( + CONST.IOU.ACTION.EDIT, + CONST.IOU.TYPE.REQUEST, + transaction?.transactionID ?? '', + report.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ) + } wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} brickRoadIndicator={hasViolations('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} numberOfLinesTitle={0} @@ -279,7 +285,7 @@ function MoneyRequestView({ {({show}) => ( diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 98116a089d73..311e63332f5c 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -19,6 +19,7 @@ import ControlSelection from '@libs/ControlSelection'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -104,11 +105,11 @@ function ReportPreview({ const {translate} = useLocalize(); const {canUseViolations} = usePermissions(); - const {hasMissingSmartscanFields, areAllRequestsBeingSmartScanned, hasOnlyDistanceRequests, hasNonReimbursableTransactions} = useMemo( + const {hasMissingSmartscanFields, areAllRequestsBeingSmartScanned, hasOnlyTransactionsWithPendingRoutes, hasNonReimbursableTransactions} = useMemo( () => ({ hasMissingSmartscanFields: ReportUtils.hasMissingSmartscanFields(iouReportID), areAllRequestsBeingSmartScanned: ReportUtils.areAllRequestsBeingSmartScanned(iouReportID, action), - hasOnlyDistanceRequests: ReportUtils.hasOnlyDistanceRequestTransactions(iouReportID), + hasOnlyTransactionsWithPendingRoutes: ReportUtils.hasOnlyTransactionsWithPendingRoutes(iouReportID), hasNonReimbursableTransactions: ReportUtils.hasNonReimbursableTransactions(iouReportID), }), // When transactions get updated these status may have changed, so that is a case where we also want to run this. @@ -140,14 +141,11 @@ function ReportPreview({ const hasErrors = (hasReceipts && hasMissingSmartscanFields) || (canUseViolations && ReportUtils.hasViolations(iouReportID, transactionViolations)); const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); const lastThreeReceipts = lastThreeTransactionsWithReceipts.map((transaction) => ReceiptUtils.getThumbnailAndImageURIs(transaction)); + let formattedMerchant = numberOfRequests === 1 && hasReceipts ? TransactionUtils.getMerchant(transactionsWithReceipts[0]) : null; if (TransactionUtils.isPartialMerchant(formattedMerchant ?? '')) { formattedMerchant = null; } - const hasPendingWaypoints = formattedMerchant && hasOnlyDistanceRequests && transactionsWithReceipts.every((transaction) => transaction.pendingFields?.waypoints); - if (formattedMerchant && hasPendingWaypoints) { - formattedMerchant = formattedMerchant.replace(CONST.REGEX.FIRST_SPACE, translate('common.tbd')); - } const previewSubtitle = // Formatted merchant can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -166,17 +164,14 @@ function ReportPreview({ ); const getDisplayAmount = (): string => { - if (hasPendingWaypoints) { - return translate('common.tbd'); - } if (totalDisplaySpend) { return CurrencyUtils.convertToDisplayString(totalDisplaySpend, iouReport?.currency); } if (isScanning) { return translate('iou.receiptScanning'); } - if (hasOnlyDistanceRequests) { - return translate('common.tbd'); + if (hasOnlyTransactionsWithPendingRoutes) { + return translate('iou.routePending'); } // If iouReport is not available, get amount from the action message (Ex: "Domain20821's Workspace owes $33.00" or "paid ₫60" or "paid -₫60 elsewhere") @@ -219,6 +214,8 @@ function ReportPreview({ ? // In a paid group policy, the admin approver can pay the report directly by skipping the approval step isPolicyAdmin && (isApproved || isCurrentUserManager) : isPolicyAdmin || (isMoneyRequestReport && isCurrentUserManager); + const isOnInstantSubmitPolicy = PolicyUtils.isInstantSubmitEnabled(policy); + const isOnSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); const shouldShowPayButton = useMemo( () => isPayer && !isDraftExpenseReport && !iouSettled && !iouReport?.isWaitingOnBankAccount && reimbursableSpend !== 0 && !iouCanceled && !isAutoReimbursable, [isPayer, isDraftExpenseReport, iouSettled, reimbursableSpend, iouCanceled, isAutoReimbursable, iouReport], @@ -227,9 +224,24 @@ function ReportPreview({ if (!isPaidGroupPolicy) { return false; } + if (isOnInstantSubmitPolicy && isOnSubmitAndClosePolicy) { + return false; + } return isCurrentUserManager && !isDraftExpenseReport && !isApproved && !iouSettled; - }, [isPaidGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, iouSettled]); + }, [isPaidGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, isOnInstantSubmitPolicy, isOnSubmitAndClosePolicy, iouSettled]); const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; + + /* + Show subtitle if at least one of the money requests is not being smart scanned, and either: + - There is more than one money request – in this case, the "X requests, Y scanning" subtitle is shown; + - There is only one money request, it has a receipt and is not being smart scanned – in this case, the request merchant is shown; + + * There is an edge case when there is only one distance request with a pending route and amount = 0. + In this case, we don't want to show the merchant because it says: "Pending route...", which is already displayed in the amount field. + */ + const shouldShowSingleRequestMerchant = numberOfRequests === 1 && !!formattedMerchant && !(hasOnlyTransactionsWithPendingRoutes && !totalDisplaySpend); + const shouldShowSubtitle = !isScanning && (shouldShowSingleRequestMerchant || numberOfRequests > 1); + return ( - {!isScanning && (numberOfRequests > 1 || (hasReceipts && numberOfRequests === 1 && formattedMerchant)) && ( + {shouldShowSubtitle && ( {previewSubtitle || moneyRequestComment} diff --git a/src/components/RoomNameInput/roomNameInputPropTypes.js b/src/components/RoomNameInput/roomNameInputPropTypes.js index 60be8430b056..f634c6e0b3d6 100644 --- a/src/components/RoomNameInput/roomNameInputPropTypes.js +++ b/src/components/RoomNameInput/roomNameInputPropTypes.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import refPropTypes from '@components/refPropTypes'; +import {translatableTextPropTypes} from '@libs/Localize'; const propTypes = { /** Callback to execute when the text input is modified correctly */ @@ -12,7 +13,7 @@ const propTypes = { disabled: PropTypes.bool, /** Error text to show */ - errorText: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]), + errorText: translatableTextPropTypes, /** A ref forwarded to the TextInput */ forwardedRef: refPropTypes, diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index a7d05a335d43..8b6a894cdd51 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -10,6 +10,7 @@ import useEnvironment from '@hooks/useEnvironment'; import useInitialDimensions from '@hooks/useInitialWindowDimensions'; import useKeyboardState from '@hooks/useKeyboardState'; import useNetwork from '@hooks/useNetwork'; +import useTackInputFocus from '@hooks/useTackInputFocus'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; @@ -79,6 +80,9 @@ type ScreenWrapperProps = { /** Whether to show offline indicator */ shouldShowOfflineIndicator?: boolean; + /** Whether to avoid scroll on virtual viewport */ + shouldAvoidScrollOnVirtualViewport?: boolean; + /** * The navigation prop is passed by the navigator. It is used to trigger the onEntryTransitionEnd callback * when the screen transition ends. @@ -109,6 +113,7 @@ function ScreenWrapper( onEntryTransitionEnd, testID, navigation: navigationProp, + shouldAvoidScrollOnVirtualViewport = true, shouldShowOfflineIndicatorInWideScreen = false, }: ScreenWrapperProps, ref: ForwardedRef, @@ -122,7 +127,7 @@ function ScreenWrapper( */ const navigationFallback = useNavigation>(); const navigation = navigationProp ?? navigationFallback; - const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); + const {windowHeight, isSmallScreenWidth} = useWindowDimensions(shouldEnableMaxHeight); const {initialHeight} = useInitialDimensions(); const styles = useThemeStyles(); const keyboardState = useKeyboardState(); @@ -192,6 +197,8 @@ function ScreenWrapper( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const isAvoidingViewportScroll = useTackInputFocus(shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileSafari()); + return ( {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => { @@ -220,12 +227,12 @@ function ScreenWrapper( {...keyboardDissmissPanResponder.panHandlers} > diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx index 58e89d5bff76..7737927e5307 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Lottie from '@components/Lottie'; @@ -42,8 +42,8 @@ type SectionProps = ChildrenProps & { /** Customize the Section container */ containerStyles?: StyleProp; - /** Customize the Section container */ - titleStyles?: StyleProp; + /** Customize the Section title */ + titleStyles?: StyleProp; /** Customize the Section container */ subtitleStyles?: StyleProp; @@ -114,9 +114,9 @@ function Section({ )} - + - {title} + {title} {cardLayout === CARD_LAYOUT.ICON_ON_RIGHT && ( {!!subtitle && ( - + {subtitle} )} diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index 058def7a34ad..80bedc84f069 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -147,7 +147,7 @@ function SettlementButton({ value: CONST.IOU.PAYMENT_TYPE.VBBA, }, [CONST.IOU.PAYMENT_TYPE.ELSEWHERE]: { - text: translate('iou.payElsewhere'), + text: translate('iou.payElsewhere', {formattedAmount}), icon: Expensicons.Cash, value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, }, @@ -182,7 +182,7 @@ function SettlementButton({ // Put the preferred payment method to the front of the array, so it's shown as default if (paymentMethod) { - return buttonOptions.sort((method) => (method.value === paymentMethod ? 0 : 1)); + return buttonOptions.sort((method) => (method.value === paymentMethod ? -1 : 0)); } return buttonOptions; // We don't want to reorder the options when the preferred payment method changes while the button is still visible diff --git a/src/components/StatePicker/index.tsx b/src/components/StatePicker/index.tsx index a03e4f15fba0..b00111319b4a 100644 --- a/src/components/StatePicker/index.tsx +++ b/src/components/StatePicker/index.tsx @@ -6,13 +6,14 @@ import FormHelpMessage from '@components/FormHelpMessage'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import type {CountryData} from '@libs/searchCountryOptions'; import StateSelectorModal from './StateSelectorModal'; import type {State} from './StateSelectorModal'; type StatePickerProps = { /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** State to display */ value?: State; diff --git a/src/components/SubscriptAvatar.tsx b/src/components/SubscriptAvatar.tsx index c2de41d8b4db..3e0f5fb9785a 100644 --- a/src/components/SubscriptAvatar.tsx +++ b/src/components/SubscriptAvatar.tsx @@ -1,4 +1,5 @@ import React, {memo} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -46,9 +47,21 @@ type SubscriptAvatarProps = { /** Whether to show the tooltip */ showTooltip?: boolean; + + /** Additional style for container of subscription icon */ + subscriptionContainerAdditionalStyles?: StyleProp; }; -function SubscriptAvatar({mainAvatar, secondaryAvatar, subscriptIcon, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: SubscriptAvatarProps) { +function SubscriptAvatar({ + mainAvatar, + secondaryAvatar, + subscriptIcon, + size = CONST.AVATAR_SIZE.DEFAULT, + backgroundColor, + noMargin = false, + showTooltip = true, + subscriptionContainerAdditionalStyles = undefined, +}: SubscriptAvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -113,6 +126,7 @@ function SubscriptAvatar({mainAvatar, secondaryAvatar, subscriptIcon, size = CON styles.subscriptIcon, styles.dFlex, styles.justifyContentCenter, + subscriptionContainerAdditionalStyles, ]} // Hover on overflowed part of icon will not work on Electron if dragArea is true // https://stackoverflow.com/questions/56338939/hover-in-css-is-not-working-with-electron diff --git a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js index 78f06b4075e0..e6077bde71b3 100644 --- a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import sourcePropTypes from '@components/Image/sourcePropTypes'; +import {translatableTextPropTypes} from '@libs/Localize'; const propTypes = { /** Input label */ @@ -18,7 +19,7 @@ const propTypes = { placeholder: PropTypes.string, /** Error text to display */ - errorText: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]), + errorText: translatableTextPropTypes, /** Icon to display in right side of text input */ icon: sourcePropTypes, @@ -68,7 +69,7 @@ const propTypes = { maxLength: PropTypes.number, /** Hint text to display below the TextInput */ - hint: PropTypes.string, + hint: translatableTextPropTypes, /** Prefix character */ prefixCharacter: PropTypes.string, diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 01400adb0440..a637dc22d72e 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -67,7 +67,7 @@ type CustomBaseTextInputProps = { hideFocusedState?: boolean; /** Hint text to display below the TextInput */ - hint?: string; + hint?: MaybePhraseKey; /** Prefix character */ prefixCharacter?: string; diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 72172365df13..a20dc353394e 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -1,5 +1,3 @@ -/* eslint-disable react/jsx-props-no-spreading */ -import PropTypes from 'prop-types'; import React, {useEffect, useMemo} from 'react'; import useThemePreferenceWithStaticOverride from '@hooks/useThemePreferenceWithStaticOverride'; import DomUtils from '@libs/DomUtils'; @@ -8,11 +6,6 @@ import themes from '@styles/theme'; import ThemeContext from '@styles/theme/context/ThemeContext'; import type {ThemePreferenceWithoutSystem} from '@styles/theme/types'; -const propTypes = { - /** Rendered child component */ - children: PropTypes.node.isRequired, -}; - type ThemeProviderProps = React.PropsWithChildren & { theme?: ThemePreferenceWithoutSystem; }; @@ -29,7 +22,6 @@ function ThemeProvider({children, theme: staticThemePreference}: ThemeProviderPr return {children}; } -ThemeProvider.propTypes = propTypes; ThemeProvider.displayName = 'ThemeProvider'; export default ThemeProvider; diff --git a/src/components/TimePicker/TimePicker.js b/src/components/TimePicker/TimePicker.js index 4664251ca765..f57f2540dfb3 100644 --- a/src/components/TimePicker/TimePicker.js +++ b/src/components/TimePicker/TimePicker.js @@ -536,7 +536,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) { {isError ? ( ) : ( diff --git a/src/components/ValuePicker/index.js b/src/components/ValuePicker/index.js index d90529114af4..28fa1ab26af2 100644 --- a/src/components/ValuePicker/index.js +++ b/src/components/ValuePicker/index.js @@ -7,12 +7,13 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import refPropTypes from '@components/refPropTypes'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import {translatableTextPropTypes} from '@libs/Localize'; import variables from '@styles/variables'; import ValueSelectorModal from './ValueSelectorModal'; const propTypes = { /** Form Error description */ - errorText: PropTypes.string, + errorText: translatableTextPropTypes, /** Item to display */ value: PropTypes.string, diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx deleted file mode 100755 index 9f615cef525d..000000000000 --- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {Dimensions, View} from 'react-native'; -import GoogleMeetIcon from '@assets/images/google-meet.svg'; -import ZoomIcon from '@assets/images/zoom-icon.svg'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import MenuItem from '@components/MenuItem'; -import Popover from '@components/Popover'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; -import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as Link from '@userActions/Link'; -import * as Session from '@userActions/Session'; -import CONST from '@src/CONST'; -import type VideoChatButtonAndMenuProps from './types'; - -type BaseVideoChatButtonAndMenuProps = VideoChatButtonAndMenuProps & { - /** Link to open when user wants to create a new google meet meeting */ - googleMeetURL: string; -}; - -function BaseVideoChatButtonAndMenu({googleMeetURL, isConcierge = false, guideCalendarLink}: BaseVideoChatButtonAndMenuProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {isSmallScreenWidth} = useWindowDimensions(); - const [isVideoChatMenuActive, setIsVideoChatMenuActive] = useState(false); - const [videoChatIconPosition, setVideoChatIconPosition] = useState({x: 0, y: 0}); - const videoChatIconWrapperRef = useRef(null); - const videoChatButtonRef = useRef(null); - - const menuItemData = [ - { - icon: ZoomIcon, - text: translate('videoChatButtonAndMenu.zoom'), - onPress: () => { - setIsVideoChatMenuActive(false); - Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL); - }, - }, - { - icon: GoogleMeetIcon, - text: translate('videoChatButtonAndMenu.googleMeet'), - onPress: () => { - setIsVideoChatMenuActive(false); - Link.openExternalLink(googleMeetURL); - }, - }, - ]; - - /** - * This gets called onLayout to find the coordinates of the wrapper for the video chat button. - */ - const measureVideoChatIconPosition = useCallback(() => { - if (!videoChatIconWrapperRef.current) { - return; - } - - videoChatIconWrapperRef.current.measureInWindow((x, y) => { - setVideoChatIconPosition({x, y}); - }); - }, []); - - useEffect(() => { - const dimensionsEventListener = Dimensions.addEventListener('change', measureVideoChatIconPosition); - - return () => { - if (!dimensionsEventListener) { - return; - } - - dimensionsEventListener.remove(); - }; - }, [measureVideoChatIconPosition]); - - return ( - <> - - - { - // Drop focus to avoid blue focus ring. - videoChatButtonRef.current?.blur(); - - // If this is the Concierge chat, we'll open the modal for requesting a setup call instead - if (isConcierge && guideCalendarLink) { - Link.openExternalLink(guideCalendarLink); - return; - } - setIsVideoChatMenuActive((previousVal) => !previousVal); - })} - style={styles.touchableButtonImage} - accessibilityLabel={translate('videoChatButtonAndMenu.tooltip')} - role={CONST.ROLE.BUTTON} - > - - - - - - setIsVideoChatMenuActive(false)} - isVisible={isVideoChatMenuActive} - anchorPosition={{ - left: videoChatIconPosition.x - 150, - top: videoChatIconPosition.y + 40, - }} - withoutOverlay - anchorRef={videoChatButtonRef} - > - - {menuItemData.map(({icon, text, onPress}) => ( - - ))} - - - - ); -} - -BaseVideoChatButtonAndMenu.displayName = 'BaseVideoChatButtonAndMenu'; - -export default BaseVideoChatButtonAndMenu; diff --git a/src/components/VideoChatButtonAndMenu/index.android.tsx b/src/components/VideoChatButtonAndMenu/index.android.tsx deleted file mode 100644 index 838d296074fa..000000000000 --- a/src/components/VideoChatButtonAndMenu/index.android.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import CONST from '@src/CONST'; -import BaseVideoChatButtonAndMenu from './BaseVideoChatButtonAndMenu'; -import type VideoChatButtonAndMenuProps from './types'; - -// On Android creating a new google meet meeting requires the CALL_PHONE permission in some cases -// so we're just opening the google meet app instead, more details: -// https://github.com/Expensify/App/issues/8851#issuecomment-1120236904 -function VideoChatButtonAndMenu(props: VideoChatButtonAndMenuProps) { - return ( - - ); -} - -VideoChatButtonAndMenu.displayName = 'VideoChatButtonAndMenu'; - -export default VideoChatButtonAndMenu; diff --git a/src/components/VideoChatButtonAndMenu/index.tsx b/src/components/VideoChatButtonAndMenu/index.tsx deleted file mode 100644 index fa381c18d64a..000000000000 --- a/src/components/VideoChatButtonAndMenu/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import CONST from '@src/CONST'; -import BaseVideoChatButtonAndMenu from './BaseVideoChatButtonAndMenu'; -import type VideoChatButtonAndMenuProps from './types'; - -function VideoChatButtonAndMenu(props: VideoChatButtonAndMenuProps) { - return ( - - ); -} - -VideoChatButtonAndMenu.displayName = 'VideoChatButtonAndMenu'; - -export default VideoChatButtonAndMenu; diff --git a/src/components/VideoChatButtonAndMenu/types.ts b/src/components/VideoChatButtonAndMenu/types.ts deleted file mode 100644 index b8e263b48d01..000000000000 --- a/src/components/VideoChatButtonAndMenu/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -type VideoChatButtonAndMenuProps = { - /** If this is the Concierge chat, we'll open the modal for requesting a setup call instead of showing popover menu */ - isConcierge?: boolean; - - /** URL to the assigned guide's appointment booking calendar */ - guideCalendarLink?: string; -}; - -export default VideoChatButtonAndMenuProps; diff --git a/src/components/WorkspaceSwitcherButton.tsx b/src/components/WorkspaceSwitcherButton.tsx index b7485fbab7a8..963782bb50d5 100644 --- a/src/components/WorkspaceSwitcherButton.tsx +++ b/src/components/WorkspaceSwitcherButton.tsx @@ -13,6 +13,7 @@ import type {Policy} from '@src/types/onyx'; import * as Expensicons from './Icon/Expensicons'; import {PressableWithFeedback} from './Pressable'; import SubscriptAvatar from './SubscriptAvatar'; +import Tooltip from './Tooltip'; type WorkspaceSwitcherButtonOnyxProps = { policy: OnyxEntry; @@ -38,30 +39,33 @@ function WorkspaceSwitcherButton({activeWorkspaceID, policy}: WorkspaceSwitcherB }, [policy, activeWorkspaceID]); return ( - - interceptAnonymousUser(() => { - Navigation.navigate(ROUTES.WORKSPACE_SWITCHER); - }) - } - > - {({hovered}) => ( - - )} - + + + interceptAnonymousUser(() => { + Navigation.navigate(ROUTES.WORKSPACE_SWITCHER); + }) + } + > + {({hovered}) => ( + + )} + + ); } diff --git a/src/components/transactionPropTypes.js b/src/components/transactionPropTypes.js index c70a2e524583..bdcf60bec5da 100644 --- a/src/components/transactionPropTypes.js +++ b/src/components/transactionPropTypes.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; +import {translatableTextPropTypes} from '@libs/Localize'; import CONST from '@src/CONST'; import sourcePropTypes from './Image/sourcePropTypes'; @@ -80,5 +81,5 @@ export default PropTypes.shape({ }), /** Server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), }); diff --git a/src/hooks/usePrivatePersonalDetails.ts b/src/hooks/usePrivatePersonalDetails.ts index 89d9951cef11..f17600e9878f 100644 --- a/src/hooks/usePrivatePersonalDetails.ts +++ b/src/hooks/usePrivatePersonalDetails.ts @@ -15,6 +15,6 @@ export default function usePrivatePersonalDetails() { return; } - PersonalDetails.openPersonalDetailsPage(); + PersonalDetails.openPersonalDetails(); }, [network?.isOffline]); } diff --git a/src/hooks/useTackInputFocus/index.native.ts b/src/hooks/useTackInputFocus/index.native.ts new file mode 100644 index 000000000000..683040d7421a --- /dev/null +++ b/src/hooks/useTackInputFocus/index.native.ts @@ -0,0 +1,6 @@ +/** + * Detects input or text area focus on browser. Native doesn't support DOM so default to false + */ +export default function useTackInputFocus(): boolean { + return false; +} diff --git a/src/hooks/useTackInputFocus/index.ts b/src/hooks/useTackInputFocus/index.ts new file mode 100644 index 000000000000..124f8460127c --- /dev/null +++ b/src/hooks/useTackInputFocus/index.ts @@ -0,0 +1,49 @@ +import {useCallback, useEffect} from 'react'; +import useDebouncedState from '@hooks/useDebouncedState'; + +/** + * Detects input or text area focus on browsers, to avoid scrolling on virtual viewports + */ +export default function useTackInputFocus(enable = false): boolean { + const [, isInputFocusDebounced, setIsInputFocus] = useDebouncedState(false); + + const handleFocusIn = useCallback( + (event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (targetElement.tagName === 'INPUT' || targetElement.tagName === 'TEXTAREA') { + setIsInputFocus(true); + } + }, + [setIsInputFocus], + ); + + const handleFocusOut = useCallback( + (event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (targetElement.tagName === 'INPUT' || targetElement.tagName === 'TEXTAREA') { + setIsInputFocus(false); + } + }, + [setIsInputFocus], + ); + + const resetScrollPositionOnVisualViewport = useCallback(() => { + window.scrollTo({top: 0}); + }, []); + + useEffect(() => { + if (!enable) { + return; + } + window.addEventListener('focusin', handleFocusIn); + window.addEventListener('focusout', handleFocusOut); + window.visualViewport?.addEventListener('scroll', resetScrollPositionOnVisualViewport); + return () => { + window.removeEventListener('focusin', handleFocusIn); + window.removeEventListener('focusout', handleFocusOut); + window.visualViewport?.removeEventListener('scroll', resetScrollPositionOnVisualViewport); + }; + }, [enable, handleFocusIn, handleFocusOut, resetScrollPositionOnVisualViewport]); + + return isInputFocusDebounced; +} diff --git a/src/hooks/useWindowDimensions/index.ts b/src/hooks/useWindowDimensions/index.ts index b0a29e9f901b..4ba2c4ad9b41 100644 --- a/src/hooks/useWindowDimensions/index.ts +++ b/src/hooks/useWindowDimensions/index.ts @@ -1,13 +1,21 @@ +import {useEffect, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {Dimensions, useWindowDimensions} from 'react-native'; +import * as Browser from '@libs/Browser'; import variables from '@styles/variables'; import type WindowDimensions from './types'; +const initalViewportHeight = window.visualViewport?.height ?? window.innerHeight; +const tagNamesOpenKeyboard = ['INPUT', 'TEXTAREA']; + /** * A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints. */ -export default function (): WindowDimensions { +export default function (useCachedViewportHeight = false): WindowDimensions { + const isCachedViewportHeight = useCachedViewportHeight && Browser.isMobileSafari(); + const cachedViewportHeightWithKeyboardRef = useRef(initalViewportHeight); const {width: windowWidth, height: windowHeight} = useWindowDimensions(); + // When the soft keyboard opens on mWeb, the window height changes. Use static screen height instead to get real screenHeight. const screenHeight = Dimensions.get('screen').height; const isExtraSmallScreenHeight = screenHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint; @@ -15,9 +23,61 @@ export default function (): WindowDimensions { const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint; const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint; + const [cachedViewportHeight, setCachedViewportHeight] = useState(windowHeight); + + const handleFocusIn = useRef((event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (tagNamesOpenKeyboard.includes(targetElement.tagName)) { + setCachedViewportHeight(cachedViewportHeightWithKeyboardRef.current); + } + }); + + useEffect(() => { + if (!isCachedViewportHeight) { + return; + } + window.addEventListener('focusin', handleFocusIn.current); + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + window.removeEventListener('focusin', handleFocusIn.current); + }; + }, [isCachedViewportHeight]); + + const handleFocusOut = useRef((event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (tagNamesOpenKeyboard.includes(targetElement.tagName)) { + setCachedViewportHeight(initalViewportHeight); + } + }); + + useEffect(() => { + if (!isCachedViewportHeight) { + return; + } + window.addEventListener('focusout', handleFocusOut.current); + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + window.removeEventListener('focusout', handleFocusOut.current); + }; + }, [isCachedViewportHeight]); + + useEffect(() => { + if (!isCachedViewportHeight && windowHeight >= cachedViewportHeightWithKeyboardRef.current) { + return; + } + setCachedViewportHeight(windowHeight); + }, [windowHeight, isCachedViewportHeight]); + + useEffect(() => { + if (!isCachedViewportHeight || !window.matchMedia('(orientation: portrait)').matches || windowHeight >= initalViewportHeight) { + return; + } + cachedViewportHeightWithKeyboardRef.current = windowHeight; + }, [isCachedViewportHeight, windowHeight]); + return { windowWidth, - windowHeight, + windowHeight: isCachedViewportHeight ? cachedViewportHeight : windowHeight, isExtraSmallScreenHeight, isSmallScreenWidth, isMediumScreenWidth, diff --git a/src/languages/en.ts b/src/languages/en.ts index 54993efd8660..cd1797d78220 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -25,7 +25,6 @@ import type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, - IncorrectZipFormatParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, @@ -382,8 +381,6 @@ export default { }, videoChatButtonAndMenu: { tooltip: 'Start a call', - zoom: 'Zoom', - googleMeet: 'Google Meet', }, hello: 'Hello', phoneCountryCode: '1', @@ -589,6 +586,7 @@ export default { canceled: 'Canceled', posted: 'Posted', deleteReceipt: 'Delete receipt', + routePending: 'Route pending...', receiptScanning: 'Scan in progress…', receiptMissingDetails: 'Receipt missing details', receiptStatusTitle: 'Scanning…', @@ -602,7 +600,7 @@ export default { settledExpensify: 'Paid', settledElsewhere: 'Paid elsewhere', settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with Expensify` : `Pay with Expensify`), - payElsewhere: 'Pay elsewhere', + payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} elsewhere` : `Pay elsewhere`), nextStep: 'Next Steps', finished: 'Finished', requestAmount: ({amount}: RequestAmountParams) => `request ${amount}`, @@ -712,6 +710,14 @@ export default { offline: 'Offline', syncing: 'Syncing', profileAvatar: 'Profile avatar', + publicSection: { + title: 'Public', + subtitle: 'These details are displayed on your public profile, available for people to see.', + }, + privateSection: { + title: 'Private', + subtitle: 'These details are used for travel and payments. They are never shown on your public profile.', + }, }, loungeAccessPage: { loungeAccess: 'Lounge access', @@ -897,6 +903,9 @@ export default { sharedNoteMessage: 'Keep notes about this chat here. Expensify employees and other users on the team.expensify.com domain can view these notes.', composerLabel: 'Notes', myNote: 'My note', + error: { + genericFailureMessage: "Private notes couldn't be saved", + }, }, addDebitCardPage: { addADebitCard: 'Add a debit card', @@ -1166,7 +1175,7 @@ export default { dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `Date should be before ${dateString}.`, dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `Date should be after ${dateString}.`, hasInvalidCharacter: 'Name can only include Latin characters.', - incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, + incorrectZipFormat: (zipFormat?: string) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, }, }, resendValidationForm: { @@ -2111,6 +2120,20 @@ export default { welcomeMessage: 'Welcome to Expensify', welcomeSubtitle: 'What would you like to do?', }, + manageTeams: { + [CONST.MANAGE_TEAMS_CHOICE.MULTI_LEVEL]: 'Multi level approval', + [CONST.MANAGE_TEAMS_CHOICE.CUSTOM_EXPENSE]: 'Custom expense coding', + [CONST.MANAGE_TEAMS_CHOICE.CARD_TRACKING]: 'Company card tracking', + [CONST.MANAGE_TEAMS_CHOICE.ACCOUNTING]: 'Accounting integrations', + [CONST.MANAGE_TEAMS_CHOICE.RULE]: 'Rule enforcement', + title: 'Do you require any of the following features?', + }, + expensifyClassic: { + title: "Expensify Classic has everything you'll need", + firstDescription: "While we're busy working on New Expensify, it currently doesn't support some of the features you're looking for.", + secondDescription: "Don't worry, Expensify Classic has everything you need.", + buttonText: 'Take me to Expensify Classic', + }, violations: { allTagLevelsRequired: 'All tags required', autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 664efee1cba0..9cc46cb2ced5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -24,7 +24,6 @@ import type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, - IncorrectZipFormatParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, @@ -372,8 +371,6 @@ export default { }, videoChatButtonAndMenu: { tooltip: 'Iniciar una llamada', - zoom: 'Zoom', - googleMeet: 'Google Meet', }, hello: 'Hola', phoneCountryCode: '34', @@ -581,6 +578,7 @@ export default { canceled: 'Canceló', posted: 'Contabilizado', deleteReceipt: 'Eliminar recibo', + routePending: 'Ruta pendiente...', receiptScanning: 'Escaneo en curso…', receiptMissingDetails: 'Recibo con campos vacíos', receiptStatusTitle: 'Escaneando…', @@ -594,7 +592,7 @@ export default { settledExpensify: 'Pagado', settledElsewhere: 'Pagado de otra forma', settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con Expensify` : `Pagar con Expensify`), - payElsewhere: 'Pagar de otra forma', + payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} de otra forma` : `Pagar de otra forma`), nextStep: 'Pasos Siguientes', finished: 'Finalizado', requestAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, @@ -706,6 +704,14 @@ export default { offline: 'Desconectado', syncing: 'Sincronizando', profileAvatar: 'Perfil avatar', + publicSection: { + title: 'Público', + subtitle: 'Estos detalles se muestran en tu perfil público, a disposición de los demás.', + }, + privateSection: { + title: 'Privada', + subtitle: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en tu perfil público.', + }, }, loungeAccessPage: { loungeAccess: 'Acceso a la sala vip', @@ -892,6 +898,9 @@ export default { sharedNoteMessage: 'Guarda notas sobre este chat aquí. Los empleados de Expensify y otros usuarios del dominio team.expensify.com pueden ver estas notas.', composerLabel: 'Notas', myNote: 'Mi nota', + error: { + genericFailureMessage: 'Las notas privadas no han podido ser guardadas', + }, }, addDebitCardPage: { addADebitCard: 'Añadir una tarjeta de débito', @@ -1163,7 +1172,7 @@ export default { error: { dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `La fecha debe ser anterior a ${dateString}.`, dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `La fecha debe ser posterior a ${dateString}.`, - incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, + incorrectZipFormat: (zipFormat?: string) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, hasInvalidCharacter: 'El nombre sólo puede incluir caracteres latinos.', }, }, @@ -2598,6 +2607,20 @@ export default { welcomeMessage: 'Bienvenido a Expensify', welcomeSubtitle: '¿Qué te gustaría hacer?', }, + manageTeams: { + [CONST.MANAGE_TEAMS_CHOICE.MULTI_LEVEL]: 'Aprobación multinivel', + [CONST.MANAGE_TEAMS_CHOICE.CUSTOM_EXPENSE]: 'Codificación personalizada de gastos', + [CONST.MANAGE_TEAMS_CHOICE.CARD_TRACKING]: 'Seguimiento de tarjetas corporativas', + [CONST.MANAGE_TEAMS_CHOICE.ACCOUNTING]: 'Integraciones de contaduría', + [CONST.MANAGE_TEAMS_CHOICE.RULE]: 'Aplicación de reglas', + title: '¿Necesitas alguna de las siguientes funciones?', + }, + expensifyClassic: { + title: 'Expensify Classic tiene todo lo que necesitas', + firstDescription: 'Aunque estamos ocupados trabajando en el Nuevo Expensify, actualmente no soporta algunas de las funciones que estás buscando.', + secondDescription: 'No te preocupes, Expensify Classic tiene todo lo que necesitas.', + buttonText: 'Llévame a Expensify Classic', + }, violations: { allTagLevelsRequired: 'Todas las etiquetas son obligatorias', autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, diff --git a/src/languages/types.ts b/src/languages/types.ts index d4ec48eb3b41..c9442c6560a3 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -163,8 +163,6 @@ type DateShouldBeBeforeParams = {dateString: string}; type DateShouldBeAfterParams = {dateString: string}; -type IncorrectZipFormatParams = {zipFormat?: string}; - type WeSentYouMagicSignInLinkParams = {login: string; loginType: string}; type ToValidateLoginParams = {primaryLogin: string; secondaryLogin: string}; @@ -315,7 +313,6 @@ export type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, - IncorrectZipFormatParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index a4ab3db9a7cd..4b383bacddaa 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -287,7 +287,7 @@ const READ_COMMANDS = { OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView', GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken', OPEN_PAYMENTS_PAGE: 'OpenPaymentsPage', - OPEN_PERSONAL_DETAILS_PAGE: 'OpenPersonalDetailsPage', + OPEN_PERSONAL_DETAILS: 'OpenPersonalDetailsPage', OPEN_PUBLIC_PROFILE_PAGE: 'OpenPublicProfilePage', OPEN_PLAID_BANK_LOGIN: 'OpenPlaidBankLogin', OPEN_PLAID_BANK_ACCOUNT_SELECTOR: 'OpenPlaidBankAccountSelector', @@ -321,7 +321,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: EmptyObject; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: EmptyObject; [READ_COMMANDS.OPEN_PAYMENTS_PAGE]: EmptyObject; - [READ_COMMANDS.OPEN_PERSONAL_DETAILS_PAGE]: EmptyObject; + [READ_COMMANDS.OPEN_PERSONAL_DETAILS]: EmptyObject; [READ_COMMANDS.OPEN_PUBLIC_PROFILE_PAGE]: Parameters.OpenPublicProfilePageParams; [READ_COMMANDS.OPEN_PLAID_BANK_LOGIN]: Parameters.OpenPlaidBankLoginParams; [READ_COMMANDS.OPEN_PLAID_BANK_ACCOUNT_SELECTOR]: Parameters.OpenPlaidBankAccountSelectorParams; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 42387e03c80b..cec9d1e09088 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -2,7 +2,6 @@ import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import type {OnyxValues} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; -import * as Localize from './Localize'; import BaseLocaleListener from './Localize/LocaleListener/BaseLocaleListener'; import * as NumberFormatUtils from './NumberFormatUtils'; @@ -98,13 +97,8 @@ function convertToFrontendAmount(amountAsInt: number): number { * * @param amountInCents – should be an integer. Anything after a decimal place will be dropped. * @param currency - IOU currency - * @param shouldFallbackToTbd - whether to return 'TBD' instead of a falsy value (e.g. 0.00) */ -function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD, shouldFallbackToTbd = false): string { - if (shouldFallbackToTbd && !amountInCents) { - return Localize.translateLocal('common.tbd'); - } - +function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD): string { const convertedAmount = convertToFrontendAmount(amountInCents); return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index c92e9bfd3f67..a42cb6a8f756 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -95,14 +95,17 @@ function getDistanceMerchant( translate: LocaleContextProps['translate'], toLocaleDigit: LocaleContextProps['toLocaleDigit'], ): string { - const distanceInUnits = hasRoute ? getRoundedDistanceInUnits(distanceInMeters, unit) : translate('common.tbd'); + if (!hasRoute || !rate) { + return translate('iou.routePending'); + } + const distanceInUnits = getRoundedDistanceInUnits(distanceInMeters, unit); const distanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.miles') : translate('common.kilometers'); const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); const unitString = distanceInUnits === '1' ? singularDistanceUnit : distanceUnit; - const ratePerUnit = rate ? PolicyUtils.getUnitRateValue({rate}, toLocaleDigit) : translate('common.tbd'); + const ratePerUnit = PolicyUtils.getUnitRateValue({rate}, toLocaleDigit); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const currencySymbol = rate ? CurrencyUtils.getCurrencySymbol(currency) || `${currency} ` : ''; + const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `; return `${distanceInUnits} ${unitString} @ ${currencySymbol}${ratePerUnit} / ${singularDistanceUnit}`; } diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 6fbba1e750dc..8cfaa684917e 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -1,3 +1,4 @@ +import mapValues from 'lodash/mapValues'; import CONST from '@src/CONST'; import type {TranslationFlatObject, TranslationPaths} from '@src/languages/types'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; @@ -38,8 +39,8 @@ function getAuthenticateErrorMessage(response: Response): keyof TranslationFlatO * Method used to get an error object with microsecond as the key. * @param error - error key or message to be saved */ -function getMicroSecondOnyxError(error: string | null): Errors { - return {[DateUtils.getMicroseconds()]: error}; +function getMicroSecondOnyxError(error: string | null, isTranslated = false): Errors { + return {[DateUtils.getMicroseconds()]: error && [error, {isTranslated}]}; } /** @@ -50,11 +51,16 @@ function getMicroSecondOnyxErrorObject(error: Errors): ErrorFields { return {[DateUtils.getMicroseconds()]: error}; } +// We can assume that if error is a string, it has already been translated because it is server error +function getErrorMessageWithTranslationData(error: Localize.MaybePhraseKey): Localize.MaybePhraseKey { + return typeof error === 'string' ? [error, {isTranslated: true}] : error; +} + type OnyxDataWithErrors = { errors?: Errors | null; }; -function getLatestErrorMessage(onyxData: TOnyxData): string | null { +function getLatestErrorMessage(onyxData: TOnyxData): Localize.MaybePhraseKey { const errors = onyxData.errors ?? {}; if (Object.keys(errors).length === 0) { @@ -62,8 +68,7 @@ function getLatestErrorMessage(onyxData: T } const key = Object.keys(errors).sort().reverse()[0]; - - return errors[key]; + return getErrorMessageWithTranslationData(errors[key]); } function getLatestErrorMessageField(onyxData: TOnyxData): Errors { @@ -90,8 +95,7 @@ function getLatestErrorField(onyxData } const key = Object.keys(errorsForField).sort().reverse()[0]; - - return {[key]: errorsForField[key]}; + return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } function getEarliestErrorField(onyxData: TOnyxData, fieldName: string): Errors { @@ -102,18 +106,33 @@ function getEarliestErrorField(onyxDa } const key = Object.keys(errorsForField).sort()[0]; - - return {[key]: errorsForField[key]}; + return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } -type ErrorsList = Record; +/** + * Method used to attach already translated message with isTranslated property + * @param errors - An object containing current errors in the form + * @returns Errors in the form of {timestamp: [message, {isTranslated}]} + */ +function getErrorsWithTranslationData(errors: Localize.MaybePhraseKey | Errors): Errors { + if (!errors || (Array.isArray(errors) && errors.length === 0)) { + return {}; + } + + if (typeof errors === 'string' || Array.isArray(errors)) { + // eslint-disable-next-line @typescript-eslint/naming-convention + return {'0': getErrorMessageWithTranslationData(errors)}; + } + + return mapValues(errors, getErrorMessageWithTranslationData); +} /** * Method used to generate error message for given inputID * @param errors - An object containing current errors in the form * @param message - Message to assign to the inputID errors */ -function addErrorMessage(errors: ErrorsList, inputID?: string, message?: TKey | Localize.MaybePhraseKey) { +function addErrorMessage(errors: Errors, inputID?: string, message?: TKey | Localize.MaybePhraseKey) { if (!message || !inputID) { return; } @@ -138,6 +157,8 @@ export { getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, + getErrorMessageWithTranslationData, + getErrorsWithTranslationData, addErrorMessage, getLatestErrorMessageField, }; diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts index 0df9e25eff25..7c0cd437d5f9 100644 --- a/src/libs/Localize/index.ts +++ b/src/libs/Localize/index.ts @@ -1,3 +1,4 @@ +import PropTypes from 'prop-types'; import * as RNLocalize from 'react-native-localize'; import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; @@ -98,7 +99,15 @@ function translateLocal(phrase: TKey, ...variable return translate(BaseLocaleListener.getPreferredLocale(), phrase, ...variables); } -type MaybePhraseKey = string | null | [string, Record & {isTranslated?: true}] | []; +/** + * Traslatable text with phrase key and/or variables + * Use MaybePhraseKey for Typescript + * + * E.g. ['common.error.characterLimitExceedCounter', {length: 5, limit: 20}] + */ +const translatableTextPropTypes = PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]); + +type MaybePhraseKey = string | null | [string, Record & {isTranslated?: boolean}] | []; /** * Return translated string for given error. @@ -177,5 +186,5 @@ function getDevicePreferredLocale(): string { return RNLocalize.findBestAvailableLanguage([CONST.LOCALES.EN, CONST.LOCALES.ES])?.languageTag ?? CONST.LOCALES.DEFAULT; } -export {translate, translateLocal, translateIfPhraseKey, formatList, formatMessageElementList, getDevicePreferredLocale}; +export {translatableTextPropTypes, translate, translateLocal, translateIfPhraseKey, formatList, formatMessageElementList, getDevicePreferredLocale}; export type {PhraseParameters, Phrase, MaybePhraseKey}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 110c13fa07bf..c7be135e8b57 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -12,6 +12,7 @@ import type { MoneyRequestNavigatorParamList, NewChatNavigatorParamList, NewTaskNavigatorParamList, + OnboardEngagementNavigatorParamList, ParticipantsNavigatorParamList, PrivateNotesNavigatorParamList, ProfileNavigatorParamList, @@ -99,7 +100,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, [SCREENS.MONEY_REQUEST.DATE]: () => require('../../../pages/iou/MoneyRequestDatePage').default as React.ComponentType, - [SCREENS.MONEY_REQUEST.DESCRIPTION]: () => require('../../../pages/iou/MoneyRequestDescriptionPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.MERCHANT]: () => require('../../../pages/iou/MoneyRequestMerchantPage').default as React.ComponentType, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType, @@ -174,6 +174,12 @@ const NewTaskModalStackNavigator = createModalStackNavigator require('../../../pages/tasks/NewTaskDescriptionPage').default as React.ComponentType, }); +const OnboardEngagementModalStackNavigator = createModalStackNavigator({ + [SCREENS.ONBOARD_ENGAGEMENT.ROOT]: () => require('../../../pages/OnboardEngagement/PurposeForUsingExpensifyPage').default as React.ComponentType, + [SCREENS.ONBOARD_ENGAGEMENT.MANAGE_TEAMS_EXPENSES]: () => require('../../../pages/OnboardEngagement/ManageTeamsExpensesPage').default as React.ComponentType, + [SCREENS.ONBOARD_ENGAGEMENT.EXPENSIFY_CLASSIC]: () => require('../../../pages/OnboardEngagement/ExpensifyClassicPage').default as React.ComponentType, +}); + const NewTeachersUniteNavigator = createModalStackNavigator({ [SCREENS.SAVE_THE_WORLD.ROOT]: () => require('../../../pages/TeachersUnite/SaveTheWorldPage').default as React.ComponentType, [SCREENS.I_KNOW_A_TEACHER]: () => require('../../../pages/TeachersUnite/KnowATeacherPage').default as React.ComponentType, @@ -202,11 +208,10 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/DisplayNamePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.TIMEZONE]: () => require('../../../pages/settings/Profile/TimezoneInitialPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT]: () => require('../../../pages/settings/Profile/TimezoneSelectPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL]: () => require('../../../pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME]: () => require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH]: () => require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS]: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY]: () => require('../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: () => require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: () => require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.ADDRESS]: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: () => require('../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: () => require('../../../pages/settings/Profile/Contacts/ContactMethodsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: () => require('../../../pages/settings/Profile/Contacts/ContactMethodDetailsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: () => require('../../../pages/settings/Profile/Contacts/NewContactMethodPage').default as React.ComponentType, @@ -292,6 +297,7 @@ export { AccountSettingsModalStackNavigator, AddPersonalBankAccountModalStackNavigator, DetailsModalStackNavigator, + OnboardEngagementModalStackNavigator, EditRequestStackNavigator, EnablePaymentsStackNavigator, FlagCommentStackNavigator, diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 93d2f8fba989..c421bdc82028 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -85,6 +85,10 @@ function RightModalNavigator({navigation}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.NEW_TASK} component={ModalStackNavigators.NewTaskModalStackNavigator} /> + ; +}; +type PurposeForUsingExpensifyModalProps = PurposeForUsingExpensifyModalOnyxProps; + +function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {activeWorkspaceID} = useActiveWorkspace(); + const navigation = useNavigation(); + + useEffect(() => { + const navigationState = navigation.getState(); + const routes = navigationState.routes; + const currentRoute = routes[navigationState.index]; + + if (currentRoute && currentRoute.name !== NAVIGATORS.BOTTOM_TAB_NAVIGATOR && currentRoute.name !== NAVIGATORS.CENTRAL_PANE_NAVIGATOR) { + return; + } + + Welcome.show(routes, () => Navigation.navigate(ROUTES.ONBOARD)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoadingApp]); + // Parent navigator of the bottom tab bar is the root navigator. const currentTabName = useNavigationState((state) => { const topmostBottomTabRoute = getTopmostBottomTabRoute(state); @@ -86,11 +110,14 @@ function BottomTabBar() { - ); } BottomTabBar.displayName = 'BottomTabBar'; -export default BottomTabBar; +export default withOnyx({ + isLoadingApp: { + key: ONYXKEYS.IS_LOADING_APP, + }, +})(BottomTabBar); diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx index b07c12d37b5a..e8a321984f1c 100644 --- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx @@ -1,6 +1,6 @@ import type {ParamListBase, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; import {StackRouter} from '@react-navigation/native'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import SCREENS from '@src/SCREENS'; import type {FullScreenNavigatorRouterOptions} from './types'; @@ -9,11 +9,11 @@ type StackState = StackNavigationState | PartialState !!state.routes.find((route) => route.name === screenName); function adaptStateIfNecessary(state: StackState) { - const isSmallScreenWidth = getIsSmallScreenWidth(); + const isNarrowLayout = getIsNarrowLayout(); // If the screen is wide, there should be at least two screens inside: // - SETINGS.ROOT to cover left pane. // - SETTINGS_CENTRAL_PANE to cover central pane. - if (!isSmallScreenWidth) { + if (!isNarrowLayout) { if (!isAtLeastOneInState(state, SCREENS.SETTINGS.ROOT)) { // @ts-expect-error Updating read only property // noinspection JSConstantReassignment diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts index a59150019142..504803026994 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts @@ -1,7 +1,7 @@ import type {RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; import {getPathFromState, StackRouter} from '@react-navigation/native'; import type {ParamListBase} from '@react-navigation/routers'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import linkingConfig from '@libs/Navigation/linkingConfig'; @@ -38,10 +38,10 @@ function compareAndAdaptState(state: StackNavigationState) { // We need to be sure that the bottom tab state is defined. const topmostBottomTabRoute = getTopmostBottomTabRoute(state); - const isSmallScreenWidth = getIsSmallScreenWidth(); + 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 && !isSmallScreenWidth) { + 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. diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts index 49dcee71eda4..5a765d9a7d37 100644 --- a/src/libs/Navigation/linkTo.ts +++ b/src/libs/Navigation/linkTo.ts @@ -1,7 +1,7 @@ import {getActionFromState} from '@react-navigation/core'; import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import type {Writable} from 'type-fest'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -212,7 +212,7 @@ export default function linkTo(navigation: NavigationContainerRef> = { SCREENS.SETTINGS.PROFILE.PRONOUNS, SCREENS.SETTINGS.PROFILE.TIMEZONE, SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY, + SCREENS.SETTINGS.PROFILE.LEGAL_NAME, + SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH, + SCREENS.SETTINGS.PROFILE.ADDRESS, + SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY, ], [SCREENS.SETTINGS.PREFERENCES.ROOT]: [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE, SCREENS.SETTINGS.PREFERENCES.LANGUAGE, SCREENS.SETTINGS.PREFERENCES.THEME], [SCREENS.SETTINGS.WALLET.ROOT]: [ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 6f96642953af..12577e360784 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -190,24 +190,20 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_NEW_CONTACT_METHOD.route, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS, + [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: { + path: ROUTES.SETTINGS_LEGAL_NAME, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME, + [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: { + path: ROUTES.SETTINGS_DATE_OF_BIRTH, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH, + [SCREENS.SETTINGS.PROFILE.ADDRESS]: { + path: ROUTES.SETTINGS_ADDRESS, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS, - exact: true, - }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.route, + [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: { + path: ROUTES.SETTINGS_ADDRESS_COUNTRY.route, exact: true, }, [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: { @@ -313,6 +309,13 @@ const config: LinkingOptions['config'] = { [SCREENS.NEW_TASK.DESCRIPTION]: ROUTES.NEW_TASK_DESCRIPTION, }, }, + [SCREENS.RIGHT_MODAL.ONBOARD_ENGAGEMENT]: { + screens: { + [SCREENS.ONBOARD_ENGAGEMENT.ROOT]: ROUTES.ONBOARD, + [SCREENS.ONBOARD_ENGAGEMENT.MANAGE_TEAMS_EXPENSES]: ROUTES.ONBOARD_MANAGE_EXPENSES, + [SCREENS.ONBOARD_ENGAGEMENT.EXPENSIFY_CLASSIC]: ROUTES.ONBOARD_EXPENSIFY_CLASSIC, + }, + }, [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: { screens: { [SCREENS.SAVE_THE_WORLD.ROOT]: ROUTES.TEACHERS_UNITE, @@ -404,7 +407,6 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route, [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route, - [SCREENS.MONEY_REQUEST.DESCRIPTION]: ROUTES.MONEY_REQUEST_DESCRIPTION.route, [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route, [SCREENS.MONEY_REQUEST.MERCHANT]: ROUTES.MONEY_REQUEST_MERCHANT.route, [SCREENS.MONEY_REQUEST.RECEIPT]: ROUTES.MONEY_REQUEST_RECEIPT.route, diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 06e58282da70..8e246d82ff72 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -1,7 +1,7 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; import {getStateFromPath} from '@react-navigation/native'; import {isAnonymousUser} from '@libs/actions/Session'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import getTopmostNestedRHPRoute from '@libs/Navigation/getTopmostNestedRHPRoute'; import type {BottomTabName, CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; @@ -132,7 +132,7 @@ function getMatchingRootRouteForRHPRoute( } function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType { - const isSmallScreenWidth = getIsSmallScreenWidth(); + const isNarrowLayout = getIsNarrowLayout(); const metainfo = { isCentralPaneAndBottomTabMandatory: true, isFullScreenNavigatorMandatory: true, @@ -194,7 +194,7 @@ function getAdaptedState(state: PartialState policyID, ), ); - if (!isSmallScreenWidth) { + if (!isNarrowLayout) { routes.push( createCentralPaneNavigator({ name: SCREENS.REPORT, @@ -226,7 +226,7 @@ function getAdaptedState(state: PartialState policyID, ), ); - if (!isSmallScreenWidth) { + if (!isNarrowLayout) { routes.push(createCentralPaneNavigator({name: SCREENS.REPORT})); } routes.push(fullScreenNavigator); @@ -254,7 +254,7 @@ function getAdaptedState(state: PartialState // Routes // - found bottom tab // - matching central pane on desktop layout - if (isSmallScreenWidth) { + if (isNarrowLayout) { return { adaptedState: state, metainfo, diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts index 4b9259c76ad7..9afb325eee99 100644 --- a/src/libs/Navigation/switchPolicyID.ts +++ b/src/libs/Navigation/switchPolicyID.ts @@ -2,7 +2,7 @@ import {getActionFromState} from '@react-navigation/core'; import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import {getPathFromState} from '@react-navigation/native'; import type {ValueOf, Writable} from 'type-fest'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import type {Route} from '@src/ROUTES'; @@ -95,7 +95,7 @@ export default function switchPolicyID(navigation: NavigationContainerRef; + iouType: ValueOf; + transactionID: string; reportID: string; - field: string; - threadReportID: string; + backTo: string; }; [SCREENS.MONEY_REQUEST.CATEGORY]: { iouType: string; @@ -278,6 +278,12 @@ type NewTaskNavigatorParamList = { [SCREENS.NEW_TASK.DESCRIPTION]: undefined; }; +type OnboardEngagementNavigatorParamList = { + [SCREENS.ONBOARD_ENGAGEMENT.ROOT]: undefined; + [SCREENS.ONBOARD_ENGAGEMENT.MANAGE_TEAMS_EXPENSES]: undefined; + [SCREENS.ONBOARD_ENGAGEMENT.EXPENSIFY_CLASSIC]: undefined; +}; + type TeachersUniteNavigatorParamList = { [SCREENS.SAVE_THE_WORLD.ROOT]: undefined; [SCREENS.I_KNOW_A_TEACHER]: undefined; @@ -374,6 +380,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.ROOM_INVITE]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.NEW_TASK]: NavigatorScreenParams; + [SCREENS.RIGHT_MODAL.ONBOARD_ENGAGEMENT]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TASK_DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.ENABLE_PAYMENTS]: NavigatorScreenParams; @@ -522,5 +529,6 @@ export type { ReimbursementAccountNavigatorParamList, State, WorkspaceSwitcherNavigatorParamList, + OnboardEngagementNavigatorParamList, SwitchPolicyIDParams, }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index d9e57f5e3bb8..0a7a2d40e0f6 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -204,6 +204,21 @@ function isPaidGroupPolicy(policy: OnyxEntry | EmptyObject): boolean { return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE; } +/** + * Checks if policy's scheduled submit / auto reporting frequency is "instant". + * Note: Free policies have "instant" submit always enabled. + */ +function isInstantSubmitEnabled(policy: OnyxEntry): boolean { + return policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT || policy?.type === CONST.POLICY.TYPE.FREE; +} + +/** + * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" + */ +function isSubmitAndClose(policy: OnyxEntry): boolean { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; +} + function extractPolicyIDFromPath(path: string) { return path.match(CONST.REGEX.POLICY_ID_FROM_PATH)?.[1]; } @@ -232,7 +247,9 @@ export { shouldShowPolicy, isExpensifyTeam, isExpensifyGuideTeam, + isInstantSubmitEnabled, isPolicyAdmin, + isSubmitAndClose, getMemberAccountIDsForWorkspace, getIneligibleInvitees, getTag, diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index f31a1aa811a0..36479136c6ad 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -11,6 +11,7 @@ import ROUTES from '@src/ROUTES'; import type {Transaction} from '@src/types/onyx'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import * as FileUtils from './fileDownload/FileUtils'; +import * as TransactionUtils from './TransactionUtils'; type ThumbnailAndImageURI = { image: ImageSourcePropType | string; @@ -33,7 +34,7 @@ type FileNameAndExtension = { * @param receiptFileName */ function getThumbnailAndImageURIs(transaction: OnyxEntry, receiptPath: string | null = null, receiptFileName: string | null = null): ThumbnailAndImageURI { - if (Object.hasOwn(transaction?.pendingFields ?? {}, 'waypoints')) { + if (TransactionUtils.isFetchingWaypointsFromServer(transaction)) { return {thumbnail: null, image: ReceiptGeneric, isLocalFile: true}; } // URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 64d79a3cd812..ba78d8a2cc38 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -172,8 +172,8 @@ type ReportRouteParams = { }; type ReportOfflinePendingActionAndErrors = { - addWorkspaceRoomOrChatPendingAction: PendingAction | undefined; - addWorkspaceRoomOrChatErrors: Errors | null | undefined; + reportPendingAction: PendingAction | undefined; + reportErrors: Errors | null | undefined; }; type OptimisticApprovedReportAction = Pick< @@ -1096,10 +1096,9 @@ function hasSingleParticipant(report: OnyxEntry): boolean { } /** - * Checks whether all the transactions linked to the IOU report are of the Distance Request type - * + * Checks whether all the transactions linked to the IOU report are of the Distance Request type with pending routes */ -function hasOnlyDistanceRequestTransactions(iouReportID: string | undefined): boolean { +function hasOnlyTransactionsWithPendingRoutes(iouReportID: string | undefined): boolean { const transactions = TransactionUtils.getAllReportTransactions(iouReportID); // Early return false in case not having any transaction @@ -1107,7 +1106,7 @@ function hasOnlyDistanceRequestTransactions(iouReportID: string | undefined): bo return false; } - return transactions.every((transaction) => TransactionUtils.isDistanceRequest(transaction)); + return transactions.every((transaction) => TransactionUtils.isFetchingWaypointsFromServer(transaction)); } /** @@ -1227,6 +1226,7 @@ function canDeleteReportAction(reportAction: OnyxEntry, reportID: const report = getReport(reportID); const isActionOwner = reportAction?.actorAccountID === currentUserAccountID; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`] ?? null; if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) { // For now, users cannot delete split actions @@ -1237,6 +1237,10 @@ function canDeleteReportAction(reportAction: OnyxEntry, reportID: } if (isActionOwner) { + if (!isEmptyObject(report) && isPaidGroupPolicyExpenseReport(report)) { + // If it's a paid policy expense report, only allow deleting the request if it's not submitted or the user is the policy admin + return isDraftExpenseReport(report) || PolicyUtils.isPolicyAdmin(policy); + } return true; } } @@ -1250,7 +1254,6 @@ function canDeleteReportAction(reportAction: OnyxEntry, reportID: return false; } - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN && !isEmptyObject(report) && !isDM(report); return isActionOwner || isAdmin; @@ -1945,7 +1948,7 @@ function getMoneyRequestReportName(report: OnyxEntry, policy: OnyxEntry< } const moneyRequestTotal = getMoneyRequestSpendBreakdown(report).totalDisplaySpend; - const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency, hasOnlyDistanceRequestTransactions(report?.reportID)); + const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report?.currency); const payerOrApproverName = isExpenseReport(report) && !hasNonReimbursableTransactions(report?.reportID ?? '') ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report?.managerID) ?? ''; const payerPaidAmountMessage = Localize.translateLocal('iou.payerPaidAmount', { @@ -2186,6 +2189,11 @@ function getTransactionReportName(reportAction: OnyxEntry): string // Transaction data might be empty on app's first load, if so we fallback to Request return Localize.translateLocal('iou.request'); } + + if (TransactionUtils.isFetchingWaypointsFromServer(transaction)) { + return Localize.translateLocal('iou.routePending'); + } + if (TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction)) { return Localize.translateLocal('iou.receiptScanning'); } @@ -2197,7 +2205,7 @@ function getTransactionReportName(reportAction: OnyxEntry): string const transactionDetails = getTransactionDetails(transaction); return Localize.translateLocal(ReportActionsUtils.isSentMoneyReportAction(reportAction) ? 'iou.threadSentMoneyReportName' : 'iou.threadRequestReportName', { - formattedAmount: CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency, TransactionUtils.isDistanceRequest(transaction)) ?? '', + formattedAmount: CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency) ?? '', comment: (!TransactionUtils.isMerchantMissing(transaction) ? transactionDetails?.merchant : transactionDetails?.comment) ?? '', }); } @@ -2210,7 +2218,7 @@ function getTransactionReportName(reportAction: OnyxEntry): string function getReportPreviewMessage( report: OnyxEntry | EmptyObject, reportAction: OnyxEntry | EmptyObject = {}, - shouldConsiderReceiptBeingScanned = false, + shouldConsiderScanningReceiptOrPendingRoute = false, isPreviewMessageForParentChatReport = false, policy: OnyxEntry = null, isForListPreview = false, @@ -2260,7 +2268,7 @@ function getReportPreviewMessage( } let linkedTransaction; - if (!isEmptyObject(reportAction) && shouldConsiderReceiptBeingScanned && reportAction && ReportActionsUtils.isMoneyRequestAction(reportAction)) { + if (!isEmptyObject(reportAction) && shouldConsiderScanningReceiptOrPendingRoute && reportAction && ReportActionsUtils.isMoneyRequestAction(reportAction)) { linkedTransaction = TransactionUtils.getLinkedTransaction(reportAction); } @@ -2268,6 +2276,10 @@ function getReportPreviewMessage( return Localize.translateLocal('iou.receiptScanning'); } + if (!isEmptyObject(linkedTransaction) && TransactionUtils.isFetchingWaypointsFromServer(linkedTransaction) && !TransactionUtils.getAmount(linkedTransaction)) { + return Localize.translateLocal('iou.routePending'); + } + const originalMessage = reportAction?.originalMessage as IOUMessage | undefined; // Show Paid preview message if it's settled or if the amount is paid & stuck at receivers end for only chat reports. @@ -4232,14 +4244,20 @@ function getOriginalReportID(reportID: string, reportAction: OnyxEntry): ReportOfflinePendingActionAndErrors { - // We are either adding a workspace room, or we're creating a chat, it isn't possible for both of these to be pending, or to have errors for the same report at the same time, so - // simply looking up the first truthy value for each case will get the relevant property if it's set. - const addWorkspaceRoomOrChatPendingAction = report?.pendingFields?.addWorkspaceRoom ?? report?.pendingFields?.createChat; - const addWorkspaceRoomOrChatErrors = getAddWorkspaceRoomOrChatReportErrors(report); - return {addWorkspaceRoomOrChatPendingAction, addWorkspaceRoomOrChatErrors}; + // It shouldn't be possible for all of these actions to be pending (or to have errors) for the same report at the same time, so just take the first that exists + const reportPendingAction = report?.pendingFields?.addWorkspaceRoom ?? report?.pendingFields?.createChat ?? report?.pendingFields?.reimbursed; + + const reportErrors = getAddWorkspaceRoomOrChatReportErrors(report); + return {reportPendingAction, reportErrors}; } function getPolicyExpenseChatReportIDByOwner(policyOwner: string): string | null { @@ -4933,7 +4951,7 @@ export { buildTransactionThread, areAllRequestsBeingSmartScanned, getTransactionsWithReceipts, - hasOnlyDistanceRequestTransactions, + hasOnlyTransactionsWithPendingRoutes, hasNonReimbursableTransactions, hasMissingSmartscanFields, getIOUReportActionDisplayMessage, diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 8a814f311481..454b85cc3152 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -262,8 +262,8 @@ function getDescription(transaction: OnyxEntry): string { /** * Return the amount field from the transaction, return the modifiedAmount if present. */ -function getAmount(transaction: OnyxEntry, isFromExpenseReport?: boolean): number { - // IOU requests cannot have negative values but they can be stored as negative values, let's return absolute value +function getAmount(transaction: OnyxEntry, isFromExpenseReport = false): number { + // IOU requests cannot have negative values, but they can be stored as negative values, let's return absolute value if (!isFromExpenseReport) { const amount = transaction?.modifiedAmount ?? 0; if (amount) { @@ -311,6 +311,13 @@ function getOriginalAmount(transaction: Transaction): number { return Math.abs(amount); } +/** + * Verify if the transaction is expecting the distance to be calculated on the server + */ +function isFetchingWaypointsFromServer(transaction: OnyxEntry): boolean { + return !!transaction?.pendingFields?.waypoints; +} + /** * Return the merchant field from the transaction, return the modifiedMerchant if present. */ @@ -577,6 +584,7 @@ export { isReceiptBeingScanned, getValidWaypoints, isDistanceRequest, + isFetchingWaypointsFromServer, isExpensifyCardTransaction, isCardTransaction, isPending, diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 38a421409ade..2cc32616562d 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -3,7 +3,6 @@ import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; import type {ActivatePhysicalExpensifyCardParams, ReportVirtualExpensifyCardFraudParams, RequestReplacementExpensifyCardParams, RevealExpensifyCardDetailsParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import * as Localize from '@libs/Localize'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Response} from '@src/types/onyx'; @@ -167,12 +166,14 @@ function revealVirtualCardDetails(cardID: number): Promise { API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_EXPENSIFY_CARD_DETAILS, parameters) .then((response) => { if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - reject(Localize.translateLocal('cardPage.cardDetailsLoadingFailure')); + // eslint-disable-next-line prefer-promise-reject-errors + reject('cardPage.cardDetailsLoadingFailure'); return; } resolve(response); }) - .catch(() => reject(Localize.translateLocal('cardPage.cardDetailsLoadingFailure'))); + // eslint-disable-next-line prefer-promise-reject-errors + .catch(() => reject('cardPage.cardDetailsLoadingFailure')); }); } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index dd118c36a8a1..e16f879b6913 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -49,7 +49,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Participant, Split} from '@src/types/onyx/IOU'; -import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; +import type {ErrorFields, Errors, PendingFields} from '@src/types/onyx/OnyxCommon'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -286,9 +286,8 @@ function setMoneyRequestOriginalCurrency_temporaryForRefactor(transactionID: str Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {originalCurrency}); } -// eslint-disable-next-line @typescript-eslint/naming-convention -function setMoneyRequestDescription_temporaryForRefactor(transactionID: string, comment: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {comment: comment.trim()}}); +function setMoneyRequestDescription(transactionID: string, comment: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {comment: comment.trim()}}); } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -296,6 +295,10 @@ function setMoneyRequestMerchant_temporaryForRefactor(transactionID: string, mer Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {merchant: merchant.trim()}); } +function setMoneyRequestPendingFields(transactionID: string, pendingFields: PendingFields) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields}); +} + // eslint-disable-next-line @typescript-eslint/naming-convention function setMoneyRequestCategory_temporaryForRefactor(transactionID: string, category: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category}); @@ -996,7 +999,7 @@ function getUpdateMoneyRequestParams( const iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); - const updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) : null; + let updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) : null; const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); if (transactionDetails?.waypoints) { @@ -1014,12 +1017,41 @@ function getUpdateMoneyRequestParams( transactionID, }; + const hasPendingWaypoints = 'waypoints' in transactionChanges; + if (transaction && updatedTransaction && hasPendingWaypoints) { + updatedTransaction = { + ...updatedTransaction, + amount: CONST.IOU.DEFAULT_AMOUNT, + modifiedAmount: CONST.IOU.DEFAULT_AMOUNT, + modifiedMerchant: Localize.translateLocal('iou.routePending'), + }; + + // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors + successData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + value: null, + }); + + // Revert the transaction's amount to the original value on failure. + // The IOU Report will be fully reverted in the failureData further below. + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + amount: transaction.amount, + modifiedAmount: transaction.modifiedAmount, + modifiedMerchant: transaction.modifiedMerchant, + }, + }); + } + // Step 3: Build the modified expense report actions // We don't create a modified report action if we're updating the waypoints, // since there isn't actually any optimistic data we can create for them and the report action is created on the server // with the response from the MapBox API - if (!('waypoints' in transactionChanges)) { - const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport); + const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport); + if (!hasPendingWaypoints) { params.reportActionID = updatedReportAction.reportActionID; optimisticData.push({ @@ -1046,33 +1078,33 @@ function getUpdateMoneyRequestParams( }, }, }); + } - // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. - // Should only update if the transaction matches the currency of the report, else we wait for the update - // from the server with the currency conversion - let updatedMoneyRequestReport = {...iouReport}; - if (updatedTransaction?.currency === iouReport?.currency && updatedTransaction?.modifiedAmount) { - const diff = TransactionUtils.getAmount(transaction, true) - TransactionUtils.getAmount(updatedTransaction, true); - if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { - updatedMoneyRequestReport.total += diff; - } else { - updatedMoneyRequestReport = iouReport - ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false) - : {}; - } - - updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedTransaction.currency); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: updatedMoneyRequestReport, - }); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {pendingAction: null}, - }); + // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. + // Should only update if the transaction matches the currency of the report, else we wait for the update + // from the server with the currency conversion + let updatedMoneyRequestReport = {...iouReport}; + if ((hasPendingWaypoints || updatedTransaction?.modifiedAmount) && updatedTransaction?.currency === iouReport?.currency) { + const diff = TransactionUtils.getAmount(transaction, true) - TransactionUtils.getAmount(updatedTransaction, true); + if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { + updatedMoneyRequestReport.total += diff; + } else { + updatedMoneyRequestReport = iouReport + ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false) + : {}; } + + updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedTransaction?.currency); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: updatedMoneyRequestReport, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null}, + }); } // Optimistically modify the transaction @@ -1082,7 +1114,7 @@ function getUpdateMoneyRequestParams( value: { ...updatedTransaction, pendingFields, - isLoading: 'waypoints' in transactionChanges, + isLoading: hasPendingWaypoints, errorFields: null, }, }); @@ -1145,15 +1177,6 @@ function getUpdateMoneyRequestParams( }, }); - if ('waypoints' in transactionChanges) { - // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors - successData.push({ - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, - value: null, - }); - } - // Clear out loading states, pending fields, and add the error fields failureData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -1166,7 +1189,7 @@ function getUpdateMoneyRequestParams( }); if (iouReport) { - // Reset the iouReport to it's original state + // Reset the iouReport to its original state failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, @@ -3140,6 +3163,10 @@ function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxT lastMessageHtml: optimisticIOUReportAction.message?.[0].html, hasOutstandingChildRequest: false, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + pendingFields: { + preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, }, }, { @@ -3159,6 +3186,16 @@ function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxT }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: { + preview: null, + reimbursed: null, + }, + }, + }, ]; const failureData: OnyxUpdate[] = [ @@ -3554,10 +3591,6 @@ function setMoneyRequestCurrency(currency: string) { Onyx.merge(ONYXKEYS.IOU, {currency}); } -function setMoneyRequestDescription(comment: string) { - Onyx.merge(ONYXKEYS.IOU, {comment: comment.trim()}); -} - function setMoneyRequestMerchant(merchant: string) { Onyx.merge(ONYXKEYS.IOU, {merchant: merchant.trim()}); } @@ -3698,17 +3731,17 @@ export { setMoneyRequestCategory_temporaryForRefactor, setMoneyRequestCreated_temporaryForRefactor, setMoneyRequestCurrency_temporaryForRefactor, + setMoneyRequestDescription, setMoneyRequestOriginalCurrency_temporaryForRefactor, - setMoneyRequestDescription_temporaryForRefactor, setMoneyRequestMerchant_temporaryForRefactor, setMoneyRequestParticipants_temporaryForRefactor, + setMoneyRequestPendingFields, setMoneyRequestReceipt, setMoneyRequestAmount, setMoneyRequestBillable, setMoneyRequestCategory, setMoneyRequestCreated, setMoneyRequestCurrency, - setMoneyRequestDescription, setMoneyRequestId, setMoneyRequestMerchant, setMoneyRequestParticipantsFromReport, diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 26c8937de3aa..53491b386b8c 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -112,7 +112,7 @@ function updateLegalName(legalFirstName: string, legalLastName: string) { ], }); - Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); + Navigation.goBack(); } /** @@ -133,7 +133,7 @@ function updateDateOfBirth({dob}: DateOfBirthForm) { ], }); - Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); + Navigation.goBack(); } function updateAddress(street: string, street2: string, city: string, state: string, zip: string, country: string) { @@ -170,7 +170,7 @@ function updateAddress(street: string, street2: string, city: string, state: str ], }); - Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); + Navigation.goBack(); } /** @@ -241,7 +241,7 @@ function updateSelectedTimezone(selectedTimezone: SelectedTimezone) { /** * Fetches additional personal data like legal name, date of birth, address */ -function openPersonalDetailsPage() { +function openPersonalDetails() { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -272,7 +272,7 @@ function openPersonalDetailsPage() { }, ]; - API.read(READ_COMMANDS.OPEN_PERSONAL_DETAILS_PAGE, {}, {optimisticData, successData, failureData}); + API.read(READ_COMMANDS.OPEN_PERSONAL_DETAILS, {}, {optimisticData, successData, failureData}); } /** @@ -455,7 +455,7 @@ export { clearAvatarErrors, deleteAvatar, getPrivatePersonalDetails, - openPersonalDetailsPage, + openPersonalDetails, openPublicProfilePage, updateAddress, updateAutomaticTimezone, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 5b4fb8160894..2d13624277f0 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2529,7 +2529,7 @@ const updatePrivateNotes = (reportID: string, accountID: number, note: string) = value: { privateNotes: { [accountID]: { - errors: ErrorUtils.getMicroSecondOnyxError("Private notes couldn't be saved"), + errors: ErrorUtils.getMicroSecondOnyxError('privateNotes.error.genericFailureMessage'), }, }, }, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 4fbeba0abaa6..d000d5ebfbec 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -607,7 +607,7 @@ function clearAccountMessages() { } function setAccountError(error: string) { - Onyx.merge(ONYXKEYS.ACCOUNT, {errors: ErrorUtils.getMicroSecondOnyxError(error)}); + Onyx.merge(ONYXKEYS.ACCOUNT, {errors: ErrorUtils.getMicroSecondOnyxError(error, true)}); } // It's necessary to throttle requests to reauthenticate since calling this multiple times will cause Pusher to diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 03c5d18aabb4..1d9af01f2fa0 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -66,7 +66,9 @@ function saveWaypoint(transactionID: string, index: string, waypoint: RecentWayp [`waypoint${index}`]: waypoint, }, }, - amount: CONST.IOU.DEFAULT_AMOUNT, + // We want to reset the amount only for draft transactions (when creating the request). + // When modifying an existing transaction, the amount will be updated on the actual IOU update operation. + ...(isDraft && {amount: CONST.IOU.DEFAULT_AMOUNT}), // Empty out errors when we're saving a new waypoint as this indicates the user is updating their input errorFields: { route: null, @@ -137,7 +139,9 @@ function removeWaypoint(transaction: Transaction, currentIndex: string, isDraft: ...transaction.comment, waypoints: reIndexedWaypoints, }, - amount: CONST.IOU.DEFAULT_AMOUNT, + // We want to reset the amount only for draft transactions (when creating the request). + // When modifying an existing transaction, the amount will be updated on the actual IOU update operation. + ...(isDraft && {amount: CONST.IOU.DEFAULT_AMOUNT}), }; if (!isRemovedWaypointEmpty) { @@ -247,7 +251,9 @@ function updateWaypoints(transactionID: string, waypoints: WaypointCollection, i comment: { waypoints, }, - amount: CONST.IOU.DEFAULT_AMOUNT, + // We want to reset the amount only for draft transactions (when creating the request). + // When modifying an existing transaction, the amount will be updated on the actual IOU update operation. + ...(isDraft && {amount: CONST.IOU.DEFAULT_AMOUNT}), // Empty out errors when we're saving new waypoints as this indicates the user is updating their input errorFields: { route: null, diff --git a/src/libs/checkForUpdates.ts b/src/libs/checkForUpdates.ts index 51ce12335e29..532253d7494f 100644 --- a/src/libs/checkForUpdates.ts +++ b/src/libs/checkForUpdates.ts @@ -1,9 +1,6 @@ -const UPDATE_INTERVAL = 1000 * 60 * 60 * 8; +import type PlatformSpecificUpdater from '@src/setup/platformSetup/types'; -type PlatformSpecificUpdater = { - update: () => void; - init?: () => void; -}; +const UPDATE_INTERVAL = 1000 * 60 * 60 * 8; function checkForUpdates(platformSpecificUpdater: PlatformSpecificUpdater) { if (typeof platformSpecificUpdater.init === 'function') { @@ -16,4 +13,4 @@ function checkForUpdates(platformSpecificUpdater: PlatformSpecificUpdater) { }, UPDATE_INTERVAL); } -module.exports = checkForUpdates; +export default checkForUpdates; diff --git a/src/libs/getIsNarrowLayout/index.native.ts b/src/libs/getIsNarrowLayout/index.native.ts new file mode 100644 index 000000000000..c43130a63b2a --- /dev/null +++ b/src/libs/getIsNarrowLayout/index.native.ts @@ -0,0 +1,3 @@ +export default function getIsNarrowLayout() { + return true; +} diff --git a/src/libs/getIsNarrowLayout/index.ts b/src/libs/getIsNarrowLayout/index.ts new file mode 100644 index 000000000000..e901f04e7d26 --- /dev/null +++ b/src/libs/getIsNarrowLayout/index.ts @@ -0,0 +1,5 @@ +import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; + +export default function getIsNarrowLayout() { + return getIsSmallScreenWidth(); +} diff --git a/src/pages/EditRequestDescriptionPage.js b/src/pages/EditRequestDescriptionPage.js deleted file mode 100644 index 9b2a9e465746..000000000000 --- a/src/pages/EditRequestDescriptionPage.js +++ /dev/null @@ -1,91 +0,0 @@ -import {useFocusEffect} from '@react-navigation/native'; -import PropTypes from 'prop-types'; -import React, {useCallback, useRef} from 'react'; -import {View} from 'react-native'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapperWithRef from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import TextInput from '@components/TextInput'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as Browser from '@libs/Browser'; -import updateMultilineInputRange from '@libs/updateMultilineInputRange'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const propTypes = { - /** Transaction default description value */ - defaultDescription: PropTypes.string.isRequired, - - /** Callback to fire when the Save button is pressed */ - onSubmit: PropTypes.func.isRequired, -}; - -function EditRequestDescriptionPage({defaultDescription, onSubmit}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const descriptionInputRef = useRef(null); - const focusTimeoutRef = useRef(null); - - useFocusEffect( - useCallback(() => { - focusTimeoutRef.current = setTimeout(() => { - if (descriptionInputRef.current) { - descriptionInputRef.current.focus(); - } - return () => { - if (!focusTimeoutRef.current) { - return; - } - clearTimeout(focusTimeoutRef.current); - }; - }, CONST.ANIMATED_TRANSITION); - }, []), - ); - - return ( - - - - - { - if (!el) { - return; - } - descriptionInputRef.current = el; - updateMultilineInputRange(descriptionInputRef.current); - }} - autoGrowHeight - containerStyles={[styles.autoGrowHeightMultilineInput]} - submitOnEnter={!Browser.isMobile()} - /> - - - - ); -} - -EditRequestDescriptionPage.propTypes = propTypes; -EditRequestDescriptionPage.displayName = 'EditRequestDescriptionPage'; - -export default EditRequestDescriptionPage; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 3eb9d88f1120..7e1a9f7d9b7b 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -22,7 +22,6 @@ import ROUTES from '@src/ROUTES'; import EditRequestAmountPage from './EditRequestAmountPage'; import EditRequestCategoryPage from './EditRequestCategoryPage'; import EditRequestCreatedPage from './EditRequestCreatedPage'; -import EditRequestDescriptionPage from './EditRequestDescriptionPage'; import EditRequestDistancePage from './EditRequestDistancePage'; import EditRequestMerchantPage from './EditRequestMerchantPage'; import EditRequestReceiptPage from './EditRequestReceiptPage'; @@ -74,7 +73,6 @@ function EditRequestPage({report, route, policyCategories, policyTags, parentRep const { amount: transactionAmount, currency: transactionCurrency, - comment: transactionDescription, merchant: transactionMerchant, category: transactionCategory, tag: transactionTag, @@ -180,26 +178,6 @@ function EditRequestPage({report, route, policyCategories, policyTags, parentRep [transactionCategory, transaction.transactionID, report.reportID], ); - const saveComment = useCallback( - ({comment: newComment}) => { - // Only update comment if it has changed - if (newComment.trim() !== transactionDescription) { - IOU.updateMoneyRequestDescription(transaction.transactionID, report.reportID, newComment.trim()); - } - Navigation.dismissModal(); - }, - [transactionDescription, transaction.transactionID, report.reportID], - ); - - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) { - return ( - - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { return ( { - setDraftSplitTransaction({ - comment: transactionChanges.comment.trim(), - }); - }} - /> - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { return ( diff --git a/src/pages/FlagCommentPage.js b/src/pages/FlagCommentPage.js deleted file mode 100644 index 47d2ad356dad..000000000000 --- a/src/pages/FlagCommentPage.js +++ /dev/null @@ -1,209 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useCallback} from 'react'; -import {ScrollView, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Expensicons from '@components/Icon/Expensicons'; -import MenuItem from '@components/MenuItem'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as Report from '@userActions/Report'; -import * as Session from '@userActions/Session'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import reportActionPropTypes from './home/report/reportActionPropTypes'; -import withReportAndReportActionOrNotFound from './home/report/withReportAndReportActionOrNotFound'; -import reportPropTypes from './reportPropTypes'; - -const propTypes = { - /** Array of report actions for this report */ - reportActions: PropTypes.shape(reportActionPropTypes), - - /** The active report */ - report: reportPropTypes, - - /** Route params */ - route: PropTypes.shape({ - params: PropTypes.shape({ - /** Report ID passed via route r/:reportID/:reportActionID */ - reportID: PropTypes.string, - - /** ReportActionID passed via route r/:reportID/:reportActionID */ - reportActionID: PropTypes.string, - }), - }).isRequired, - - ...withLocalizePropTypes, - - /* Onyx Props */ - /** All the report actions from the parent report */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), -}; - -const defaultProps = { - reportActions: {}, - parentReportActions: {}, - report: {}, -}; - -/** - * Get the reportID for the associated chatReport - * - * @param {Object} route - * @param {Object} route.params - * @param {String} route.params.reportID - * @returns {String} - */ -function getReportID(route) { - return route.params.reportID.toString(); -} - -function FlagCommentPage(props) { - const styles = useThemeStyles(); - const severities = [ - { - severity: CONST.MODERATION.FLAG_SEVERITY_SPAM, - name: props.translate('moderation.spam'), - icon: Expensicons.FlagLevelOne, - description: props.translate('moderation.spamDescription'), - furtherDetails: props.translate('moderation.levelOneResult'), - furtherDetailsIcon: Expensicons.FlagLevelOne, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_INCONSIDERATE, - name: props.translate('moderation.inconsiderate'), - icon: Expensicons.FlagLevelOne, - description: props.translate('moderation.inconsiderateDescription'), - furtherDetails: props.translate('moderation.levelOneResult'), - furtherDetailsIcon: Expensicons.FlagLevelOne, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_INTIMIDATION, - name: props.translate('moderation.intimidation'), - icon: Expensicons.FlagLevelTwo, - description: props.translate('moderation.intimidationDescription'), - furtherDetails: props.translate('moderation.levelTwoResult'), - furtherDetailsIcon: Expensicons.FlagLevelTwo, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_BULLYING, - name: props.translate('moderation.bullying'), - icon: Expensicons.FlagLevelTwo, - description: props.translate('moderation.bullyingDescription'), - furtherDetails: props.translate('moderation.levelTwoResult'), - furtherDetailsIcon: Expensicons.FlagLevelTwo, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_HARASSMENT, - name: props.translate('moderation.harassment'), - icon: Expensicons.FlagLevelThree, - description: props.translate('moderation.harassmentDescription'), - furtherDetails: props.translate('moderation.levelThreeResult'), - furtherDetailsIcon: Expensicons.FlagLevelThree, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_ASSAULT, - name: props.translate('moderation.assault'), - icon: Expensicons.FlagLevelThree, - description: props.translate('moderation.assaultDescription'), - furtherDetails: props.translate('moderation.levelThreeResult'), - furtherDetailsIcon: Expensicons.FlagLevelThree, - }, - ]; - - const getActionToFlag = useCallback(() => { - let reportAction = props.reportActions[`${props.route.params.reportActionID.toString()}`]; - - // Handle threads if needed - if (reportAction === undefined || reportAction.reportActionID === undefined) { - reportAction = props.parentReportActions[props.report.parentReportActionID] || {}; - } - - return reportAction; - }, [props.report, props.reportActions, props.route.params.reportActionID, props.parentReportActions]); - - const flagComment = (severity) => { - let reportID = getReportID(props.route); - const reportAction = getActionToFlag(); - const parentReportAction = props.parentReportActions[props.report.parentReportActionID] || {}; - - // Handle threads if needed - if (ReportUtils.isChatThread(props.report) && reportAction.reportActionID === parentReportAction.reportActionID) { - reportID = ReportUtils.getParentReport(props.report).reportID; - } - - if (ReportUtils.canFlagReportAction(reportAction, reportID)) { - Report.flagComment(reportID, reportAction, severity); - } - - Navigation.dismissModal(); - }; - - const severityMenuItems = _.map(severities, (item, index) => ( - flagComment(item.severity))} - style={[styles.pt2, styles.pb4, styles.ph5, styles.flexRow]} - furtherDetails={item.furtherDetails} - furtherDetailsIcon={item.furtherDetailsIcon} - /> - )); - - return ( - - {({safeAreaPaddingBottomStyle}) => ( - - { - Navigation.goBack(); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.report.reportID)); - }} - /> - - - - {props.translate('moderation.flagDescription')} - - - {props.translate('moderation.chooseAReason')} - {severityMenuItems} - - - )} - - ); -} - -FlagCommentPage.propTypes = propTypes; -FlagCommentPage.defaultProps = defaultProps; -FlagCommentPage.displayName = 'FlagCommentPage'; - -export default compose( - withLocalize, - withReportAndReportActionOrNotFound, - withOnyx({ - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID || report.reportID}`, - canEvict: false, - }, - }), -)(FlagCommentPage); diff --git a/src/pages/FlagCommentPage.tsx b/src/pages/FlagCommentPage.tsx new file mode 100644 index 000000000000..00c38dabc4ec --- /dev/null +++ b/src/pages/FlagCommentPage.tsx @@ -0,0 +1,190 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback} from 'react'; +import {ScrollView, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {SvgProps} from 'react-native-svg'; +import type {ValueOf} from 'type-fest'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +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 {FlagCommentNavigatorParamList} from '@libs/Navigation/types'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as Report from '@userActions/Report'; +import * as Session from '@userActions/Session'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; +import withReportAndReportActionOrNotFound from './home/report/withReportAndReportActionOrNotFound'; +import type {WithReportAndReportActionOrNotFoundProps} from './home/report/withReportAndReportActionOrNotFound'; + +type FlagCommentPageWithOnyxProps = { + /** The report action from the parent report */ + parentReportAction: OnyxEntry; +}; + +type FlagCommentPageNavigationProps = StackScreenProps; + +type FlagCommentPageProps = FlagCommentPageNavigationProps & WithReportAndReportActionOrNotFoundProps & FlagCommentPageWithOnyxProps; + +type Severity = ValueOf; + +type SeverityItem = { + severity: Severity; + name: string; + icon: React.FC; + description: string; + furtherDetails: string; + furtherDetailsIcon: React.FC; +}; + +type SeverityItemList = SeverityItem[]; + +/** + * Get the reportID for the associated chatReport + */ +function getReportID(route: FlagCommentPageNavigationProps['route']) { + return route.params.reportID.toString(); +} + +function FlagCommentPage({parentReportAction, route, report, reportActions}: FlagCommentPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const severities: SeverityItemList = [ + { + severity: CONST.MODERATION.FLAG_SEVERITY_SPAM, + name: translate('moderation.spam'), + icon: Expensicons.FlagLevelOne, + description: translate('moderation.spamDescription'), + furtherDetails: translate('moderation.levelOneResult'), + furtherDetailsIcon: Expensicons.FlagLevelOne, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_INCONSIDERATE, + name: translate('moderation.inconsiderate'), + icon: Expensicons.FlagLevelOne, + description: translate('moderation.inconsiderateDescription'), + furtherDetails: translate('moderation.levelOneResult'), + furtherDetailsIcon: Expensicons.FlagLevelOne, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_INTIMIDATION, + name: translate('moderation.intimidation'), + icon: Expensicons.FlagLevelTwo, + description: translate('moderation.intimidationDescription'), + furtherDetails: translate('moderation.levelTwoResult'), + furtherDetailsIcon: Expensicons.FlagLevelTwo, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_BULLYING, + name: translate('moderation.bullying'), + icon: Expensicons.FlagLevelTwo, + description: translate('moderation.bullyingDescription'), + furtherDetails: translate('moderation.levelTwoResult'), + furtherDetailsIcon: Expensicons.FlagLevelTwo, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_HARASSMENT, + name: translate('moderation.harassment'), + icon: Expensicons.FlagLevelThree, + description: translate('moderation.harassmentDescription'), + furtherDetails: translate('moderation.levelThreeResult'), + furtherDetailsIcon: Expensicons.FlagLevelThree, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_ASSAULT, + name: translate('moderation.assault'), + icon: Expensicons.FlagLevelThree, + description: translate('moderation.assaultDescription'), + furtherDetails: translate('moderation.levelThreeResult'), + furtherDetailsIcon: Expensicons.FlagLevelThree, + }, + ]; + + const getActionToFlag = useCallback((): OnyxTypes.ReportAction | null => { + let reportAction = reportActions?.[`${route.params.reportActionID.toString()}`]; + + // Handle threads if needed + if (reportAction?.reportActionID === undefined && parentReportAction) { + reportAction = parentReportAction; + } + + if (!reportAction) { + return null; + } + + return reportAction; + }, [reportActions, route.params.reportActionID, parentReportAction]); + + const flagComment = (severity: Severity) => { + let reportID: string | undefined = getReportID(route); + const reportAction = getActionToFlag(); + + // Handle threads if needed + if (ReportUtils.isChatThread(report) && reportAction?.reportActionID === parentReportAction?.reportActionID) { + reportID = ReportUtils.getParentReport(report)?.reportID; + } + + if (reportAction && ReportUtils.canFlagReportAction(reportAction, reportID)) { + Report.flagComment(reportID ?? '', reportAction, severity); + } + + Navigation.dismissModal(); + }; + + const severityMenuItems = severities.map((item) => ( + flagComment(item.severity))} + style={[styles.pt2, styles.pb4, styles.ph5, styles.flexRow]} + furtherDetails={item.furtherDetails} + furtherDetailsIcon={item.furtherDetailsIcon} + /> + )); + + return ( + + {({safeAreaPaddingBottomStyle}) => ( + + { + Navigation.goBack(); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report?.reportID ?? '')); + }} + /> + + + + {translate('moderation.flagDescription')} + + + {translate('moderation.chooseAReason')} + {severityMenuItems} + + + )} + + ); +} + +FlagCommentPage.displayName = 'FlagCommentPage'; + +export default withReportAndReportActionOrNotFound(FlagCommentPage); diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 44131de01fa6..df95fc0a01b7 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -21,7 +21,6 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import variables from '@styles/variables'; import * as Report from '@userActions/Report'; -import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import personalDetailsPropType from './personalDetailsPropType'; @@ -235,10 +234,6 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i updateOptions(); }, [didScreenTransitionEnd, updateOptions]); - const dismissCallToAction = (referralContentType) => { - User.dismissReferralBanner(referralContentType); - }; - const {inputCallbackRef} = useAutoFocusInput(); return ( @@ -276,9 +271,8 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i shouldShowConfirmButton shouldShowReferralCTA={!dismissedReferralBanners[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]} referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT} - onCallToActionClosed={dismissCallToAction} confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} - textInputAlert={isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''} + textInputAlert={isOffline ? [`${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}`, {isTranslated: true}] : ''} onConfirmSelection={createGroup} textInputLabel={translate('optionsSelector.nameEmailOrPhoneNumber')} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} diff --git a/src/pages/OnboardEngagement/ExpensifyClassicPage.tsx b/src/pages/OnboardEngagement/ExpensifyClassicPage.tsx new file mode 100644 index 000000000000..ad704156435d --- /dev/null +++ b/src/pages/OnboardEngagement/ExpensifyClassicPage.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import HeaderPageLayout from '@components/HeaderPageLayout'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; +import * as Link from '@userActions/Link'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +function ExpensifyClassicModal() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isExtraSmallScreenHeight} = useWindowDimensions(); + const theme = useTheme(); + + const navigateBack = () => { + Navigation.goBack(ROUTES.ONBOARD_MANAGE_EXPENSES); + }; + + const navigateToOldDot = () => { + Link.openOldDotLink(`${CONST.OLDDOT_URLS.INBOX}${CONST.OLDDOT_URLS.DISMMISSED_REASON}`); + }; + + return ( + + } + footer={ +