diff --git a/.github/actions/javascript/postTestBuildComment/action.yml b/.github/actions/javascript/postTestBuildComment/action.yml index 00c826badf9f..d6c3391f3c26 100644 --- a/.github/actions/javascript/postTestBuildComment/action.yml +++ b/.github/actions/javascript/postTestBuildComment/action.yml @@ -3,22 +3,22 @@ description: "Mark pull requests as deployed on production or staging" inputs: PR_NUMBER: description: "Pull request number" - required: true + required: false GITHUB_TOKEN: description: "Github token for authentication" default: "${{ github.token }}" ANDROID: description: "Android job result ('success', 'failure', 'cancelled', or 'skipped')" - required: true + required: false DESKTOP: description: "Desktop job result ('success', 'failure', 'cancelled', or 'skipped')" - required: true + required: false IOS: description: "iOS job result ('success', 'failure', 'cancelled', or 'skipped')" - required: true + required: false WEB: description: "Web job result ('success', 'failure', 'cancelled', or 'skipped')" - required: true + required: false ANDROID_LINK: description: "Link for the Android build" required: false diff --git a/.github/actions/javascript/postTestBuildComment/index.js b/.github/actions/javascript/postTestBuildComment/index.js index aa0d0ec802b7..8906bb597d63 100644 --- a/.github/actions/javascript/postTestBuildComment/index.js +++ b/.github/actions/javascript/postTestBuildComment/index.js @@ -11502,31 +11502,38 @@ const github_1 = __nccwpck_require__(5438); const CONST_1 = __importDefault(__nccwpck_require__(9873)); const GithubUtils_1 = __importDefault(__nccwpck_require__(9296)); function getTestBuildMessage() { - console.log('Input for android', core.getInput('ANDROID', { required: true })); - const androidSuccess = core.getInput('ANDROID', { required: true }) === 'success'; - const desktopSuccess = core.getInput('DESKTOP', { required: true }) === 'success'; - const iOSSuccess = core.getInput('IOS', { required: true }) === 'success'; - const webSuccess = core.getInput('WEB', { required: true }) === 'success'; - const androidLink = androidSuccess ? core.getInput('ANDROID_LINK') : '❌ FAILED ❌'; - const desktopLink = desktopSuccess ? core.getInput('DESKTOP_LINK') : '❌ FAILED ❌'; - const iOSLink = iOSSuccess ? core.getInput('IOS_LINK') : '❌ FAILED ❌'; - const webLink = webSuccess ? core.getInput('WEB_LINK') : '❌ FAILED ❌'; - const androidQRCode = androidSuccess - ? `![Android](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${androidLink})` - : "The QR code can't be generated, because the android build failed"; - const desktopQRCode = desktopSuccess - ? `![Desktop](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${desktopLink})` - : "The QR code can't be generated, because the Desktop build failed"; - const iOSQRCode = iOSSuccess ? `![iOS](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${iOSLink})` : "The QR code can't be generated, because the iOS build failed"; - const webQRCode = webSuccess ? `![Web](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${webLink})` : "The QR code can't be generated, because the web build failed"; + const inputs = ['ANDROID', 'DESKTOP', 'IOS', 'WEB']; + const names = { + [inputs[0]]: 'Android', + [inputs[1]]: 'Desktop', + [inputs[2]]: 'iOS', + [inputs[3]]: 'Web', + }; + const result = inputs.reduce((acc, platform) => { + const input = core.getInput(platform, { required: false }); + if (!input) { + acc[platform] = { link: 'N/A', qrCode: 'N/A' }; + return acc; + } + const isSuccess = input === 'success'; + const link = isSuccess ? core.getInput(`${platform}_LINK`) : '❌ FAILED ❌'; + const qrCode = isSuccess + ? `![${names[platform]}](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${link})` + : `The QR code can't be generated, because the ${names[platform]} build failed`; + acc[platform] = { + link, + qrCode, + }; + return acc; + }, {}); const message = `:test_tube::test_tube: Use the links below to test this adhoc build on Android, iOS, Desktop, and Web. Happy testing! :test_tube::test_tube: | Android :robot: | iOS :apple: | | ------------- | ------------- | -| ${androidLink} | ${iOSLink} | -| ${androidQRCode} | ${iOSQRCode} | +| ${result.ANDROID.link} | ${result.IOS.link} | +| ${result.ANDROID.qrCode} | ${result.IOS.qrCode} | | Desktop :computer: | Web :spider_web: | -| ${desktopLink} | ${webLink} | -| ${desktopQRCode} | ${webQRCode} | +| ${result.DESKTOP.link} | ${result.WEB.link} | +| ${result.DESKTOP.qrCode} | ${result.WEB.qrCode} | --- diff --git a/.github/actions/javascript/postTestBuildComment/postTestBuildComment.ts b/.github/actions/javascript/postTestBuildComment/postTestBuildComment.ts index 4fba1a6cb2ad..813665f2dff5 100644 --- a/.github/actions/javascript/postTestBuildComment/postTestBuildComment.ts +++ b/.github/actions/javascript/postTestBuildComment/postTestBuildComment.ts @@ -1,37 +1,48 @@ import * as core from '@actions/core'; import {context} from '@actions/github'; +import type {TupleToUnion} from 'type-fest'; import CONST from '@github/libs/CONST'; import GithubUtils from '@github/libs/GithubUtils'; function getTestBuildMessage(): string { - console.log('Input for android', core.getInput('ANDROID', {required: true})); - const androidSuccess = core.getInput('ANDROID', {required: true}) === 'success'; - const desktopSuccess = core.getInput('DESKTOP', {required: true}) === 'success'; - const iOSSuccess = core.getInput('IOS', {required: true}) === 'success'; - const webSuccess = core.getInput('WEB', {required: true}) === 'success'; + const inputs = ['ANDROID', 'DESKTOP', 'IOS', 'WEB'] as const; + const names = { + [inputs[0]]: 'Android', + [inputs[1]]: 'Desktop', + [inputs[2]]: 'iOS', + [inputs[3]]: 'Web', + }; - const androidLink = androidSuccess ? core.getInput('ANDROID_LINK') : '❌ FAILED ❌'; - const desktopLink = desktopSuccess ? core.getInput('DESKTOP_LINK') : '❌ FAILED ❌'; - const iOSLink = iOSSuccess ? core.getInput('IOS_LINK') : '❌ FAILED ❌'; - const webLink = webSuccess ? core.getInput('WEB_LINK') : '❌ FAILED ❌'; + const result = inputs.reduce((acc, platform) => { + const input = core.getInput(platform, {required: false}); - const androidQRCode = androidSuccess - ? `![Android](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${androidLink})` - : "The QR code can't be generated, because the android build failed"; - const desktopQRCode = desktopSuccess - ? `![Desktop](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${desktopLink})` - : "The QR code can't be generated, because the Desktop build failed"; - const iOSQRCode = iOSSuccess ? `![iOS](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${iOSLink})` : "The QR code can't be generated, because the iOS build failed"; - const webQRCode = webSuccess ? `![Web](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${webLink})` : "The QR code can't be generated, because the web build failed"; + if (!input) { + acc[platform] = {link: 'N/A', qrCode: 'N/A'}; + return acc; + } + + const isSuccess = input === 'success'; + + const link = isSuccess ? core.getInput(`${platform}_LINK`) : '❌ FAILED ❌'; + const qrCode = isSuccess + ? `![${names[platform]}](https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${link})` + : `The QR code can't be generated, because the ${names[platform]} build failed`; + + acc[platform] = { + link, + qrCode, + }; + return acc; + }, {} as Record, {link: string; qrCode: string}>); const message = `:test_tube::test_tube: Use the links below to test this adhoc build on Android, iOS, Desktop, and Web. Happy testing! :test_tube::test_tube: | Android :robot: | iOS :apple: | | ------------- | ------------- | -| ${androidLink} | ${iOSLink} | -| ${androidQRCode} | ${iOSQRCode} | +| ${result.ANDROID.link} | ${result.IOS.link} | +| ${result.ANDROID.qrCode} | ${result.IOS.qrCode} | | Desktop :computer: | Web :spider_web: | -| ${desktopLink} | ${webLink} | -| ${desktopQRCode} | ${webQRCode} | +| ${result.DESKTOP.link} | ${result.WEB.link} | +| ${result.DESKTOP.qrCode} | ${result.WEB.qrCode} | --- diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2cacdf557560..3ce3114d6ab0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -114,6 +114,51 @@ 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 @@ -386,6 +431,12 @@ 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" @@ -503,6 +554,7 @@ jobs: run: | op document get --output ./OldApp_AppStore.mobileprovision OldApp_AppStore op document get --output ./OldApp_AppStore_Share_Extension.mobileprovision OldApp_AppStore_Share_Extension + op document get --output ./OldApp_AppStore_Notification_Service.mobileprovision OldApp_AppStore_Notification_Service - name: Decrypt AppStore profile run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg @@ -679,7 +731,7 @@ jobs: name: Post a Slack message when any platform fails to build or deploy runs-on: ubuntu-latest if: ${{ failure() }} - needs: [buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] + needs: [buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] steps: - name: Checkout uses: actions/checkout@v4 @@ -694,15 +746,21 @@ 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, android_hybrid, desktop, iOS, iOS_hybrid, web] + needs: [buildAndroid, uploadAndroid, submitAndroid, 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 [ "${{ needs.uploadAndroid.result }}" == "success" ]; then - isAtLeastOnePlatformDeployed="true" + 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 fi if [ "${{ needs.iOS.result }}" == "success" ] || \ @@ -727,8 +785,14 @@ jobs: isAllPlatformsDeployed="true" fi - if [ "${{ needs.uploadAndroid.result }}" != "success" ]; then - isAllPlatformsDeployed="false" + 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 fi echo "IS_ALL_PLATFORMS_DEPLOYED=$isAllPlatformsDeployed" >> "$GITHUB_OUTPUT" @@ -876,7 +940,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, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, submitAndroid, 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 @@ -930,11 +994,11 @@ jobs: postGithubComments: uses: ./.github/workflows/postDeployComments.yml if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} - needs: [prep, buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, submitAndroid, 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.uploadAndroid.result }} + android: ${{ github.ref == 'refs/heads/production' && needs.submitAndroid.result || needs.uploadAndroid.result }} android_hybrid: ${{ needs.android_hybrid.result }} ios: ${{ needs.iOS.result }} ios_hybrid: ${{ needs.iOS_hybrid.result }} diff --git a/.github/workflows/testBuildHybrid.yml b/.github/workflows/testBuildHybrid.yml new file mode 100644 index 000000000000..c84fe41fddae --- /dev/null +++ b/.github/workflows/testBuildHybrid.yml @@ -0,0 +1,221 @@ +name: Build and deploy hybird apps for testing + +on: + workflow_dispatch: + inputs: + PULL_REQUEST_NUMBER: + description: Pull Request number for correct placement of apps + required: true + pull_request_target: + types: [opened, synchronize, labeled] + branches: ['*ci-test/**'] + +env: + PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} + +jobs: + validateActor: + runs-on: ubuntu-latest + outputs: + READY_TO_BUILD: ${{ fromJSON(steps.isExpensifyEmployee.outputs.IS_EXPENSIFY_EMPLOYEE) && fromJSON(steps.hasReadyToBuildLabel.outputs.HAS_READY_TO_BUILD_LABEL) }} + steps: + - name: Is Expensify employee + id: isExpensifyEmployee + run: | + if gh api /orgs/Expensify/teams/expensify-expensify/memberships/${{ github.actor }} --silent; then + echo "IS_EXPENSIFY_EMPLOYEE=true" >> "$GITHUB_OUTPUT" + else + echo "IS_EXPENSIFY_EMPLOYEE=false" >> "$GITHUB_OUTPUT" + fi + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + + - id: hasReadyToBuildLabel + name: Set HAS_READY_TO_BUILD_LABEL flag + run: | + echo "HAS_READY_TO_BUILD_LABEL=$(gh pr view "${{ env.PULL_REQUEST_NUMBER }}" --repo Expensify/App --json labels --jq '.labels[].name' | grep -q 'Ready To Build' && echo 'true')" >> "$GITHUB_OUTPUT" + if [[ "$HAS_READY_TO_BUILD_LABEL" != 'true' ]]; then + echo "The 'Ready to Build' label is not attached to the PR #${{ env.PULL_REQUEST_NUMBER }}" + fi + env: + GITHUB_TOKEN: ${{ github.token }} + + getBranchRef: + runs-on: ubuntu-latest + needs: validateActor + if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} + outputs: + REF: ${{ steps.getHeadRef.outputs.REF }} + steps: + - name: Checkout + if: ${{ github.event_name == 'workflow_dispatch' }} + uses: actions/checkout@v4 + + - name: Check if pull request number is correct + if: ${{ github.event_name == 'workflow_dispatch' }} + id: getHeadRef + run: | + set -e + echo "REF=$(gh pr view ${{ github.event.inputs.PULL_REQUEST_NUMBER }} --json headRefOid --jq '.headRefOid')" >> "$GITHUB_OUTPUT" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + postGitHubCommentBuildStarted: + runs-on: ubuntu-latest + needs: [validateActor, getBranchRef] + if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} + steps: + - name: Add build start comment + uses: actions/github-script@v7 + with: + github-token: ${{ github.token }} + script: | + const workflowURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.PULL_REQUEST_NUMBER, + body: `🚧 @${{ github.actor }} has triggered a test hybrid app build. You can view the [workflow run here](${workflowURL}).` + }); + + androidHybrid: + name: Build Android HybridApp + needs: [validateActor, getBranchRef] + runs-on: ubuntu-latest-xl + defaults: + run: + working-directory: Mobile-Expensify/react-native + outputs: + S3_APK_PATH: ${{ steps.exportAndroidS3Path.outputs.S3_APK_PATH }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + repository: 'Expensify/Mobile-Expensify' + submodules: true + path: 'Mobile-Expensify' + token: ${{ secrets.OS_BOTIFY_TOKEN }} + # fetch-depth: 0 is required in order to fetch the correct submodule branch + fetch-depth: 0 + + - name: Update submodule + run: | + git submodule update --init + git fetch + git checkout ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version-file: 'Mobile-Expensify/react-native/.nvmrc' + cache: npm + cache-dependency-path: 'Mobile-Expensify/react-native' + + - name: Setup dotenv + run: | + cp .env.staging .env.adhoc + sed -i 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc + echo "PULL_REQUEST_NUMBER=${{ inputs.pull_request_number }}" >> .env.adhoc + + - name: Install node modules + run: | + npm install + cd .. && npm install + + # Fixes https://github.com/Expensify/App/issues/51682 + npm run grunt:build:shared + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'oracle' + java-version: '17' + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + working-directory: 'Mobile-Expensify/react-native' + + - name: Install 1Password CLI + uses: 1password/install-cli-action@v1 + + - name: Load files from 1Password + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + run: | + op document get --output ./upload-key.keystore upload-key.keystore + op document get --output ./android-fastlane-json-key.json android-fastlane-json-key.json + # Copy the keystore to the Android directory for Fullstory + cp ./upload-key.keystore ../Android + + - name: Load Android upload keystore credentials from 1Password + id: load-credentials + uses: 1password/load-secrets-action@v2 + with: + export-env: false + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + ANDROID_UPLOAD_KEYSTORE_PASSWORD: op://Mobile-Deploy-CI/Repository-Secrets/ANDROID_UPLOAD_KEYSTORE_PASSWORD + ANDROID_UPLOAD_KEYSTORE_ALIAS: op://Mobile-Deploy-CI/Repository-Secrets/ANDROID_UPLOAD_KEYSTORE_ALIAS + ANDROID_UPLOAD_KEY_PASSWORD: op://Mobile-Deploy-CI/Repository-Secrets/ANDROID_UPLOAD_KEY_PASSWORD + + - 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: Build Android app + id: build + env: + ANDROID_UPLOAD_KEYSTORE_PASSWORD: ${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} + ANDROID_UPLOAD_KEYSTORE_ALIAS: ${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEYSTORE_ALIAS }} + ANDROID_UPLOAD_KEY_PASSWORD: ${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEY_PASSWORD }} + run: bundle exec fastlane android build_adhoc_hybrid + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Upload Android AdHoc build to S3 + run: bundle exec fastlane android upload_s3 + env: + S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} + S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + S3_BUCKET: ad-hoc-expensify-cash + S3_REGION: us-east-1 + + - name: Export S3 path + id: exportAndroidS3Path + run: | + # $s3APKPath is set from within the Fastfile, android upload_s3 lane + echo "S3_APK_PATH=$s3APKPath" >> "$GITHUB_OUTPUT" + + postGithubComment: + runs-on: ubuntu-latest + name: Post a GitHub comment with app download links for testing + needs: [validateActor, getBranchRef, androidHybrid] + if: ${{ always() }} + steps: + - name: Checkout + uses: actions/checkout@v4 + if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} + with: + ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} + + - name: Download Artifact + uses: actions/download-artifact@v4 + if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} + + - name: Publish links to apps for download + if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} + uses: ./.github/actions/javascript/postTestBuildComment + with: + PR_NUMBER: ${{ env.PULL_REQUEST_NUMBER }} + GITHUB_TOKEN: ${{ github.token }} + ANDROID: ${{ needs.androidHybrid.result }} + ANDROID_LINK: ${{ needs.androidHybrid.outputs.S3_APK_PATH }} \ No newline at end of file diff --git a/.storybook/webpack.config.ts b/.storybook/webpack.config.ts index 92cea8666bc2..1c22608160cf 100644 --- a/.storybook/webpack.config.ts +++ b/.storybook/webpack.config.ts @@ -95,7 +95,7 @@ const webpackConfig = ({config}: {config: Configuration}) => { }); config.module.rules?.push({ - test: /pdf\.worker\.mjs$/, + test: /pdf\.worker\.min\.mjs$/, type: 'asset/source', }); diff --git a/android/app/build.gradle b/android/app/build.gradle index 6488baa13d41..3294a7f3ed8d 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 1009006902 - versionName "9.0.69-2" + versionCode 1009007102 + versionName "9.0.71-2" // 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" @@ -199,7 +199,7 @@ afterEvaluate { def hermesCTask = gradle.includedBuild("react-native").task(":packages:react-native:ReactAndroid:hermes-engine:buildHermesC") android.applicationVariants.configureEach { variant -> - if (variant.buildType.name == "release") { + if (variant.buildType.name == "release" || variant.buildType.name == "adhoc") { def variantName = variant.name.capitalize() def bundleTask = tasks.named("createBundle${variantName}JsAndAssets").getOrNull() diff --git a/assets/images/receipt-placeholder-plus.svg b/assets/images/receipt-placeholder-plus.svg new file mode 100644 index 000000000000..3ebc08b40b06 --- /dev/null +++ b/assets/images/receipt-placeholder-plus.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 8aa8f5aa566c..c60670c72324 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -175,7 +175,7 @@ const getCommonConfiguration = ({file = '.env', platform = 'web'}: Environment): // We are importing this worker as a string by using asset/source otherwise it will default to loading via an HTTPS request later. // This causes issues if we have gone offline before the pdfjs web worker is set up as we won't be able to load it from the server. { - test: new RegExp('node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'), + test: new RegExp('node_modules/pdfjs-dist/legacy/build/pdf.worker.min.mjs'), type: 'asset/source', }, diff --git a/contributingGuides/BUGZERO_CHECKLIST.md b/contributingGuides/BUGZERO_CHECKLIST.md index 96fb1c29432e..1aeff6053eca 100644 --- a/contributingGuides/BUGZERO_CHECKLIST.md +++ b/contributingGuides/BUGZERO_CHECKLIST.md @@ -13,9 +13,8 @@ Source of bug: - [ ] 1z. Other: Where bug was reported: - - [ ] 2a. Reported on production - - [ ] 2b. Reported on staging (deploy blocker) - - [ ] 2c. Reported on both staging and production + - [ ] 2a. Reported on production (eg. bug slipped through the normal regression and PR testing process on staging) + - [ ] 2b. Reported on staging (eg. found during regression or PR testing) - [ ] 2d. Reported on a PR - [ ] 2z. Other: diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md index a7b7ed1c4f4f..b77f4c88605e 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md @@ -10,7 +10,7 @@ To connect a deposit-only account, 1. Hover over **Settings**, then click **Account**. 2. Click the **Payments** tab on the left. -3. Click **Add Deposit-Only Bank Account**, then click **Connect to your bank**. +3. Click **Add Deposit-Only Bank Account**, then click **Connect to your bank**. 4. Click **Continue**. 5. Search for your bank account in the list of banks and follow the prompts to sign in to your bank account. - If your bank doesn’t appear, click the X in the right corner of the Plaid pop-up window, then click **Connect Manually**. You’ll then manually enter your account information and click **Save & Continue**. @@ -19,6 +19,10 @@ To connect a deposit-only account, You’ll now receive reimbursements for your expense reports and invoices directly to this bank account. +{% include info.html %} +If your organization has global reimbursement enabled and you want to add a bank account outside of the US, you can do so by following the steps above. However, after clicking on **Add Deposit-Only Bank Account**, look for a button that says **Switch Country**. This will allow you to add a deposit account from a supported country and receive reimbursements in your local currency. +{% include end-info.html %} + {% include faq-begin.md %} **I connected my deposit-only bank account. Why haven’t I received my reimbursement?** diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Configure-Sage-Intacct.md b/docs/articles/expensify-classic/connections/sage-intacct/Configure-Sage-Intacct.md index 1f0be2f4571a..0c9e6c87f9ab 100644 --- a/docs/articles/expensify-classic/connections/sage-intacct/Configure-Sage-Intacct.md +++ b/docs/articles/expensify-classic/connections/sage-intacct/Configure-Sage-Intacct.md @@ -11,6 +11,8 @@ There are several options for exporting Expensify reports to Sage Intacct. Let's To access these settings, go to **Settings > Workspace > Group > Connections** and select the **Configure** button. +![Highlighting the Configure button for the Sage Intacct Integration]({{site.url}}/assets/images/SageConfigureIntegrationConfigureButton.png){:width="100%"} + ## Export Options ### Preferred Exporter @@ -95,6 +97,8 @@ To find the Integration Name in Sage Intacct: 1. Go to **Platform Services > Objects > List** 2. Set "filter by application" to "user-defined dimensions." +![Image of Sage Intacct Objects filtered by User Defined Dimension]({{site.url}}/assets/images/SageConfigureUserDefinedDimensionsFilter.png){:width="100%"} + Now, in Expensify, navigate to **Settings > Workspaces > Group > [Workspace Name] > Connections**, and click **Configure** under Sage Intacct. On the Coding tab, enable the toggle next to User Defined Dimensions. Enter the "Integration name" and choose whether to import it into Expensify as an expense-level Tag or as a Report Field, then click **Save**. You'll now see the values for your custom segment available under Tags settings or Report Fields settings in Expensify. diff --git a/docs/articles/expensify-classic/connections/sage-intacct/Connect-To-Sage-Intacct.md b/docs/articles/expensify-classic/connections/sage-intacct/Connect-To-Sage-Intacct.md index 76851a35ce4c..a01464cd0740 100644 --- a/docs/articles/expensify-classic/connections/sage-intacct/Connect-To-Sage-Intacct.md +++ b/docs/articles/expensify-classic/connections/sage-intacct/Connect-To-Sage-Intacct.md @@ -5,7 +5,7 @@ order: 1 --- # Overview -Expensify’s seamless integration with Sage Intacct allows you to connect using either Role-based permissions or User-based permissions. +Expensify’s seamless integration with Sage Intacct allows you to connect using either Role-based permissions or User-based permissions. Once connected to Intacct you’re able to automate report exports, customize your coding preferences, and utilize Sage Intacct’s advanced features. When you’ve configured these settings in Expensify correctly, you can use the integration's settings to automate many tasks, streamlining your workflow for increased efficiency. @@ -53,7 +53,12 @@ Setup the user using these configurations: - **User Type:** "Business" - **Admin Privileges:** "Full" - **Status:** "Active" -Once you've created the user, you'll need to set the correct permissions. To set those, go to the **subscription** link for this user in the user list, **click on the checkbox** next to the Application/Module and then click on the **Permissions** link to modify those. + +![Image of Sage Intacct Web Services User setup]({{site.url}}/assets/images/SageConnectSettingUpWebServicesUser.png){:width="100%"} + +Once you've created the user, you'll need to set the correct permissions. To set those, go to the **subscription** link for this user in the user list, **click on the checkbox** next to the Application/Module and then click on the **Permissions** link to modify those. + +![Image showing the Application/Module checkbox to click]({{site.url}}/assets/images/SageConnectSubscriptionSettings.png){:width="100%"} These are the permissions required for a user to export reimbursable expenses as Expense Reports: - **Administration (All)** @@ -64,8 +69,7 @@ These are the permissions required for a user to export reimbursable expenses as - **Projects (Read-only)** (only needed if using Projects and Customers) - **Accounts Payable (All)** (only needed for exporting non-reimbursable expenses as vendor bills) -**Note:** you can set permissions for each Application/Module by selecting the radio button next to the desired Permission and clicking **Save**. - +**Note:** You can set permissions for each Application/Module by selecting the radio button next to the desired Permission and clicking **Save**. ### Step 2: Enable the Time & Expenses Module (Only required if exporting reimbursable expenses as Expense Reports) The Time & Expenses (T&E) module is often included in your Sage Intacct instance, but if it wasn't part of your initial Sage Intacct setup, you may need to enable it. **Enabling the T&E module is a paid subscription through Sage Intacct. For information on the costs of enabling this module, please contact your Sage Intacct account manager**. It's necessary for our integration and only takes a few minutes to configure. @@ -76,7 +80,9 @@ The Time & Expenses (T&E) module is often included in your Sage Intacct instance - **Expense Report:** EXP - **Employee:** EMP - **Duplicate Numbers:** Select “Do not allow creation” - + + ![Image of Sage Intacct Time and Expense Auto-numbering Sequences Settings]({{site.url}}/assets/images/SageConnectTimeandExpenseSequenceNumbers.png){:width="100%"} + - To create the EXP sequence, **click on the down arrow on the expense report line and select **Add**: - **Sequence ID:** EXP - **Print Title:** EXPENSE REPORT @@ -99,7 +105,7 @@ To set up Employees in Sage Intacct, follow these steps: - **Primary contact name** - **Email address** - In the **Primary contact name** field, click the dropdown arrow. - - Select the employee if they've already been created. + - Select the employee if they've already been created. - Otherwise, click **+ Add** to create a new employee. - Fill in their **Primary Email Address** along with any other required information. @@ -126,8 +132,14 @@ To enable Customization Services go to **Company > Subscriptions > Customization ### Step 6: Create a Test Workspace in Expensify and Download the [Expensify Package](https://www.expensify.com/tools/integrations/downloadPackage) Creating a test workspace in Expensify allows you to have a sandbox environment for testing before implementing the integration live. If you are already using Expensify, creating a test workspace ensures that your existing group workspace rules and approval workflows remain intact. Here's how to set it up: 1. Go to **expensify.com > Settings > Workspaces > New Workspace**. + + ![Image of creating a new Workspace in Expensify]({{site.url}}/assets/images/SageConnectCreatingWorkspace.png){:width="100%"} + 2. Name the workspace something like "Sage Intacct Test Workspace." 3. Go to **Connections > Sage Intacct > Connect to Sage Intacct**. + + ![Image of selecting the Sage Intacct integration in Expensify]({{site.url}}/assets/images/SageConnectEnableSage.png){:width="100%"} + 4. Select **Download Package** (You only need to download the file; we'll upload it from your Downloads folder later). @@ -150,6 +162,7 @@ If you use **Platform Services**: 1. Go to **Company > Company Info > Security** in Intacct and click **Edit**. 2. Scroll down to **Web Services Authorizations** and add "expensify" (all lower case) as a Sender ID. +![Image of Web Services Authorizations in Sage Intacct]({{site.url}}/assets/images/SageConnectWebServicesAuthorizations.png){:width="100%"} ### Step 9: Enter Credentials and Connect Expensify and Sage Intacct @@ -158,6 +171,8 @@ If you use **Platform Services**: 2. Click **Connect to Sage Intacct** and enter the credentials you've set for your web services user. 3. Click **Send** once you're done. +![Image of Sage Intacct credentials being entered in Expensify to connect the integration]({{site.url}}/assets/images/SageConnectEnterCredentials.png){:width="100%"} + Next, you’ll configure the Export, Coding, and Advanced tabs of the connection configuration in Expensify. diff --git a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md index 14b5225801d0..71993956f4f4 100644 --- a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md +++ b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md @@ -1,6 +1,6 @@ --- -title: Add Domain Members and Admins -description: Add members and admins to a domain +title: Add and remove Domain Members and Admins +description: Add and remove members and admins to a domain ---
@@ -34,7 +34,19 @@ Once the member verifies their email address, all Domain Admins will be notified 1. Hover over Settings, then click **Domains**. 2. Click the name of the domain. 3. Click the **Domain Members** tab on the left. -4. Under the Domain Members section, enter the first part of the member’s email address and click **Invite**. +4. Under the Domain Members section, enter the first part of the member’s email address and click **Invite**. + +# Close a Domain Member’s account + +1. Hover over Settings, then click **Domains**. +2. Click the name of the domain. +3. Click the **Domain Members** tab on the left. +4. Find the user account you’d like to close, and select it +5. Click **Close** to close the account + +{% include info.html %} +Any closed account can be reopened at any time, by reinviting the user via the Domain Member page +{% include end-info.html %} # Add Domain Admin @@ -47,4 +59,12 @@ Once the member verifies their email address, all Domain Admins will be notified This can be any email address—it does not have to be an email address under the domain. {% include end-info.html %} +# Remove Domain Admin + +1. Hover over Settings, then click **Domains**. +2. Click the name of the domain. +3. Click the **Domain Admins** tab on the left. +4. Under the Domain Admins section, click the red trash can button next to the Domain Admin you’d like to remove +
+ diff --git a/docs/articles/expensify-classic/expenses/Create-Expense-Rules.md b/docs/articles/expensify-classic/expenses/Create-Expense-Rules.md new file mode 100644 index 000000000000..e83640403ce4 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Create-Expense-Rules.md @@ -0,0 +1,61 @@ +--- +title: Create Expense Rules +description: Automatically categorize, tag, and report expenses based on the merchant's name +--- + +Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant’s name. + +# Create expense rules + +1. Hover over **Settings** and click **Account**. +2. Click **Expense Rules**. +2. Click **New Rule**. +3. Add what the merchant name should contain in order for the rule to be applied. *Note: If you enter just a period, the rule will apply to all expenses regardless of the merchant name. Universal Rules will always take precedence over all other expense rules.* +4. Choose from the following rules: +- **Merchant:** Updates the merchant name (e.g., “Starbucks #238” could be changed to “Starbucks”) +- **Category:** Applies a workspace category to the expense +- **Tag:** Applies a tag to the expense (e.g., a Department or Location) +- **Description:** Adds a description to the description field on the expense +- **Reimbursability:** Determines whether the expense will be marked as reimbursable or non-reimbursable +- **Billable**: Determines whether the expense is billable +- **Add to a report named:** Adds the expense to a report with the name you type into the field. If no report with that name exists, a new report will be created if the "Create report if necessary" checkbox is selected. + +![Fields to create a new expense rule, including the characters a merchant's name should contain for the rule to apply, as well as what changes should be applied to the expense including the merchant name, category, tag, description, reimbursability, whether it is billable, and what report it will be added to.](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_01.png){:width="100%"} + +{:start="6"} +6. (Optional) To apply the rule to previously entered expenses, select the **Apply to existing matching expenses** checkbox. You can also click **Preview Matching Expenses** to see if your rule matches the intended expenses. + +# How rules are applied + +In general, your expense rules will be applied in order, from **top to bottom**, (i.e., from the first rule). However, other settings can impact how expense rules are applied. Here is the hierarchy that determines how these are applied: + +1. A Universal Rule will **always** be applied over any other expense category rules. Rules that would otherwise change the expense category will **not** override the Universal Rule. +2. If Scheduled Submit and the setting “Enforce Default Report Title” are enabled on the workspace, this will take precedence over any rules trying to add the expense to a report. +3. If the expense is from a company card that is forced to a workspace with strict rule enforcement, those rules will take precedence over individual expense rules. +4. If you belong to a workspace that is tied to an accounting integration, the configuration settings for this connection may update your expense details upon export, even if the expense rules were successfully applied to the expense. + +# Create an expense rule from changes made to an expense + +If you open an expense and change it, you can then create an expense rule based on those changes by selecting the “Create a rule based on your changes" checkbox. *Note: The expense must be saved, reopened, and edited for this option to appear.* + +![The "Create a rule based on your changes" checkbox is located in the bottom right corner of the popup window, to the left of the Save button.](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_02.png){:width="100%"} + +# Delete an expense rule + +To delete an expense rule, + +1. Hover over **Settings** and click **Account**. +2. Click **Expense Rules**. +3. Scroll down to the rule you’d like to remove and click the trash can icon. + +![The Trash icon to delete an expense rule is located at the top right of the box containing the expense rule, to the left of the Edit icon.](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_03.png){:width="100%"} + +{% include faq-begin.md %} + +## How can I use expense rules to vendor match when exporting to an accounting package? + +When exporting non-reimbursable expenses to your connected accounting package, the payee field will list "Credit Card Misc." if the merchant name on the expense in Expensify is not an exact match to a vendor in the accounting package. When an exact match is unavailable, "Credit Card Misc." prevents multiple variations of the same vendor (e.g., Starbucks and Starbucks #1234, as is often seen in credit card statements) from being created in your accounting package. + +For repeated expenses, the best practice is to use Expense Rules, which will automatically update the merchant name without having to do it manually each time. This only works for connections to QuickBooks Online, Desktop, and Xero. Vendor matching cannot be performed in this manner for NetSuite or Sage Intacct due to limitations in the API of the accounting package. + +{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/expenses/Expense-Rules.md b/docs/articles/expensify-classic/expenses/Expense-Rules.md deleted file mode 100644 index 295aa8d00cc9..000000000000 --- a/docs/articles/expensify-classic/expenses/Expense-Rules.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Expense Rules -description: Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant's name. - ---- -# Overview -Expense rules allow you to automatically categorize, tag, and report expenses based on the merchant’s name. - -# How to use Expense Rules -**To create an expense rule, follow these steps:** -1. Navigate to **Settings > Account > Expense Rules** -2. Click on **New Rule** -3. Fill in the required information to set up your rule - -When creating an expense rule, you will be able to apply the following rules to expenses: - -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_01.png){:width="100%"} - -- **Merchant:** Updates the merchant name, e.g., “Starbucks #238” could be changed to “Starbucks” -- **Category:** Applies a workspace category to the expense -- **Tag:** Applies a tag to the expense, e.g., a Department or Location -- **Description:** Adds a description to the description field on the expense -- **Reimbursability:** Determines whether the expense will be marked as reimbursable or non-reimbursable -- **Billable**: Determines whether the expense is billable -- **Add to a report named:** Adds the expense to a report with the name you type into the field. If no report with that name exists, a new report will be created - -## Tips on using Expense Rules -- If you'd like to apply a rule to all expenses (“Universal Rule”) rather than just one merchant, simply enter a period [.] and nothing else into the **“When the merchant name contains:”** field. **Note:** Universal Rules will always take precedence over all other rules for category (more on this below). -- You can apply a rule to previously entered expenses by checking the **Apply to existing matching expenses** checkbox. Click “Preview Matching Expenses” to see if your rule matches the intended expenses. -- You can create expense rules while editing an expense. To do this, simply check the box **“Create a rule based on your changes"** at the time of editing. Note that the expense must be saved, reopened, and edited for this option to appear. - - -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_02.png){:width="100%"} - - -To delete an expense rule, go to **Settings > Account > Expense Rules**, scroll down to the rule you’d like to remove, and then click the trash can icon in the upper right corner of the rule: - -![Insert alt text for accessibility here](https://help.expensify.com/assets/images/ExpensifyHelp_ExpenseRules_03.png){:width="100%"} - -# Deep Dive -In general, your expense rules will be applied in order, from **top to bottom**, i.e., from the first rule. However, other settings can impact how expense rules are applied. Here is the hierarchy that determines how these are applied: -1. A Universal Rule will **always** precede over any other expense category rules. Rules that would otherwise change the expense category will **not** override the Universal Rule. -2. If Scheduled Submit and the setting “Enforce Default Report Title” are enabled on the workspace, this will take precedence over any rules trying to add the expense to a report. -3. If the expense is from a Company Card that is forced to a workspace with strict rule enforcement, those rules will take precedence over individual expense rules. -4. If you belong to a workspace that is tied to an accounting integration, the configuration settings for this connection may update your expense details upon export, even if the expense rules were successfully applied to the expense. - - -{% include faq-begin.md %} -## How can I use Expense Rules to vendor match when exporting to an accounting package? -When exporting non-reimbursable expenses to your connected accounting package, the payee field will list "Credit Card Misc." if the merchant name on the expense in Expensify is not an exact match to a vendor in the accounting package. -When an exact match is unavailable, "Credit Card Misc." prevents multiple variations of the same vendor (e.g., Starbucks and Starbucks #1234, as is often seen in credit card statements) from being created in your accounting package. -For repeated expenses, the best practice is to use Expense Rules, which will automatically update the merchant name without having to do it manually each time. -This only works for connections to QuickBooks Online, Desktop, and Xero. Vendor matching cannot be performed in this manner for NetSuite or Sage Intacct due to limitations in the API of the accounting package. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/settings/Set-Notifications.md b/docs/articles/expensify-classic/settings/Email-Notifications.md similarity index 60% rename from docs/articles/expensify-classic/settings/Set-Notifications.md rename to docs/articles/expensify-classic/settings/Email-Notifications.md index da55dafb833c..ff7449c5f9fd 100644 --- a/docs/articles/expensify-classic/settings/Set-Notifications.md +++ b/docs/articles/expensify-classic/settings/Email-Notifications.md @@ -1,10 +1,9 @@ --- -title: Set notifications -description: This article is about how to troubleshoot notifications from Expensify. +title: Expensify Email notifications +description: Troubleshooting steps for receiving emails and notifications from Expensify. --- -# Overview -Sometimes members may have trouble receiving important email notifications from Expensify, such as Expensify Magic Code emails, account validation emails, secondary login validations, integration emails, or report action notifications (rejections, approvals, etc.). +Occasionally, members may have trouble receiving email notifications from Expensify, such as Expensify Magic Code emails, account validation emails, secondary login validations, integration emails, or report action notifications. # Troubleshooting missing Expensify notifications @@ -12,45 +11,48 @@ Sometimes members may have trouble receiving important email notifications from Emails can sometimes be delayed and could take up to 30-60 minutes to arrive in your inbox. If you're expecting a notification that still hasn't arrived after waiting: - Check your **Email Preferences** on the web via **Settings > Account > Preferences**. In the **Contact Preferences** section, ensure that the relevant boxes are checked for the email type you're missing. - Check your email spam and trash folders, as Expensify messages might end up there inadvertently. - - Check to make sure you haven't unintentionally blocked Expensify emails. Allowlist the domain expensify.com with your email provider. + - Check to make sure you haven't unintentionally blocked Expensify emails. whitelist the domain expensify.com with your email provider. ## Issue: A banner that says “We’re having trouble emailing you” shows the top of your screen. -Confirm the email address on your Expensify account is a deliverable email address, and then click the link in the banner that says "here". If successful, you will see a confirmation that your email was unblocked. +Confirm that the email address on your Expensify account is deliverable, and then click the link in the banner that says "here." If successful, you will see a confirmation that your email was unblocked. ![ExpensifyHelp_EmailError]({{site.url}}/assets/images/ExpensifyHelp_EmailError.png){:width="100%"} **If unsuccessful, you will see another error:** - If the new error or SMTP message includes a URL, navigate to that URL for further instructions. - If the new error or SMTP message includes "mimecast.com", consult with your company's IT team. - - If the new error or SMTP message includes "blacklist", it means your company has configured their email servers to use a third-party email reputation or blocklisting service. Consult with your company's IT team. + - If the new error or SMTP message includes "blacklist," it means your company has configured its email servers to use a third-party email reputation or blocklisting service. Consult with your company's IT team. ![ExpensifyHelp_SMTPError]({{site.url}}/assets/images/ExpensifyHelp_SMTPError.png){:width="100%"} # Further troubleshooting for public domains -If you are still not receiving Expensify notifications and have an email address on a public domain such as gmail.com or yahoo.com, you may need to add Expensify's domain expensify.com to your email's allowlist by taking the following steps: +If you are still not receiving Expensify notifications and have an email address on a public domain such as gmail.com or yahoo.com, you may need to add Expensify's domain expensify.com to your email's whitelist by taking the following steps: - Search for messages from expensify.com in your spam folder, open them, and click “Not Spam” at the top of each message. - - Configure an email filter that identifies Expensify's email domain expensify.com and directs all incoming messages to your inbox, to avoid messages going to spam. - - Add specific known Expensify email addresses such as concierge@expensify.com to your email contacts list. + Configure an email filter that identifies Expensify's email domain as expensify.com and directs all incoming messages to your inbox to prevent messages from going to spam. + - Add specific known Expensify email addresses, such as concierge@expensify.com, to your email contacts list. # Further troubleshooting for private domains If your organization uses a private domain, Expensify emails may be blocked at the server level. This can sometimes happen unexpectedly due to broader changes in email provider's handling or filtering of incoming messages. Consult your internal IT team to assist with the following: - - Ensure that the domain expensify.com is allowlisted on domain email servers. This domains is the sources of various notification emails, so it's important it is allowlisted. - - Confirm there is no server-level email blocking and that spam filters are not blocking Expensify emails. Even if you have received messages from our Concierge support in the past, ensure that expensify.com is allowlisted. + - Ensure that the domain expensify.com is allowed on the domain email servers. This domain is the source of various notification emails, so it's important it is whitelisted. + - Confirm there is no server-level email blocking + - Make sure spam filters are not blocking Expensify emails. + +Even if you have received messages from our Concierge support in the past, ensure that expensify.com is whitelisted. ## Companies using Outlook - Add Expensify to your personal Safe Senders list by following these steps: [Outlook email client](https://support.microsoft.com/en-us/office/add-recipients-of-my-email-messages-to-the-safe-senders-list-be1baea0-beab-4a30-b968-9004332336ce) / [Outlook.com](https://support.microsoft.com/en-us/office/safe-senders-in-outlook-com-470d4ee6-e3b6-402b-8cd9-a6f00eda7339) - **Company IT administrators:** Add Expensify to your domain's Safe Sender list by following the steps here: [Create safe sender lists in EOP](https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365) -- **Company IT administrators:** Add expensify.com to the domain's explicit allowlist. You may need to contact Outlook support for specific instructions, as each company's setup varies. +**Company IT administrators:** Add expensify.com to the domain's explicit whitelist. As each company's setup varies, you may need to contact Outlook support for specific instructions. - **Company administrators:** Contact Outlook support to see if there are additional steps to take based on your domain's email configuration. ## Companies using Google Workspaces: -- **Company IT administrators:** Adjust your domain's email allowlist and safe senders lists to include expensify.com by following these steps: [Allowlists, denylists, and approved senders](https://support.google.com/a/answer/60752) +- **Company IT administrators:** Adjust your domain's email whitelist and safe senders lists to include expensify.com by following these steps: [Allowlists, denylists, and approved senders](https://support.google.com/a/answer/60752) {% include faq-begin.md %} @@ -60,10 +62,10 @@ Expensify's emails are SPF and DKIM-signed, meaning they are cryptographically s ## Why do legitimate emails from Expensify sometimes end up marked as spam? -The problem typically arises when our domain or one of our sending IP addresses gets erroneously flagged by a 3rd party domain or IP reputation services. Many IT departments use lists published by such services to filter email for the entire company. +The problem typically arises when a third-party domain or IP reputation service erroneously flags our domain or one of our sending IP addresses. Many IT departments use lists published by such services to filter email for the entire company. ## What is the best way to ensure emails are not accidentally marked as Spam? -For server-level spam detection, the safest approach to allowlisting email from Expensify is to verify DKIM and SPF, rather than solely relying on the third-party reputation of the sending IP address. +For server-level spam detection, the safest approach to whitelisting email from Expensify is to verify DKIM and SPF, rather than solely relying on the third-party reputation of the sending IP address. {% include faq-end.md %} diff --git a/docs/assets/images/search-download.png b/docs/assets/images/search-download.png new file mode 100644 index 000000000000..eb8591dea110 Binary files /dev/null and b/docs/assets/images/search-download.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index e1c0e12eb070..5bf8fa223ab9 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -591,6 +591,7 @@ https://help.expensify.com/articles/expensify-classic/articles/expensify-classic https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/add-a-payment-card-and-view-your-subscription,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page-coming-soon,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Billing-page +https://help.expensify.com/articles/expensify-classic/expenses/Expense-Rules,https://help.expensify.com/articles/expensify-classic/expenses/Create-Expense-Rules https://help.expensify.com/articles/expensify-classic/expenses/The-Expenses-Page,https://help.expensify.com/articles/expensify-classic/expenses/Navigate-the-Expenses-Page https://help.expensify.com/articles/expensify-classic/expenses/Add-expenses-in-bulk,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense https://help.expensify.com/articles/expensify-classic/expenses/Track-group-expenses,https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense @@ -604,3 +605,4 @@ https://help.expensify.com/articles/expensify-classic/spending-insights/Default- https://help.expensify.com/articles/expensify-classic/spending-insights/Other-Export-Options,https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports/ https://help.expensify.com/articles/expensify-classic/travel/Edit-or-cancel-travel-arrangements,https://help.expensify.com/articles/expensify-classic/travel/Book-with-Expensify-Travel https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills +https://help.expensify.com/articles/expensify-classic/settings/Set-Notifications,https://help.expensify.com/articles/expensify-classic/settings/Email-Notifications diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 54084367040c..20c85f494c80 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -86,6 +86,22 @@ platform :android do setGradleOutputsInEnv() end + desc "Generate AdHoc HybridApp apk" + lane :build_adhoc_hybrid do + ENV["ENVFILE"]="../.env.adhoc.hybridapp" + gradle( + project_dir: '../Android', + task: 'assembleAdhoc', + properties: { + "android.injected.signing.store.file" => './upload-key.keystore', + "android.injected.signing.store.password" => ENV["ANDROID_UPLOAD_KEYSTORE_PASSWORD"], + "android.injected.signing.key.alias" => ENV["ANDROID_UPLOAD_KEYSTORE_ALIAS"], + "android.injected.signing.key.password" => ENV["ANDROID_UPLOAD_KEY_PASSWORD"], + } + ) + setGradleOutputsInEnv() + end + desc "Generate a new local APK" lane :build_local do ENV["ENVFILE"]=".env.production" @@ -373,6 +389,10 @@ platform :ios do path: "./OldApp_AppStore_Share_Extension.mobileprovision" ) + install_provisioning_profile( + path: "./OldApp_AppStore_Notification_Service.mobileprovision" + ) + build_app( workspace: "../iOS/Expensify.xcworkspace", scheme: "Expensify", @@ -382,7 +402,8 @@ platform :ios do manageAppVersionAndBuildNumber: false, provisioningProfiles: { "com.expensify.expensifylite" => "(OldApp) AppStore", - "com.expensify.expensifylite.SmartScanExtension" => "(OldApp) AppStore: Share Extension" + "com.expensify.expensifylite.SmartScanExtension" => "(OldApp) AppStore: Share Extension", + "com.expensify.expensifylite.NotificationServiceExtension" => "(OldApp) AppStore: Notification Service", } } ) diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 508e53fd80f8..a268b601b5db 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.69 + 9.0.71 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.69.2 + 9.0.71.2 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 0b234edc126e..cae0d7a07cf6 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.69 + 9.0.71 CFBundleSignature ???? CFBundleVersion - 9.0.69.2 + 9.0.71.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 48f735acdea3..4b2c45a06882 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.69 + 9.0.71 CFBundleVersion - 9.0.69.2 + 9.0.71.2 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 8e55b5318cfb..c3079b1f9a9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.69-2", + "version": "9.0.71-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.69-2", + "version": "9.0.71-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -73,7 +73,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.15", + "react-fast-pdf": "1.0.20", "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", @@ -33149,9 +33149,10 @@ } }, "node_modules/react-fast-pdf": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.15.tgz", - "integrity": "sha512-xXrwIfRUD3KSRrBdfAeGnLZTf0kYUa+d6GGee1Hu0PFAv5QPBeF3tcV+DU+Cm/JMjSuR7s5g0KK9bePQ/xiQ+w==", + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.20.tgz", + "integrity": "sha512-E2PJOO5oEqi6eNPllNOlQ8y0DiLZ3AW8t+MCN7AgJPp5pY04SeDveXHWvPN0nPU4X5sRBZ7CejeYce2QMMQDyg==", + "license": "MIT", "dependencies": { "react-pdf": "^9.1.1", "react-window": "^1.8.10" diff --git a/package.json b/package.json index 9979db1d41d4..dfc64c7be0c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.69-2", + "version": "9.0.71-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -130,7 +130,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.15", + "react-fast-pdf": "1.0.20", "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", diff --git a/patches/react-native+0.75.2+024+measureText-full-width-if-wraps.patch b/patches/react-native+0.75.2+024+measureText-full-width-if-wraps.patch new file mode 100644 index 000000000000..fb4c857e13b2 --- /dev/null +++ b/patches/react-native+0.75.2+024+measureText-full-width-if-wraps.patch @@ -0,0 +1,53 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java +index 2921f84..93da34c 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java +@@ -579,6 +579,10 @@ public class TextLayoutManager { + for (int lineIndex = 0; lineIndex < calculatedLineCount; lineIndex++) { + boolean endsWithNewLine = + text.length() > 0 && text.charAt(layout.getLineEnd(lineIndex) - 1) == '\n'; ++ if (!endsWithNewLine && lineIndex + 1 < layout.getLineCount()) { ++ calculatedWidth = width; ++ break; ++ } + float lineWidth = + endsWithNewLine ? layout.getLineMax(lineIndex) : layout.getLineWidth(lineIndex); + if (lineWidth > calculatedWidth) { +diff --git a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +index b4a7033..499e12e 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm ++++ b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +@@ -285,8 +285,33 @@ static NSLineBreakMode RCTNSLineBreakModeFromEllipsizeMode(EllipsizeMode ellipsi + NSTextContainer *textContainer = layoutManager.textContainers.firstObject; + [layoutManager ensureLayoutForTextContainer:textContainer]; + ++ NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; ++ __block BOOL textDidWrap = NO; ++ [layoutManager ++ enumerateLineFragmentsForGlyphRange:glyphRange ++ usingBlock:^( ++ CGRect overallRect, ++ CGRect usedRect, ++ NSTextContainer *_Nonnull usedTextContainer, ++ NSRange lineGlyphRange, ++ BOOL *_Nonnull stop) { ++ NSRange range = [layoutManager characterRangeForGlyphRange:lineGlyphRange ++ actualGlyphRange:nil]; ++ NSUInteger lastCharacterIndex = range.location + range.length - 1; ++ BOOL endsWithNewLine = ++ [textStorage.string characterAtIndex:lastCharacterIndex] == '\n'; ++ if (!endsWithNewLine && textStorage.string.length > lastCharacterIndex + 1) { ++ textDidWrap = YES; ++ *stop = YES; ++ } ++ }]; ++ + CGSize size = [layoutManager usedRectForTextContainer:textContainer].size; + ++ if (textDidWrap) { ++ size.width = textContainer.size.width; ++ } ++ + size = (CGSize){RCTCeilPixelValue(size.width), RCTCeilPixelValue(size.height)}; + + __block auto attachments = TextMeasurement::Attachments{}; diff --git a/src/CONST.ts b/src/CONST.ts index 57ef980df86c..5e7a9796d1a0 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -146,6 +146,7 @@ const onboardingEmployerOrSubmitMessage: OnboardingMessage = { const combinedTrackSubmitOnboardingEmployerOrSubmitMessage: OnboardingMessage = { ...onboardingEmployerOrSubmitMessage, tasks: [ + selfGuidedTourTask, { type: 'submitExpense', autoCompleted: false, @@ -191,6 +192,7 @@ const onboardingPersonalSpendMessage: OnboardingMessage = { height: 960, }, tasks: [ + selfGuidedTourTask, { type: 'trackExpense', autoCompleted: false, @@ -668,7 +670,6 @@ const CONST = { BETAS: { ALL: 'all', DEFAULT_ROOMS: 'defaultRooms', - DUPE_DETECTION: 'dupeDetection', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', SPOTNANA_TRAVEL: 'spotnanaTravel', REPORT_FIELDS_FEATURE: 'reportFieldsFeature', @@ -1308,6 +1309,9 @@ const CONST = { SIDEBAR_LOADED: 'sidebar_loaded', LOAD_SEARCH_OPTIONS: 'load_search_options', SEND_MESSAGE: 'send_message', + APPLY_AIRSHIP_UPDATES: 'apply_airship_updates', + APPLY_PUSHER_UPDATES: 'apply_pusher_updates', + APPLY_HTTPS_UPDATES: 'apply_https_updates', COLD: 'cold', WARM: 'warm', REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500, @@ -5061,10 +5065,7 @@ const CONST = { }, ], }, - [onboardingChoices.PERSONAL_SPEND]: { - ...onboardingPersonalSpendMessage, - tasks: [selfGuidedTourTask, ...onboardingPersonalSpendMessage.tasks], - }, + [onboardingChoices.PERSONAL_SPEND]: onboardingPersonalSpendMessage, [onboardingChoices.CHAT_SPLIT]: { message: 'Splitting bills with friends is as easy as sending a message. Here’s how.', video: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 34d87723b758..581186deda82 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -219,6 +219,9 @@ const ONYXKEYS = { /** The NVP containing all information related to educational tooltip in workspace chat */ NVP_WORKSPACE_TOOLTIP: 'workspaceTooltip', + /** The NVP containing the target url to navigate to when deleting a transaction */ + NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL: 'nvp_deleteTransactionNavigateBackURL', + /** Whether to show save search rename tooltip */ SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP: 'shouldShowSavedSearchRenameTooltip', @@ -1011,6 +1014,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; [ONYXKEYS.NVP_WORKSPACE_TOOLTIP]: OnyxTypes.WorkspaceTooltip; + [ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL]: string | undefined; [ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP]: boolean; [ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[]; [ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE]: string; diff --git a/src/components/AddPaymentCard/PaymentCardForm.tsx b/src/components/AddPaymentCard/PaymentCardForm.tsx index 5aaa23b238f7..9843996602f1 100644 --- a/src/components/AddPaymentCard/PaymentCardForm.tsx +++ b/src/components/AddPaymentCard/PaymentCardForm.tsx @@ -165,10 +165,6 @@ function PaymentCardForm({ errors.addressStreet = translate(label.error.addressStreet); } - if (values.addressZipCode && !ValidationUtils.isValidZipCode(values.addressZipCode)) { - errors.addressZipCode = translate(label.error.addressZipCode); - } - if (!values.acceptTerms) { errors.acceptTerms = translate('common.error.acceptTerms'); } @@ -283,10 +279,9 @@ function PaymentCardForm({ InputComponent={TextInput} defaultValue={data?.addressZipCode} inputID={INPUT_IDS.ADDRESS_ZIP_CODE} - label={translate('common.zip')} - aria-label={translate('common.zip')} + label={translate('common.zipPostCode')} + aria-label={translate('common.zipPostCode')} role={CONST.ROLE.PRESENTATION} - inputMode={CONST.INPUT_MODE.NUMERIC} maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE} containerStyles={[styles.mt5]} /> diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index f86e0c2da999..7cf752a61214 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -17,7 +17,7 @@ import type {AnchorPosition} from '@src/styles'; import type {ButtonWithDropdownMenuProps} from './types'; function ButtonWithDropdownMenu({ - success = false, + success = true, isSplitButton = true, isLoading = false, isDisabled = false, diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index d5f72f7088b2..cea339de07e2 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -57,6 +57,25 @@ function Composer( inputCallbackRef(autoFocus ? textInput.current : null); }, [autoFocus, inputCallbackRef, autoFocusInputRef]); + useEffect(() => { + if (!textInput.current || !textInput.current.setSelection || !selection || isComposerFullSize) { + return; + } + + // We need the delay for setSelection to properly work for IOS in bridgeless mode due to a react native + // internal bug of dispatching the event before the component is ready for it. + // (see https://github.com/Expensify/App/pull/50520#discussion_r1861960311 for more context) + const timeoutID = setTimeout(() => { + // We are setting selection twice to trigger a scroll to the cursor on toggling to smaller composer size. + textInput.current?.setSelection((selection.start || 1) - 1, selection.start); + textInput.current?.setSelection(selection.start, selection.start); + }, 0); + + return () => clearTimeout(timeoutID); + + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [isComposerFullSize]); + /** * Set the TextInput Ref * @param {Element} el diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index e71ade65e66d..98ac9e00a98a 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -75,6 +75,7 @@ function Composer( const [isRendered, setIsRendered] = useState(false); const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); const [prevScroll, setPrevScroll] = useState(); + const [prevHeight, setPrevHeight] = useState(); const isReportFlatListScrolling = useRef(false); useEffect(() => { @@ -243,11 +244,11 @@ function Composer( }, []); useEffect(() => { - if (!textInput.current || prevScroll === undefined) { + if (!textInput.current || prevScroll === undefined || prevHeight === undefined) { return; } // eslint-disable-next-line react-compiler/react-compiler - textInput.current.scrollTop = prevScroll; + textInput.current.scrollTop = prevScroll + prevHeight - textInput.current.clientHeight; // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isComposerFullSize]); @@ -353,6 +354,7 @@ function Composer( {...props} onSelectionChange={addCursorPositionToSelectionChange} onContentSizeChange={(e) => { + setPrevHeight(e.nativeEvent.contentSize.height); setHasMultipleLines(e.nativeEvent.contentSize.height > variables.componentSizeLarge); }} disabled={isDisabled} diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index 2f6cb408292e..716bc90c6adf 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -53,6 +53,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear onModalHide={onClose} hideModalContentWhileAnimating useNativeDriver + shouldHandleNavigationBack > (null); - const [currentDateView, setCurrentDateView] = useState(getInitialCurrentDateView(value, minDate, maxDate)); + const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const minYear = getYear(new Date(minDate)); const maxYear = getYear(new Date(maxDate)); - const [years, setYears] = useState( + const [years, setYears] = useState(() => Array.from({length: maxYear - minYear + 1}, (v, i) => i + minYear).map((year) => ({ text: year.toString(), value: year, diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index bd4bb64da050..09f9a3b896ad 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -154,6 +154,7 @@ import Printer from '@assets/images/printer.svg'; import Profile from '@assets/images/profile.svg'; import QrCode from '@assets/images/qrcode.svg'; import QuestionMark from '@assets/images/question-mark-circle.svg'; +import ReceiptPlaceholderPlus from '@assets/images/receipt-placeholder-plus.svg'; import ReceiptPlus from '@assets/images/receipt-plus.svg'; import ReceiptScan from '@assets/images/receipt-scan.svg'; import ReceiptSearch from '@assets/images/receipt-search.svg'; @@ -343,6 +344,7 @@ export { QrCode, QuestionMark, Receipt, + ReceiptPlaceholderPlus, ReceiptPlus, ReceiptScan, ReceiptSlash, diff --git a/src/components/Lottie/index.tsx b/src/components/Lottie/index.tsx index 017d68aa4b56..a6b1374b1c8f 100644 --- a/src/components/Lottie/index.tsx +++ b/src/components/Lottie/index.tsx @@ -62,6 +62,7 @@ function Lottie({source, webStyle, shouldLoadAfterInteractions, ...props}: Props } const unsubscribeNavigationFocus = navigator.addListener('focus', () => { setHasNavigatedAway(false); + animationRef.current?.play(); }); return unsubscribeNavigationFocus; }, [browser, navigationContainerRef, navigator]); diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index 70818eaf343a..e1c5a7c48b86 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -210,6 +210,7 @@ function BaseModal( const modalContextValue = useMemo( () => ({ activeModalType: isVisible ? type : undefined, + default: false, }), [isVisible, type], ); diff --git a/src/components/Modal/ModalContext.ts b/src/components/Modal/ModalContext.ts index a5c2eb21ab1d..b9d76bcc18b4 100644 --- a/src/components/Modal/ModalContext.ts +++ b/src/components/Modal/ModalContext.ts @@ -5,11 +5,12 @@ type ModalContextType = { // The type of the currently displayed modal, or undefined if there is no currently displayed modal. // Note that React Native can only display one modal at a time. activeModalType?: ModalType; + default: boolean; }; // This context is meant to inform modal children that they are rendering in a modal (and what type of modal they are rendering in) // Note that this is different than ONYXKEYS.MODAL.isVisible data point in that that is a global variable for whether a modal is visible or not, // whereas this context is provided by the BaseModal component, and thus is only available to components rendered inside a modal. -const ModalContext = createContext({}); +const ModalContext = createContext({default: true}); export default ModalContext; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index d01b69ed5649..9ca142aae1b0 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -170,7 +170,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea shouldShowExportIntegrationButton; const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, moneyRequestReport?.currency); - const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(moneyRequestReport, policy); + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(moneyRequestReport, shouldShowPayButton); const isAnyTransactionOnHold = ReportUtils.hasHeldExpenses(moneyRequestReport?.reportID); const displayedAmount = isAnyTransactionOnHold && canAllowSettlement && hasValidNonHeldAmount ? nonHeldAmount : formattedAmount; const isMoreContentShown = shouldShowNextStep || shouldShowStatusBar || (shouldShowAnyButton && shouldUseNarrowLayout); @@ -502,7 +502,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea isVisible={isDeleteRequestModalVisible} onConfirm={deleteTransaction} onCancel={() => setIsDeleteRequestModalVisible(false)} - onModalHide={() => ReportUtils.navigateBackAfterDeleteTransaction(navigateBackToAfterDelete.current)} + onModalHide={() => ReportUtils.navigateBackOnDeleteTransaction(navigateBackToAfterDelete.current)} prompt={translate('iou.deleteConfirmation', {count: 1})} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index b87c51cc6b64..6a888a09b60b 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -853,7 +853,6 @@ function MoneyRequestConfirmationList({ /> ) : ( confirm(value as PaymentMethodType)} options={splitOrRequestOptions} diff --git a/src/components/PDFThumbnail/index.tsx b/src/components/PDFThumbnail/index.tsx index 1115ea21dad4..495c14ff76e1 100644 --- a/src/components/PDFThumbnail/index.tsx +++ b/src/components/PDFThumbnail/index.tsx @@ -1,6 +1,6 @@ import 'core-js/proposals/promise-with-resolvers'; // eslint-disable-next-line import/extensions -import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker.mjs'; +import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs'; import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import {Document, pdfjs, Thumbnail} from 'react-pdf'; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 9b5c0b1b6f56..7432c683e0a7 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -127,6 +127,9 @@ type PopoverMenuProps = Partial & { /** Whether to update the focused index on a row select */ shouldUpdateFocusedIndex?: boolean; + + /** Should we apply padding style in modal itself. If this value is false, we will handle it in ScreenWrapper */ + shouldUseModalPaddingStyle?: boolean; }; const renderWithConditionalWrapper = (shouldUseScrollView: boolean, contentContainerStyle: StyleProp, children: ReactNode): React.JSX.Element => { @@ -137,6 +140,10 @@ const renderWithConditionalWrapper = (shouldUseScrollView: boolean, contentConta return <>{children}; }; +function getSelectedItemIndex(menuItems: PopoverMenuItem[]) { + return menuItems.findIndex((option) => option.isSelected); +} + function PopoverMenu({ menuItems, onItemSelected, @@ -166,6 +173,7 @@ function PopoverMenu({ scrollContainerStyle, shouldUseScrollView = false, shouldUpdateFocusedIndex = true, + shouldUseModalPaddingStyle, }: PopoverMenuProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -174,7 +182,7 @@ function PopoverMenu({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const [currentMenuItems, setCurrentMenuItems] = useState(menuItems); - const currentMenuItemsFocusedIndex = currentMenuItems?.findIndex((option) => option.isSelected); + const currentMenuItemsFocusedIndex = getSelectedItemIndex(currentMenuItems); const [enteredSubMenuIndexes, setEnteredSubMenuIndexes] = useState(CONST.EMPTY_ARRAY); const {windowHeight} = useWindowDimensions(); @@ -300,7 +308,7 @@ function PopoverMenu({ ); const onModalHide = () => { - setFocusedIndex(-1); + setFocusedIndex(currentMenuItemsFocusedIndex); }; // When the menu items are changed, we want to reset the sub-menu to make sure @@ -312,7 +320,17 @@ function PopoverMenu({ } setEnteredSubMenuIndexes(CONST.EMPTY_ARRAY); setCurrentMenuItems(menuItems); - }, [menuItems]); + + // Update the focused item to match the selected item, but only when the popover is not visible. + // This ensures that if the popover is visible, highlight from the keyboard navigation is not overridden + // by external updates. + if (isVisible) { + return; + } + setFocusedIndex(getSelectedItemIndex(menuItems)); + + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [menuItems, setFocusedIndex]); return ( diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index f1a72cc7fb8e..3d6ad9006dc5 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -4,7 +4,6 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import Navigation from '@libs/Navigation/Navigation'; import {isLinkedTransactionHeld} from '@libs/ReportActionsUtils'; -import playSound, {SOUNDS} from '@libs/Sound'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -75,7 +74,6 @@ function ProcessMoneyReportHoldMenu({ if (startAnimation) { startAnimation(); } - playSound(SOUNDS.SUCCESS); IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport, full); } onClose(); diff --git a/src/components/ReceiptEmptyState.tsx b/src/components/ReceiptEmptyState.tsx index 71d64c7483f1..046026190a5b 100644 --- a/src/components/ReceiptEmptyState.tsx +++ b/src/components/ReceiptEmptyState.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import Icon from './Icon'; @@ -14,12 +16,15 @@ type ReceiptEmptyStateProps = { onPress?: () => void; disabled?: boolean; + + isThumbnail?: boolean; }; // Returns an SVG icon indicating that the user should attach a receipt -function ReceiptEmptyState({hasError = false, onPress = () => {}, disabled = false}: ReceiptEmptyStateProps) { +function ReceiptEmptyState({hasError = false, onPress = () => {}, disabled = false, isThumbnail = false}: ReceiptEmptyStateProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const theme = useTheme(); return ( {}, disabled = fal onPress={onPress} disabled={disabled} disabledStyle={styles.cursorDefault} - style={[styles.alignItemsCenter, styles.justifyContentCenter, styles.moneyRequestViewImage, styles.moneyRequestAttachReceipt, hasError && styles.borderColorDanger]} + style={[ + styles.alignItemsCenter, + styles.justifyContentCenter, + styles.moneyRequestViewImage, + isThumbnail ? styles.moneyRequestAttachReceiptThumbnail : styles.moneyRequestAttachReceipt, + hasError && styles.borderColorDanger, + ]} > - + + + {!isThumbnail && ( + + )} + ); } diff --git a/src/components/ReceiptImage.tsx b/src/components/ReceiptImage.tsx index 8c980838b841..bca1ef8b74e6 100644 --- a/src/components/ReceiptImage.tsx +++ b/src/components/ReceiptImage.tsx @@ -7,6 +7,7 @@ import EReceiptThumbnail from './EReceiptThumbnail'; import type {IconSize} from './EReceiptThumbnail'; import Image from './Image'; import PDFThumbnail from './PDFThumbnail'; +import ReceiptEmptyState from './ReceiptEmptyState'; import ThumbnailImage from './ThumbnailImage'; type Style = {height: number; borderRadius: number; margin: number}; @@ -79,6 +80,11 @@ type ReceiptImageProps = ( /** The background color of fallback icon */ fallbackIconBackground?: string; + + isEmptyReceipt?: boolean; + + /** Callback to be called on pressing the image */ + onPress?: () => void; }; function ReceiptImage({ @@ -97,9 +103,21 @@ function ReceiptImage({ shouldUseInitialObjectPosition = false, fallbackIconColor, fallbackIconBackground, + isEmptyReceipt = false, + onPress, }: ReceiptImageProps) { const styles = useThemeStyles(); + if (isEmptyReceipt) { + return ( + + ); + } + if (isPDFThumbnail) { return ( { if (isCardTransaction) { @@ -326,6 +326,8 @@ function MoneyRequestPreviewContent({ } }; + const shouldDisableOnPress = isBillSplit && isEmptyObject(transaction); + const childContainer = ( - {hasReceipt && ( - - )} + {isEmptyObject(transaction) && !ReportActionsUtils.isMessageDeleted(action) && action.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? ( ) : ( - + @@ -465,7 +466,7 @@ function MoneyRequestPreviewContent({ numberOfLines={1} style={[styles.textMicroSupporting, styles.pre, styles.flexShrink1]} > - {tag} + {PolicyUtils.getCleanedTagName(tag)} )} @@ -484,8 +485,6 @@ function MoneyRequestPreviewContent({ return childContainer; } - const shouldDisableOnPress = isBillSplit && isEmptyObject(transaction); - return ( void; }; /** @@ -73,12 +78,14 @@ function ReportActionItemImage({ enablePreviewModal = false, transaction, isLocalFile = false, + isEmptyReceipt = false, fileExtension, filename, isSingleImage = true, readonly = false, shouldMapHaveBorderRadius, isFromReviewDuplicates = false, + onPress, }: ReportActionItemImageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -128,6 +135,8 @@ function ReportActionItemImage({ isAuthTokenRequired: false, source: thumbnail ?? image ?? '', shouldUseInitialObjectPosition: isDistanceRequest, + isEmptyReceipt, + onPress, }; } diff --git a/src/components/ReportActionItem/ReportActionItemImages.tsx b/src/components/ReportActionItem/ReportActionItemImages.tsx index f27596556c53..9995b7f77860 100644 --- a/src/components/ReportActionItem/ReportActionItemImages.tsx +++ b/src/components/ReportActionItem/ReportActionItemImages.tsx @@ -27,6 +27,9 @@ type ReportActionItemImagesProps = { /** if the corresponding report action item is hovered */ isHovered?: boolean; + + /** Callback to be called on onPress */ + onPress?: () => void; }; /** @@ -38,7 +41,7 @@ type ReportActionItemImagesProps = { * additional number when subtracted from size. */ -function ReportActionItemImages({images, size, total, isHovered = false}: ReportActionItemImagesProps) { +function ReportActionItemImages({images, size, total, isHovered = false, onPress}: ReportActionItemImagesProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -67,7 +70,7 @@ function ReportActionItemImages({images, size, total, isHovered = false}: Report - {shownImages.map(({thumbnail, isThumbnail, image, transaction, isLocalFile, fileExtension, filename}, index) => { + {shownImages.map(({thumbnail, isThumbnail, image, isEmptyReceipt, transaction, isLocalFile, fileExtension, filename}, index) => { // Show a border to separate multiple images. Shown to the right for each except the last. const shouldShowBorder = shownImages.length > 1 && index < shownImages.length - 1; const borderStyle = shouldShowBorder ? styles.reportActionItemImageBorder : {}; @@ -81,11 +84,13 @@ function ReportActionItemImages({images, size, total, isHovered = false}: Report fileExtension={fileExtension} image={image} isLocalFile={isLocalFile} + isEmptyReceipt={isEmptyReceipt} filename={filename} transaction={transaction} isThumbnail={isThumbnail} isSingleImage={numberOfShownImages === 1} shouldMapHaveBorderRadius={false} + onPress={onPress} /> ); diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index e3ddb91d0528..19ab01a27c57 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -102,6 +102,9 @@ function ReportPreview({ const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET); + const [invoiceReceiverPolicy] = useOnyx( + `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : -1}`, + ); const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -123,13 +126,21 @@ function ReportPreview({ const [isPaidAnimationRunning, setIsPaidAnimationRunning] = useState(false); const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); const [requestType, setRequestType] = useState(); - const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, policy); - const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID ?? ''); + const [paymentType, setPaymentType] = useState(); - const [invoiceReceiverPolicy] = useOnyx( - `${ONYXKEYS.COLLECTION.POLICY}${chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : -1}`, + + const getCanIOUBePaid = useCallback( + (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, onlyShowPayElsewhere), + [iouReport, chatReport, policy, allTransactions], ); + const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); + const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); + const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; + + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, shouldShowPayButton); + const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID ?? ''); + const managerID = iouReport?.managerID ?? action.childManagerAccountID ?? 0; const {totalDisplaySpend, reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); @@ -162,8 +173,8 @@ function ReportPreview({ ReportUtils.hasWarningTypeViolations(iouReportID, transactionViolations, true) || (ReportUtils.isReportOwner(iouReport) && ReportUtils.hasReportViolations(iouReportID)) || ReportUtils.hasActionsWithErrors(iouReportID); - const lastThreeTransactionsWithReceipts = transactionsWithReceipts.slice(-3); - const lastThreeReceipts = lastThreeTransactionsWithReceipts.map((transaction) => ({...ReceiptUtils.getThumbnailAndImageURIs(transaction), transaction})); + const lastThreeTransactions = allTransactions.slice(-3); + const lastThreeReceipts = lastThreeTransactions.map((transaction) => ({...ReceiptUtils.getThumbnailAndImageURIs(transaction), transaction})); const showRTERViolationMessage = numberOfRequests === 1 && TransactionUtils.hasPendingUI(allTransactions.at(0), TransactionUtils.getTransactionViolations(allTransactions.at(0)?.transactionID ?? '-1', transactionViolations)); @@ -329,14 +340,6 @@ function ReportPreview({ ]); const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); - const getCanIOUBePaid = useCallback( - (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, onlyShowPayElsewhere), - [iouReport, chatReport, policy, allTransactions], - ); - - const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); - const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); - const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]); const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport); @@ -445,6 +448,12 @@ function ReportPreview({ checkMarkScale.set(isPaidAnimationRunning ? withDelay(CONST.ANIMATION_PAID_CHECKMARK_DELAY, withSpring(1, {duration: CONST.ANIMATION_PAID_DURATION})) : 1); }, [isPaidAnimationRunning, iouSettled, checkMarkScale]); + const openReportFromPreview = useCallback(() => { + Performance.markStart(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); + Timing.start(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID)); + }, [iouReportID]); + return ( { - Performance.markStart(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); - Timing.start(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(iouReportID)); - }} + onPress={openReportFromPreview} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} @@ -467,13 +472,12 @@ function ReportPreview({ accessibilityLabel={translate('iou.viewDetails')} > - {hasReceipts && ( - - )} + diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 1e3ce6119315..cc7dbed1f6e9 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -46,12 +47,18 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { const welcomeMessage = SidebarUtils.getWelcomeMessage(report, policy); const moneyRequestOptions = ReportUtils.temporary_getMoneyRequestOptions(report, policy, participantAccountIDs); const canEditReportDescription = ReportUtils.canEditReportDescription(report, policy); + const {canUseCombinedTrackSubmit} = usePermissions(); const filteredOptions = moneyRequestOptions.filter( (item): item is Exclude => item !== CONST.IOU.TYPE.INVOICE, ); const additionalText = filteredOptions - .map((item, index) => `${index === filteredOptions.length - 1 && index > 0 ? `${translate('common.or')} ` : ''}${translate(`reportActionsView.iouTypes.${item}`)}`) + .map( + (item, index) => + `${index === filteredOptions.length - 1 && index > 0 ? `${translate('common.or')} ` : ''}${translate( + canUseCombinedTrackSubmit && item === 'submit' ? `reportActionsView.create` : `reportActionsView.iouTypes.${item}`, + )}`, + ) .join(', '); const canEditPolicyDescription = ReportUtils.canEditPolicyDescription(policy); const reportName = ReportUtils.getReportName(report); diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index e264b7fd9a55..09315bfb8a8e 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -1,6 +1,6 @@ import {UNSTABLE_usePreventRemove, useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import type {ForwardedRef, ReactNode} from 'react'; -import React, {createContext, forwardRef, useEffect, useMemo, useRef, useState} from 'react'; +import React, {createContext, forwardRef, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {Keyboard, NativeModules, PanResponder, View} from 'react-native'; import {PickerAvoidingView} from 'react-native-picker-select'; @@ -25,6 +25,7 @@ import type FocusTrapForScreenProps from './FocusTrap/FocusTrapForScreen/FocusTr import HeaderGap from './HeaderGap'; import ImportedStateIndicator from './ImportedStateIndicator'; import KeyboardAvoidingView from './KeyboardAvoidingView'; +import ModalContext from './Modal/ModalContext'; import OfflineIndicator from './OfflineIndicator'; import withNavigationFallback from './withNavigationFallback'; @@ -149,6 +150,8 @@ function ScreenWrapper( const navigation = navigationProp ?? navigationFallback; const isFocused = useIsFocused(); const {windowHeight} = useWindowDimensions(shouldUseCachedViewportHeight); + // since Modals are drawn in separate native view hierarchy we should always add paddings + const ignoreInsetsConsumption = !useContext(ModalContext).default; // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout for a case where we want to show the offline indicator only on small screens // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -237,19 +240,25 @@ function ScreenWrapper( // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); - const {insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle} = useStyledSafeAreaInsets(); + const {insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle, unmodifiedPaddings} = useStyledSafeAreaInsets(); const paddingStyle: StyleProp = {}; const isSafeAreaTopPaddingApplied = includePaddingTop; if (includePaddingTop) { paddingStyle.paddingTop = paddingTop; } + if (includePaddingTop && ignoreInsetsConsumption) { + paddingStyle.paddingTop = unmodifiedPaddings.top; + } // We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked. const isSafeAreaBottomPaddingApplied = includeSafeAreaPaddingBottom || (isOffline && shouldShowOfflineIndicator); if (isSafeAreaBottomPaddingApplied) { paddingStyle.paddingBottom = paddingBottom; } + if (isSafeAreaBottomPaddingApplied && ignoreInsetsConsumption) { + paddingStyle.paddingBottom = unmodifiedPaddings.bottom; + } const isAvoidingViewportScroll = useTackInputFocus(isFocused && shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileWebKit()); const contextValue = useMemo( diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx index d76f2e76ab02..19b89ed71e14 100644 --- a/src/components/Search/SearchMultipleSelectionPicker.tsx +++ b/src/components/Search/SearchMultipleSelectionPicker.tsx @@ -4,6 +4,7 @@ import SelectionList from '@components/SelectionList'; import SelectableListItem from '@components/SelectionList/SelectableListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; import type {OptionData} from '@libs/ReportUtils'; @@ -25,6 +26,7 @@ type SearchMultipleSelectionPickerProps = { function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTitle, onSaveSelection, shouldShowTextInput = true}: SearchMultipleSelectionPickerProps) { const {translate} = useLocalize(); + const styles = useThemeStyles(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedItems, setSelectedItems] = useState(initiallySelectedItems ?? []); @@ -106,13 +108,14 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit () => (