diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 21a8fa73289a..354b78d437a3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -114,51 +114,6 @@ jobs: env: BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - submitAndroid: - name: Submit Android app for production review - needs: prep - if: ${{ github.ref == 'refs/heads/production' }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - name: Get Android native version - id: getAndroidVersion - run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_OUTPUT" - - - name: Decrypt json w/ Google Play credentials - run: gpg --batch --yes --decrypt --passphrase="${{ secrets.LARGE_SECRET_PASSPHRASE }}" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg - working-directory: android/app - - - name: Submit Android build for review - run: bundle exec fastlane android upload_google_play_production - env: - VERSION: ${{ steps.getAndroidVersion.outputs.VERSION_CODE }} - - - name: Warn deployers if Android production deploy failed - if: ${{ failure() }} - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: "#DB4545", - pretext: ``, - text: `💥 Android production deploy failed. Please manually submit ${{ needs.prep.outputs.APP_VERSION }} in the . 💥`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - android_hybrid: name: Build and deploy Android HybridApp needs: prep @@ -430,12 +385,6 @@ jobs: APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} - - name: Submit build for App Store review - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane ios submit_for_review - env: - VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} - - name: Upload iOS build to Browser Stack if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa" @@ -714,7 +663,7 @@ jobs: name: Post a Slack message when any platform fails to build or deploy runs-on: ubuntu-latest if: ${{ failure() }} - needs: [buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] + needs: [buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] steps: - name: Checkout uses: actions/checkout@v4 @@ -729,21 +678,15 @@ jobs: outputs: IS_AT_LEAST_ONE_PLATFORM_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAtLeastOnePlatform.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }} IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAllPlatforms.outputs.IS_ALL_PLATFORMS_DEPLOYED }} - needs: [buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] + needs: [buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] if: ${{ always() }} steps: - name: Check deployment success on at least one platform id: checkDeploymentSuccessOnAtLeastOnePlatform run: | isAtLeastOnePlatformDeployed="false" - if [ ${{ github.ref }} == 'refs/heads/production' ]; then - if [ "${{ needs.submitAndroid.result }}" == "success" ]; then - isAtLeastOnePlatformDeployed="true" - fi - else - if [ "${{ needs.uploadAndroid.result }}" == "success" ]; then - isAtLeastOnePlatformDeployed="true" - fi + if [ "${{ needs.uploadAndroid.result }}" == "success" ]; then + isAtLeastOnePlatformDeployed="true" fi if [ "${{ needs.iOS.result }}" == "success" ] || \ @@ -768,14 +711,8 @@ jobs: isAllPlatformsDeployed="true" fi - if [ ${{ github.ref }} == 'refs/heads/production' ]; then - if [ "${{ needs.submitAndroid.result }}" != "success" ]; then - isAllPlatformsDeployed="false" - fi - else - if [ "${{ needs.uploadAndroid.result }}" != "success" ]; then - isAllPlatformsDeployed="false" - fi + if [ "${{ needs.uploadAndroid.result }}" != "success" ]; then + isAllPlatformsDeployed="false" fi echo "IS_ALL_PLATFORMS_DEPLOYED=$isAllPlatformsDeployed" >> "$GITHUB_OUTPUT" @@ -923,7 +860,7 @@ jobs: name: Post a Slack message when all platforms deploy successfully runs-on: ubuntu-latest if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} - needs: [prep, buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] steps: - name: 'Announces the deploy in the #announce Slack room' uses: 8398a7/action-slack@v3 @@ -977,11 +914,11 @@ jobs: postGithubComments: uses: ./.github/workflows/postDeployComments.yml if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} - needs: [prep, buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] with: version: ${{ needs.prep.outputs.APP_VERSION }} env: ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} - android: ${{ github.ref == 'refs/heads/production' && needs.submitAndroid.result || needs.uploadAndroid.result }} + android: ${{ github.ref == 'refs/heads/production' || needs.uploadAndroid.result }} android_hybrid: ${{ needs.android_hybrid.result }} ios: ${{ needs.iOS.result }} ios_hybrid: ${{ needs.iOS_hybrid.result }} diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index ed5803c35b42..fb7a34d6fa01 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode @@ -22,6 +24,22 @@ jobs: git config --global user.email "test@test.com" git config --global user.name "Test" + - name: Get common ancestor commit + run: | + git fetch origin main + common_ancestor=$(git merge-base "${{ github.sha }}" origin/main) + echo "COMMIT_HASH=$common_ancestor" >> "$GITHUB_ENV" + + - name: Clean up deleted files + run: | + DELETED_FILES=$(git diff --name-only --diff-filter=D "$COMMIT_HASH" "${{ github.sha }}") + for file in $DELETED_FILES; do + if [ -n "$file" ]; then + rm -f "$file" + echo "Deleted file: $file" + fi + done + - name: Run performance testing script shell: bash run: | diff --git a/.github/workflows/sendReassurePerfData.yml b/.github/workflows/sendReassurePerfData.yml index 6ae528557faf..884182bfc896 100644 --- a/.github/workflows/sendReassurePerfData.yml +++ b/.github/workflows/sendReassurePerfData.yml @@ -12,6 +12,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode diff --git a/Mobile-Expensify b/Mobile-Expensify index af549932c171..d81135521e25 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit af549932c17151a57655466e4912e038b21f501a +Subproject commit d81135521e25add10bfebebf83fa5ec4a67cca13 diff --git a/__mocks__/@expensify/react-native-live-markdown.ts b/__mocks__/@expensify/react-native-live-markdown.ts new file mode 100644 index 000000000000..3ee327efed40 --- /dev/null +++ b/__mocks__/@expensify/react-native-live-markdown.ts @@ -0,0 +1 @@ +export * from '@expensify/react-native-live-markdown/mock'; diff --git a/android/app/build.gradle b/android/app/build.gradle index 1391594d72a8..71b9f0481067 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009007506 - versionName "9.0.75-6" + versionCode 1009007606 + versionName "9.0.76-6" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/docs/articles/expensify-classic/expensify-billing/Tax-Exempt.md b/docs/articles/expensify-classic/expensify-billing/Tax-Exempt.md index 92c92e4e3a44..a91454b4965b 100644 --- a/docs/articles/expensify-classic/expensify-billing/Tax-Exempt.md +++ b/docs/articles/expensify-classic/expensify-billing/Tax-Exempt.md @@ -11,6 +11,8 @@ If your organization is recognized by the IRS or other local tax authorities as 1. Our team will review your document and let you know if we need any more information. 1. Once everything is verified, we'll update your account accordingly. +![Click the request tax exempt status button]({{site.url}}/assets/images/Tax Exempt - Classic.png){:width="100%"} + Once your account is marked as tax-exempt, the corresponding state tax will no longer be applied to future billing. If you need to remove your tax-exempt status, let your Account Manager know or contact Concierge. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/Tax-exempt.md b/docs/articles/new-expensify/billing-and-subscriptions/Tax-exempt.md new file mode 100644 index 000000000000..fa38741d3b97 --- /dev/null +++ b/docs/articles/new-expensify/billing-and-subscriptions/Tax-exempt.md @@ -0,0 +1,26 @@ +--- +title: Tax Exempt +description: Tax-exempt status in Expensify for organizations recognized by the IRS or local tax authorities. +--- + +# Overview +If your organization is recognized by the IRS or other local tax authorities as tax-exempt, that means you don’t need to pay any tax on your Expensify monthly bill. Please follow these instructions to request tax-exempt status. +# How to request tax-exempt status in Expensify +1. Go to **Settings > Subscription > Subscription details**. +2. Click **More** in the top right, then **Request tax exempt status**. +3. After you've requested tax-exempt status, Concierge (our support service) will start a conversation with you. They will ask you to upload a PDF of your tax-exempt documentation. This document should include your VAT number (or "RUT" in Chile). You can use one of the following documents: 501(c), ST-119, or a foreign tax-exempt declaration. +4. Our team will review your document and let you know if we need any more information. +5. Once everything is verified, we'll update your account accordingly. + +![Tap More and then Request tax exempt status]({{site.url}}/assets/images/Tax Exempt - New Expensify.png){:width="100%"} + +Once your account is marked as tax-exempt, the corresponding state tax will no longer be applied to future billing. + +If you need to remove your tax-exempt status, let your account manager know or contact Concierge. + +{% include faq-begin.md %} +## What happens to my past Expensify bills that incorrectly had tax added to them? +Expensify can provide a refund for the tax you were charged on your previous bills. Please let your Account Manager know or contact Concierge if this is the case. + +{% include faq-end.md %} + diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index cd2598608a0f..3d9119add122 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -637,7 +637,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/GoogleUtilities/GoogleUtilities_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", - "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js", "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", @@ -658,7 +657,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleUtilities_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", @@ -843,7 +841,6 @@ "${PODS_CONFIGURATION_BUILD_DIR}/GoogleUtilities/GoogleUtilities_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", - "${PODS_ROOT}/../../node_modules/@expensify/react-native-live-markdown/parser/react-native-live-markdown-parser.js", "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", @@ -864,7 +861,6 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleUtilities_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/react-native-live-markdown-parser.js", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 74d34f52214b..27de0846d9d3 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.75 + 9.0.76 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.75.6 + 9.0.76.6 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index c594f105f833..d2bfbdefba61 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.75 + 9.0.76 CFBundleSignature ???? CFBundleVersion - 9.0.75.6 + 9.0.76.6 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 2b8181d88d5b..0b61137e2127 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.75 + 9.0.76 CFBundleVersion - 9.0.75.6 + 9.0.76.6 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0db33e40e7fb..31ff58598c82 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2410,7 +2410,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.187): + - RNLiveMarkdown (0.1.207): - DoubleConversion - glog - hermes-engine @@ -2430,9 +2430,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.187) + - RNLiveMarkdown/newarch (= 0.1.207) + - RNReanimated/worklets - Yoga - - RNLiveMarkdown/newarch (0.1.187): + - RNLiveMarkdown/newarch (0.1.207): - DoubleConversion - glog - hermes-engine @@ -2452,6 +2453,7 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core + - RNReanimated/worklets - Yoga - RNLocalize (2.2.6): - React-Core @@ -2522,7 +2524,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNReanimated (3.16.3): + - RNReanimated (3.16.4): - DoubleConversion - glog - hermes-engine @@ -2542,10 +2544,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNReanimated/reanimated (= 3.16.3) - - RNReanimated/worklets (= 3.16.3) + - RNReanimated/reanimated (= 3.16.4) + - RNReanimated/worklets (= 3.16.4) - Yoga - - RNReanimated/reanimated (3.16.3): + - RNReanimated/reanimated (3.16.4): - DoubleConversion - glog - hermes-engine @@ -2565,9 +2567,9 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNReanimated/reanimated/apple (= 3.16.3) + - RNReanimated/reanimated/apple (= 3.16.4) - Yoga - - RNReanimated/reanimated/apple (3.16.3): + - RNReanimated/reanimated/apple (3.16.4): - DoubleConversion - glog - hermes-engine @@ -2588,7 +2590,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNReanimated/worklets (3.16.3): + - RNReanimated/worklets (3.16.4): - DoubleConversion - glog - hermes-engine @@ -3290,12 +3292,12 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: 8338447b39fcd86596c74b9e0e9509e365a2dd3b + RNLiveMarkdown: 8f9d9b32a25969ddb5f59eb92136b73823bbd141 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 RNReactNativeHapticFeedback: 73756a3477a5a622fa16862a3ab0d0fc5e5edff5 - RNReanimated: 03ba2447d5a7789e2843df2ee05108d93b6441d6 + RNReanimated: d95f865e1e42c34ca56b987e0719a8c72fc02dbc RNScreens: de6e57426ba0e6cbc3fb5b4f496e7f08cb2773c2 RNShare: bd4fe9b95d1ee89a200778cc0753ebe650154bb0 RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 diff --git a/package-lock.json b/package-lock.json index c9bc9ec2f28e..947e3a4cc1bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "new.expensify", - "version": "9.0.75-6", + "version": "9.0.76-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.75-6", + "version": "9.0.76-6", "hasInstallScript": true, "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.187", + "@expensify/react-native-live-markdown": "0.1.207", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -51,7 +51,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.106", + "expensify-common": "2.0.109", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", @@ -104,7 +104,7 @@ "react-native-plaid-link-sdk": "11.11.0", "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-nitro-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", - "react-native-reanimated": "3.16.3", + "react-native-reanimated": "3.16.4", "react-native-release-profiler": "^0.2.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.10.9", @@ -3498,12 +3498,12 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.187", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.187.tgz", - "integrity": "sha512-bw+dfhRN31u2xfG8LCI3e28g5EG/BfkyX1EqjPBRQlDZo4fZsdA61UFW6P8Y4rHlqspjYXJ0vk4ctECRWYl4Yg==", + "version": "0.1.207", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.207.tgz", + "integrity": "sha512-8snKeruLuHJCecnwQ+ru6pJhrDeI2Y3EywmXf/keT4aMk2xcW1fyCAr925zikTWANMDghcKkeuR/JqLe2b3rkA==", + "hasInstallScript": true, "license": "MIT", "workspaces": [ - "./parser", "./example", "./WebExample" ], @@ -3511,8 +3511,10 @@ "node": ">= 18.0.0" }, "peerDependencies": { + "expensify-common": ">=2.0.108", "react": "*", - "react-native": "*" + "react-native": "*", + "react-native-reanimated": ">=3.16.4" } }, "node_modules/@expo/bunyan": { @@ -17480,6 +17482,8 @@ }, "node_modules/classnames": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.0.tgz", + "integrity": "sha512-FQuRlyKinxrb5gwJlfVASbSrDlikDJ07426TrfPsdGLvtochowmkbnSFdQGJ2aoXrSetq5KqGV9emvWpy+91xA==", "license": "MIT", "workspaces": [ "benchmarks" @@ -17563,6 +17567,8 @@ }, "node_modules/clipboard": { "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", "license": "MIT", "dependencies": { "good-listener": "^1.2.2", @@ -19128,6 +19134,8 @@ }, "node_modules/delegate": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", "license": "MIT" }, "node_modules/delegates": { @@ -21554,9 +21562,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.106", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.106.tgz", - "integrity": "sha512-KmxKvglbIUJb0sAcmNxb/AXYAqa3GIZfu3MbmtlYDNJx24mjDjtbGkKhm+16TICDoPj2PDRNogIqgUGWmSSZFQ==", + "version": "2.0.109", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.109.tgz", + "integrity": "sha512-5XTrJxiDSjQhojnJfXH1G+fSgRM92oAJ5HiLo28HppmJQuA350GOONVo88rRalcI029rlYGIMh8WfhMlOuE/gA==", "license": "MIT", "dependencies": { "awesome-phonenumber": "^5.4.0", @@ -21578,6 +21586,7 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -21586,7 +21595,9 @@ } }, "node_modules/expensify-common/node_modules/ua-parser-js": { - "version": "1.0.38", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==", "funding": [ { "type": "opencollective", @@ -21602,6 +21613,9 @@ } ], "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, "engines": { "node": "*" } @@ -23186,6 +23200,8 @@ }, "node_modules/good-listener": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", "license": "MIT", "dependencies": { "delegate": "^3.1.2" @@ -24004,6 +24020,8 @@ }, "node_modules/immediate": { "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, "node_modules/import-fresh": { @@ -27285,6 +27303,8 @@ }, "node_modules/jquery": { "version": "3.6.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.0.tgz", + "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", "license": "MIT" }, "node_modules/js-base64": { @@ -27699,6 +27719,8 @@ }, "node_modules/lie": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", "license": "MIT", "dependencies": { "immediate": "~3.0.5" @@ -27954,6 +27976,8 @@ }, "node_modules/localforage": { "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", "license": "Apache-2.0", "dependencies": { "lie": "3.1.1" @@ -32279,9 +32303,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.16.3", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.3.tgz", - "integrity": "sha512-OWlA6e1oHhytTpc7WiSZ7Tmb8OYwLKYZz29Sz6d6WAg60Hm5GuAiKIWUG7Ako7FLcYhFkA0pEQ2xPMEYUo9vlw==", + "version": "3.16.4", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.4.tgz", + "integrity": "sha512-dF1Vvu8gG+p0+DmBhKMTx5X9iw/rH1ZF9WaIn2nW0c5rxsVFf00axmDgaAdPxNWblmtLnroaKwrV7SjMUyOx+g==", "license": "MIT", "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", @@ -33658,6 +33682,8 @@ }, "node_modules/select": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", "license": "MIT" }, "node_modules/select-hose": { diff --git a/package.json b/package.json index 59602f900e14..02ef81489e01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.75-6", + "version": "9.0.76-6", "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.", @@ -74,7 +74,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.187", + "@expensify/react-native-live-markdown": "0.1.207", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -114,7 +114,7 @@ "date-fns-tz": "^3.2.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.106", + "expensify-common": "2.0.109", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", @@ -167,7 +167,7 @@ "react-native-plaid-link-sdk": "11.11.0", "react-native-qrcode-svg": "6.3.11", "react-native-quick-sqlite": "git+https://github.com/margelo/react-native-nitro-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0", - "react-native-reanimated": "3.16.3", + "react-native-reanimated": "3.16.4", "react-native-release-profiler": "^0.2.1", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.10.9", diff --git a/patches/html-entities+2.5.2.patch b/patches/html-entities+2.5.2.patch new file mode 100644 index 000000000000..0df30b6bd686 --- /dev/null +++ b/patches/html-entities+2.5.2.patch @@ -0,0 +1,9 @@ +diff --git a/node_modules/html-entities/lib/index.js b/node_modules/html-entities/lib/index.js +index 3a44c85..c7dfa67 100644 +--- a/node_modules/html-entities/lib/index.js ++++ b/node_modules/html-entities/lib/index.js +@@ -1,2 +1,3 @@ ++"worklet"; // This function is used in react-native-live-markdown parser and it must be a worklet to run in UI thread (react-native-reanimated) + "use strict";var __assign=this&&this.__assign||function(){__assign=Object.assign||function(t){for(var s,i=1,n=arguments.length;i'"&]/g,nonAscii:/[<>'"&\u0080-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/g,nonAsciiPrintable:/[<>'"&\x01-\x08\x11-\x15\x17-\x1F\x7f-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/g,nonAsciiPrintableOnly:/[\x01-\x08\x11-\x15\x17-\x1F\x7f-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/g,extensive:/[\x01-\x0c\x0e-\x1f\x21-\x2c\x2e-\x2f\x3a-\x40\x5b-\x60\x7b-\x7d\x7f-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/g};var defaultEncodeOptions={mode:"specialChars",level:"all",numeric:"decimal"};function encode(text,_a){var _b=_a===void 0?defaultEncodeOptions:_a,_c=_b.mode,mode=_c===void 0?"specialChars":_c,_d=_b.numeric,numeric=_d===void 0?"decimal":_d,_e=_b.level,level=_e===void 0?"all":_e;if(!text){return""}var encodeRegExp=encodeRegExps[mode];var references=allNamedReferences[level].characters;var isHex=numeric==="hexadecimal";return replaceUsingRegExp(text,encodeRegExp,(function(input){var result=references[input];if(!result){var code=input.length>1?surrogate_pairs_1.getCodePoint(input,0):input.charCodeAt(0);result=(isHex?"&#x"+code.toString(16):"&#"+code)+";"}return result}))}exports.encode=encode;var defaultDecodeOptions={scope:"body",level:"all"};var strict=/&(?:#\d+|#[xX][\da-fA-F]+|[0-9a-zA-Z]+);/g;var attribute=/&(?:#\d+|#[xX][\da-fA-F]+|[0-9a-zA-Z]+)[;=]?/g;var baseDecodeRegExps={xml:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.xml},html4:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.html4},html5:{strict:strict,attribute:attribute,body:named_references_1.bodyRegExps.html5}};var decodeRegExps=__assign(__assign({},baseDecodeRegExps),{all:baseDecodeRegExps.html5});var fromCharCode=String.fromCharCode;var outOfBoundsChar=fromCharCode(65533);var defaultDecodeEntityOptions={level:"all"};function getDecodedEntity(entity,references,isAttribute,isStrict){var decodeResult=entity;var decodeEntityLastChar=entity[entity.length-1];if(isAttribute&&decodeEntityLastChar==="="){decodeResult=entity}else if(isStrict&&decodeEntityLastChar!==";"){decodeResult=entity}else{var decodeResultByReference=references[entity];if(decodeResultByReference){decodeResult=decodeResultByReference}else if(entity[0]==="&"&&entity[1]==="#"){var decodeSecondChar=entity[2];var decodeCode=decodeSecondChar=="x"||decodeSecondChar=="X"?parseInt(entity.substr(3),16):parseInt(entity.substr(2));decodeResult=decodeCode>=1114111?outOfBoundsChar:decodeCode>65535?surrogate_pairs_1.fromCodePoint(decodeCode):fromCharCode(numeric_unicode_map_1.numericUnicodeMap[decodeCode]||decodeCode)}}return decodeResult}function decodeEntity(entity,_a){var _b=(_a===void 0?defaultDecodeEntityOptions:_a).level,level=_b===void 0?"all":_b;if(!entity){return""}return getDecodedEntity(entity,allNamedReferences[level].entities,false,false)}exports.decodeEntity=decodeEntity;function decode(text,_a){var _b=_a===void 0?defaultDecodeOptions:_a,_c=_b.level,level=_c===void 0?"all":_c,_d=_b.scope,scope=_d===void 0?level==="xml"?"strict":"body":_d;if(!text){return""}var decodeRegExp=decodeRegExps[level][scope];var references=allNamedReferences[level].entities;var isAttribute=scope==="attribute";var isStrict=scope==="strict";return replaceUsingRegExp(text,decodeRegExp,(function(entity){return getDecodedEntity(entity,references,isAttribute,isStrict)}))}exports.decode=decode; + //# sourceMappingURL=./index.js.map +\ No newline at end of file diff --git a/patches/react-native-reanimated+3.16.3+002+dontWhitelistTextProp.patch b/patches/react-native-reanimated+3.16.4+002+dontWhitelistTextProp.patch similarity index 95% rename from patches/react-native-reanimated+3.16.3+002+dontWhitelistTextProp.patch rename to patches/react-native-reanimated+3.16.4+002+dontWhitelistTextProp.patch index 6084dca4adc8..583cc7015ee4 100644 --- a/patches/react-native-reanimated+3.16.3+002+dontWhitelistTextProp.patch +++ b/patches/react-native-reanimated+3.16.4+002+dontWhitelistTextProp.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-reanimated/src/component/PerformanceMonitor.tsx b/node_modules/react-native-reanimated/src/component/PerformanceMonitor.tsx -index 38e3d39..9936670 100644 +index d4b31f2..ced6561 100644 --- a/node_modules/react-native-reanimated/src/component/PerformanceMonitor.tsx +++ b/node_modules/react-native-reanimated/src/component/PerformanceMonitor.tsx @@ -46,7 +46,6 @@ function createCircularDoublesBuffer(size: number) { diff --git a/src/CONST.ts b/src/CONST.ts index 4fcc1cada6ff..204ccaccf394 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1051,6 +1051,7 @@ const CONST = { MODIFIED_EXPENSE: 'MODIFIEDEXPENSE', MOVED: 'MOVED', OUTDATED_BANK_ACCOUNT: 'OUTDATEDBANKACCOUNT', // OldDot Action + REIMBURSED: 'REIMBURSED', REIMBURSEMENT_ACH_BOUNCE: 'REIMBURSEMENTACHBOUNCE', // OldDot Action REIMBURSEMENT_ACH_CANCELLED: 'REIMBURSEMENTACHCANCELLED', // OldDot Action REIMBURSEMENT_ACCOUNT_CHANGED: 'REIMBURSEMENTACCOUNTCHANGED', // OldDot Action @@ -6105,6 +6106,33 @@ const CONST = { AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion', SEARCH: 'searchItem', }, + SEARCH_USER_FRIENDLY_KEYS: { + TYPE: 'type', + STATUS: 'status', + SORT_BY: 'sort-by', + SORT_ORDER: 'sort-order', + POLICY_ID: 'workspace', + DATE: 'date', + AMOUNT: 'amount', + EXPENSE_TYPE: 'expense-type', + CURRENCY: 'currency', + MERCHANT: 'merchant', + DESCRIPTION: 'description', + FROM: 'from', + TO: 'to', + CATEGORY: 'category', + TAG: 'tag', + TAX_RATE: 'tax-rate', + CARD_ID: 'card', + REPORT_ID: 'reportid', + KEYWORD: 'keyword', + IN: 'in', + SUBMITTED: 'submitted', + APPROVED: 'approved', + PAID: 'paid', + EXPORTED: 'exported', + POSTED: 'posted', + }, DATE_MODIFIERS: { BEFORE: 'Before', AFTER: 'After', @@ -6413,6 +6441,10 @@ const CONST = { RENAME_SAVED_SEARCH: 'renameSavedSearch', QUICK_ACTION_BUTTON: 'quickActionButton', WORKSAPCE_CHAT_CREATE: 'workspaceChatCreate', + SEARCH_FILTER_BUTTON_TOOLTIP: 'filterButtonTooltip', + BOTTOM_NAV_INBOX_TOOLTIP: 'bottomNavInboxTooltip', + LHN_WORKSPACE_CHAT_TOOLTIP: 'workspaceChatLHNTooltip', + GLOBAL_CREATE_TOOLTIP: 'globalCreateTooltip', }, } as const; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e19aa71cce52..bd77ed7e8af4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -104,6 +104,12 @@ const ONYXKEYS = { /** Store the information of magic code */ VALIDATE_ACTION_CODE: 'validate_action_code', + /** A list of policies that a user can join */ + JOINABLE_POLICIES: 'joinablePolicies', + + /** Flag to indicate if the joinablePolicies are loading */ + JOINABLE_POLICIES_LOADING: 'joinablePoliciesLoading', + /** Information about the current session (authToken, accountID, email, loading, error) */ SESSION: 'session', STASHED_SESSION: 'stashedSession', @@ -903,6 +909,8 @@ type OnyxValuesMapping = { [ONYXKEYS.LOGIN_LIST]: OnyxTypes.LoginList; [ONYXKEYS.PENDING_CONTACT_ACTION]: OnyxTypes.PendingContactAction; [ONYXKEYS.VALIDATE_ACTION_CODE]: OnyxTypes.ValidateMagicCodeAction; + [ONYXKEYS.JOINABLE_POLICIES]: OnyxTypes.JoinablePolicies; + [ONYXKEYS.JOINABLE_POLICIES_LOADING]: boolean; [ONYXKEYS.SESSION]: OnyxTypes.Session; [ONYXKEYS.USER_METADATA]: OnyxTypes.UserMetadata; [ONYXKEYS.STASHED_SESSION]: OnyxTypes.Session; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 4abd5c6d3d49..b9544d81bece 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1373,6 +1373,10 @@ const ROUTES = { route: 'onboarding/personal-details', getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/personal-details`, backTo), }, + ONBOARDING_PRIVATE_DOMAIN: { + route: 'onboarding/private-domain', + getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/private-domain`, backTo), + }, ONBOARDING_EMPLOYEES: { route: 'onboarding/employees', getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/employees`, backTo), @@ -1385,6 +1389,10 @@ const ROUTES = { route: 'onboarding/purpose', getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/purpose`, backTo), }, + ONBOARDING_WORKSPACES: { + route: 'onboarding/join-workspaces', + getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/join-workspaces`, backTo), + }, WELCOME_VIDEO_ROOT: 'onboarding/welcome-video', EXPLANATION_MODAL_ROOT: 'onboarding/explanation', MIGRATED_USER_WELCOME_MODAL: 'onboarding/migrated-user-welcome', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 47090bd7075b..6c4547e94c37 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -579,8 +579,10 @@ const SCREENS = { ONBOARDING: { PERSONAL_DETAILS: 'Onboarding_Personal_Details', PURPOSE: 'Onboarding_Purpose', + PRIVATE_DOMAIN: 'Onboarding_Private_Domain', EMPLOYEES: 'Onboarding_Employees', ACCOUNTING: 'Onboarding_Accounting', + WORKSPACES: 'Onboarding_Workspaces', }, WELCOME_VIDEO: { diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 3c831301db8b..e0f0ff4e6dcd 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -5,10 +5,16 @@ import type {GestureResponderEvent, Role, Text, View} from 'react-native'; import {Platform} from 'react-native'; import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Svg, {Path} from 'react-native-svg'; +import useBottomTabIsFocused from '@hooks/useBottomTabIsFocused'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import getPlatform from '@libs/getPlatform'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import {PressableWithoutFeedback} from './Pressable'; +import {useProductTrainingContext} from './ProductTrainingContext'; +import EducationalTooltip from './Tooltip/EducationalTooltip'; const AnimatedPath = Animated.createAnimatedComponent(Path); AnimatedPath.displayName = 'AnimatedPath'; @@ -56,6 +62,14 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo const styles = useThemeStyles(); const borderRadius = styles.floatingActionButton.borderRadius; const fabPressable = useRef(null); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const platform = getPlatform(); + const isNarrowScreenOnWeb = shouldUseNarrowLayout && platform === CONST.PLATFORM.WEB; + const isFocused = useBottomTabIsFocused(); + const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.GLOBAL_CREATE_TOOLTIP, + isFocused, + ); const sharedValue = useSharedValue(isActive ? 1 : 0); const buttonRef = ref; @@ -97,32 +111,45 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role}: Flo }; return ( - { - fabPressable.current = el ?? null; - if (buttonRef && 'current' in buttonRef) { - buttonRef.current = el ?? null; - } + {}} - role={role} - shouldUseHapticsOnLongPress={false} + shouldUseOverlay + shiftHorizontal={isNarrowScreenOnWeb ? 0 : variables.fabTooltipShiftHorizontal} + renderTooltipContent={renderProductTrainingTooltip} + wrapperStyle={styles.productTrainingTooltipWrapper} + onHideTooltip={hideProductTrainingTooltip} > - - - - - - + { + fabPressable.current = el ?? null; + if (buttonRef && 'current' in buttonRef) { + buttonRef.current = el ?? null; + } + }} + style={[styles.h100, styles.bottomTabBarItem]} + accessibilityLabel={accessibilityLabel} + onPress={toggleFabAction} + onLongPress={() => {}} + role={role} + shouldUseHapticsOnLongPress={false} + > + + + + + + + ); } diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 6b8cf173b0fd..efdd9659c845 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -1,5 +1,5 @@ import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, ViewStyle} from 'react-native'; import {StyleSheet, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -47,19 +47,21 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${optionItem?.reportID || -1}`); - + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const isActiveWorkspaceChat = ReportUtils.isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat && activePolicyID === report?.policyID; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const session = useSession(); - - // Guides are assigned for the MANAGE_TEAM onboarding action, except for emails that have a '+'. const isOnboardingGuideAssigned = introSelected?.choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !session?.email?.includes('+'); - const shouldShowToooltipOnThisReport = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report); + const shouldShowGetStartedTooltip = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report); - const shouldShowGetStartedTooltip = shouldShowToooltipOnThisReport && isScreenFocused; - const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( - CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONCEIRGE_LHN_GBR, - shouldShowGetStartedTooltip, - ); + const {tooltipToRender, shouldShowTooltip} = useMemo(() => { + const tooltip = shouldShowGetStartedTooltip ? CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONCEIRGE_LHN_GBR : CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.LHN_WORKSPACE_CHAT_TOOLTIP; + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return {tooltipToRender: tooltip, shouldShowTooltip: shouldUseNarrowLayout ? isScreenFocused : true}; + }, [shouldShowGetStartedTooltip, isScreenFocused, shouldUseNarrowLayout]); + + const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(tooltipToRender, shouldShowTooltip); const {translate} = useLocalize(); const [isContextMenuActive, setIsContextMenuActive] = useState(false); @@ -156,17 +158,18 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti needsOffscreenAlphaCompositing > diff --git a/src/components/OnboardingWrapper.tsx b/src/components/OnboardingWrapper.tsx new file mode 100644 index 000000000000..1e7db38f0e6a --- /dev/null +++ b/src/components/OnboardingWrapper.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import FocusTrapForScreens from './FocusTrap/FocusTrapForScreen'; + +type OnboardingWrapperProps = { + /** Rendered child component */ + children: React.ReactNode; +}; + +function OnboardingWrapper({children}: OnboardingWrapperProps) { + const styles = useThemeStyles(); + + return ( + + {children} + + ); +} + +OnboardingWrapper.displayName = 'OnboardingWrapper'; + +export default OnboardingWrapper; diff --git a/src/components/ProductTrainingContext/PRODUCT_TRAINING_TOOLTIP_DATA.ts b/src/components/ProductTrainingContext/PRODUCT_TRAINING_TOOLTIP_DATA.ts deleted file mode 100644 index d7f2a27d94d2..000000000000 --- a/src/components/ProductTrainingContext/PRODUCT_TRAINING_TOOLTIP_DATA.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type {ValueOf} from 'type-fest'; -import {dismissProductTraining} from '@libs/actions/Welcome'; -import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; - -const {CONCEIRGE_LHN_GBR, RENAME_SAVED_SEARCH, WORKSAPCE_CHAT_CREATE, QUICK_ACTION_BUTTON} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; - -type ProductTrainingTooltipName = ValueOf; - -type ShouldShowConditionProps = { - shouldUseNarrowLayout?: boolean; -}; - -type TooltipData = { - content: Array<{text: TranslationPaths; isBold: boolean}>; - onHideTooltip: () => void; - name: ProductTrainingTooltipName; - priority: number; - shouldShow: (props: ShouldShowConditionProps) => boolean; -}; - -const PRODUCT_TRAINING_TOOLTIP_DATA: Record = { - [CONCEIRGE_LHN_GBR]: { - content: [ - {text: 'productTrainingTooltip.conciergeLHNGBR.part1', isBold: false}, - {text: 'productTrainingTooltip.conciergeLHNGBR.part2', isBold: true}, - ], - onHideTooltip: () => dismissProductTraining(CONCEIRGE_LHN_GBR), - name: CONCEIRGE_LHN_GBR, - priority: 1300, - shouldShow: ({shouldUseNarrowLayout}) => !!shouldUseNarrowLayout, - }, - [RENAME_SAVED_SEARCH]: { - content: [ - {text: 'productTrainingTooltip.saveSearchTooltip.part1', isBold: true}, - {text: 'productTrainingTooltip.saveSearchTooltip.part2', isBold: false}, - ], - onHideTooltip: () => dismissProductTraining(RENAME_SAVED_SEARCH), - name: RENAME_SAVED_SEARCH, - priority: 1250, - shouldShow: ({shouldUseNarrowLayout}) => !shouldUseNarrowLayout, - }, - [QUICK_ACTION_BUTTON]: { - content: [ - {text: 'productTrainingTooltip.quickActionButton.part1', isBold: true}, - {text: 'productTrainingTooltip.quickActionButton.part2', isBold: false}, - ], - onHideTooltip: () => dismissProductTraining(QUICK_ACTION_BUTTON), - name: QUICK_ACTION_BUTTON, - priority: 1200, - shouldShow: () => true, - }, - [WORKSAPCE_CHAT_CREATE]: { - content: [ - {text: 'productTrainingTooltip.workspaceChatCreate.part1', isBold: false}, - {text: 'productTrainingTooltip.workspaceChatCreate.part2', isBold: true}, - {text: 'productTrainingTooltip.workspaceChatCreate.part3', isBold: false}, - ], - onHideTooltip: () => dismissProductTraining(WORKSAPCE_CHAT_CREATE), - name: WORKSAPCE_CHAT_CREATE, - priority: 1100, - shouldShow: () => true, - }, -}; - -export default PRODUCT_TRAINING_TOOLTIP_DATA; -export type {ProductTrainingTooltipName}; diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts new file mode 100644 index 000000000000..dc2a761a4903 --- /dev/null +++ b/src/components/ProductTrainingContext/TOOLTIPS.ts @@ -0,0 +1,118 @@ +import type {ValueOf} from 'type-fest'; +import {dismissProductTraining} from '@libs/actions/Welcome'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; + +const { + CONCEIRGE_LHN_GBR, + RENAME_SAVED_SEARCH, + WORKSAPCE_CHAT_CREATE, + QUICK_ACTION_BUTTON, + SEARCH_FILTER_BUTTON_TOOLTIP, + BOTTOM_NAV_INBOX_TOOLTIP, + LHN_WORKSPACE_CHAT_TOOLTIP, + GLOBAL_CREATE_TOOLTIP, +} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; + +type ProductTrainingTooltipName = ValueOf; + +type ShouldShowConditionProps = { + shouldUseNarrowLayout?: boolean; +}; + +type TooltipData = { + content: Array<{text: TranslationPaths; isBold: boolean}>; + onHideTooltip: () => void; + name: ProductTrainingTooltipName; + priority: number; + shouldShow: (props: ShouldShowConditionProps) => boolean; +}; + +const TOOLTIPS: Record = { + [CONCEIRGE_LHN_GBR]: { + content: [ + {text: 'productTrainingTooltip.conciergeLHNGBR.part1', isBold: false}, + {text: 'productTrainingTooltip.conciergeLHNGBR.part2', isBold: true}, + ], + onHideTooltip: () => dismissProductTraining(CONCEIRGE_LHN_GBR), + name: CONCEIRGE_LHN_GBR, + priority: 1300, + shouldShow: ({shouldUseNarrowLayout}) => !!shouldUseNarrowLayout, + }, + [RENAME_SAVED_SEARCH]: { + content: [ + {text: 'productTrainingTooltip.saveSearchTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.saveSearchTooltip.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(RENAME_SAVED_SEARCH), + name: RENAME_SAVED_SEARCH, + priority: 1250, + shouldShow: ({shouldUseNarrowLayout}) => !shouldUseNarrowLayout, + }, + [GLOBAL_CREATE_TOOLTIP]: { + content: [ + {text: 'productTrainingTooltip.globalCreateTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.globalCreateTooltip.part2', isBold: false}, + {text: 'productTrainingTooltip.globalCreateTooltip.part3', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(GLOBAL_CREATE_TOOLTIP), + name: GLOBAL_CREATE_TOOLTIP, + priority: 1200, + shouldShow: () => true, + }, + [QUICK_ACTION_BUTTON]: { + content: [ + {text: 'productTrainingTooltip.quickActionButton.part1', isBold: true}, + {text: 'productTrainingTooltip.quickActionButton.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(QUICK_ACTION_BUTTON), + name: QUICK_ACTION_BUTTON, + priority: 1150, + shouldShow: () => true, + }, + [WORKSAPCE_CHAT_CREATE]: { + content: [ + {text: 'productTrainingTooltip.workspaceChatCreate.part1', isBold: true}, + {text: 'productTrainingTooltip.workspaceChatCreate.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(WORKSAPCE_CHAT_CREATE), + name: WORKSAPCE_CHAT_CREATE, + priority: 1100, + shouldShow: () => true, + }, + [SEARCH_FILTER_BUTTON_TOOLTIP]: { + content: [ + {text: 'productTrainingTooltip.searchFilterButtonTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.searchFilterButtonTooltip.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(SEARCH_FILTER_BUTTON_TOOLTIP), + name: SEARCH_FILTER_BUTTON_TOOLTIP, + priority: 1000, + shouldShow: () => true, + }, + [BOTTOM_NAV_INBOX_TOOLTIP]: { + content: [ + {text: 'productTrainingTooltip.bottomNavInboxTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.bottomNavInboxTooltip.part2', isBold: false}, + {text: 'productTrainingTooltip.bottomNavInboxTooltip.part3', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(BOTTOM_NAV_INBOX_TOOLTIP), + name: BOTTOM_NAV_INBOX_TOOLTIP, + priority: 900, + shouldShow: () => true, + }, + [LHN_WORKSPACE_CHAT_TOOLTIP]: { + content: [ + {text: 'productTrainingTooltip.workspaceChatTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.workspaceChatTooltip.part2', isBold: false}, + {text: 'productTrainingTooltip.workspaceChatTooltip.part3', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(LHN_WORKSPACE_CHAT_TOOLTIP), + name: LHN_WORKSPACE_CHAT_TOOLTIP, + priority: 800, + shouldShow: () => true, + }, +}; + +export default TOOLTIPS; +export type {ProductTrainingTooltipName}; diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx index 92997fe70af3..7cfcf4d3bfa7 100644 --- a/src/components/ProductTrainingContext/index.tsx +++ b/src/components/ProductTrainingContext/index.tsx @@ -9,11 +9,12 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; -import Permissions from '@libs/Permissions'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import type {ProductTrainingTooltipName} from './PRODUCT_TRAINING_TOOLTIP_DATA'; -import PRODUCT_TRAINING_TOOLTIP_DATA from './PRODUCT_TRAINING_TOOLTIP_DATA'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {ProductTrainingTooltipName} from './TOOLTIPS'; +import TOOLTIPS from './TOOLTIPS'; type ProductTrainingContextType = { shouldRenderTooltip: (tooltipName: ProductTrainingTooltipName) => boolean; @@ -30,11 +31,10 @@ const ProductTrainingContext = createContext({ function ProductTrainingContextProvider({children}: ChildrenProps) { const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT); const hasBeenAddedToNudgeMigration = !!tryNewDot?.nudgeMigration?.timestamp; - const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + const [isOnboardingCompleted = true, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasCompletedGuidedSetupFlowSelector, }); const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); - const [allBetas] = useOnyx(ONYXKEYS.BETAS); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [activeTooltips, setActiveTooltips] = useState>(new Set()); @@ -58,7 +58,7 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { const sortedTooltips = Array.from(activeTooltips) .map((name) => ({ name, - priority: PRODUCT_TRAINING_TOOLTIP_DATA[name]?.priority ?? 0, + priority: TOOLTIPS[name]?.priority ?? 0, })) .sort((a, b) => b.priority - a.priority); @@ -73,14 +73,22 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { const shouldTooltipBeVisible = useCallback( (tooltipName: ProductTrainingTooltipName) => { + if (isLoadingOnyxValue(isOnboardingCompletedMetadata)) { + return false; + } + const isDismissed = !!dismissedProductTraining?.[tooltipName]; - if (isDismissed || !Permissions.shouldShowProductTrainingElements(allBetas)) { + if (isDismissed) { return false; } - const tooltipConfig = PRODUCT_TRAINING_TOOLTIP_DATA[tooltipName]; + const tooltipConfig = TOOLTIPS[tooltipName]; - if (!isOnboardingCompleted && !hasBeenAddedToNudgeMigration) { + // if hasBeenAddedToNudgeMigration is true, and welcome modal is not dismissed, don't show tooltip + if (hasBeenAddedToNudgeMigration && !dismissedProductTraining?.[CONST.MIGRATED_USER_WELCOME_MODAL]) { + return false; + } + if (isOnboardingCompleted === false) { return false; } @@ -88,7 +96,7 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { shouldUseNarrowLayout, }); }, - [allBetas, dismissedProductTraining, hasBeenAddedToNudgeMigration, isOnboardingCompleted, shouldUseNarrowLayout], + [dismissedProductTraining, hasBeenAddedToNudgeMigration, isOnboardingCompleted, isOnboardingCompletedMetadata, shouldUseNarrowLayout], ); const registerTooltip = useCallback( @@ -156,21 +164,21 @@ const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shou }, [tooltipName, registerTooltip, unregisterTooltip, shouldShow]); const renderProductTrainingTooltip = useCallback(() => { - const tooltip = PRODUCT_TRAINING_TOOLTIP_DATA[tooltipName]; + const tooltip = TOOLTIPS[tooltipName]; return ( - + - + {tooltip.content.map(({text, isBold}) => { const translatedText = translate(text); return ( {translatedText} @@ -183,12 +191,14 @@ const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shou styles.alignItemsCenter, styles.flexRow, styles.flexWrap, - styles.gap1, + styles.gap3, styles.justifyContentCenter, + styles.mw100, styles.p2, - styles.quickActionTooltipSubtitle, + styles.productTrainingTooltipText, styles.textAlignCenter, styles.textBold, + styles.textWrap, theme.tooltipHighlightText, tooltipName, translate, @@ -199,7 +209,7 @@ const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shou }, [shouldRenderTooltip, tooltipName]); const hideProductTrainingTooltip = useCallback(() => { - const tooltip = PRODUCT_TRAINING_TOOLTIP_DATA[tooltipName]; + const tooltip = TOOLTIPS[tooltipName]; tooltip.onHideTooltip(); unregisterTooltip(tooltipName); }, [tooltipName, unregisterTooltip]); @@ -207,7 +217,7 @@ const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shou return { renderProductTrainingTooltip, hideProductTrainingTooltip, - shouldShowProductTrainingTooltip: shouldShow && shouldShowProductTrainingTooltip, + shouldShowProductTrainingTooltip, }; }; diff --git a/src/components/RNMarkdownTextInput.tsx b/src/components/RNMarkdownTextInput.tsx index 11f8a852dbcf..e8ed0256bf0a 100644 --- a/src/components/RNMarkdownTextInput.tsx +++ b/src/components/RNMarkdownTextInput.tsx @@ -1,8 +1,7 @@ import type {MarkdownTextInputProps} from '@expensify/react-native-live-markdown'; -import {MarkdownTextInput} from '@expensify/react-native-live-markdown'; +import {MarkdownTextInput, parseExpensiMark} from '@expensify/react-native-live-markdown'; import type {ForwardedRef} from 'react'; import React from 'react'; -import type {TextInput} from 'react-native'; import Animated from 'react-native-reanimated'; import useTheme from '@hooks/useTheme'; import CONST from '@src/CONST'; @@ -10,9 +9,11 @@ import CONST from '@src/CONST'; // Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet const AnimatedMarkdownTextInput = Animated.createAnimatedComponent(MarkdownTextInput); -type AnimatedMarkdownTextInputRef = typeof AnimatedMarkdownTextInput & TextInput & HTMLInputElement; +type AnimatedMarkdownTextInputRef = typeof AnimatedMarkdownTextInput & MarkdownTextInput & HTMLInputElement; -function RNMarkdownTextInputWithRef({maxLength, ...props}: MarkdownTextInputProps, ref: ForwardedRef) { +type RNMarkdownTextInputProps = Omit; + +function RNMarkdownTextInputWithRef({maxLength, ...props}: RNMarkdownTextInputProps, ref: ForwardedRef) { const theme = useTheme(); return ( @@ -20,6 +21,7 @@ function RNMarkdownTextInputWithRef({maxLength, ...props}: MarkdownTextInputProp allowFontScaling={false} textBreakStrategy="simple" keyboardAppearance={theme.colorScheme} + parser={parseExpensiMark} ref={(refHandle) => { if (typeof ref !== 'function') { return; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 44e3b7488ba3..72e0a43ed5e5 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -147,7 +147,8 @@ function MoneyRequestPreviewContent({ // When there are no settled transactions in duplicates, show the "Keep this one" button const shouldShowKeepButton = !!(allDuplicates.length && duplicates.length && allDuplicates.length === duplicates.length); - const shouldShowCategoryOrTag = !!tag || !!category; + const shouldShowTag = !!tag && isPolicyExpenseChat; + const shouldShowCategoryOrTag = shouldShowTag || !!category; const shouldShowRBR = hasNoticeTypeViolations || hasWarningTypeViolations || hasViolations || hasFieldErrors || (!isFullySettled && !isFullyApproved && isOnHold); const showCashOrCard = isCardTransaction ? translate('iou.card') : translate('iou.cash'); // We don't use isOnHold because it's true for duplicated transaction too and we only want to show hold message if the transaction is truly on hold @@ -439,7 +440,16 @@ function MoneyRequestPreviewContent({ {shouldShowCategoryOrTag && ( {!!category && ( - + )} - {!!tag && ( + {shouldShowTag && ( ) : ( - + )} + {/** + These are the actionable buttons that appear at the bottom of a Concierge message + for example: Invite a user mentioned but not a member of the room + https://github.com/Expensify/App/issues/32741 + */} + {actionableItemButtons.length > 0 && ( + + )} + + ) : ( + + )} + + + + ); + } + const numberOfThreadReplies = action.childVisibleActionCount ?? 0; + + const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(action, isThreadReportParentAction); + const oldestFourAccountIDs = + action.childOldestFourAccountIDs + ?.split(',') + .map((accountID) => Number(accountID)) + .filter((accountID): accountID is number => typeof accountID === 'number') ?? []; + const draftMessageRightAlign = draftMessage !== undefined ? styles.chatItemReactionsDraftRight : {}; + + return ( + <> + {children} + {Permissions.canUseLinkPreviews() && !isHidden && (action.linkMetadata?.length ?? 0) > 0 && ( + + !isEmptyObject(item))} /> + + )} + {!ReportActionsUtils.isMessageDeleted(action) && ( + + { + if (Session.isAnonymousUser()) { + hideContextMenu(false); + + InteractionManager.runAfterInteractions(() => { + Session.signOutAndRedirectToSignIn(); + }); + } else { + toggleReaction(emoji, ignoreSkinToneOnCompare); + } + }} + setIsEmojiPickerActive={setIsEmojiPickerActive} + /> + + )} + + {shouldDisplayThreadReplies && ( + + + + )} + + ); + }; + + /** + * Get ReportActionItem with a proper wrapper + * @param hovered whether the ReportActionItem is hovered + * @param isWhisper whether the ReportActionItem is a whisper + * @param hasErrors whether the report action has any errors + * @returns report action item + */ + + const renderReportActionItem = (hovered: boolean, isWhisper: boolean, hasErrors: boolean): React.JSX.Element => { + const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper, hasErrors); + + if (draftMessage !== undefined) { + return {content}; + } + + if (!displayAsGroup) { + return ( + item === moderationDecision) && + !ReportActionsUtils.isPendingRemove(action) + } + > + {content} + + ); + } + + return {content}; + }; + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + const transactionID = ReportActionsUtils.isMoneyRequestAction(parentReportActionForTransactionThread) + ? ReportActionsUtils.getOriginalMessage(parentReportActionForTransactionThread)?.IOUTransactionID + : '-1'; + + return ( + + ); + } + if (ReportActionsUtils.isChronosOOOListAction(action)) { + return ( + + ); + } + + // For the `pay` IOU action on non-pay expense flow, we don't want to render anything if `isWaitingOnBankAccount` is true + // Otherwise, we will see two system messages informing the payee needs to add a bank account or wallet + if ( + ReportActionsUtils.isMoneyRequestAction(action) && + !!report?.isWaitingOnBankAccount && + ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && + !isSendingMoney + ) { + return null; + } + + // If action is actionable whisper and resolved by user, then we don't want to render anything + if (isActionableWhisper && (hasResolutionInOriginalMessage ? originalMessage.resolution : null)) { + return null; + } + + // We currently send whispers to all report participants and hide them in the UI for users that shouldn't see them. + // This is a temporary solution needed for comment-linking. + // The long term solution will leverage end-to-end encryption and only targeted users will be able to decrypt. + if (ReportActionsUtils.isWhisperActionTargetedToOthers(action)) { + return null; + } + + const hasErrors = !isEmptyObject(action.errors); + const whisperedTo = ReportActionsUtils.getWhisperedTo(action); + const isMultipleParticipant = whisperedTo.length > 1; + + const iouReportID = + ReportActionsUtils.isMoneyRequestAction(action) && ReportActionsUtils.getOriginalMessage(action)?.IOUReportID + ? (ReportActionsUtils.getOriginalMessage(action)?.IOUReportID ?? '').toString() + : '-1'; + const transactionsWithReceipts = getTransactionsWithReceipts(iouReportID); + const isWhisper = whisperedTo.length > 0 && transactionsWithReceipts.length === 0; + const whisperedToPersonalDetails = isWhisper + ? (Object.values(personalDetails ?? {}).filter((details) => whisperedTo.includes(details?.accountID ?? -1)) as OnyxTypes.PersonalDetails[]) + : []; + const isWhisperOnlyVisibleByUser = isWhisper && isCurrentUserTheOnlyParticipant(whisperedTo); + const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; + + return ( + shouldUseNarrowLayout && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onSecondaryInteraction={showPopover} + preventDefaultContextMenu={draftMessage === undefined && !hasErrors} + withoutFocusOnSecondaryInteraction + accessibilityLabel={translate('accessibilityHints.chatMessage')} + accessible + > + + {(hovered) => ( + + {shouldDisplayNewMarker && (!shouldUseThreadDividerLine || !isFirstVisibleReportAction) && } + {shouldDisplayContextMenu && ( + + )} + + { + const transactionID = ReportActionsUtils.isMoneyRequestAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : undefined; + if (transactionID) { + clearError(transactionID); + } + clearAllRelatedReportActionErrors(reportID, action); + }} + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + pendingAction={ + draftMessage !== undefined ? undefined : action.pendingAction ?? (action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : undefined) + } + shouldHideOnDelete={!isThreadReportParentAction} + errors={linkedTransactionRouteError ?? ErrorUtils.getLatestErrorMessageField(action as ErrorUtils.OnyxDataWithErrors)} + errorRowStyles={[styles.ml10, styles.mr2]} + needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(action)} + shouldDisableStrikeThrough + > + {isWhisper && ( + + + + + + {translate('reportActionContextMenu.onlyVisible')} +   + + + + )} + {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper, hasErrors)} + + + + )} + + + + + + ); +} +export type {PureReportActionItemProps}; +export default memo(PureReportActionItem, (prevProps, nextProps) => { + const prevParentReportAction = prevProps.parentReportAction; + const nextParentReportAction = nextProps.parentReportAction; + return ( + prevProps.displayAsGroup === nextProps.displayAsGroup && + prevProps.isMostRecentIOUReportAction === nextProps.isMostRecentIOUReportAction && + prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && + lodashIsEqual(prevProps.action, nextProps.action) && + lodashIsEqual(prevProps.report?.pendingFields, nextProps.report?.pendingFields) && + lodashIsEqual(prevProps.report?.isDeletedParentAction, nextProps.report?.isDeletedParentAction) && + lodashIsEqual(prevProps.report?.errorFields, nextProps.report?.errorFields) && + prevProps.report?.statusNum === nextProps.report?.statusNum && + prevProps.report?.stateNum === nextProps.report?.stateNum && + prevProps.report?.parentReportID === nextProps.report?.parentReportID && + prevProps.report?.parentReportActionID === nextProps.report?.parentReportActionID && + // TaskReport's created actions render the TaskView, which updates depending on certain fields in the TaskReport + ReportUtils.isTaskReport(prevProps.report) === ReportUtils.isTaskReport(nextProps.report) && + prevProps.action.actionName === nextProps.action.actionName && + prevProps.report?.reportName === nextProps.report?.reportName && + prevProps.report?.description === nextProps.report?.description && + ReportUtils.isCompletedTaskReport(prevProps.report) === ReportUtils.isCompletedTaskReport(nextProps.report) && + prevProps.report?.managerID === nextProps.report?.managerID && + prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine && + prevProps.report?.total === nextProps.report?.total && + prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal && + prevProps.report?.policyAvatar === nextProps.report?.policyAvatar && + prevProps.linkedReportActionID === nextProps.linkedReportActionID && + lodashIsEqual(prevProps.report?.fieldList, nextProps.report?.fieldList) && + lodashIsEqual(prevProps.transactionThreadReport, nextProps.transactionThreadReport) && + lodashIsEqual(prevProps.reportActions, nextProps.reportActions) && + lodashIsEqual(prevParentReportAction, nextParentReportAction) && + prevProps.draftMessage === nextProps.draftMessage && + prevProps.iouReport?.reportID === nextProps.iouReport?.reportID && + lodashIsEqual(prevProps.emojiReactions, nextProps.emojiReactions) && + lodashIsEqual(prevProps.linkedTransactionRouteError, nextProps.linkedTransactionRouteError) && + lodashIsEqual(prevProps.reportNameValuePairs, nextProps.reportNameValuePairs) && + prevProps.isUserValidated === nextProps.isUserValidated && + prevProps.parentReport?.reportID === nextProps.parentReport?.reportID && + lodashIsEqual(prevProps.personalDetails, nextProps.personalDetails) && + lodashIsEqual(prevProps.blockedFromConcierge, nextProps.blockedFromConcierge) && + prevProps.originalReportID === nextProps.originalReportID && + prevProps.isArchivedRoom === nextProps.isArchivedRoom && + prevProps.isChronosReport === nextProps.isChronosReport && + prevProps.isClosedExpenseReportWithNoExpenses === nextProps.isClosedExpenseReportWithNoExpenses && + lodashIsEqual(prevProps.missingPaymentMethod, nextProps.missingPaymentMethod) && + prevProps.reimbursementDeQueuedActionMessage === nextProps.reimbursementDeQueuedActionMessage && + prevProps.modifiedExpenseMessage === nextProps.modifiedExpenseMessage && + prevProps.userBillingFundID === nextProps.userBillingFundID && + prevProps.reportAutomaticallyForwardedMessage === nextProps.reportAutomaticallyForwardedMessage + ); +}); diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index 1ee361b45224..1887bf9d348a 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -1,171 +1,20 @@ -import lodashIsEqual from 'lodash/isEqual'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import type {GestureResponderEvent, TextInput} from 'react-native'; -import {InteractionManager, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import React, {useMemo} from 'react'; import {useOnyx} from 'react-native-onyx'; -import type {Emoji} from '@assets/emojis/types'; -import {AttachmentContext} from '@components/AttachmentContext'; -import Button from '@components/Button'; -import DisplayNames from '@components/DisplayNames'; -import Hoverable from '@components/Hoverable'; -import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import InlineSystemMessage from '@components/InlineSystemMessage'; -import KYCWall from '@components/KYCWall'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {OnyxEntry} from 'react-native-onyx'; import {useBlockedFromConcierge, usePersonalDetails} from '@components/OnyxProvider'; -import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; -import ReportActionItemEmojiReactions from '@components/Reactions/ReportActionItemEmojiReactions'; -import RenderHTML from '@components/RenderHTML'; -import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; -import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; -import ChronosOOOListActions from '@components/ReportActionItem/ChronosOOOListActions'; -import ExportIntegration from '@components/ReportActionItem/ExportIntegration'; -import IssueCardMessage from '@components/ReportActionItem/IssueCardMessage'; -import MoneyRequestAction from '@components/ReportActionItem/MoneyRequestAction'; -import ReportPreview from '@components/ReportActionItem/ReportPreview'; -import TaskAction from '@components/ReportActionItem/TaskAction'; -import TaskPreview from '@components/ReportActionItem/TaskPreview'; -import TripRoomPreview from '@components/ReportActionItem/TripRoomPreview'; -import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; -import Text from '@components/Text'; -import UnreadActionIndicator from '@components/UnreadActionIndicator'; -import useLocalize from '@hooks/useLocalize'; -import usePrevious from '@hooks/usePrevious'; -import useReportScrollManager from '@hooks/useReportScrollManager'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import ControlSelection from '@libs/ControlSelection'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import focusComposerWithDelay from '@libs/focusComposerWithDelay'; -import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage'; -import Navigation from '@libs/Navigation/Navigation'; -import Permissions from '@libs/Permissions'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import SelectionScraper from '@libs/SelectionScraper'; -import shouldRenderAddPaymentCard from '@libs/shouldRenderAppPaymentCard'; -import {ReactionListContext} from '@pages/home/ReportScreenContext'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; -import * as Member from '@userActions/Policy/Member'; import * as Report from '@userActions/Report'; import * as ReportActions from '@userActions/ReportActions'; -import * as Session from '@userActions/Session'; import * as Transaction from '@userActions/Transaction'; -import * as User from '@userActions/User'; -import CONST from '@src/CONST'; +import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {RestrictedReadOnlyContextMenuActions} from './ContextMenu/ContextMenuActions'; -import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMenu'; -import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; -import {hideContextMenu} from './ContextMenu/ReportActionContextMenu'; -import LinkPreviewer from './LinkPreviewer'; -import ReportActionItemBasicMessage from './ReportActionItemBasicMessage'; -import ReportActionItemContentCreated from './ReportActionItemContentCreated'; -import ReportActionItemDraft from './ReportActionItemDraft'; -import ReportActionItemGrouped from './ReportActionItemGrouped'; -import ReportActionItemMessage from './ReportActionItemMessage'; -import ReportActionItemMessageEdit from './ReportActionItemMessageEdit'; -import ReportActionItemSingle from './ReportActionItemSingle'; -import ReportActionItemThread from './ReportActionItemThread'; -import ReportAttachmentsContext from './ReportAttachmentsContext'; - -type ReportActionItemProps = { - /** Report for this action */ - report: OnyxEntry; - - /** The transaction thread report associated with the report for this action, if any */ - transactionThreadReport?: OnyxEntry; - - /** Array of report actions for the report for this action */ - // eslint-disable-next-line react/no-unused-prop-types - reportActions: OnyxTypes.ReportAction[]; - - /** Report action belonging to the report's parent */ - parentReportAction: OnyxEntry; - - /** The transaction thread report's parentReportAction */ - /** It's used by withOnyx HOC */ - // eslint-disable-next-line react/no-unused-prop-types - parentReportActionForTransactionThread?: OnyxEntry; - - /** All the data of the action item */ - action: OnyxTypes.ReportAction; - - /** Should the comment have the appearance of being grouped with the previous comment? */ - displayAsGroup: boolean; - - /** Is this the most recent IOU Action? */ - isMostRecentIOUReportAction: boolean; - - /** Should we display the new marker on top of the comment? */ - shouldDisplayNewMarker: boolean; +import type {ReportAction} from '@src/types/onyx'; +import type {PureReportActionItemProps} from './PureReportActionItem'; +import PureReportActionItem from './PureReportActionItem'; - /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */ - shouldShowSubscriptAvatar?: boolean; - - /** Position index of the report action in the overall report FlatList view */ - index: number; - - /** Flag to show, hide the thread divider line */ - shouldHideThreadDividerLine?: boolean; - - linkedReportActionID?: string; - - /** Callback to be called on onPress */ - onPress?: () => void; - - /** If this is the first visible report action */ - isFirstVisibleReportAction: boolean; - - /** - * Is the action a thread's parent reportAction viewed from within the thread report? - * It will be false if we're viewing the same parent report action from the report it belongs to rather than the thread. - */ - isThreadReportParentAction?: boolean; - - /** IF the thread divider line will be used */ - shouldUseThreadDividerLine?: boolean; - - /** Whether context menu should be displayed */ - shouldDisplayContextMenu?: boolean; -}; - -function ReportActionItem({ - action, - report, - transactionThreadReport, - linkedReportActionID, - displayAsGroup, - index, - isMostRecentIOUReportAction, - parentReportAction, - shouldDisplayNewMarker, - shouldHideThreadDividerLine = false, - shouldShowSubscriptAvatar = false, - onPress = undefined, - isFirstVisibleReportAction = false, - isThreadReportParentAction = false, - shouldUseThreadDividerLine = false, - shouldDisplayContextMenu = true, - parentReportActionForTransactionThread, -}: ReportActionItemProps) { - const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const blockedFromConcierge = useBlockedFromConcierge(); +function ReportActionItem({action, report, ...props}: PureReportActionItemProps) { const reportID = report?.reportID ?? ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const originalReportID = useMemo(() => ReportUtils.getOriginalReportID(reportID, action) || '-1', [reportID, action]); @@ -182,909 +31,58 @@ function ReportActionItem({ `${ONYXKEYS.COLLECTION.TRANSACTION}${ReportActionsUtils.isMoneyRequestAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID ?? -1 : -1}`, {selector: (transaction) => transaction?.errorFields?.route ?? null}, ); - const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); - const theme = useTheme(); - const styles = useThemeStyles(); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- This is needed to prevent the app from crashing when the app is using imported state. const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID || '-1'}`); - const StyleUtils = useStyleUtils(); - const personalDetails = usePersonalDetails(); - const [isContextMenuActive, setIsContextMenuActive] = useState(() => ReportActionContextMenu.isActiveReportAction(action.reportActionID)); - const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); - const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useState(); - const [isHidden, setIsHidden] = useState(false); - const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); - const reactionListRef = useContext(ReactionListContext); - const {updateHiddenAttachments} = useContext(ReportAttachmentsContext); - const textInputRef = useRef(null); - const popoverAnchorRef = useRef>(null); - const downloadedPreviews = useRef([]); - const prevDraftMessage = usePrevious(draftMessage); const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated}); // The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID || -1}`); - const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; - const reportScrollManager = useReportScrollManager(); - const isActionableWhisper = - ReportActionsUtils.isActionableMentionWhisper(action) || ReportActionsUtils.isActionableTrackExpense(action) || ReportActionsUtils.isActionableReportMentionWhisper(action); - const originalMessage = ReportActionsUtils.getOriginalMessage(action); - - const highlightedBackgroundColorIfNeeded = useMemo( - () => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(theme.messageHighlightBG) : {}), - [StyleUtils, isReportActionLinked, theme.messageHighlightBG], - ); - - const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(action); - const isOriginalMessageAnObject = originalMessage && typeof originalMessage === 'object'; - const hasResolutionInOriginalMessage = isOriginalMessageAnObject && 'resolution' in originalMessage; - const prevActionResolution = usePrevious(isActionableWhisper && hasResolutionInOriginalMessage ? originalMessage?.resolution : null); - - // IOUDetails only exists when we are sending money - const isSendingMoney = - ReportActionsUtils.isMoneyRequestAction(action) && - ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && - ReportActionsUtils.getOriginalMessage(action)?.IOUDetails; - - const updateHiddenState = useCallback( - (isHiddenValue: boolean) => { - setIsHidden(isHiddenValue); - const message = Array.isArray(action.message) ? action.message?.at(-1) : action.message; - const isAttachment = ReportUtils.isReportMessageAttachment(message); - if (!isAttachment) { - return; - } - updateHiddenAttachments(action.reportActionID, isHiddenValue); - }, - [action.reportActionID, action.message, updateHiddenAttachments], - ); - - useEffect( - () => () => { - // ReportActionContextMenu, EmojiPicker and PopoverReactionList are global components, - // we should also hide them when the current component is destroyed - if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { - ReportActionContextMenu.hideContextMenu(); - ReportActionContextMenu.hideDeleteModal(); - } - if (EmojiPickerAction.isActive(action.reportActionID)) { - EmojiPickerAction.hideEmojiPicker(true); - } - if (reactionListRef?.current?.isActiveReportAction(action.reportActionID)) { - reactionListRef?.current?.hideReactionList(); - } - }, - [action.reportActionID, reactionListRef], - ); - - useEffect(() => { - // We need to hide EmojiPicker when this is a deleted parent action - if (!isDeletedParentAction || !EmojiPickerAction.isActive(action.reportActionID)) { - return; - } - - EmojiPickerAction.hideEmojiPicker(true); - }, [isDeletedParentAction, action.reportActionID]); - - useEffect(() => { - if (prevDraftMessage !== undefined || draftMessage === undefined) { - return; - } - - focusComposerWithDelay(textInputRef.current)(true); - }, [prevDraftMessage, draftMessage]); - - useEffect(() => { - if (!Permissions.canUseLinkPreviews()) { - return; - } - - const urls = ReportActionsUtils.extractLinksFromMessageHtml(action); - if (lodashIsEqual(downloadedPreviews.current, urls) || action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return; - } - - downloadedPreviews.current = urls; - Report.expandURLPreview(reportID, action.reportActionID); - }, [action, reportID]); - - useEffect(() => { - if (draftMessage === undefined || !ReportActionsUtils.isDeletedAction(action)) { - return; - } - Report.deleteReportActionDraft(reportID, action); - }, [draftMessage, action, reportID]); - - // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator - // Removed messages should not be shown anyway and should not need this flow - const latestDecision = ReportActionsUtils.getReportActionMessage(action)?.moderationDecision?.decision ?? ''; - useEffect(() => { - if (action.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) { - return; - } - - // Hide reveal message button and show the message if latestDecision is changed to empty - if (!latestDecision) { - setModerationDecision(CONST.MODERATION.MODERATOR_DECISION_APPROVED); - setIsHidden(false); - return; - } - - setModerationDecision(latestDecision); - if ( - ![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === latestDecision) && - !ReportActionsUtils.isPendingRemove(action) - ) { - setIsHidden(true); - return; - } - setIsHidden(false); - }, [latestDecision, action]); - - const toggleContextMenuFromActiveReportAction = useCallback(() => { - setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(action.reportActionID)); - }, [action.reportActionID]); - - const isArchivedRoom = ReportUtils.isArchivedRoomWithID(originalReportID); - const disabledActions = useMemo(() => (!ReportUtils.canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []), [report]); - const isChronosReport = ReportUtils.chatIncludesChronosWithID(originalReportID); - /** - * Show the ReportActionContextMenu modal popover. - * - * @param [event] - A press event. - */ - const showPopover = useCallback( - (event: GestureResponderEvent | MouseEvent) => { - // Block menu on the message being Edited or if the report action item has errors - if (draftMessage !== undefined || !isEmptyObject(action.errors) || !shouldDisplayContextMenu) { - return; - } - - setIsContextMenuActive(true); - const selection = SelectionScraper.getCurrentSelection(); - ReportActionContextMenu.showContextMenu( - CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - popoverAnchorRef.current, - reportID, - action.reportActionID, - originalReportID, - draftMessage ?? '', - () => setIsContextMenuActive(true), - toggleContextMenuFromActiveReportAction, - isArchivedRoom, - isChronosReport, - false, - false, - disabledActions, - false, - setIsEmojiPickerActive as () => void, - undefined, - isThreadReportParentAction, - ); - }, - [ - draftMessage, - action, - reportID, - toggleContextMenuFromActiveReportAction, - originalReportID, - shouldDisplayContextMenu, - disabledActions, - isArchivedRoom, - isChronosReport, - isThreadReportParentAction, - ], - ); - - // Handles manual scrolling to the bottom of the chat when the last message is an actionable whisper and it's resolved. - // This fixes an issue where InvertedFlatList fails to auto scroll down and results in an empty space at the bottom of the chat in IOS. - useEffect(() => { - if (index !== 0 || !isActionableWhisper) { - return; - } - - if (prevActionResolution !== (hasResolutionInOriginalMessage ? originalMessage.resolution : null)) { - reportScrollManager.scrollToIndex(index); - } - }, [index, originalMessage, prevActionResolution, reportScrollManager, isActionableWhisper, hasResolutionInOriginalMessage]); - - const toggleReaction = useCallback( - (emoji: Emoji, ignoreSkinToneOnCompare?: boolean) => { - Report.toggleEmojiReaction(reportID, action, emoji, emojiReactions, undefined, ignoreSkinToneOnCompare); - }, - [reportID, action, emojiReactions], - ); - - const contextValue = useMemo( - () => ({ - anchor: popoverAnchorRef.current, - report: {...report, reportID: report?.reportID ?? ''}, - reportNameValuePairs, - action, - transactionThreadReport, - checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, - isDisabled: false, - }), - [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, reportNameValuePairs], - ); - - const attachmentContextValue = useMemo(() => ({reportID, type: CONST.ATTACHMENT_TYPE.REPORT}), [reportID]); - - const mentionReportContextValue = useMemo(() => ({currentReportID: report?.reportID ?? '-1'}), [report?.reportID]); - - const actionableItemButtons: ActionableItem[] = useMemo(() => { - if (ReportActionsUtils.isActionableAddPaymentCard(action) && userBillingFundID === undefined && shouldRenderAddPaymentCard()) { - return [ - { - text: 'subscription.cardSection.addCardButton', - key: `${action.reportActionID}-actionableAddPaymentCard-submit`, - onPress: () => { - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD); - }, - isMediumSized: true, - isPrimary: true, - }, - ]; - } - - if (!isActionableWhisper && (!ReportActionsUtils.isActionableJoinRequest(action) || ReportActionsUtils.getOriginalMessage(action)?.choice !== ('' as JoinWorkspaceResolution))) { - return []; - } - - if (ReportActionsUtils.isActionableTrackExpense(action)) { - const transactionID = ReportActionsUtils.getOriginalMessage(action)?.transactionID; - return [ - { - text: 'actionableMentionTrackExpense.submit', - key: `${action.reportActionID}-actionableMentionTrackExpense-submit`, - onPress: () => { - ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SUBMIT, action.reportActionID); - }, - isMediumSized: true, - }, - { - text: 'actionableMentionTrackExpense.categorize', - key: `${action.reportActionID}-actionableMentionTrackExpense-categorize`, - onPress: () => { - ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.CATEGORIZE, action.reportActionID); - }, - isMediumSized: true, - }, - { - text: 'actionableMentionTrackExpense.share', - key: `${action.reportActionID}-actionableMentionTrackExpense-share`, - onPress: () => { - ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SHARE, action.reportActionID); - }, - isMediumSized: true, - }, - { - text: 'actionableMentionTrackExpense.nothing', - key: `${action.reportActionID}-actionableMentionTrackExpense-nothing`, - onPress: () => { - Report.dismissTrackExpenseActionableWhisper(reportID, action); - }, - isMediumSized: true, - }, - ]; - } - - if (ReportActionsUtils.isActionableJoinRequest(action)) { - return [ - { - text: 'actionableMentionJoinWorkspaceOptions.accept', - key: `${action.reportActionID}-actionableMentionJoinWorkspace-${CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT}`, - onPress: () => Member.acceptJoinRequest(reportID, action), - isPrimary: true, - }, - { - text: 'actionableMentionJoinWorkspaceOptions.decline', - key: `${action.reportActionID}-actionableMentionJoinWorkspace-${CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.DECLINE}`, - onPress: () => Member.declineJoinRequest(reportID, action), - }, - ]; - } - - if (ReportActionsUtils.isActionableReportMentionWhisper(action)) { - return [ - { - text: 'common.yes', - key: `${action.reportActionID}-actionableReportMentionWhisper-${CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.CREATE}`, - onPress: () => Report.resolveActionableReportMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.CREATE), - isPrimary: true, - }, - { - text: 'common.no', - key: `${action.reportActionID}-actionableReportMentionWhisper-${CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.NOTHING}`, - onPress: () => Report.resolveActionableReportMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.NOTHING), - }, - ]; - } - - return [ - { - text: 'actionableMentionWhisperOptions.invite', - key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE}`, - onPress: () => Report.resolveActionableMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE), - isPrimary: true, - }, - { - text: 'actionableMentionWhisperOptions.nothing', - key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING}`, - onPress: () => Report.resolveActionableMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING), - }, - ]; - }, [action, isActionableWhisper, reportID, userBillingFundID]); - - /** - * Get the content of ReportActionItem - * @param hovered whether the ReportActionItem is hovered - * @param isWhisper whether the report action is a whisper - * @param hasErrors whether the report action has any errors - * @returns child component(s) - */ - const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false): React.JSX.Element => { - let children; - - // Show the MoneyRequestPreview for when expense is present - if ( - ReportActionsUtils.isMoneyRequestAction(action) && - ReportActionsUtils.getOriginalMessage(action) && - // For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message - (ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || - ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || - ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) - ) { - // There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID - const iouReportID = ReportActionsUtils.getOriginalMessage(action)?.IOUReportID ? ReportActionsUtils.getOriginalMessage(action)?.IOUReportID?.toString() ?? '-1' : '-1'; - children = ( - - ); - } else if (ReportActionsUtils.isTripPreview(action)) { - children = ( - - ); - } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - children = ReportUtils.isClosedExpenseReportWithNoExpenses(iouReport) ? ( - ${translate('parentReportAction.deletedReport')}`} /> - ) : ( - setIsPaymentMethodPopoverActive(true)} - onPaymentOptionsHide={() => setIsPaymentMethodPopoverActive(false)} - isWhisper={isWhisper} - /> - ); - } else if (ReportActionsUtils.isTaskAction(action)) { - children = ; - } else if (ReportActionsUtils.isCreatedTaskReportAction(action)) { - children = ( - - - - ); - } else if (ReportActionsUtils.isReimbursementQueuedAction(action)) { - const linkedReport = ReportUtils.isChatThread(report) ? parentReport : report; - const submitterDisplayName = LocalePhoneNumber.formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[linkedReport?.ownerAccountID ?? -1])); - const paymentType = ReportActionsUtils.getOriginalMessage(action)?.paymentType ?? ''; - - const missingPaymentMethod = ReportUtils.getIndicatedMissingPaymentMethod(userWallet, linkedReport?.reportID ?? '-1', action); - children = ( - - <> - {missingPaymentMethod === 'bankAccount' && ( - - )} - {/** - These are the actionable buttons that appear at the bottom of a Concierge message - for example: Invite a user mentioned but not a member of the room - https://github.com/Expensify/App/issues/32741 - */} - {actionableItemButtons.length > 0 && ( - - )} - - ) : ( - - )} - - - - ); - } - const numberOfThreadReplies = action.childVisibleActionCount ?? 0; - - const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(action, isThreadReportParentAction); - const oldestFourAccountIDs = - action.childOldestFourAccountIDs - ?.split(',') - .map((accountID) => Number(accountID)) - .filter((accountID): accountID is number => typeof accountID === 'number') ?? []; - const draftMessageRightAlign = draftMessage !== undefined ? styles.chatItemReactionsDraftRight : {}; - - return ( - <> - {children} - {Permissions.canUseLinkPreviews() && !isHidden && (action.linkMetadata?.length ?? 0) > 0 && ( - - !isEmptyObject(item))} /> - - )} - {!ReportActionsUtils.isMessageDeleted(action) && ( - - { - if (Session.isAnonymousUser()) { - hideContextMenu(false); - - InteractionManager.runAfterInteractions(() => { - Session.signOutAndRedirectToSignIn(); - }); - } else { - toggleReaction(emoji, ignoreSkinToneOnCompare); - } - }} - setIsEmojiPickerActive={setIsEmojiPickerActive} - /> - - )} - - {shouldDisplayThreadReplies && ( - - - - )} - - ); - }; - - /** - * Get ReportActionItem with a proper wrapper - * @param hovered whether the ReportActionItem is hovered - * @param isWhisper whether the ReportActionItem is a whisper - * @param hasErrors whether the report action has any errors - * @returns report action item - */ - - const renderReportActionItem = (hovered: boolean, isWhisper: boolean, hasErrors: boolean): React.JSX.Element => { - const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper, hasErrors); - - if (draftMessage !== undefined) { - return {content}; - } - - if (!displayAsGroup) { - return ( - item === moderationDecision) && - !ReportActionsUtils.isPendingRemove(action) - } - > - {content} - - ); - } - - return {content}; - }; - - if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - const transactionID = ReportActionsUtils.isMoneyRequestAction(parentReportActionForTransactionThread) - ? ReportActionsUtils.getOriginalMessage(parentReportActionForTransactionThread)?.IOUTransactionID - : '-1'; - - return ( - - ); - } - if (ReportActionsUtils.isChronosOOOListAction(action)) { - return ( - - ); - } - - // For the `pay` IOU action on non-pay expense flow, we don't want to render anything if `isWaitingOnBankAccount` is true - // Otherwise, we will see two system messages informing the payee needs to add a bank account or wallet - if ( - ReportActionsUtils.isMoneyRequestAction(action) && - !!report?.isWaitingOnBankAccount && - ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && - !isSendingMoney - ) { - return null; - } - - // If action is actionable whisper and resolved by user, then we don't want to render anything - if (isActionableWhisper && (hasResolutionInOriginalMessage ? originalMessage.resolution : null)) { - return null; - } - - // We currently send whispers to all report participants and hide them in the UI for users that shouldn't see them. - // This is a temporary solution needed for comment-linking. - // The long term solution will leverage end-to-end encryption and only targeted users will be able to decrypt. - if (ReportActionsUtils.isWhisperActionTargetedToOthers(action)) { - return null; - } - - const hasErrors = !isEmptyObject(action.errors); - const whisperedTo = ReportActionsUtils.getWhisperedTo(action); - const isMultipleParticipant = whisperedTo.length > 1; - - const iouReportID = - ReportActionsUtils.isMoneyRequestAction(action) && ReportActionsUtils.getOriginalMessage(action)?.IOUReportID - ? (ReportActionsUtils.getOriginalMessage(action)?.IOUReportID ?? '').toString() - : '-1'; - const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(iouReportID); - const isWhisper = whisperedTo.length > 0 && transactionsWithReceipts.length === 0; - const whisperedToPersonalDetails = isWhisper - ? (Object.values(personalDetails ?? {}).filter((details) => whisperedTo.includes(details?.accountID ?? -1)) as OnyxTypes.PersonalDetails[]) - : []; - const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedTo); - const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; + const personalDetails = usePersonalDetails(); + const blockedFromConcierge = useBlockedFromConcierge(); + const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); + const linkedReport = ReportUtils.isChatThread(report) ? parentReport : report; + const missingPaymentMethod = ReportUtils.getIndicatedMissingPaymentMethod(userWallet, linkedReport?.reportID ?? '-1', action); return ( - shouldUseNarrowLayout && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} - onPressOut={() => ControlSelection.unblock()} - onSecondaryInteraction={showPopover} - preventDefaultContextMenu={draftMessage === undefined && !hasErrors} - withoutFocusOnSecondaryInteraction - accessibilityLabel={translate('accessibilityHints.chatMessage')} - accessible - > - - {(hovered) => ( - - {shouldDisplayNewMarker && (!shouldUseThreadDividerLine || !isFirstVisibleReportAction) && } - {shouldDisplayContextMenu && ( - - )} - - { - const transactionID = ReportActionsUtils.isMoneyRequestAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : undefined; - if (transactionID) { - Transaction.clearError(transactionID); - } - ReportActions.clearAllRelatedReportActionErrors(reportID, action); - }} - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - pendingAction={ - draftMessage !== undefined ? undefined : action.pendingAction ?? (action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : undefined) - } - shouldHideOnDelete={!isThreadReportParentAction} - errors={linkedTransactionRouteError ?? ErrorUtils.getLatestErrorMessageField(action as ErrorUtils.OnyxDataWithErrors)} - errorRowStyles={[styles.ml10, styles.mr2]} - needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(action)} - shouldDisableStrikeThrough - > - {isWhisper && ( - - - - - - {translate('reportActionContextMenu.onlyVisible')} -   - - - - )} - {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper, hasErrors)} - - - - )} - - - - - + >, + report, + )} + modifiedExpenseMessage={ModifiedExpenseMessage.getForReportAction(reportID, action)} + getTransactionsWithReceipts={ReportUtils.getTransactionsWithReceipts} + clearError={Transaction.clearError} + clearAllRelatedReportActionErrors={ReportActions.clearAllRelatedReportActionErrors} + dismissTrackExpenseActionableWhisper={Report.dismissTrackExpenseActionableWhisper} + userBillingFundID={userBillingFundID} + reportAutomaticallyForwardedMessage={ReportUtils.getReportAutomaticallyForwardedMessage(action as ReportAction, reportID)} + /> ); } -export default memo(ReportActionItem, (prevProps, nextProps) => { - const prevParentReportAction = prevProps.parentReportAction; - const nextParentReportAction = nextProps.parentReportAction; - return ( - prevProps.displayAsGroup === nextProps.displayAsGroup && - prevProps.isMostRecentIOUReportAction === nextProps.isMostRecentIOUReportAction && - prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && - lodashIsEqual(prevProps.action, nextProps.action) && - lodashIsEqual(prevProps.report?.pendingFields, nextProps.report?.pendingFields) && - lodashIsEqual(prevProps.report?.isDeletedParentAction, nextProps.report?.isDeletedParentAction) && - lodashIsEqual(prevProps.report?.errorFields, nextProps.report?.errorFields) && - prevProps.report?.statusNum === nextProps.report?.statusNum && - prevProps.report?.stateNum === nextProps.report?.stateNum && - prevProps.report?.parentReportID === nextProps.report?.parentReportID && - prevProps.report?.parentReportActionID === nextProps.report?.parentReportActionID && - // TaskReport's created actions render the TaskView, which updates depending on certain fields in the TaskReport - ReportUtils.isTaskReport(prevProps.report) === ReportUtils.isTaskReport(nextProps.report) && - prevProps.action.actionName === nextProps.action.actionName && - prevProps.report?.reportName === nextProps.report?.reportName && - prevProps.report?.description === nextProps.report?.description && - ReportUtils.isCompletedTaskReport(prevProps.report) === ReportUtils.isCompletedTaskReport(nextProps.report) && - prevProps.report?.managerID === nextProps.report?.managerID && - prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine && - prevProps.report?.total === nextProps.report?.total && - prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal && - prevProps.report?.policyAvatar === nextProps.report?.policyAvatar && - prevProps.linkedReportActionID === nextProps.linkedReportActionID && - lodashIsEqual(prevProps.report?.fieldList, nextProps.report?.fieldList) && - lodashIsEqual(prevProps.transactionThreadReport, nextProps.transactionThreadReport) && - lodashIsEqual(prevProps.reportActions, nextProps.reportActions) && - lodashIsEqual(prevParentReportAction, nextParentReportAction) - ); -}); +export default ReportActionItem; diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index 679a9b3f87e6..109a71ed9980 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -406,7 +406,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal, tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2, renderTooltipContent: renderProductTrainingTooltip, - tooltipWrapperStyle: styles.quickActionTooltipWrapper, + tooltipWrapperStyle: styles.productTrainingTooltipWrapper, onHideTooltip: hideProductTrainingTooltip, shouldRenderTooltip: shouldShowProductTrainingTooltip, }; @@ -452,9 +452,9 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl quickActionAvatars, styles.popoverMenuItem.paddingHorizontal, styles.popoverMenuItem.paddingVertical, - styles.quickActionTooltipWrapper, styles.pt3, styles.pb2, + styles.productTrainingTooltipWrapper, renderProductTrainingTooltip, hideProductTrainingTooltip, quickAction?.action, diff --git a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx index c65ae8957dbe..07b888aaa5f0 100644 --- a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx @@ -51,6 +51,7 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) { useEffect(() => { submitButton.current?.focus(); + User.resetValidateActionCodeSent(); }, []); useEffect(() => { diff --git a/src/styles/index.ts b/src/styles/index.ts index a09796000b36..ae2f97b6b72f 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4001,19 +4001,15 @@ const styles = (theme: ThemeColors) => borderRadius: variables.componentBorderRadiusMedium, }, - quickActionTooltipWrapper: { + productTrainingTooltipWrapper: { backgroundColor: theme.tooltipHighlightBG, + borderRadius: variables.componentBorderRadiusNormal, }, - quickActionTooltipTitle: { - ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD, - fontSize: variables.fontSizeLabel, - color: theme.tooltipHighlightText, - }, - - quickActionTooltipSubtitle: { + productTrainingTooltipText: { fontSize: variables.fontSizeLabel, color: theme.textDark, + lineHeight: variables.lineHeightLarge, }, quickReactionsContainer: { diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 223fc1c56818..68132de5fcad 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -81,7 +81,7 @@ const darkTheme = { ourMentionText: colors.green100, ourMentionBG: colors.green600, tooltipHighlightBG: colors.green100, - tooltipHighlightText: colors.green500, + tooltipHighlightText: colors.green400, tooltipSupportingText: colors.productLight800, tooltipPrimaryText: colors.productLight900, trialBannerBackgroundColor: colors.green700, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 151388e77136..7be69e5461d1 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -81,7 +81,7 @@ const lightTheme = { ourMentionText: colors.green600, ourMentionBG: colors.green100, tooltipHighlightBG: colors.green100, - tooltipHighlightText: colors.green500, + tooltipHighlightText: colors.green400, tooltipSupportingText: colors.productDark800, tooltipPrimaryText: colors.productDark900, trialBannerBackgroundColor: colors.green100, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index c8a6f7025912..2a2f9a6fc9ef 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -258,6 +258,12 @@ export default { composerTooltipShiftHorizontal: 10, composerTooltipShiftVertical: -10, gbrTooltipShiftHorizontal: -20, + fabTooltipShiftHorizontal: -15, + workspaceLHNtooltipShiftHorizontal: 26, + searchFiltersTooltipShiftHorizontal: -20, + searchFiltersTooltipShiftHorizontalNarrow: -10, + searchFiltersTooltipShiftVerticalNarrow: 5, + bottomTabInboxTooltipShiftHorizontal: 36, inlineImagePreviewMinSize: 64, inlineImagePreviewMaxSize: 148, diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts index fe24dc061aee..53df7c403ca0 100644 --- a/src/types/onyx/DismissedProductTraining.ts +++ b/src/types/onyx/DismissedProductTraining.ts @@ -1,6 +1,15 @@ import CONST from '@src/CONST'; -const {CONCEIRGE_LHN_GBR, RENAME_SAVED_SEARCH, WORKSAPCE_CHAT_CREATE, QUICK_ACTION_BUTTON} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; +const { + CONCEIRGE_LHN_GBR, + RENAME_SAVED_SEARCH, + WORKSAPCE_CHAT_CREATE, + QUICK_ACTION_BUTTON, + SEARCH_FILTER_BUTTON_TOOLTIP, + BOTTOM_NAV_INBOX_TOOLTIP, + LHN_WORKSPACE_CHAT_TOOLTIP, + GLOBAL_CREATE_TOOLTIP, +} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; /** * This type is used to store the timestamp of when the user dismisses a product training ui elements. */ @@ -29,6 +38,26 @@ type DismissedProductTraining = { * When user dismisses the quickActionButton product training tooltip, we store the timestamp here. */ [QUICK_ACTION_BUTTON]: Date; + + /** + * When user dismisses the searchFilterButtonTooltip product training tooltip, we store the timestamp here. + */ + [SEARCH_FILTER_BUTTON_TOOLTIP]: Date; + + /** + * When user dismisses the bottomNavInboxTooltip product training tooltip, we store the timestamp here. + */ + [BOTTOM_NAV_INBOX_TOOLTIP]: Date; + + /** + * When user dismisses the lhnWorkspaceChatTooltip product training tooltip, we store the timestamp here. + */ + [LHN_WORKSPACE_CHAT_TOOLTIP]: Date; + + /** + * When user dismisses the globalCreateTooltip product training tooltip, we store the timestamp here. + */ + [GLOBAL_CREATE_TOOLTIP]: Date; }; export default DismissedProductTraining; diff --git a/src/types/onyx/JoinablePolicies.ts b/src/types/onyx/JoinablePolicies.ts new file mode 100644 index 000000000000..6ccdd9f971de --- /dev/null +++ b/src/types/onyx/JoinablePolicies.ts @@ -0,0 +1,22 @@ +/** Model of Joinable Policy */ +type JoinablePolicy = { + /** Policy id of the workspace */ + policyID: string; + /** Owner of the workspace */ + policyOwner: string; + /** Name of the workspace */ + policyName: string; + /** Count of members in the policy */ + employeeCount: number; + /** If the user has already requested access, and is currently awaiting decision */ + hasPendingAccess: boolean; + /** Weather the user needs an approval to join the workspace or not */ + automaticJoiningEnabled: boolean; +}; + +/** Model of Joinable Policies */ +type JoinablePolicies = Record; + +export default JoinablePolicies; + +export type {JoinablePolicy}; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index fd94531abb0f..0cb9a735aad4 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -598,6 +598,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE]: OriginalMessageModifiedExpense; [CONST.REPORT.ACTIONS.TYPE.MOVED]: OriginalMessageMoved; [CONST.REPORT.ACTIONS.TYPE.OUTDATED_BANK_ACCOUNT]: never; + [CONST.REPORT.ACTIONS.TYPE.REIMBURSED]: never; [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_BOUNCE]: never; [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACH_CANCELLED]: never; [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_ACCOUNT_CHANGED]: never; diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 16d09a687346..4197fa402530 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -7,7 +7,7 @@ import type {ReportActionListItemType, ReportListItemType, TransactionListItemTy import type CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import type {ACHAccount, ApprovalRule, ExpenseRule} from './Policy'; -import type {InvoiceReceiver} from './Report'; +import type {InvoiceReceiver, Participants} from './Report'; import type ReportActionName from './ReportActionName'; import type ReportNameValuePairs from './ReportNameValuePairs'; @@ -157,6 +157,9 @@ type SearchReport = { /** Whether the report has violations or errors */ hasError?: boolean; + + /** Collection of report participants, indexed by their accountID */ + participants?: Participants; }; /** Model of report action search result */ diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index eeda322f6205..9d4d319d05e8 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -31,6 +31,7 @@ import type ImportedSpreadsheet from './ImportedSpreadsheet'; import type IntroSelected from './IntroSelected'; import type InvitedEmailsToAccountIDs from './InvitedEmailsToAccountIDs'; import type IOU from './IOU'; +import type JoinablePolicies from './JoinablePolicies'; import type LastExportMethod from './LastExportMethod'; import type LastPaymentMethod from './LastPaymentMethod'; import type LastSelectedDistanceRates from './LastSelectedDistanceRates'; @@ -240,5 +241,6 @@ export type { Onboarding, OnboardingPurpose, ValidateMagicCodeAction, + JoinablePolicies, DismissedProductTraining, }; diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index b94740375261..c14753d15920 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -596,4 +596,44 @@ describe('ReportActionsUtils', () => { ); }); }); + + describe('getReportActionMessageFragments', () => { + it('should return the correct fragment for the REIMBURSED action', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.REIMBURSED, + reportActionID: '1', + created: '1', + message: [ + { + type: 'TEXT', + style: 'strong', + text: 'Concierge', + }, + { + type: 'TEXT', + style: 'normal', + text: ' reimbursed this report', + }, + { + type: 'TEXT', + style: 'normal', + text: ' on behalf of you', + }, + { + type: 'TEXT', + style: 'normal', + text: ' from the bank account ending in 1111', + }, + { + type: 'TEXT', + style: 'normal', + text: '. Money is on its way to your bank account ending in 0000. Reimbursement estimated to complete on Dec 16.', + }, + ], + }; + const expectedMessage = ReportActionsUtils.getReportActionMessageText(action); + const expectedFragments = ReportActionsUtils.getReportActionMessageFragments(action); + expect(expectedFragments).toEqual([{text: expectedMessage, html: `${expectedMessage}`, type: 'COMMENT'}]); + }); + }); }); diff --git a/tests/unit/SearchAutocompleteParserTest.ts b/tests/unit/SearchAutocompleteParserTest.ts index 2571b03089b1..9e8fd6e872ad 100644 --- a/tests/unit/SearchAutocompleteParserTest.ts +++ b/tests/unit/SearchAutocompleteParserTest.ts @@ -1,43 +1,84 @@ import type {SearchQueryJSON} from '@components/Search/types'; import * as autocompleteParser from '@libs/SearchParser/autocompleteParser'; +import parserCommonTests from '../utils/fixtures/searchParsersCommonQueries'; const tests = [ { - query: 'date>2024-01-01 amount>100 merchant:"A B" description:A,B,C ,, reportID:123456789 word', + query: parserCommonTests.simple, expected: { - autocomplete: null, - ranges: [], + autocomplete: { + key: 'status', + value: 'all', + start: 20, + length: 3, + }, + ranges: [ + {key: 'type', value: 'expense', start: 5, length: 7}, + {key: 'status', value: 'all', start: 20, length: 3}, + ], }, }, { - query: ',', + query: parserCommonTests.userFriendlyNames, expected: { autocomplete: null, - ranges: [], + ranges: [ + {key: 'taxRate', value: 'rate1', start: 9, length: 5}, + {key: 'expenseType', value: 'card', start: 28, length: 4}, + {key: 'cardID', value: 'Big Bank', start: 38, length: 10}, + ], }, }, { - query: 'tag:,,', + query: parserCommonTests.oldNames, expected: { autocomplete: null, - ranges: [], + ranges: [ + {key: 'taxRate', value: 'rate1', start: 8, length: 5}, + {key: 'expenseType', value: 'card', start: 26, length: 4}, + {key: 'cardID', value: 'Big Bank', start: 38, length: 10}, + ], }, }, { - query: 'type:expense status:all', + query: parserCommonTests.complex, expected: { autocomplete: { - key: 'status', - value: 'all', - start: 20, - length: 3, + key: 'category', + length: 22, + start: 102, + value: 'meal & entertainment', }, ranges: [ - {key: 'type', value: 'expense', start: 5, length: 7}, - {key: 'status', value: 'all', start: 20, length: 3}, + {key: 'expenseType', length: 4, start: 24, value: 'cash'}, + {key: 'expenseType', length: 4, start: 29, value: 'card'}, + {key: 'category', length: 6, start: 89, value: 'travel'}, + {key: 'category', length: 5, start: 96, value: 'hotel'}, + {key: 'category', length: 22, start: 102, value: 'meal & entertainment'}, ], }, }, + { + query: 'date>2024-01-01 amount>100 merchant:"A B" description:A,B,C ,, reportid:123456789 word', + expected: { + autocomplete: null, + ranges: [], + }, + }, + { + query: ',', + expected: { + autocomplete: null, + ranges: [], + }, + }, + { + query: 'tag:,,', + expected: { + autocomplete: null, + ranges: [], + }, + }, { query: 'in:123456 currency:USD ', expected: { @@ -131,12 +172,12 @@ const tests = [ }, }, { - query: 'in:"Big Room" from:Friend category:Car,"Cell Phone" status:all expenseType:card,cash', + query: 'in:"Big Room" from:Friend category:Car,"Cell Phone" status:all expense-type:card,cash', expected: { autocomplete: { key: 'expenseType', value: 'cash', - start: 80, + start: 81, length: 4, }, ranges: [ @@ -145,28 +186,28 @@ const tests = [ {key: 'category', value: 'Car', start: 35, length: 3}, {key: 'category', value: 'Cell Phone', start: 39, length: 12}, {key: 'status', value: 'all', start: 59, length: 3}, - {key: 'expenseType', value: 'card', start: 75, length: 4}, - {key: 'expenseType', value: 'cash', start: 80, length: 4}, + {key: 'expenseType', value: 'card', start: 76, length: 4}, + {key: 'expenseType', value: 'cash', start: 81, length: 4}, ], }, }, { - query: 'currency:PLN,USD keyword taxRate:tax merchant:"Expensify, Inc." tag:"General Overhead",IT expenseType:card,distance', + query: 'currency:PLN,USD keyword tax-rate:tax merchant:"Expensify, Inc." tag:"General Overhead",IT expense-type:card,distance', expected: { autocomplete: { key: 'expenseType', value: 'distance', - start: 108, + start: 110, length: 8, }, ranges: [ {key: 'currency', value: 'PLN', start: 9, length: 3}, {key: 'currency', value: 'USD', start: 13, length: 3}, - {key: 'taxRate', value: 'tax', start: 33, length: 3}, - {key: 'tag', value: 'General Overhead', start: 69, length: 18}, - {key: 'tag', value: 'IT', start: 88, length: 2}, - {key: 'expenseType', value: 'card', start: 103, length: 4}, - {key: 'expenseType', value: 'distance', start: 108, length: 8}, + {key: 'taxRate', value: 'tax', start: 34, length: 3}, + {key: 'tag', value: 'General Overhead', start: 70, length: 18}, + {key: 'tag', value: 'IT', start: 89, length: 2}, + {key: 'expenseType', value: 'card', start: 105, length: 4}, + {key: 'expenseType', value: 'distance', start: 110, length: 8}, ], }, }, diff --git a/tests/unit/SearchParserTest.ts b/tests/unit/SearchParserTest.ts index 2964e406b512..8eaf012a3c4b 100644 --- a/tests/unit/SearchParserTest.ts +++ b/tests/unit/SearchParserTest.ts @@ -1,9 +1,10 @@ import type {SearchQueryJSON} from '@components/Search/types'; import * as searchParser from '@libs/SearchParser/searchParser'; +import parserCommonTests from '../utils/fixtures/searchParsersCommonQueries'; const tests = [ { - query: 'type:expense status:all', + query: parserCommonTests.simple, expected: { type: 'expense', status: 'all', @@ -12,6 +13,128 @@ const tests = [ filters: null, }, }, + { + query: parserCommonTests.userFriendlyNames, + expected: { + type: 'expense', + status: 'all', + sortBy: 'date', + sortOrder: 'desc', + filters: { + operator: 'and', + left: { + operator: 'and', + left: { + operator: 'and', + left: { + operator: 'eq', + left: 'taxRate', + right: 'rate1', + }, + right: { + operator: 'eq', + left: 'expenseType', + right: 'card', + }, + }, + right: { + operator: 'eq', + left: 'cardID', + right: 'Big Bank', + }, + }, + right: { + operator: 'eq', + left: 'reportID', + right: 'report', + }, + }, + }, + }, + { + query: parserCommonTests.oldNames, + expected: { + type: 'expense', + status: 'all', + sortBy: 'date', + sortOrder: 'desc', + filters: { + operator: 'and', + left: { + operator: 'and', + left: { + operator: 'and', + left: { + operator: 'eq', + left: 'taxRate', + right: 'rate1', + }, + right: { + operator: 'eq', + left: 'expenseType', + right: 'card', + }, + }, + right: { + operator: 'eq', + left: 'cardID', + right: 'Big Bank', + }, + }, + right: { + operator: 'eq', + left: 'reportID', + right: 'report', + }, + }, + }, + }, + { + query: parserCommonTests.complex, + expected: { + type: 'expense', + status: 'all', + sortBy: 'date', + sortOrder: 'desc', + filters: { + operator: 'and', + left: { + operator: 'and', + left: { + operator: 'and', + left: { + operator: 'and', + left: { + operator: 'gt', + left: 'amount', + right: '200', + }, + right: { + operator: 'eq', + left: 'expenseType', + right: ['cash', 'card'], + }, + }, + right: { + operator: 'eq', + left: 'description', + right: 'Las Vegas party', + }, + }, + right: { + operator: 'eq', + left: 'date', + right: '2024-06-01', + }, + }, + right: { + operator: 'eq', + left: 'category', + right: ['travel', 'hotel', 'meal & entertainment'], + }, + }, + }, + }, { query: ',', expected: { @@ -159,7 +282,7 @@ const tests = [ }, }, { - query: 'amount>100 amount<200 from:usera@user.com taxRate:1234 cardID:1234 reportID:12345 tag:ecx date>2023-01-01', + query: 'amount>100 amount<200 from:usera@user.com tax-rate:1234 card:1234 reportid:12345 tag:ecx date>2023-01-01', expected: { type: 'expense', status: 'all', @@ -228,52 +351,6 @@ const tests = [ }, }, }, - { - query: 'amount>200 expenseType:cash,card description:"Las Vegas party" date:2024-06-01 category:travel,hotel,"meal & entertainment"', - expected: { - type: 'expense', - status: 'all', - sortBy: 'date', - sortOrder: 'desc', - filters: { - operator: 'and', - left: { - operator: 'and', - left: { - operator: 'and', - left: { - operator: 'and', - left: { - operator: 'gt', - left: 'amount', - right: '200', - }, - right: { - operator: 'eq', - left: 'expenseType', - right: ['cash', 'card'], - }, - }, - right: { - operator: 'eq', - left: 'description', - right: 'Las Vegas party', - }, - }, - right: { - operator: 'eq', - left: 'date', - right: '2024-06-01', - }, - }, - right: { - operator: 'eq', - left: 'category', - right: ['travel', 'hotel', 'meal & entertainment'], - }, - }, - }, - }, { query: 'amount>200 las vegas', expected: { diff --git a/tests/utils/fixtures/searchParsersCommonQueries.ts b/tests/utils/fixtures/searchParsersCommonQueries.ts new file mode 100644 index 000000000000..1f398717d994 --- /dev/null +++ b/tests/utils/fixtures/searchParsersCommonQueries.ts @@ -0,0 +1,11 @@ +/** + * Tests to ensure that both parsers use the same set of base rules + */ +const parserCommonTests = { + simple: 'type:expense status:all', + userFriendlyNames: 'tax-rate:rate1 expense-type:card card:"Big Bank" reportid:report', + oldNames: 'taxRate:rate1 expenseType:card cardID:"Big Bank" reportID:report', + complex: 'amount>200 expense-type:cash,card description:"Las Vegas party" date:2024-06-01 category:travel,hotel,"meal & entertainment"', +}; + +export default parserCommonTests;