diff --git a/.claude/agents/deploy-blocker-investigator.md b/.claude/agents/deploy-blocker-investigator.md index 02a06c5aec1d..5805d7363e6a 100644 --- a/.claude/agents/deploy-blocker-investigator.md +++ b/.claude/agents/deploy-blocker-investigator.md @@ -1,7 +1,7 @@ --- name: deploy-blocker-investigator description: Investigates deploy blockers to find the causing PR and recommend resolution. -tools: Glob, Grep, Read, Bash, BashOutput +tools: Glob, Grep, Read, Write, Bash, BashOutput model: inherit --- @@ -114,11 +114,9 @@ See Decision Tree below for label actions. ### Step 7: Post comment and update labels -Post your findings (use single quotes for the body to handle special characters): +Write the comment body to a temp file using the Write tool, then post it: ```bash -gh issue comment "$ISSUE_URL" --body '## ๐Ÿ” Investigation Summary -...your comment here... -' +gh issue comment "$ISSUE_URL" --body-file /tmp/investigation-summary.md ``` Remove label only if the decision tree warrants it: @@ -131,7 +129,12 @@ Call scripts by name only (e.g., `removeDeployBlockerLabel.sh`), not with full p ### Step 8: Assign contributors -When a causing PR is identified with medium or high confidence, assign the PR author and all approving reviewers to the deploy blocker issue using `gh issue edit "$ISSUE_URL" --add-assignee`. Extract approving reviewers from `gh pr view --json reviews`. +If the primary causing PR has at least medium confidence, assign its author and approving reviewers to the deploy blocker issue. Only assign contributors from this single PR. + +```bash +gh pr view --json reviews +gh issue edit "$ISSUE_URL" --add-assignee +``` --- @@ -172,7 +175,7 @@ Choose ONE: ## Comment Format -Post ONE comment using this exact format: +Use the Write tool to create `/tmp/investigation-summary.md` with the following markdown structure. Every field and heading must be present. ```markdown ## ๐Ÿ” Investigation Summary @@ -186,7 +189,7 @@ Post ONE comment using this exact format: Brief explanation of why this recommendation (1-2 sentences). -**Assigned**: @author (PR author), @reviewer (approving reviewer) โ€” or omit if no causing PR identified +**Assigned**: @author (PR author), @reviewer (approving reviewer) from highest-confidence causing PR only โ€” omit if no causing PR identified **Labels**: [Describe any label changes made]
@@ -206,6 +209,8 @@ Technical explanation of what went wrong in the code.
``` + +Then post with: `gh issue comment "$ISSUE_URL" --body-file /tmp/investigation-summary.md` --- ## Constraints @@ -218,7 +223,7 @@ Technical explanation of what went wrong in the code. - Make assumptions about code you haven't read - Recommend DEMOTE for bugs affecting core functionality (auth, payments, data loss) - Close the issueโ€”only update labels and comment -- Use heredocs, temp files, or shell redirects for comments +- Use heredocs or shell redirects for comments **DO:** - Always verify the causing PR touches the affected code before concluding diff --git a/.claude/commands/investigate-deploy-blocker.md b/.claude/commands/investigate-deploy-blocker.md index 814f219f184f..ce2992c5f33a 100644 --- a/.claude/commands/investigate-deploy-blocker.md +++ b/.claude/commands/investigate-deploy-blocker.md @@ -1,5 +1,5 @@ --- -allowed-tools: Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue edit:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(removeDeployBlockerLabel.sh:*),Glob,Grep,Read +allowed-tools: Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue edit:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(removeDeployBlockerLabel.sh:*),Glob,Grep,Read,Write description: Investigate a deploy blocker issue to find the causing PR and recommend resolution --- diff --git a/.claude/scripts/createInlineComment.sh b/.claude/scripts/createInlineComment.sh index 5a9bb54f6e06..5ebfe4cd9da3 100755 --- a/.claude/scripts/createInlineComment.sh +++ b/.claude/scripts/createInlineComment.sh @@ -3,7 +3,7 @@ # Secure proxy script to create an inline comment on a GitHub PR. set -eu -readonly ALLOWED_RULES_FILE="${GITHUB_WORKSPACE}/.claude/allowed-rules.txt" +readonly ALLOWED_RULES_FILE="${ALLOWED_RULES_FILE:-${GITHUB_WORKSPACE}/.claude/allowed-rules.txt}" # Print error and exit. die() { diff --git a/.claude/settings.json b/.claude/settings.json index a63b4a04a775..3cd2d1b82934 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -52,7 +52,9 @@ "mcp__sentry__search_events", "mcp__sentry__search_issue_events", "mcp__sentry__search_issues", - "mcp__sentry__whoami" + "mcp__sentry__whoami", + "Bash(agent-device *)", + "Bash(echo \"$(npm root -g)/agent-device/skills/agent-device\")" ] }, "enabledPlugins": { diff --git a/.claude/skills/agent-device/SKILL.md b/.claude/skills/agent-device/SKILL.md new file mode 100644 index 000000000000..b542aae75be5 --- /dev/null +++ b/.claude/skills/agent-device/SKILL.md @@ -0,0 +1,15 @@ +--- +name: agent-device +description: Drive iOS and Android devices for the Expensify App - testing, debugging, performance profiling, bug reproduction, and feature verification. Use when the developer needs to interact with the mobile app on a device. +allowed-tools: Bash(agent-device *) Bash(npm root *) +--- + +# agent-device + +## Pre-flight + +`agent-device` CLI version: !`agent-device --version 2>&1 || echo "NOT_INSTALLED"` + +Canonical skill reference path (read these files directly for device automation guidance - bootstrap, exploration, verification, debugging): !`echo "$(npm root -g)/agent-device/skills/agent-device"` + +> If the version line above shows `NOT_INSTALLED` or a command-not-found error, **STOP** and instruct the developer to install it: `npm install -g agent-device`. All device interaction depends on it. diff --git a/.claude/skills/coding-standards/rules/clean-react-0-compiler.md b/.claude/skills/coding-standards/rules/clean-react-0-compiler.md index 67dfc4692b8d..58199a3aabd5 100644 --- a/.claude/skills/coding-standards/rules/clean-react-0-compiler.md +++ b/.claude/skills/coding-standards/rules/clean-react-0-compiler.md @@ -18,7 +18,7 @@ Manual memoization is therefore: The codebase enforces this via: - **Babel plugin**: `babel-plugin-react-compiler` in `babel.config.js` - **ESLint processor**: `eslint-plugin-react-compiler-compat` suppresses redundant lint rules when files compile successfully -- **CI compliance check**: `scripts/react-compiler-compliance-check.ts` blocks PRs with manual memoization in new files +- **CI compliance check**: `scripts/react-compiler-compliance-check.ts` enforces that new components/hooks compile and that existing compiled files don't regress Reference: [React Compiler documentation](https://react.dev/learn/react-compiler) diff --git a/.github/actions/composite/isContributorPlus/action.yml b/.github/actions/composite/isContributorPlus/action.yml new file mode 100644 index 000000000000..42c3d2f044dc --- /dev/null +++ b/.github/actions/composite/isContributorPlus/action.yml @@ -0,0 +1,33 @@ +name: Is Contributor+ +description: Check whether a GitHub user is a member of the Expensify/contributor-plus team. Sets IS_CPLUS=true when the user is a member, IS_CPLUS=false otherwise. + +inputs: + USERNAME: + description: The GitHub login of the user to check. + required: true + OS_BOTIFY_TOKEN: + description: OSBotify token. Needed to read team memberships (the default GITHUB_TOKEN lacks the read:org scope). + required: true + +outputs: + IS_CPLUS: + description: "'true' if the user is a member of Expensify/contributor-plus, 'false' otherwise." + value: ${{ steps.check.outputs.IS_CPLUS }} + +runs: + using: composite + steps: + - name: Check Contributor+ membership + id: check + shell: bash + env: + GH_TOKEN: ${{ inputs.OS_BOTIFY_TOKEN }} + USERNAME: ${{ inputs.USERNAME }} + run: | + if gh api "/orgs/Expensify/teams/contributor-plus/memberships/$USERNAME" --silent; then + echo "::notice::โœ… $USERNAME is a Contributor+ member" + echo "IS_CPLUS=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::$USERNAME is not a Contributor+ member" + echo "IS_CPLUS=false" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/actions/javascript/authorChecklist/categories/newComponentCategory.ts b/.github/actions/javascript/authorChecklist/categories/newComponentCategory.ts index fbe9c9443881..64dad56d580a 100644 --- a/.github/actions/javascript/authorChecklist/categories/newComponentCategory.ts +++ b/.github/actions/javascript/authorChecklist/categories/newComponentCategory.ts @@ -65,10 +65,7 @@ function detectReactComponent(code: string, filename: string): boolean | undefin // eslint-disable-next-line @typescript-eslint/naming-convention ClassDeclaration(path) { const {superClass} = path.node as unknown as SuperClassType; - if ( - superClass && - ((superClass.object && superClass.object.name === 'React' && isComponentOrPureComponent(superClass.property.name)) || isComponentOrPureComponent(superClass.name)) - ) { + if (superClass && ((superClass.object?.name === 'React' && isComponentOrPureComponent(superClass.property.name)) || isComponentOrPureComponent(superClass.name))) { isReactComponent = true; path.stop(); } diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index 6fb947077e24..87f0587ec36d 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -15767,11 +15767,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index c655339397f8..92168e1c5c31 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -12545,11 +12545,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/checkAndroidStatus/index.js b/.github/actions/javascript/checkAndroidStatus/index.js index 43973e398085..cba463f67965 100644 --- a/.github/actions/javascript/checkAndroidStatus/index.js +++ b/.github/actions/javascript/checkAndroidStatus/index.js @@ -737288,11 +737288,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/checkDeployBlockers/index.js b/.github/actions/javascript/checkDeployBlockers/index.js index 4f9879646d9e..320a6a190cf0 100644 --- a/.github/actions/javascript/checkDeployBlockers/index.js +++ b/.github/actions/javascript/checkDeployBlockers/index.js @@ -11812,11 +11812,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/checkSVGCompression/index.js b/.github/actions/javascript/checkSVGCompression/index.js index 08035a5a2ea1..2a1bad543517 100644 --- a/.github/actions/javascript/checkSVGCompression/index.js +++ b/.github/actions/javascript/checkSVGCompression/index.js @@ -20337,11 +20337,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/formatCodeCovComment/index.js b/.github/actions/javascript/formatCodeCovComment/index.js index f2dee5c3d4b5..2478c7b47258 100644 --- a/.github/actions/javascript/formatCodeCovComment/index.js +++ b/.github/actions/javascript/formatCodeCovComment/index.js @@ -11999,11 +11999,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/getArtifactInfo/index.js b/.github/actions/javascript/getArtifactInfo/index.js index ba1dafc205bb..b031dfa3dbc4 100644 --- a/.github/actions/javascript/getArtifactInfo/index.js +++ b/.github/actions/javascript/getArtifactInfo/index.js @@ -11773,11 +11773,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index a20dce0f925b..cc1b5935b5d4 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -12154,11 +12154,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/getGraphiteString/action.yml b/.github/actions/javascript/getGraphiteString/action.yml deleted file mode 100644 index 58670082972e..000000000000 --- a/.github/actions/javascript/getGraphiteString/action.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: 'Get and Save graphite string' -description: 'Parse reassure output.json file and create string which can be sent to the graphite server' - -inputs: - PR_NUMBER: - description: Number of merged PR - required: true -outputs: - GRAPHITE_STRING: - description: String with reassure data which can be directly sent to the graphite server -runs: - using: 'node24' - main: './index.js' diff --git a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts b/.github/actions/javascript/getGraphiteString/getGraphiteString.ts deleted file mode 100644 index f2cc107082a5..000000000000 --- a/.github/actions/javascript/getGraphiteString/getGraphiteString.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as core from '@actions/core'; -import fs from 'fs'; - -type RegressionEntry = { - metadata?: { - creationDate: string; - }; - name: string; - meanDuration: number; - meanCount: number; -}; - -const run = () => { - // Prefix path to the graphite metric - const GRAPHITE_PATH = 'reassure'; - const PR_NUMBER = core.getInput('PR_NUMBER', {required: true}); - - // Read the contents of the file, the file is in the JSONL format - const regressionFile = fs.readFileSync('.reassure/baseline.perf', 'utf8'); - - // Split file contents by newline to get individual JSON entries - const regressionEntries = regressionFile.split('\n'); - - // Initialize string to store Graphite metrics - let graphiteString = ''; - let timestamp: number | null = null; - - // Iterate over each entry - for (const entry of regressionEntries) { - // Skip empty lines - if (entry.trim() === '') { - continue; - } - - try { - const current = JSON.parse(entry) as RegressionEntry; - - // Extract timestamp, Graphite accepts timestamp in seconds - if (current.metadata?.creationDate) { - timestamp = Math.floor(new Date(current.metadata.creationDate).getTime() / 1000); - } - - if (current.name && current.meanDuration && current.meanCount && timestamp) { - const formattedName = current.name.split(' ').join('-'); - - const renderDurationString = `${GRAPHITE_PATH}.${formattedName}.renderDuration ${current.meanDuration} ${timestamp}`; - const renderCountString = `${GRAPHITE_PATH}.${formattedName}.renderCount ${current.meanCount} ${timestamp}`; - const renderPRNumberString = `${GRAPHITE_PATH}.${formattedName}.prNumber ${PR_NUMBER} ${timestamp}`; - - // Concatenate Graphite strings - graphiteString += `${renderDurationString}\n${renderCountString}\n${renderPRNumberString}\n`; - } - } catch (e) { - const error = new Error('Error parsing baseline.perf JSON file'); - console.error(error.message); - core.setFailed(error); - } - } - - // Set generated graphite string to the github variable - core.setOutput('GRAPHITE_STRING', graphiteString); -}; - -if (require.main === module) { - run(); -} - -export default run; diff --git a/.github/actions/javascript/getGraphiteString/index.js b/.github/actions/javascript/getGraphiteString/index.js deleted file mode 100644 index bcb9ae193dd1..000000000000 --- a/.github/actions/javascript/getGraphiteString/index.js +++ /dev/null @@ -1,2921 +0,0 @@ -/** - * NOTE: This is a compiled file. DO NOT directly edit this file. - */ -/******/ (() => { // webpackBootstrap -/******/ var __webpack_modules__ = ({ - -/***/ 351: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.issue = exports.issueCommand = void 0; -const os = __importStar(__nccwpck_require__(37)); -const utils_1 = __nccwpck_require__(278); -/** - * Commands - * - * Command Format: - * ::name key=value,key=value::message - * - * Examples: - * ::warning::This is the message - * ::set-env name=MY_VAR::some value - */ -function issueCommand(command, properties, message) { - const cmd = new Command(command, properties, message); - process.stdout.write(cmd.toString() + os.EOL); -} -exports.issueCommand = issueCommand; -function issue(name, message = '') { - issueCommand(name, {}, message); -} -exports.issue = issue; -const CMD_STRING = '::'; -class Command { - constructor(command, properties, message) { - if (!command) { - command = 'missing.command'; - } - this.command = command; - this.properties = properties; - this.message = message; - } - toString() { - let cmdStr = CMD_STRING + this.command; - if (this.properties && Object.keys(this.properties).length > 0) { - cmdStr += ' '; - let first = true; - for (const key in this.properties) { - if (this.properties.hasOwnProperty(key)) { - const val = this.properties[key]; - if (val) { - if (first) { - first = false; - } - else { - cmdStr += ','; - } - cmdStr += `${key}=${escapeProperty(val)}`; - } - } - } - } - cmdStr += `${CMD_STRING}${escapeData(this.message)}`; - return cmdStr; - } -} -function escapeData(s) { - return utils_1.toCommandValue(s) - .replace(/%/g, '%25') - .replace(/\r/g, '%0D') - .replace(/\n/g, '%0A'); -} -function escapeProperty(s) { - return utils_1.toCommandValue(s) - .replace(/%/g, '%25') - .replace(/\r/g, '%0D') - .replace(/\n/g, '%0A') - .replace(/:/g, '%3A') - .replace(/,/g, '%2C'); -} -//# sourceMappingURL=command.js.map - -/***/ }), - -/***/ 186: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getIDToken = exports.getState = exports.saveState = exports.group = exports.endGroup = exports.startGroup = exports.info = exports.notice = exports.warning = exports.error = exports.debug = exports.isDebug = exports.setFailed = exports.setCommandEcho = exports.setOutput = exports.getBooleanInput = exports.getMultilineInput = exports.getInput = exports.addPath = exports.setSecret = exports.exportVariable = exports.ExitCode = void 0; -const command_1 = __nccwpck_require__(351); -const file_command_1 = __nccwpck_require__(185); -const utils_1 = __nccwpck_require__(278); -const os = __importStar(__nccwpck_require__(37)); -const path = __importStar(__nccwpck_require__(17)); -const oidc_utils_1 = __nccwpck_require__(41); -/** - * The code to exit an action - */ -var ExitCode; -(function (ExitCode) { - /** - * A code indicating that the action was successful - */ - ExitCode[ExitCode["Success"] = 0] = "Success"; - /** - * A code indicating that the action was a failure - */ - ExitCode[ExitCode["Failure"] = 1] = "Failure"; -})(ExitCode = exports.ExitCode || (exports.ExitCode = {})); -//----------------------------------------------------------------------- -// Variables -//----------------------------------------------------------------------- -/** - * Sets env variable for this action and future actions in the job - * @param name the name of the variable to set - * @param val the value of the variable. Non-string values will be converted to a string via JSON.stringify - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function exportVariable(name, val) { - const convertedVal = utils_1.toCommandValue(val); - process.env[name] = convertedVal; - const filePath = process.env['GITHUB_ENV'] || ''; - if (filePath) { - return file_command_1.issueFileCommand('ENV', file_command_1.prepareKeyValueMessage(name, val)); - } - command_1.issueCommand('set-env', { name }, convertedVal); -} -exports.exportVariable = exportVariable; -/** - * Registers a secret which will get masked from logs - * @param secret value of the secret - */ -function setSecret(secret) { - command_1.issueCommand('add-mask', {}, secret); -} -exports.setSecret = setSecret; -/** - * Prepends inputPath to the PATH (for this action and future actions) - * @param inputPath - */ -function addPath(inputPath) { - const filePath = process.env['GITHUB_PATH'] || ''; - if (filePath) { - file_command_1.issueFileCommand('PATH', inputPath); - } - else { - command_1.issueCommand('add-path', {}, inputPath); - } - process.env['PATH'] = `${inputPath}${path.delimiter}${process.env['PATH']}`; -} -exports.addPath = addPath; -/** - * Gets the value of an input. - * Unless trimWhitespace is set to false in InputOptions, the value is also trimmed. - * Returns an empty string if the value is not defined. - * - * @param name name of the input to get - * @param options optional. See InputOptions. - * @returns string - */ -function getInput(name, options) { - const val = process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] || ''; - if (options && options.required && !val) { - throw new Error(`Input required and not supplied: ${name}`); - } - if (options && options.trimWhitespace === false) { - return val; - } - return val.trim(); -} -exports.getInput = getInput; -/** - * Gets the values of an multiline input. Each value is also trimmed. - * - * @param name name of the input to get - * @param options optional. See InputOptions. - * @returns string[] - * - */ -function getMultilineInput(name, options) { - const inputs = getInput(name, options) - .split('\n') - .filter(x => x !== ''); - if (options && options.trimWhitespace === false) { - return inputs; - } - return inputs.map(input => input.trim()); -} -exports.getMultilineInput = getMultilineInput; -/** - * Gets the input value of the boolean type in the YAML 1.2 "core schema" specification. - * Support boolean input list: `true | True | TRUE | false | False | FALSE` . - * The return value is also in boolean type. - * ref: https://yaml.org/spec/1.2/spec.html#id2804923 - * - * @param name name of the input to get - * @param options optional. See InputOptions. - * @returns boolean - */ -function getBooleanInput(name, options) { - const trueValue = ['true', 'True', 'TRUE']; - const falseValue = ['false', 'False', 'FALSE']; - const val = getInput(name, options); - if (trueValue.includes(val)) - return true; - if (falseValue.includes(val)) - return false; - throw new TypeError(`Input does not meet YAML 1.2 "Core Schema" specification: ${name}\n` + - `Support boolean input list: \`true | True | TRUE | false | False | FALSE\``); -} -exports.getBooleanInput = getBooleanInput; -/** - * Sets the value of an output. - * - * @param name name of the output to set - * @param value value to store. Non-string values will be converted to a string via JSON.stringify - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function setOutput(name, value) { - const filePath = process.env['GITHUB_OUTPUT'] || ''; - if (filePath) { - return file_command_1.issueFileCommand('OUTPUT', file_command_1.prepareKeyValueMessage(name, value)); - } - process.stdout.write(os.EOL); - command_1.issueCommand('set-output', { name }, utils_1.toCommandValue(value)); -} -exports.setOutput = setOutput; -/** - * Enables or disables the echoing of commands into stdout for the rest of the step. - * Echoing is disabled by default if ACTIONS_STEP_DEBUG is not set. - * - */ -function setCommandEcho(enabled) { - command_1.issue('echo', enabled ? 'on' : 'off'); -} -exports.setCommandEcho = setCommandEcho; -//----------------------------------------------------------------------- -// Results -//----------------------------------------------------------------------- -/** - * Sets the action status to failed. - * When the action exits it will be with an exit code of 1 - * @param message add error issue message - */ -function setFailed(message) { - process.exitCode = ExitCode.Failure; - error(message); -} -exports.setFailed = setFailed; -//----------------------------------------------------------------------- -// Logging Commands -//----------------------------------------------------------------------- -/** - * Gets whether Actions Step Debug is on or not - */ -function isDebug() { - return process.env['RUNNER_DEBUG'] === '1'; -} -exports.isDebug = isDebug; -/** - * Writes debug message to user log - * @param message debug message - */ -function debug(message) { - command_1.issueCommand('debug', {}, message); -} -exports.debug = debug; -/** - * Adds an error issue - * @param message error issue message. Errors will be converted to string via toString() - * @param properties optional properties to add to the annotation. - */ -function error(message, properties = {}) { - command_1.issueCommand('error', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message); -} -exports.error = error; -/** - * Adds a warning issue - * @param message warning issue message. Errors will be converted to string via toString() - * @param properties optional properties to add to the annotation. - */ -function warning(message, properties = {}) { - command_1.issueCommand('warning', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message); -} -exports.warning = warning; -/** - * Adds a notice issue - * @param message notice issue message. Errors will be converted to string via toString() - * @param properties optional properties to add to the annotation. - */ -function notice(message, properties = {}) { - command_1.issueCommand('notice', utils_1.toCommandProperties(properties), message instanceof Error ? message.toString() : message); -} -exports.notice = notice; -/** - * Writes info to log with console.log. - * @param message info message - */ -function info(message) { - process.stdout.write(message + os.EOL); -} -exports.info = info; -/** - * Begin an output group. - * - * Output until the next `groupEnd` will be foldable in this group - * - * @param name The name of the output group - */ -function startGroup(name) { - command_1.issue('group', name); -} -exports.startGroup = startGroup; -/** - * End an output group. - */ -function endGroup() { - command_1.issue('endgroup'); -} -exports.endGroup = endGroup; -/** - * Wrap an asynchronous function call in a group. - * - * Returns the same type as the function itself. - * - * @param name The name of the group - * @param fn The function to wrap in the group - */ -function group(name, fn) { - return __awaiter(this, void 0, void 0, function* () { - startGroup(name); - let result; - try { - result = yield fn(); - } - finally { - endGroup(); - } - return result; - }); -} -exports.group = group; -//----------------------------------------------------------------------- -// Wrapper action state -//----------------------------------------------------------------------- -/** - * Saves state for current action, the state can only be retrieved by this action's post job execution. - * - * @param name name of the state to store - * @param value value to store. Non-string values will be converted to a string via JSON.stringify - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function saveState(name, value) { - const filePath = process.env['GITHUB_STATE'] || ''; - if (filePath) { - return file_command_1.issueFileCommand('STATE', file_command_1.prepareKeyValueMessage(name, value)); - } - command_1.issueCommand('save-state', { name }, utils_1.toCommandValue(value)); -} -exports.saveState = saveState; -/** - * Gets the value of an state set by this action's main execution. - * - * @param name name of the state to get - * @returns string - */ -function getState(name) { - return process.env[`STATE_${name}`] || ''; -} -exports.getState = getState; -function getIDToken(aud) { - return __awaiter(this, void 0, void 0, function* () { - return yield oidc_utils_1.OidcClient.getIDToken(aud); - }); -} -exports.getIDToken = getIDToken; -/** - * Summary exports - */ -var summary_1 = __nccwpck_require__(327); -Object.defineProperty(exports, "summary", ({ enumerable: true, get: function () { return summary_1.summary; } })); -/** - * @deprecated use core.summary - */ -var summary_2 = __nccwpck_require__(327); -Object.defineProperty(exports, "markdownSummary", ({ enumerable: true, get: function () { return summary_2.markdownSummary; } })); -/** - * Path exports - */ -var path_utils_1 = __nccwpck_require__(981); -Object.defineProperty(exports, "toPosixPath", ({ enumerable: true, get: function () { return path_utils_1.toPosixPath; } })); -Object.defineProperty(exports, "toWin32Path", ({ enumerable: true, get: function () { return path_utils_1.toWin32Path; } })); -Object.defineProperty(exports, "toPlatformPath", ({ enumerable: true, get: function () { return path_utils_1.toPlatformPath; } })); -//# sourceMappingURL=core.js.map - -/***/ }), - -/***/ 185: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -// For internal use, subject to change. -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.prepareKeyValueMessage = exports.issueFileCommand = void 0; -// We use any as a valid input type -/* eslint-disable @typescript-eslint/no-explicit-any */ -const fs = __importStar(__nccwpck_require__(147)); -const os = __importStar(__nccwpck_require__(37)); -const uuid_1 = __nccwpck_require__(840); -const utils_1 = __nccwpck_require__(278); -function issueFileCommand(command, message) { - const filePath = process.env[`GITHUB_${command}`]; - if (!filePath) { - throw new Error(`Unable to find environment variable for file command ${command}`); - } - if (!fs.existsSync(filePath)) { - throw new Error(`Missing file at path: ${filePath}`); - } - fs.appendFileSync(filePath, `${utils_1.toCommandValue(message)}${os.EOL}`, { - encoding: 'utf8' - }); -} -exports.issueFileCommand = issueFileCommand; -function prepareKeyValueMessage(key, value) { - const delimiter = `ghadelimiter_${uuid_1.v4()}`; - const convertedValue = utils_1.toCommandValue(value); - // These should realistically never happen, but just in case someone finds a - // way to exploit uuid generation let's not allow keys or values that contain - // the delimiter. - if (key.includes(delimiter)) { - throw new Error(`Unexpected input: name should not contain the delimiter "${delimiter}"`); - } - if (convertedValue.includes(delimiter)) { - throw new Error(`Unexpected input: value should not contain the delimiter "${delimiter}"`); - } - return `${key}<<${delimiter}${os.EOL}${convertedValue}${os.EOL}${delimiter}`; -} -exports.prepareKeyValueMessage = prepareKeyValueMessage; -//# sourceMappingURL=file-command.js.map - -/***/ }), - -/***/ 41: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.OidcClient = void 0; -const http_client_1 = __nccwpck_require__(255); -const auth_1 = __nccwpck_require__(526); -const core_1 = __nccwpck_require__(186); -class OidcClient { - static createHttpClient(allowRetry = true, maxRetry = 10) { - const requestOptions = { - allowRetries: allowRetry, - maxRetries: maxRetry - }; - return new http_client_1.HttpClient('actions/oidc-client', [new auth_1.BearerCredentialHandler(OidcClient.getRequestToken())], requestOptions); - } - static getRequestToken() { - const token = process.env['ACTIONS_ID_TOKEN_REQUEST_TOKEN']; - if (!token) { - throw new Error('Unable to get ACTIONS_ID_TOKEN_REQUEST_TOKEN env variable'); - } - return token; - } - static getIDTokenUrl() { - const runtimeUrl = process.env['ACTIONS_ID_TOKEN_REQUEST_URL']; - if (!runtimeUrl) { - throw new Error('Unable to get ACTIONS_ID_TOKEN_REQUEST_URL env variable'); - } - return runtimeUrl; - } - static getCall(id_token_url) { - var _a; - return __awaiter(this, void 0, void 0, function* () { - const httpclient = OidcClient.createHttpClient(); - const res = yield httpclient - .getJson(id_token_url) - .catch(error => { - throw new Error(`Failed to get ID Token. \n - Error Code : ${error.statusCode}\n - Error Message: ${error.result.message}`); - }); - const id_token = (_a = res.result) === null || _a === void 0 ? void 0 : _a.value; - if (!id_token) { - throw new Error('Response json body do not have ID Token field'); - } - return id_token; - }); - } - static getIDToken(audience) { - return __awaiter(this, void 0, void 0, function* () { - try { - // New ID Token is requested from action service - let id_token_url = OidcClient.getIDTokenUrl(); - if (audience) { - const encodedAudience = encodeURIComponent(audience); - id_token_url = `${id_token_url}&audience=${encodedAudience}`; - } - core_1.debug(`ID token url is ${id_token_url}`); - const id_token = yield OidcClient.getCall(id_token_url); - core_1.setSecret(id_token); - return id_token; - } - catch (error) { - throw new Error(`Error message: ${error.message}`); - } - }); - } -} -exports.OidcClient = OidcClient; -//# sourceMappingURL=oidc-utils.js.map - -/***/ }), - -/***/ 981: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.toPlatformPath = exports.toWin32Path = exports.toPosixPath = void 0; -const path = __importStar(__nccwpck_require__(17)); -/** - * toPosixPath converts the given path to the posix form. On Windows, \\ will be - * replaced with /. - * - * @param pth. Path to transform. - * @return string Posix path. - */ -function toPosixPath(pth) { - return pth.replace(/[\\]/g, '/'); -} -exports.toPosixPath = toPosixPath; -/** - * toWin32Path converts the given path to the win32 form. On Linux, / will be - * replaced with \\. - * - * @param pth. Path to transform. - * @return string Win32 path. - */ -function toWin32Path(pth) { - return pth.replace(/[/]/g, '\\'); -} -exports.toWin32Path = toWin32Path; -/** - * toPlatformPath converts the given path to a platform-specific path. It does - * this by replacing instances of / and \ with the platform-specific path - * separator. - * - * @param pth The path to platformize. - * @return string The platform-specific path. - */ -function toPlatformPath(pth) { - return pth.replace(/[/\\]/g, path.sep); -} -exports.toPlatformPath = toPlatformPath; -//# sourceMappingURL=path-utils.js.map - -/***/ }), - -/***/ 327: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.summary = exports.markdownSummary = exports.SUMMARY_DOCS_URL = exports.SUMMARY_ENV_VAR = void 0; -const os_1 = __nccwpck_require__(37); -const fs_1 = __nccwpck_require__(147); -const { access, appendFile, writeFile } = fs_1.promises; -exports.SUMMARY_ENV_VAR = 'GITHUB_STEP_SUMMARY'; -exports.SUMMARY_DOCS_URL = 'https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary'; -class Summary { - constructor() { - this._buffer = ''; - } - /** - * Finds the summary file path from the environment, rejects if env var is not found or file does not exist - * Also checks r/w permissions. - * - * @returns step summary file path - */ - filePath() { - return __awaiter(this, void 0, void 0, function* () { - if (this._filePath) { - return this._filePath; - } - const pathFromEnv = process.env[exports.SUMMARY_ENV_VAR]; - if (!pathFromEnv) { - throw new Error(`Unable to find environment variable for $${exports.SUMMARY_ENV_VAR}. Check if your runtime environment supports job summaries.`); - } - try { - yield access(pathFromEnv, fs_1.constants.R_OK | fs_1.constants.W_OK); - } - catch (_a) { - throw new Error(`Unable to access summary file: '${pathFromEnv}'. Check if the file has correct read/write permissions.`); - } - this._filePath = pathFromEnv; - return this._filePath; - }); - } - /** - * Wraps content in an HTML tag, adding any HTML attributes - * - * @param {string} tag HTML tag to wrap - * @param {string | null} content content within the tag - * @param {[attribute: string]: string} attrs key-value list of HTML attributes to add - * - * @returns {string} content wrapped in HTML element - */ - wrap(tag, content, attrs = {}) { - const htmlAttrs = Object.entries(attrs) - .map(([key, value]) => ` ${key}="${value}"`) - .join(''); - if (!content) { - return `<${tag}${htmlAttrs}>`; - } - return `<${tag}${htmlAttrs}>${content}`; - } - /** - * Writes text in the buffer to the summary buffer file and empties buffer. Will append by default. - * - * @param {SummaryWriteOptions} [options] (optional) options for write operation - * - * @returns {Promise} summary instance - */ - write(options) { - return __awaiter(this, void 0, void 0, function* () { - const overwrite = !!(options === null || options === void 0 ? void 0 : options.overwrite); - const filePath = yield this.filePath(); - const writeFunc = overwrite ? writeFile : appendFile; - yield writeFunc(filePath, this._buffer, { encoding: 'utf8' }); - return this.emptyBuffer(); - }); - } - /** - * Clears the summary buffer and wipes the summary file - * - * @returns {Summary} summary instance - */ - clear() { - return __awaiter(this, void 0, void 0, function* () { - return this.emptyBuffer().write({ overwrite: true }); - }); - } - /** - * Returns the current summary buffer as a string - * - * @returns {string} string of summary buffer - */ - stringify() { - return this._buffer; - } - /** - * If the summary buffer is empty - * - * @returns {boolen} true if the buffer is empty - */ - isEmptyBuffer() { - return this._buffer.length === 0; - } - /** - * Resets the summary buffer without writing to summary file - * - * @returns {Summary} summary instance - */ - emptyBuffer() { - this._buffer = ''; - return this; - } - /** - * Adds raw text to the summary buffer - * - * @param {string} text content to add - * @param {boolean} [addEOL=false] (optional) append an EOL to the raw text (default: false) - * - * @returns {Summary} summary instance - */ - addRaw(text, addEOL = false) { - this._buffer += text; - return addEOL ? this.addEOL() : this; - } - /** - * Adds the operating system-specific end-of-line marker to the buffer - * - * @returns {Summary} summary instance - */ - addEOL() { - return this.addRaw(os_1.EOL); - } - /** - * Adds an HTML codeblock to the summary buffer - * - * @param {string} code content to render within fenced code block - * @param {string} lang (optional) language to syntax highlight code - * - * @returns {Summary} summary instance - */ - addCodeBlock(code, lang) { - const attrs = Object.assign({}, (lang && { lang })); - const element = this.wrap('pre', this.wrap('code', code), attrs); - return this.addRaw(element).addEOL(); - } - /** - * Adds an HTML list to the summary buffer - * - * @param {string[]} items list of items to render - * @param {boolean} [ordered=false] (optional) if the rendered list should be ordered or not (default: false) - * - * @returns {Summary} summary instance - */ - addList(items, ordered = false) { - const tag = ordered ? 'ol' : 'ul'; - const listItems = items.map(item => this.wrap('li', item)).join(''); - const element = this.wrap(tag, listItems); - return this.addRaw(element).addEOL(); - } - /** - * Adds an HTML table to the summary buffer - * - * @param {SummaryTableCell[]} rows table rows - * - * @returns {Summary} summary instance - */ - addTable(rows) { - const tableBody = rows - .map(row => { - const cells = row - .map(cell => { - if (typeof cell === 'string') { - return this.wrap('td', cell); - } - const { header, data, colspan, rowspan } = cell; - const tag = header ? 'th' : 'td'; - const attrs = Object.assign(Object.assign({}, (colspan && { colspan })), (rowspan && { rowspan })); - return this.wrap(tag, data, attrs); - }) - .join(''); - return this.wrap('tr', cells); - }) - .join(''); - const element = this.wrap('table', tableBody); - return this.addRaw(element).addEOL(); - } - /** - * Adds a collapsable HTML details element to the summary buffer - * - * @param {string} label text for the closed state - * @param {string} content collapsable content - * - * @returns {Summary} summary instance - */ - addDetails(label, content) { - const element = this.wrap('details', this.wrap('summary', label) + content); - return this.addRaw(element).addEOL(); - } - /** - * Adds an HTML image tag to the summary buffer - * - * @param {string} src path to the image you to embed - * @param {string} alt text description of the image - * @param {SummaryImageOptions} options (optional) addition image attributes - * - * @returns {Summary} summary instance - */ - addImage(src, alt, options) { - const { width, height } = options || {}; - const attrs = Object.assign(Object.assign({}, (width && { width })), (height && { height })); - const element = this.wrap('img', null, Object.assign({ src, alt }, attrs)); - return this.addRaw(element).addEOL(); - } - /** - * Adds an HTML section heading element - * - * @param {string} text heading text - * @param {number | string} [level=1] (optional) the heading level, default: 1 - * - * @returns {Summary} summary instance - */ - addHeading(text, level) { - const tag = `h${level}`; - const allowedTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag) - ? tag - : 'h1'; - const element = this.wrap(allowedTag, text); - return this.addRaw(element).addEOL(); - } - /** - * Adds an HTML thematic break (
) to the summary buffer - * - * @returns {Summary} summary instance - */ - addSeparator() { - const element = this.wrap('hr', null); - return this.addRaw(element).addEOL(); - } - /** - * Adds an HTML line break (
) to the summary buffer - * - * @returns {Summary} summary instance - */ - addBreak() { - const element = this.wrap('br', null); - return this.addRaw(element).addEOL(); - } - /** - * Adds an HTML blockquote to the summary buffer - * - * @param {string} text quote text - * @param {string} cite (optional) citation url - * - * @returns {Summary} summary instance - */ - addQuote(text, cite) { - const attrs = Object.assign({}, (cite && { cite })); - const element = this.wrap('blockquote', text, attrs); - return this.addRaw(element).addEOL(); - } - /** - * Adds an HTML anchor tag to the summary buffer - * - * @param {string} text link text/content - * @param {string} href hyperlink - * - * @returns {Summary} summary instance - */ - addLink(text, href) { - const element = this.wrap('a', text, { href }); - return this.addRaw(element).addEOL(); - } -} -const _summary = new Summary(); -/** - * @deprecated use `core.summary` - */ -exports.markdownSummary = _summary; -exports.summary = _summary; -//# sourceMappingURL=summary.js.map - -/***/ }), - -/***/ 278: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -// We use any as a valid input type -/* eslint-disable @typescript-eslint/no-explicit-any */ -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.toCommandProperties = exports.toCommandValue = void 0; -/** - * Sanitizes an input into a string so it can be passed into issueCommand safely - * @param input input to sanitize into a string - */ -function toCommandValue(input) { - if (input === null || input === undefined) { - return ''; - } - else if (typeof input === 'string' || input instanceof String) { - return input; - } - return JSON.stringify(input); -} -exports.toCommandValue = toCommandValue; -/** - * - * @param annotationProperties - * @returns The command properties to send with the actual annotation command - * See IssueCommandProperties: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionCommandManager.cs#L646 - */ -function toCommandProperties(annotationProperties) { - if (!Object.keys(annotationProperties).length) { - return {}; - } - return { - title: annotationProperties.title, - file: annotationProperties.file, - line: annotationProperties.startLine, - endLine: annotationProperties.endLine, - col: annotationProperties.startColumn, - endColumn: annotationProperties.endColumn - }; -} -exports.toCommandProperties = toCommandProperties; -//# sourceMappingURL=utils.js.map - -/***/ }), - -/***/ 526: -/***/ (function(__unused_webpack_module, exports) { - -"use strict"; - -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.PersonalAccessTokenCredentialHandler = exports.BearerCredentialHandler = exports.BasicCredentialHandler = void 0; -class BasicCredentialHandler { - constructor(username, password) { - this.username = username; - this.password = password; - } - prepareRequest(options) { - if (!options.headers) { - throw Error('The request has no headers'); - } - options.headers['Authorization'] = `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`; - } - // This handler cannot handle 401 - canHandleAuthentication() { - return false; - } - handleAuthentication() { - return __awaiter(this, void 0, void 0, function* () { - throw new Error('not implemented'); - }); - } -} -exports.BasicCredentialHandler = BasicCredentialHandler; -class BearerCredentialHandler { - constructor(token) { - this.token = token; - } - // currently implements pre-authorization - // TODO: support preAuth = false where it hooks on 401 - prepareRequest(options) { - if (!options.headers) { - throw Error('The request has no headers'); - } - options.headers['Authorization'] = `Bearer ${this.token}`; - } - // This handler cannot handle 401 - canHandleAuthentication() { - return false; - } - handleAuthentication() { - return __awaiter(this, void 0, void 0, function* () { - throw new Error('not implemented'); - }); - } -} -exports.BearerCredentialHandler = BearerCredentialHandler; -class PersonalAccessTokenCredentialHandler { - constructor(token) { - this.token = token; - } - // currently implements pre-authorization - // TODO: support preAuth = false where it hooks on 401 - prepareRequest(options) { - if (!options.headers) { - throw Error('The request has no headers'); - } - options.headers['Authorization'] = `Basic ${Buffer.from(`PAT:${this.token}`).toString('base64')}`; - } - // This handler cannot handle 401 - canHandleAuthentication() { - return false; - } - handleAuthentication() { - return __awaiter(this, void 0, void 0, function* () { - throw new Error('not implemented'); - }); - } -} -exports.PersonalAccessTokenCredentialHandler = PersonalAccessTokenCredentialHandler; -//# sourceMappingURL=auth.js.map - -/***/ }), - -/***/ 255: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -/* eslint-disable @typescript-eslint/no-explicit-any */ -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.HttpClient = exports.isHttps = exports.HttpClientResponse = exports.HttpClientError = exports.getProxyUrl = exports.MediaTypes = exports.Headers = exports.HttpCodes = void 0; -const http = __importStar(__nccwpck_require__(685)); -const https = __importStar(__nccwpck_require__(687)); -const pm = __importStar(__nccwpck_require__(835)); -const tunnel = __importStar(__nccwpck_require__(294)); -var HttpCodes; -(function (HttpCodes) { - HttpCodes[HttpCodes["OK"] = 200] = "OK"; - HttpCodes[HttpCodes["MultipleChoices"] = 300] = "MultipleChoices"; - HttpCodes[HttpCodes["MovedPermanently"] = 301] = "MovedPermanently"; - HttpCodes[HttpCodes["ResourceMoved"] = 302] = "ResourceMoved"; - HttpCodes[HttpCodes["SeeOther"] = 303] = "SeeOther"; - HttpCodes[HttpCodes["NotModified"] = 304] = "NotModified"; - HttpCodes[HttpCodes["UseProxy"] = 305] = "UseProxy"; - HttpCodes[HttpCodes["SwitchProxy"] = 306] = "SwitchProxy"; - HttpCodes[HttpCodes["TemporaryRedirect"] = 307] = "TemporaryRedirect"; - HttpCodes[HttpCodes["PermanentRedirect"] = 308] = "PermanentRedirect"; - HttpCodes[HttpCodes["BadRequest"] = 400] = "BadRequest"; - HttpCodes[HttpCodes["Unauthorized"] = 401] = "Unauthorized"; - HttpCodes[HttpCodes["PaymentRequired"] = 402] = "PaymentRequired"; - HttpCodes[HttpCodes["Forbidden"] = 403] = "Forbidden"; - HttpCodes[HttpCodes["NotFound"] = 404] = "NotFound"; - HttpCodes[HttpCodes["MethodNotAllowed"] = 405] = "MethodNotAllowed"; - HttpCodes[HttpCodes["NotAcceptable"] = 406] = "NotAcceptable"; - HttpCodes[HttpCodes["ProxyAuthenticationRequired"] = 407] = "ProxyAuthenticationRequired"; - HttpCodes[HttpCodes["RequestTimeout"] = 408] = "RequestTimeout"; - HttpCodes[HttpCodes["Conflict"] = 409] = "Conflict"; - HttpCodes[HttpCodes["Gone"] = 410] = "Gone"; - HttpCodes[HttpCodes["TooManyRequests"] = 429] = "TooManyRequests"; - HttpCodes[HttpCodes["InternalServerError"] = 500] = "InternalServerError"; - HttpCodes[HttpCodes["NotImplemented"] = 501] = "NotImplemented"; - HttpCodes[HttpCodes["BadGateway"] = 502] = "BadGateway"; - HttpCodes[HttpCodes["ServiceUnavailable"] = 503] = "ServiceUnavailable"; - HttpCodes[HttpCodes["GatewayTimeout"] = 504] = "GatewayTimeout"; -})(HttpCodes = exports.HttpCodes || (exports.HttpCodes = {})); -var Headers; -(function (Headers) { - Headers["Accept"] = "accept"; - Headers["ContentType"] = "content-type"; -})(Headers = exports.Headers || (exports.Headers = {})); -var MediaTypes; -(function (MediaTypes) { - MediaTypes["ApplicationJson"] = "application/json"; -})(MediaTypes = exports.MediaTypes || (exports.MediaTypes = {})); -/** - * Returns the proxy URL, depending upon the supplied url and proxy environment variables. - * @param serverUrl The server URL where the request will be sent. For example, https://api.github.com - */ -function getProxyUrl(serverUrl) { - const proxyUrl = pm.getProxyUrl(new URL(serverUrl)); - return proxyUrl ? proxyUrl.href : ''; -} -exports.getProxyUrl = getProxyUrl; -const HttpRedirectCodes = [ - HttpCodes.MovedPermanently, - HttpCodes.ResourceMoved, - HttpCodes.SeeOther, - HttpCodes.TemporaryRedirect, - HttpCodes.PermanentRedirect -]; -const HttpResponseRetryCodes = [ - HttpCodes.BadGateway, - HttpCodes.ServiceUnavailable, - HttpCodes.GatewayTimeout -]; -const RetryableHttpVerbs = ['OPTIONS', 'GET', 'DELETE', 'HEAD']; -const ExponentialBackoffCeiling = 10; -const ExponentialBackoffTimeSlice = 5; -class HttpClientError extends Error { - constructor(message, statusCode) { - super(message); - this.name = 'HttpClientError'; - this.statusCode = statusCode; - Object.setPrototypeOf(this, HttpClientError.prototype); - } -} -exports.HttpClientError = HttpClientError; -class HttpClientResponse { - constructor(message) { - this.message = message; - } - readBody() { - return __awaiter(this, void 0, void 0, function* () { - return new Promise((resolve) => __awaiter(this, void 0, void 0, function* () { - let output = Buffer.alloc(0); - this.message.on('data', (chunk) => { - output = Buffer.concat([output, chunk]); - }); - this.message.on('end', () => { - resolve(output.toString()); - }); - })); - }); - } -} -exports.HttpClientResponse = HttpClientResponse; -function isHttps(requestUrl) { - const parsedUrl = new URL(requestUrl); - return parsedUrl.protocol === 'https:'; -} -exports.isHttps = isHttps; -class HttpClient { - constructor(userAgent, handlers, requestOptions) { - this._ignoreSslError = false; - this._allowRedirects = true; - this._allowRedirectDowngrade = false; - this._maxRedirects = 50; - this._allowRetries = false; - this._maxRetries = 1; - this._keepAlive = false; - this._disposed = false; - this.userAgent = userAgent; - this.handlers = handlers || []; - this.requestOptions = requestOptions; - if (requestOptions) { - if (requestOptions.ignoreSslError != null) { - this._ignoreSslError = requestOptions.ignoreSslError; - } - this._socketTimeout = requestOptions.socketTimeout; - if (requestOptions.allowRedirects != null) { - this._allowRedirects = requestOptions.allowRedirects; - } - if (requestOptions.allowRedirectDowngrade != null) { - this._allowRedirectDowngrade = requestOptions.allowRedirectDowngrade; - } - if (requestOptions.maxRedirects != null) { - this._maxRedirects = Math.max(requestOptions.maxRedirects, 0); - } - if (requestOptions.keepAlive != null) { - this._keepAlive = requestOptions.keepAlive; - } - if (requestOptions.allowRetries != null) { - this._allowRetries = requestOptions.allowRetries; - } - if (requestOptions.maxRetries != null) { - this._maxRetries = requestOptions.maxRetries; - } - } - } - options(requestUrl, additionalHeaders) { - return __awaiter(this, void 0, void 0, function* () { - return this.request('OPTIONS', requestUrl, null, additionalHeaders || {}); - }); - } - get(requestUrl, additionalHeaders) { - return __awaiter(this, void 0, void 0, function* () { - return this.request('GET', requestUrl, null, additionalHeaders || {}); - }); - } - del(requestUrl, additionalHeaders) { - return __awaiter(this, void 0, void 0, function* () { - return this.request('DELETE', requestUrl, null, additionalHeaders || {}); - }); - } - post(requestUrl, data, additionalHeaders) { - return __awaiter(this, void 0, void 0, function* () { - return this.request('POST', requestUrl, data, additionalHeaders || {}); - }); - } - patch(requestUrl, data, additionalHeaders) { - return __awaiter(this, void 0, void 0, function* () { - return this.request('PATCH', requestUrl, data, additionalHeaders || {}); - }); - } - put(requestUrl, data, additionalHeaders) { - return __awaiter(this, void 0, void 0, function* () { - return this.request('PUT', requestUrl, data, additionalHeaders || {}); - }); - } - head(requestUrl, additionalHeaders) { - return __awaiter(this, void 0, void 0, function* () { - return this.request('HEAD', requestUrl, null, additionalHeaders || {}); - }); - } - sendStream(verb, requestUrl, stream, additionalHeaders) { - return __awaiter(this, void 0, void 0, function* () { - return this.request(verb, requestUrl, stream, additionalHeaders); - }); - } - /** - * Gets a typed object from an endpoint - * Be aware that not found returns a null. Other errors (4xx, 5xx) reject the promise - */ - getJson(requestUrl, additionalHeaders = {}) { - return __awaiter(this, void 0, void 0, function* () { - additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.Accept, MediaTypes.ApplicationJson); - const res = yield this.get(requestUrl, additionalHeaders); - return this._processResponse(res, this.requestOptions); - }); - } - postJson(requestUrl, obj, additionalHeaders = {}) { - return __awaiter(this, void 0, void 0, function* () { - const data = JSON.stringify(obj, null, 2); - additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.Accept, MediaTypes.ApplicationJson); - additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.ContentType, MediaTypes.ApplicationJson); - const res = yield this.post(requestUrl, data, additionalHeaders); - return this._processResponse(res, this.requestOptions); - }); - } - putJson(requestUrl, obj, additionalHeaders = {}) { - return __awaiter(this, void 0, void 0, function* () { - const data = JSON.stringify(obj, null, 2); - additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.Accept, MediaTypes.ApplicationJson); - additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.ContentType, MediaTypes.ApplicationJson); - const res = yield this.put(requestUrl, data, additionalHeaders); - return this._processResponse(res, this.requestOptions); - }); - } - patchJson(requestUrl, obj, additionalHeaders = {}) { - return __awaiter(this, void 0, void 0, function* () { - const data = JSON.stringify(obj, null, 2); - additionalHeaders[Headers.Accept] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.Accept, MediaTypes.ApplicationJson); - additionalHeaders[Headers.ContentType] = this._getExistingOrDefaultHeader(additionalHeaders, Headers.ContentType, MediaTypes.ApplicationJson); - const res = yield this.patch(requestUrl, data, additionalHeaders); - return this._processResponse(res, this.requestOptions); - }); - } - /** - * Makes a raw http request. - * All other methods such as get, post, patch, and request ultimately call this. - * Prefer get, del, post and patch - */ - request(verb, requestUrl, data, headers) { - return __awaiter(this, void 0, void 0, function* () { - if (this._disposed) { - throw new Error('Client has already been disposed.'); - } - const parsedUrl = new URL(requestUrl); - let info = this._prepareRequest(verb, parsedUrl, headers); - // Only perform retries on reads since writes may not be idempotent. - const maxTries = this._allowRetries && RetryableHttpVerbs.includes(verb) - ? this._maxRetries + 1 - : 1; - let numTries = 0; - let response; - do { - response = yield this.requestRaw(info, data); - // Check if it's an authentication challenge - if (response && - response.message && - response.message.statusCode === HttpCodes.Unauthorized) { - let authenticationHandler; - for (const handler of this.handlers) { - if (handler.canHandleAuthentication(response)) { - authenticationHandler = handler; - break; - } - } - if (authenticationHandler) { - return authenticationHandler.handleAuthentication(this, info, data); - } - else { - // We have received an unauthorized response but have no handlers to handle it. - // Let the response return to the caller. - return response; - } - } - let redirectsRemaining = this._maxRedirects; - while (response.message.statusCode && - HttpRedirectCodes.includes(response.message.statusCode) && - this._allowRedirects && - redirectsRemaining > 0) { - const redirectUrl = response.message.headers['location']; - if (!redirectUrl) { - // if there's no location to redirect to, we won't - break; - } - const parsedRedirectUrl = new URL(redirectUrl); - if (parsedUrl.protocol === 'https:' && - parsedUrl.protocol !== parsedRedirectUrl.protocol && - !this._allowRedirectDowngrade) { - throw new Error('Redirect from HTTPS to HTTP protocol. This downgrade is not allowed for security reasons. If you want to allow this behavior, set the allowRedirectDowngrade option to true.'); - } - // we need to finish reading the response before reassigning response - // which will leak the open socket. - yield response.readBody(); - // strip authorization header if redirected to a different hostname - if (parsedRedirectUrl.hostname !== parsedUrl.hostname) { - for (const header in headers) { - // header names are case insensitive - if (header.toLowerCase() === 'authorization') { - delete headers[header]; - } - } - } - // let's make the request with the new redirectUrl - info = this._prepareRequest(verb, parsedRedirectUrl, headers); - response = yield this.requestRaw(info, data); - redirectsRemaining--; - } - if (!response.message.statusCode || - !HttpResponseRetryCodes.includes(response.message.statusCode)) { - // If not a retry code, return immediately instead of retrying - return response; - } - numTries += 1; - if (numTries < maxTries) { - yield response.readBody(); - yield this._performExponentialBackoff(numTries); - } - } while (numTries < maxTries); - return response; - }); - } - /** - * Needs to be called if keepAlive is set to true in request options. - */ - dispose() { - if (this._agent) { - this._agent.destroy(); - } - this._disposed = true; - } - /** - * Raw request. - * @param info - * @param data - */ - requestRaw(info, data) { - return __awaiter(this, void 0, void 0, function* () { - return new Promise((resolve, reject) => { - function callbackForResult(err, res) { - if (err) { - reject(err); - } - else if (!res) { - // If `err` is not passed, then `res` must be passed. - reject(new Error('Unknown error')); - } - else { - resolve(res); - } - } - this.requestRawWithCallback(info, data, callbackForResult); - }); - }); - } - /** - * Raw request with callback. - * @param info - * @param data - * @param onResult - */ - requestRawWithCallback(info, data, onResult) { - if (typeof data === 'string') { - if (!info.options.headers) { - info.options.headers = {}; - } - info.options.headers['Content-Length'] = Buffer.byteLength(data, 'utf8'); - } - let callbackCalled = false; - function handleResult(err, res) { - if (!callbackCalled) { - callbackCalled = true; - onResult(err, res); - } - } - const req = info.httpModule.request(info.options, (msg) => { - const res = new HttpClientResponse(msg); - handleResult(undefined, res); - }); - let socket; - req.on('socket', sock => { - socket = sock; - }); - // If we ever get disconnected, we want the socket to timeout eventually - req.setTimeout(this._socketTimeout || 3 * 60000, () => { - if (socket) { - socket.end(); - } - handleResult(new Error(`Request timeout: ${info.options.path}`)); - }); - req.on('error', function (err) { - // err has statusCode property - // res should have headers - handleResult(err); - }); - if (data && typeof data === 'string') { - req.write(data, 'utf8'); - } - if (data && typeof data !== 'string') { - data.on('close', function () { - req.end(); - }); - data.pipe(req); - } - else { - req.end(); - } - } - /** - * Gets an http agent. This function is useful when you need an http agent that handles - * routing through a proxy server - depending upon the url and proxy environment variables. - * @param serverUrl The server URL where the request will be sent. For example, https://api.github.com - */ - getAgent(serverUrl) { - const parsedUrl = new URL(serverUrl); - return this._getAgent(parsedUrl); - } - _prepareRequest(method, requestUrl, headers) { - const info = {}; - info.parsedUrl = requestUrl; - const usingSsl = info.parsedUrl.protocol === 'https:'; - info.httpModule = usingSsl ? https : http; - const defaultPort = usingSsl ? 443 : 80; - info.options = {}; - info.options.host = info.parsedUrl.hostname; - info.options.port = info.parsedUrl.port - ? parseInt(info.parsedUrl.port) - : defaultPort; - info.options.path = - (info.parsedUrl.pathname || '') + (info.parsedUrl.search || ''); - info.options.method = method; - info.options.headers = this._mergeHeaders(headers); - if (this.userAgent != null) { - info.options.headers['user-agent'] = this.userAgent; - } - info.options.agent = this._getAgent(info.parsedUrl); - // gives handlers an opportunity to participate - if (this.handlers) { - for (const handler of this.handlers) { - handler.prepareRequest(info.options); - } - } - return info; - } - _mergeHeaders(headers) { - if (this.requestOptions && this.requestOptions.headers) { - return Object.assign({}, lowercaseKeys(this.requestOptions.headers), lowercaseKeys(headers || {})); - } - return lowercaseKeys(headers || {}); - } - _getExistingOrDefaultHeader(additionalHeaders, header, _default) { - let clientHeader; - if (this.requestOptions && this.requestOptions.headers) { - clientHeader = lowercaseKeys(this.requestOptions.headers)[header]; - } - return additionalHeaders[header] || clientHeader || _default; - } - _getAgent(parsedUrl) { - let agent; - const proxyUrl = pm.getProxyUrl(parsedUrl); - const useProxy = proxyUrl && proxyUrl.hostname; - if (this._keepAlive && useProxy) { - agent = this._proxyAgent; - } - if (this._keepAlive && !useProxy) { - agent = this._agent; - } - // if agent is already assigned use that agent. - if (agent) { - return agent; - } - const usingSsl = parsedUrl.protocol === 'https:'; - let maxSockets = 100; - if (this.requestOptions) { - maxSockets = this.requestOptions.maxSockets || http.globalAgent.maxSockets; - } - // This is `useProxy` again, but we need to check `proxyURl` directly for TypeScripts's flow analysis. - if (proxyUrl && proxyUrl.hostname) { - const agentOptions = { - maxSockets, - keepAlive: this._keepAlive, - proxy: Object.assign(Object.assign({}, ((proxyUrl.username || proxyUrl.password) && { - proxyAuth: `${proxyUrl.username}:${proxyUrl.password}` - })), { host: proxyUrl.hostname, port: proxyUrl.port }) - }; - let tunnelAgent; - const overHttps = proxyUrl.protocol === 'https:'; - if (usingSsl) { - tunnelAgent = overHttps ? tunnel.httpsOverHttps : tunnel.httpsOverHttp; - } - else { - tunnelAgent = overHttps ? tunnel.httpOverHttps : tunnel.httpOverHttp; - } - agent = tunnelAgent(agentOptions); - this._proxyAgent = agent; - } - // if reusing agent across request and tunneling agent isn't assigned create a new agent - if (this._keepAlive && !agent) { - const options = { keepAlive: this._keepAlive, maxSockets }; - agent = usingSsl ? new https.Agent(options) : new http.Agent(options); - this._agent = agent; - } - // if not using private agent and tunnel agent isn't setup then use global agent - if (!agent) { - agent = usingSsl ? https.globalAgent : http.globalAgent; - } - if (usingSsl && this._ignoreSslError) { - // we don't want to set NODE_TLS_REJECT_UNAUTHORIZED=0 since that will affect request for entire process - // http.RequestOptions doesn't expose a way to modify RequestOptions.agent.options - // we have to cast it to any and change it directly - agent.options = Object.assign(agent.options || {}, { - rejectUnauthorized: false - }); - } - return agent; - } - _performExponentialBackoff(retryNumber) { - return __awaiter(this, void 0, void 0, function* () { - retryNumber = Math.min(ExponentialBackoffCeiling, retryNumber); - const ms = ExponentialBackoffTimeSlice * Math.pow(2, retryNumber); - return new Promise(resolve => setTimeout(() => resolve(), ms)); - }); - } - _processResponse(res, options) { - return __awaiter(this, void 0, void 0, function* () { - return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { - const statusCode = res.message.statusCode || 0; - const response = { - statusCode, - result: null, - headers: {} - }; - // not found leads to null obj returned - if (statusCode === HttpCodes.NotFound) { - resolve(response); - } - // get the result from the body - function dateTimeDeserializer(key, value) { - if (typeof value === 'string') { - const a = new Date(value); - if (!isNaN(a.valueOf())) { - return a; - } - } - return value; - } - let obj; - let contents; - try { - contents = yield res.readBody(); - if (contents && contents.length > 0) { - if (options && options.deserializeDates) { - obj = JSON.parse(contents, dateTimeDeserializer); - } - else { - obj = JSON.parse(contents); - } - response.result = obj; - } - response.headers = res.message.headers; - } - catch (err) { - // Invalid resource (contents not json); leaving result obj null - } - // note that 3xx redirects are handled by the http layer. - if (statusCode > 299) { - let msg; - // if exception/error in body, attempt to get better error - if (obj && obj.message) { - msg = obj.message; - } - else if (contents && contents.length > 0) { - // it may be the case that the exception is in the body message as string - msg = contents; - } - else { - msg = `Failed request: (${statusCode})`; - } - const err = new HttpClientError(msg, statusCode); - err.result = response.result; - reject(err); - } - else { - resolve(response); - } - })); - }); - } -} -exports.HttpClient = HttpClient; -const lowercaseKeys = (obj) => Object.keys(obj).reduce((c, k) => ((c[k.toLowerCase()] = obj[k]), c), {}); -//# sourceMappingURL=index.js.map - -/***/ }), - -/***/ 835: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - -Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.checkBypass = exports.getProxyUrl = void 0; -function getProxyUrl(reqUrl) { - const usingSsl = reqUrl.protocol === 'https:'; - if (checkBypass(reqUrl)) { - return undefined; - } - const proxyVar = (() => { - if (usingSsl) { - return process.env['https_proxy'] || process.env['HTTPS_PROXY']; - } - else { - return process.env['http_proxy'] || process.env['HTTP_PROXY']; - } - })(); - if (proxyVar) { - return new URL(proxyVar); - } - else { - return undefined; - } -} -exports.getProxyUrl = getProxyUrl; -function checkBypass(reqUrl) { - if (!reqUrl.hostname) { - return false; - } - const noProxy = process.env['no_proxy'] || process.env['NO_PROXY'] || ''; - if (!noProxy) { - return false; - } - // Determine the request port - let reqPort; - if (reqUrl.port) { - reqPort = Number(reqUrl.port); - } - else if (reqUrl.protocol === 'http:') { - reqPort = 80; - } - else if (reqUrl.protocol === 'https:') { - reqPort = 443; - } - // Format the request hostname and hostname with port - const upperReqHosts = [reqUrl.hostname.toUpperCase()]; - if (typeof reqPort === 'number') { - upperReqHosts.push(`${upperReqHosts[0]}:${reqPort}`); - } - // Compare request host against noproxy - for (const upperNoProxyItem of noProxy - .split(',') - .map(x => x.trim().toUpperCase()) - .filter(x => x)) { - if (upperReqHosts.some(x => x === upperNoProxyItem)) { - return true; - } - } - return false; -} -exports.checkBypass = checkBypass; -//# sourceMappingURL=proxy.js.map - -/***/ }), - -/***/ 294: -/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { - -module.exports = __nccwpck_require__(219); - - -/***/ }), - -/***/ 219: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -var net = __nccwpck_require__(808); -var tls = __nccwpck_require__(404); -var http = __nccwpck_require__(685); -var https = __nccwpck_require__(687); -var events = __nccwpck_require__(361); -var assert = __nccwpck_require__(491); -var util = __nccwpck_require__(837); - - -exports.httpOverHttp = httpOverHttp; -exports.httpsOverHttp = httpsOverHttp; -exports.httpOverHttps = httpOverHttps; -exports.httpsOverHttps = httpsOverHttps; - - -function httpOverHttp(options) { - var agent = new TunnelingAgent(options); - agent.request = http.request; - return agent; -} - -function httpsOverHttp(options) { - var agent = new TunnelingAgent(options); - agent.request = http.request; - agent.createSocket = createSecureSocket; - agent.defaultPort = 443; - return agent; -} - -function httpOverHttps(options) { - var agent = new TunnelingAgent(options); - agent.request = https.request; - return agent; -} - -function httpsOverHttps(options) { - var agent = new TunnelingAgent(options); - agent.request = https.request; - agent.createSocket = createSecureSocket; - agent.defaultPort = 443; - return agent; -} - - -function TunnelingAgent(options) { - var self = this; - self.options = options || {}; - self.proxyOptions = self.options.proxy || {}; - self.maxSockets = self.options.maxSockets || http.Agent.defaultMaxSockets; - self.requests = []; - self.sockets = []; - - self.on('free', function onFree(socket, host, port, localAddress) { - var options = toOptions(host, port, localAddress); - for (var i = 0, len = self.requests.length; i < len; ++i) { - var pending = self.requests[i]; - if (pending.host === options.host && pending.port === options.port) { - // Detect the request to connect same origin server, - // reuse the connection. - self.requests.splice(i, 1); - pending.request.onSocket(socket); - return; - } - } - socket.destroy(); - self.removeSocket(socket); - }); -} -util.inherits(TunnelingAgent, events.EventEmitter); - -TunnelingAgent.prototype.addRequest = function addRequest(req, host, port, localAddress) { - var self = this; - var options = mergeOptions({request: req}, self.options, toOptions(host, port, localAddress)); - - if (self.sockets.length >= this.maxSockets) { - // We are over limit so we'll add it to the queue. - self.requests.push(options); - return; - } - - // If we are under maxSockets create a new one. - self.createSocket(options, function(socket) { - socket.on('free', onFree); - socket.on('close', onCloseOrRemove); - socket.on('agentRemove', onCloseOrRemove); - req.onSocket(socket); - - function onFree() { - self.emit('free', socket, options); - } - - function onCloseOrRemove(err) { - self.removeSocket(socket); - socket.removeListener('free', onFree); - socket.removeListener('close', onCloseOrRemove); - socket.removeListener('agentRemove', onCloseOrRemove); - } - }); -}; - -TunnelingAgent.prototype.createSocket = function createSocket(options, cb) { - var self = this; - var placeholder = {}; - self.sockets.push(placeholder); - - var connectOptions = mergeOptions({}, self.proxyOptions, { - method: 'CONNECT', - path: options.host + ':' + options.port, - agent: false, - headers: { - host: options.host + ':' + options.port - } - }); - if (options.localAddress) { - connectOptions.localAddress = options.localAddress; - } - if (connectOptions.proxyAuth) { - connectOptions.headers = connectOptions.headers || {}; - connectOptions.headers['Proxy-Authorization'] = 'Basic ' + - new Buffer(connectOptions.proxyAuth).toString('base64'); - } - - debug('making CONNECT request'); - var connectReq = self.request(connectOptions); - connectReq.useChunkedEncodingByDefault = false; // for v0.6 - connectReq.once('response', onResponse); // for v0.6 - connectReq.once('upgrade', onUpgrade); // for v0.6 - connectReq.once('connect', onConnect); // for v0.7 or later - connectReq.once('error', onError); - connectReq.end(); - - function onResponse(res) { - // Very hacky. This is necessary to avoid http-parser leaks. - res.upgrade = true; - } - - function onUpgrade(res, socket, head) { - // Hacky. - process.nextTick(function() { - onConnect(res, socket, head); - }); - } - - function onConnect(res, socket, head) { - connectReq.removeAllListeners(); - socket.removeAllListeners(); - - if (res.statusCode !== 200) { - debug('tunneling socket could not be established, statusCode=%d', - res.statusCode); - socket.destroy(); - var error = new Error('tunneling socket could not be established, ' + - 'statusCode=' + res.statusCode); - error.code = 'ECONNRESET'; - options.request.emit('error', error); - self.removeSocket(placeholder); - return; - } - if (head.length > 0) { - debug('got illegal response body from proxy'); - socket.destroy(); - var error = new Error('got illegal response body from proxy'); - error.code = 'ECONNRESET'; - options.request.emit('error', error); - self.removeSocket(placeholder); - return; - } - debug('tunneling connection has established'); - self.sockets[self.sockets.indexOf(placeholder)] = socket; - return cb(socket); - } - - function onError(cause) { - connectReq.removeAllListeners(); - - debug('tunneling socket could not be established, cause=%s\n', - cause.message, cause.stack); - var error = new Error('tunneling socket could not be established, ' + - 'cause=' + cause.message); - error.code = 'ECONNRESET'; - options.request.emit('error', error); - self.removeSocket(placeholder); - } -}; - -TunnelingAgent.prototype.removeSocket = function removeSocket(socket) { - var pos = this.sockets.indexOf(socket) - if (pos === -1) { - return; - } - this.sockets.splice(pos, 1); - - var pending = this.requests.shift(); - if (pending) { - // If we have pending requests and a socket gets closed a new one - // needs to be created to take over in the pool for the one that closed. - this.createSocket(pending, function(socket) { - pending.request.onSocket(socket); - }); - } -}; - -function createSecureSocket(options, cb) { - var self = this; - TunnelingAgent.prototype.createSocket.call(self, options, function(socket) { - var hostHeader = options.request.getHeader('host'); - var tlsOptions = mergeOptions({}, self.options, { - socket: socket, - servername: hostHeader ? hostHeader.replace(/:.*$/, '') : options.host - }); - - // 0 is dummy port for v0.6 - var secureSocket = tls.connect(0, tlsOptions); - self.sockets[self.sockets.indexOf(socket)] = secureSocket; - cb(secureSocket); - }); -} - - -function toOptions(host, port, localAddress) { - if (typeof host === 'string') { // since v0.10 - return { - host: host, - port: port, - localAddress: localAddress - }; - } - return host; // for v0.11 or later -} - -function mergeOptions(target) { - for (var i = 1, len = arguments.length; i < len; ++i) { - var overrides = arguments[i]; - if (typeof overrides === 'object') { - var keys = Object.keys(overrides); - for (var j = 0, keyLen = keys.length; j < keyLen; ++j) { - var k = keys[j]; - if (overrides[k] !== undefined) { - target[k] = overrides[k]; - } - } - } - } - return target; -} - - -var debug; -if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) { - debug = function() { - var args = Array.prototype.slice.call(arguments); - if (typeof args[0] === 'string') { - args[0] = 'TUNNEL: ' + args[0]; - } else { - args.unshift('TUNNEL:'); - } - console.error.apply(console, args); - } -} else { - debug = function() {}; -} -exports.debug = debug; // for test - - -/***/ }), - -/***/ 840: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -Object.defineProperty(exports, "v1", ({ - enumerable: true, - get: function () { - return _v.default; - } -})); -Object.defineProperty(exports, "v3", ({ - enumerable: true, - get: function () { - return _v2.default; - } -})); -Object.defineProperty(exports, "v4", ({ - enumerable: true, - get: function () { - return _v3.default; - } -})); -Object.defineProperty(exports, "v5", ({ - enumerable: true, - get: function () { - return _v4.default; - } -})); -Object.defineProperty(exports, "NIL", ({ - enumerable: true, - get: function () { - return _nil.default; - } -})); -Object.defineProperty(exports, "version", ({ - enumerable: true, - get: function () { - return _version.default; - } -})); -Object.defineProperty(exports, "validate", ({ - enumerable: true, - get: function () { - return _validate.default; - } -})); -Object.defineProperty(exports, "stringify", ({ - enumerable: true, - get: function () { - return _stringify.default; - } -})); -Object.defineProperty(exports, "parse", ({ - enumerable: true, - get: function () { - return _parse.default; - } -})); - -var _v = _interopRequireDefault(__nccwpck_require__(628)); - -var _v2 = _interopRequireDefault(__nccwpck_require__(409)); - -var _v3 = _interopRequireDefault(__nccwpck_require__(122)); - -var _v4 = _interopRequireDefault(__nccwpck_require__(120)); - -var _nil = _interopRequireDefault(__nccwpck_require__(332)); - -var _version = _interopRequireDefault(__nccwpck_require__(595)); - -var _validate = _interopRequireDefault(__nccwpck_require__(900)); - -var _stringify = _interopRequireDefault(__nccwpck_require__(950)); - -var _parse = _interopRequireDefault(__nccwpck_require__(746)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -/***/ }), - -/***/ 569: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _crypto = _interopRequireDefault(__nccwpck_require__(113)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function md5(bytes) { - if (Array.isArray(bytes)) { - bytes = Buffer.from(bytes); - } else if (typeof bytes === 'string') { - bytes = Buffer.from(bytes, 'utf8'); - } - - return _crypto.default.createHash('md5').update(bytes).digest(); -} - -var _default = md5; -exports["default"] = _default; - -/***/ }), - -/***/ 332: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; -var _default = '00000000-0000-0000-0000-000000000000'; -exports["default"] = _default; - -/***/ }), - -/***/ 746: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _validate = _interopRequireDefault(__nccwpck_require__(900)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function parse(uuid) { - if (!(0, _validate.default)(uuid)) { - throw TypeError('Invalid UUID'); - } - - let v; - const arr = new Uint8Array(16); // Parse ########-....-....-....-............ - - arr[0] = (v = parseInt(uuid.slice(0, 8), 16)) >>> 24; - arr[1] = v >>> 16 & 0xff; - arr[2] = v >>> 8 & 0xff; - arr[3] = v & 0xff; // Parse ........-####-....-....-............ - - arr[4] = (v = parseInt(uuid.slice(9, 13), 16)) >>> 8; - arr[5] = v & 0xff; // Parse ........-....-####-....-............ - - arr[6] = (v = parseInt(uuid.slice(14, 18), 16)) >>> 8; - arr[7] = v & 0xff; // Parse ........-....-....-####-............ - - arr[8] = (v = parseInt(uuid.slice(19, 23), 16)) >>> 8; - arr[9] = v & 0xff; // Parse ........-....-....-....-############ - // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) - - arr[10] = (v = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000 & 0xff; - arr[11] = v / 0x100000000 & 0xff; - arr[12] = v >>> 24 & 0xff; - arr[13] = v >>> 16 & 0xff; - arr[14] = v >>> 8 & 0xff; - arr[15] = v & 0xff; - return arr; -} - -var _default = parse; -exports["default"] = _default; - -/***/ }), - -/***/ 814: -/***/ ((__unused_webpack_module, exports) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; -var _default = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i; -exports["default"] = _default; - -/***/ }), - -/***/ 807: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = rng; - -var _crypto = _interopRequireDefault(__nccwpck_require__(113)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -const rnds8Pool = new Uint8Array(256); // # of random values to pre-allocate - -let poolPtr = rnds8Pool.length; - -function rng() { - if (poolPtr > rnds8Pool.length - 16) { - _crypto.default.randomFillSync(rnds8Pool); - - poolPtr = 0; - } - - return rnds8Pool.slice(poolPtr, poolPtr += 16); -} - -/***/ }), - -/***/ 274: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _crypto = _interopRequireDefault(__nccwpck_require__(113)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function sha1(bytes) { - if (Array.isArray(bytes)) { - bytes = Buffer.from(bytes); - } else if (typeof bytes === 'string') { - bytes = Buffer.from(bytes, 'utf8'); - } - - return _crypto.default.createHash('sha1').update(bytes).digest(); -} - -var _default = sha1; -exports["default"] = _default; - -/***/ }), - -/***/ 950: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _validate = _interopRequireDefault(__nccwpck_require__(900)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -/** - * Convert array of 16 byte values to UUID string format of the form: - * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX - */ -const byteToHex = []; - -for (let i = 0; i < 256; ++i) { - byteToHex.push((i + 0x100).toString(16).substr(1)); -} - -function stringify(arr, offset = 0) { - // Note: Be careful editing this code! It's been tuned for performance - // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 - const uuid = (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + '-' + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + '-' + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + '-' + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + '-' + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase(); // Consistency check for valid UUID. If this throws, it's likely due to one - // of the following: - // - One or more input array values don't map to a hex octet (leading to - // "undefined" in the uuid) - // - Invalid input values for the RFC `version` or `variant` fields - - if (!(0, _validate.default)(uuid)) { - throw TypeError('Stringified UUID is invalid'); - } - - return uuid; -} - -var _default = stringify; -exports["default"] = _default; - -/***/ }), - -/***/ 628: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _rng = _interopRequireDefault(__nccwpck_require__(807)); - -var _stringify = _interopRequireDefault(__nccwpck_require__(950)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -// **`v1()` - Generate time-based UUID** -// -// Inspired by https://github.com/LiosK/UUID.js -// and http://docs.python.org/library/uuid.html -let _nodeId; - -let _clockseq; // Previous uuid creation time - - -let _lastMSecs = 0; -let _lastNSecs = 0; // See https://github.com/uuidjs/uuid for API details - -function v1(options, buf, offset) { - let i = buf && offset || 0; - const b = buf || new Array(16); - options = options || {}; - let node = options.node || _nodeId; - let clockseq = options.clockseq !== undefined ? options.clockseq : _clockseq; // node and clockseq need to be initialized to random values if they're not - // specified. We do this lazily to minimize issues related to insufficient - // system entropy. See #189 - - if (node == null || clockseq == null) { - const seedBytes = options.random || (options.rng || _rng.default)(); - - if (node == null) { - // Per 4.5, create and 48-bit node id, (47 random bits + multicast bit = 1) - node = _nodeId = [seedBytes[0] | 0x01, seedBytes[1], seedBytes[2], seedBytes[3], seedBytes[4], seedBytes[5]]; - } - - if (clockseq == null) { - // Per 4.2.2, randomize (14 bit) clockseq - clockseq = _clockseq = (seedBytes[6] << 8 | seedBytes[7]) & 0x3fff; - } - } // UUID timestamps are 100 nano-second units since the Gregorian epoch, - // (1582-10-15 00:00). JSNumbers aren't precise enough for this, so - // time is handled internally as 'msecs' (integer milliseconds) and 'nsecs' - // (100-nanoseconds offset from msecs) since unix epoch, 1970-01-01 00:00. - - - let msecs = options.msecs !== undefined ? options.msecs : Date.now(); // Per 4.2.1.2, use count of uuid's generated during the current clock - // cycle to simulate higher resolution clock - - let nsecs = options.nsecs !== undefined ? options.nsecs : _lastNSecs + 1; // Time since last uuid creation (in msecs) - - const dt = msecs - _lastMSecs + (nsecs - _lastNSecs) / 10000; // Per 4.2.1.2, Bump clockseq on clock regression - - if (dt < 0 && options.clockseq === undefined) { - clockseq = clockseq + 1 & 0x3fff; - } // Reset nsecs if clock regresses (new clockseq) or we've moved onto a new - // time interval - - - if ((dt < 0 || msecs > _lastMSecs) && options.nsecs === undefined) { - nsecs = 0; - } // Per 4.2.1.2 Throw error if too many uuids are requested - - - if (nsecs >= 10000) { - throw new Error("uuid.v1(): Can't create more than 10M uuids/sec"); - } - - _lastMSecs = msecs; - _lastNSecs = nsecs; - _clockseq = clockseq; // Per 4.1.4 - Convert from unix epoch to Gregorian epoch - - msecs += 12219292800000; // `time_low` - - const tl = ((msecs & 0xfffffff) * 10000 + nsecs) % 0x100000000; - b[i++] = tl >>> 24 & 0xff; - b[i++] = tl >>> 16 & 0xff; - b[i++] = tl >>> 8 & 0xff; - b[i++] = tl & 0xff; // `time_mid` - - const tmh = msecs / 0x100000000 * 10000 & 0xfffffff; - b[i++] = tmh >>> 8 & 0xff; - b[i++] = tmh & 0xff; // `time_high_and_version` - - b[i++] = tmh >>> 24 & 0xf | 0x10; // include version - - b[i++] = tmh >>> 16 & 0xff; // `clock_seq_hi_and_reserved` (Per 4.2.2 - include variant) - - b[i++] = clockseq >>> 8 | 0x80; // `clock_seq_low` - - b[i++] = clockseq & 0xff; // `node` - - for (let n = 0; n < 6; ++n) { - b[i + n] = node[n]; - } - - return buf || (0, _stringify.default)(b); -} - -var _default = v1; -exports["default"] = _default; - -/***/ }), - -/***/ 409: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _v = _interopRequireDefault(__nccwpck_require__(998)); - -var _md = _interopRequireDefault(__nccwpck_require__(569)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -const v3 = (0, _v.default)('v3', 0x30, _md.default); -var _default = v3; -exports["default"] = _default; - -/***/ }), - -/***/ 998: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = _default; -exports.URL = exports.DNS = void 0; - -var _stringify = _interopRequireDefault(__nccwpck_require__(950)); - -var _parse = _interopRequireDefault(__nccwpck_require__(746)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function stringToBytes(str) { - str = unescape(encodeURIComponent(str)); // UTF8 escape - - const bytes = []; - - for (let i = 0; i < str.length; ++i) { - bytes.push(str.charCodeAt(i)); - } - - return bytes; -} - -const DNS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; -exports.DNS = DNS; -const URL = '6ba7b811-9dad-11d1-80b4-00c04fd430c8'; -exports.URL = URL; - -function _default(name, version, hashfunc) { - function generateUUID(value, namespace, buf, offset) { - if (typeof value === 'string') { - value = stringToBytes(value); - } - - if (typeof namespace === 'string') { - namespace = (0, _parse.default)(namespace); - } - - if (namespace.length !== 16) { - throw TypeError('Namespace must be array-like (16 iterable integer values, 0-255)'); - } // Compute hash of namespace and value, Per 4.3 - // Future: Use spread syntax when supported on all platforms, e.g. `bytes = - // hashfunc([...namespace, ... value])` - - - let bytes = new Uint8Array(16 + value.length); - bytes.set(namespace); - bytes.set(value, namespace.length); - bytes = hashfunc(bytes); - bytes[6] = bytes[6] & 0x0f | version; - bytes[8] = bytes[8] & 0x3f | 0x80; - - if (buf) { - offset = offset || 0; - - for (let i = 0; i < 16; ++i) { - buf[offset + i] = bytes[i]; - } - - return buf; - } - - return (0, _stringify.default)(bytes); - } // Function#name is not settable on some platforms (#270) - - - try { - generateUUID.name = name; // eslint-disable-next-line no-empty - } catch (err) {} // For CommonJS default export support - - - generateUUID.DNS = DNS; - generateUUID.URL = URL; - return generateUUID; -} - -/***/ }), - -/***/ 122: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _rng = _interopRequireDefault(__nccwpck_require__(807)); - -var _stringify = _interopRequireDefault(__nccwpck_require__(950)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function v4(options, buf, offset) { - options = options || {}; - - const rnds = options.random || (options.rng || _rng.default)(); // Per 4.4, set bits for version and `clock_seq_hi_and_reserved` - - - rnds[6] = rnds[6] & 0x0f | 0x40; - rnds[8] = rnds[8] & 0x3f | 0x80; // Copy bytes to buffer, if provided - - if (buf) { - offset = offset || 0; - - for (let i = 0; i < 16; ++i) { - buf[offset + i] = rnds[i]; - } - - return buf; - } - - return (0, _stringify.default)(rnds); -} - -var _default = v4; -exports["default"] = _default; - -/***/ }), - -/***/ 120: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _v = _interopRequireDefault(__nccwpck_require__(998)); - -var _sha = _interopRequireDefault(__nccwpck_require__(274)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -const v5 = (0, _v.default)('v5', 0x50, _sha.default); -var _default = v5; -exports["default"] = _default; - -/***/ }), - -/***/ 900: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _regex = _interopRequireDefault(__nccwpck_require__(814)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function validate(uuid) { - return typeof uuid === 'string' && _regex.default.test(uuid); -} - -var _default = validate; -exports["default"] = _default; - -/***/ }), - -/***/ 595: -/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", ({ - value: true -})); -exports["default"] = void 0; - -var _validate = _interopRequireDefault(__nccwpck_require__(900)); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function version(uuid) { - if (!(0, _validate.default)(uuid)) { - throw TypeError('Invalid UUID'); - } - - return parseInt(uuid.substr(14, 1), 16); -} - -var _default = version; -exports["default"] = _default; - -/***/ }), - -/***/ 717: -/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { - -"use strict"; - -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || (function () { - var ownKeys = function(o) { - ownKeys = Object.getOwnPropertyNames || function (o) { - var ar = []; - for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; - return ar; - }; - return ownKeys(o); - }; - return function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); - __setModuleDefault(result, mod); - return result; - }; -})(); -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", ({ value: true })); -const core = __importStar(__nccwpck_require__(186)); -const fs_1 = __importDefault(__nccwpck_require__(147)); -const run = () => { - // Prefix path to the graphite metric - const GRAPHITE_PATH = 'reassure'; - const PR_NUMBER = core.getInput('PR_NUMBER', { required: true }); - // Read the contents of the file, the file is in the JSONL format - const regressionFile = fs_1.default.readFileSync('.reassure/baseline.perf', 'utf8'); - // Split file contents by newline to get individual JSON entries - const regressionEntries = regressionFile.split('\n'); - // Initialize string to store Graphite metrics - let graphiteString = ''; - let timestamp = null; - // Iterate over each entry - for (const entry of regressionEntries) { - // Skip empty lines - if (entry.trim() === '') { - continue; - } - try { - const current = JSON.parse(entry); - // Extract timestamp, Graphite accepts timestamp in seconds - if (current.metadata?.creationDate) { - timestamp = Math.floor(new Date(current.metadata.creationDate).getTime() / 1000); - } - if (current.name && current.meanDuration && current.meanCount && timestamp) { - const formattedName = current.name.split(' ').join('-'); - const renderDurationString = `${GRAPHITE_PATH}.${formattedName}.renderDuration ${current.meanDuration} ${timestamp}`; - const renderCountString = `${GRAPHITE_PATH}.${formattedName}.renderCount ${current.meanCount} ${timestamp}`; - const renderPRNumberString = `${GRAPHITE_PATH}.${formattedName}.prNumber ${PR_NUMBER} ${timestamp}`; - // Concatenate Graphite strings - graphiteString += `${renderDurationString}\n${renderCountString}\n${renderPRNumberString}\n`; - } - } - catch (e) { - const error = new Error('Error parsing baseline.perf JSON file'); - console.error(error.message); - core.setFailed(error); - } - } - // Set generated graphite string to the github variable - core.setOutput('GRAPHITE_STRING', graphiteString); -}; -if (require.main === require.cache[eval('__filename')]) { - run(); -} -exports["default"] = run; - - -/***/ }), - -/***/ 491: -/***/ ((module) => { - -"use strict"; -module.exports = require("assert"); - -/***/ }), - -/***/ 113: -/***/ ((module) => { - -"use strict"; -module.exports = require("crypto"); - -/***/ }), - -/***/ 361: -/***/ ((module) => { - -"use strict"; -module.exports = require("events"); - -/***/ }), - -/***/ 147: -/***/ ((module) => { - -"use strict"; -module.exports = require("fs"); - -/***/ }), - -/***/ 685: -/***/ ((module) => { - -"use strict"; -module.exports = require("http"); - -/***/ }), - -/***/ 687: -/***/ ((module) => { - -"use strict"; -module.exports = require("https"); - -/***/ }), - -/***/ 808: -/***/ ((module) => { - -"use strict"; -module.exports = require("net"); - -/***/ }), - -/***/ 37: -/***/ ((module) => { - -"use strict"; -module.exports = require("os"); - -/***/ }), - -/***/ 17: -/***/ ((module) => { - -"use strict"; -module.exports = require("path"); - -/***/ }), - -/***/ 404: -/***/ ((module) => { - -"use strict"; -module.exports = require("tls"); - -/***/ }), - -/***/ 837: -/***/ ((module) => { - -"use strict"; -module.exports = require("util"); - -/***/ }) - -/******/ }); -/************************************************************************/ -/******/ // The module cache -/******/ var __webpack_module_cache__ = {}; -/******/ -/******/ // The require function -/******/ function __nccwpck_require__(moduleId) { -/******/ // Check if module is in cache -/******/ var cachedModule = __webpack_module_cache__[moduleId]; -/******/ if (cachedModule !== undefined) { -/******/ return cachedModule.exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = __webpack_module_cache__[moduleId] = { -/******/ // no module.id needed -/******/ // no module.loaded needed -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ var threw = true; -/******/ try { -/******/ __webpack_modules__[moduleId].call(module.exports, module, module.exports, __nccwpck_require__); -/******/ threw = false; -/******/ } finally { -/******/ if(threw) delete __webpack_module_cache__[moduleId]; -/******/ } -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/************************************************************************/ -/******/ /* webpack/runtime/compat */ -/******/ -/******/ if (typeof __nccwpck_require__ !== 'undefined') __nccwpck_require__.ab = __dirname + "/"; -/******/ -/************************************************************************/ -/******/ -/******/ // startup -/******/ // Load entry module and return exports -/******/ // This entry module is referenced by other modules so it can't be inlined -/******/ var __webpack_exports__ = __nccwpck_require__(717); -/******/ module.exports = __webpack_exports__; -/******/ -/******/ })() -; diff --git a/.github/actions/javascript/getPreviousVersion/index.js b/.github/actions/javascript/getPreviousVersion/index.js index b68d608b4e34..b164e5aebb24 100644 --- a/.github/actions/javascript/getPreviousVersion/index.js +++ b/.github/actions/javascript/getPreviousVersion/index.js @@ -11965,11 +11965,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js index ba050da3e984..70200845421f 100644 --- a/.github/actions/javascript/getPullRequestDetails/index.js +++ b/.github/actions/javascript/getPullRequestDetails/index.js @@ -11902,11 +11902,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js index 1a342b6645cc..9a0cb630a54c 100644 --- a/.github/actions/javascript/getPullRequestIncrementalChanges/index.js +++ b/.github/actions/javascript/getPullRequestIncrementalChanges/index.js @@ -12023,11 +12023,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', @@ -12410,8 +12410,8 @@ class Git { * @throws Error when git command fails (invalid refs, not a git repo, file not found, etc.) */ static diff(fromRef, toRef, filePaths, shouldIncludeUntrackedFiles = false) { - // Build git diff command (with 0 context lines for easier parsing) - let command = `git diff -U0 ${fromRef}`; + // Build git diff command (with 0 context lines for easier parsing, -M for rename detection) + let command = `git diff -U0 -M ${fromRef}`; if (toRef) { command += ` ${toRef}`; } @@ -12453,12 +12453,12 @@ class Git { const files = []; let currentFile = null; let currentHunk = null; - let oldFilePath = null; // Track old file path to determine fileDiffType + let oldFilePath = null; + let renameFromPath = null; for (const line of lines) { // File header: diff --git a/file b/file if (line.startsWith('diff --git')) { if (currentFile) { - // Push the current hunk to the current file before processing the new file if (currentHunk) { currentFile.hunks.push(currentHunk); } @@ -12466,41 +12466,51 @@ class Git { } currentFile = null; currentHunk = null; - oldFilePath = null; // Reset for next file + oldFilePath = null; + renameFromPath = null; + continue; + } + // Rename detection: "rename from " appears before --- / +++ + if (line.startsWith('rename from ')) { + renameFromPath = line.slice('rename from '.length); + continue; + } + if (line.startsWith('rename to ') || line.startsWith('similarity index ')) { continue; } // Old file path: --- a/file or --- /dev/null (for new files) - // This comes before +++ in git diff output if (line.startsWith('--- ')) { - oldFilePath = line.slice(4); // Store the old file path (remove '--- ') + oldFilePath = line.slice(4); continue; } // New file path: +++ b/file or +++ /dev/null (for removed files) if (line.startsWith('+++ ')) { - const newFilePath = line.slice(4); // Remove '+++ ' - // Determine fileDiffType based on old and new file paths - // Note: oldFilePath should always be set by the time we see +++, but handle null for type safety + const newFilePath = line.slice(4); let fileDiffType = 'modified'; let diffFilePath; + let previousFilePath; const oldPath = oldFilePath ?? ''; if (oldPath === '/dev/null') { - // New file: use the new file path fileDiffType = 'added'; diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; } else if (newFilePath === '/dev/null') { - // Removed file: use the old file path fileDiffType = 'removed'; diffFilePath = oldPath.startsWith('a/') ? oldPath.slice(2) : oldPath; } + else if (renameFromPath) { + fileDiffType = 'renamed'; + diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; + previousFilePath = renameFromPath; + } else { - // Modified file: use the new file path fileDiffType = 'modified'; diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; } currentFile = { filePath: diffFilePath, diffType: fileDiffType, + previousFilePath, hunks: [], addedLines: new Set(), removedLines: new Set(), @@ -12648,7 +12658,7 @@ class Git { return; // Reference is already available locally } try { - (0, Logger_1.log)(`๐Ÿ”„ Fetching missing ref: ${ref}`); + console.log(`๐Ÿ”„ Fetching missing ref: ${ref}`); await exec(`git fetch ${remote} ${ref} --no-tags --depth=1 --quiet`); // Verify the ref is now available if (!this.isValidRef(ref)) { @@ -12723,20 +12733,37 @@ class Git { return false; } } - static async getChangedFileNames(fromRef, toRef, shouldIncludeUntrackedFiles = false) { + /** + * Get changed files with their status (added, modified, removed, renamed). + * In CI, uses the GitHub API with pagination for accuracy. + * Locally, uses git diff against the provided ref. + */ + static async getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles = false) { if (IS_CI) { - const { data: changedFiles } = await GithubUtils_1.default.octokit.pulls.listFiles({ + const files = await GithubUtils_1.default.paginate(GithubUtils_1.default.octokit.pulls.listFiles, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, // eslint-disable-next-line @typescript-eslint/naming-convention pull_number: github_1.context.payload.pull_request?.number ?? 0, + // eslint-disable-next-line @typescript-eslint/naming-convention + per_page: 100, }); - return changedFiles.map((file) => file.filename); + return files.map((file) => ({ + filename: file.filename, + status: file.status, + previousFilename: file.previous_filename, + })); } - // Get the diff output and check status const diffResult = this.diff(fromRef, toRef, undefined, shouldIncludeUntrackedFiles); - const files = diffResult.files.map((file) => file.filePath); - return files; + return diffResult.files.map((file) => ({ + filename: file.filePath, + status: file.diffType, + previousFilename: file.previousFilePath, + })); + } + static async getChangedFileNames(fromRef, toRef, shouldIncludeUntrackedFiles = false) { + const files = await this.getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles); + return files.map((file) => file.filename); } /** * Get list of untracked files from git. @@ -12842,7 +12869,7 @@ exports["default"] = Git; "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.bold = exports.formatLink = exports.success = exports.error = exports.note = exports.warn = exports.info = exports.log = void 0; +exports.bold = exports.formatLink = exports.success = exports.errorDetail = exports.error = exports.note = exports.warn = exports.info = void 0; const COLOR_DIM = '\x1b[2m'; const COLOR_RESET = '\x1b[0m'; const COLOR_YELLOW = '\x1b[33m'; @@ -12857,40 +12884,34 @@ const EMOJIS = { SUCCESS: 'โœ…', ERROR: '๐Ÿ”ด', }; -const log = (...args) => { - console.debug(...args); -}; -exports.log = log; const info = (...args) => { - const lines = [EMOJIS.INFO, ...args]; - log(...lines); + console.log(EMOJIS.INFO, ...args); }; exports.info = info; const bold = (...args) => { - const lines = [COLOR_BOLD, ...args, COLOR_RESET]; - log(...lines); + console.log(COLOR_BOLD, ...args, COLOR_RESET); }; exports.bold = bold; const success = (...args) => { - const lines = [`${EMOJIS.SUCCESS}${COLOR_GREEN}`, ...args, COLOR_RESET]; - log(...lines); + console.log(`${EMOJIS.SUCCESS}${COLOR_GREEN}`, ...args, COLOR_RESET); }; exports.success = success; const warn = (...args) => { - const lines = [`${EMOJIS.WARN}${COLOR_YELLOW}`, ...args, COLOR_RESET]; - log(...lines); + console.warn(`${EMOJIS.WARN}${COLOR_YELLOW}`, ...args, COLOR_RESET); }; exports.warn = warn; const note = (...args) => { - const lines = [COLOR_DIM, ...args, COLOR_RESET]; - log(...lines); + console.log(COLOR_DIM, ...args, COLOR_RESET); }; exports.note = note; const error = (...args) => { - const lines = [`${EMOJIS.ERROR}${COLOR_RED}`, ...args, COLOR_RESET]; - log(...lines); + console.error(`${EMOJIS.ERROR}${COLOR_RED}`, ...args, COLOR_RESET); }; exports.error = error; +const errorDetail = (...args) => { + console.error(` ${COLOR_RED}โ†ณ`, ...args, COLOR_RESET); +}; +exports.errorDetail = errorDetail; const formatLink = (name, url) => `\x1b]8;;${url}\x1b\\${name}\x1b]8;;\x1b\\`; exports.formatLink = formatLink; diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index 6a954faf6be8..2d794efb63cd 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -11748,8 +11748,11 @@ async function generateDeployChecklistBodyAndAssignees({ tag, PRList, PRListMobi console.log('Found the following Internal QA PRs:', Object.fromEntries(internalQAPRMap)); const noQAPRNumbers = Array.isArray(data) ? data.filter((PR) => /\[No\s?QA]/i.test(PR.title)).map((item) => item.number) : []; console.log('Found the following NO QA PRs:', noQAPRNumbers); + const mobileExpensifyData = PRListMobileExpensify.length > 0 ? await GithubUtils_1.default.fetchAllPullRequests(PRListMobileExpensify, CONST_1.default.MOBILE_EXPENSIFY_REPO) : []; + const noQAMobileExpensifyPRNumbers = Array.isArray(mobileExpensifyData) ? mobileExpensifyData.filter((PR) => /\[No\s?QA]/i.test(PR.title)).map((item) => item.number) : []; + console.log('Found the following NO QA Mobile-Expensify PRs:', noQAMobileExpensifyPRNumbers); const verifiedAppPRs = new Set([...verifiedPRList, ...noQAPRNumbers]); - const verifiedMobileExpensifyPRs = new Set(verifiedPRListMobileExpensify); + const verifiedMobileExpensifyPRs = new Set([...verifiedPRListMobileExpensify, ...noQAMobileExpensifyPRNumbers]); const resolvedInternalQAPRSet = new Set(resolvedInternalQAPRs); const resolvedDeployBlockerSet = new Set(resolvedDeployBlockers); const internalQAPRNumbers = new Set(internalQAPRMap.keys()); @@ -11956,11 +11959,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 57d6fd19e092..3fbf1edac732 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -13236,11 +13236,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/postTestBuildComment/index.js b/.github/actions/javascript/postTestBuildComment/index.js index cacbcdd353d2..ec3d7f41af67 100644 --- a/.github/actions/javascript/postTestBuildComment/index.js +++ b/.github/actions/javascript/postTestBuildComment/index.js @@ -11902,11 +11902,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 020a4ca6dab4..3b605b449d2d 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -12076,11 +12076,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/reopenIssueWithComment/index.js b/.github/actions/javascript/reopenIssueWithComment/index.js index 39520938469d..e594793e3ac5 100644 --- a/.github/actions/javascript/reopenIssueWithComment/index.js +++ b/.github/actions/javascript/reopenIssueWithComment/index.js @@ -11783,11 +11783,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/reviewerChecklist/index.js b/.github/actions/javascript/reviewerChecklist/index.js index 2dfb1fa8ef0f..ef2e0e4d5456 100644 --- a/.github/actions/javascript/reviewerChecklist/index.js +++ b/.github/actions/javascript/reviewerChecklist/index.js @@ -11875,11 +11875,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/verifySignedCommits/index.js b/.github/actions/javascript/verifySignedCommits/index.js index 8dde1a048b1c..6562860939cf 100644 --- a/.github/actions/javascript/verifySignedCommits/index.js +++ b/.github/actions/javascript/verifySignedCommits/index.js @@ -11815,11 +11815,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/actions/javascript/waitForPreviousRuns/index.js b/.github/actions/javascript/waitForPreviousRuns/index.js index 5b333852e9ca..0ba892fca51f 100644 --- a/.github/actions/javascript/waitForPreviousRuns/index.js +++ b/.github/actions/javascript/waitForPreviousRuns/index.js @@ -11824,11 +11824,11 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers) { + static fetchAllPullRequests(pullRequestNumbers, repo = CONST_1.default.APP_REPO) { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, - repo: CONST_1.default.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/libs/CONST.ts b/.github/libs/CONST.ts index 4a6b95d9b022..2c95f3da887a 100644 --- a/.github/libs/CONST.ts +++ b/.github/libs/CONST.ts @@ -18,6 +18,7 @@ const CONST = { INTERNAL_QA: 'InternalQA', HELP_WANTED: 'Help Wanted', CP_STAGING: 'CP Staging', + DAILY: 'Daily', }, STATE: { OPEN: 'open', diff --git a/.github/libs/DeployChecklistUtils.ts b/.github/libs/DeployChecklistUtils.ts index 86c7353e9cea..c820cff0a051 100644 --- a/.github/libs/DeployChecklistUtils.ts +++ b/.github/libs/DeployChecklistUtils.ts @@ -172,8 +172,12 @@ async function generateDeployChecklistBodyAndAssignees({ const noQAPRNumbers = Array.isArray(data) ? data.filter((PR) => /\[No\s?QA]/i.test(PR.title)).map((item) => item.number) : []; console.log('Found the following NO QA PRs:', noQAPRNumbers); + const mobileExpensifyData = PRListMobileExpensify.length > 0 ? await GithubUtils.fetchAllPullRequests(PRListMobileExpensify, CONST.MOBILE_EXPENSIFY_REPO) : []; + const noQAMobileExpensifyPRNumbers = Array.isArray(mobileExpensifyData) ? mobileExpensifyData.filter((PR) => /\[No\s?QA]/i.test(PR.title)).map((item) => item.number) : []; + console.log('Found the following NO QA Mobile-Expensify PRs:', noQAMobileExpensifyPRNumbers); + const verifiedAppPRs = new Set([...verifiedPRList, ...noQAPRNumbers]); - const verifiedMobileExpensifyPRs = new Set(verifiedPRListMobileExpensify); + const verifiedMobileExpensifyPRs = new Set([...verifiedPRListMobileExpensify, ...noQAMobileExpensifyPRNumbers]); const resolvedInternalQAPRSet = new Set(resolvedInternalQAPRs); const resolvedDeployBlockerSet = new Set(resolvedDeployBlockers); diff --git a/.github/libs/GithubUtils.ts b/.github/libs/GithubUtils.ts index 37a5a6355e7e..541ef5f61740 100644 --- a/.github/libs/GithubUtils.ts +++ b/.github/libs/GithubUtils.ts @@ -133,13 +133,13 @@ class GithubUtils { /** * Fetch all pull requests given a list of PR numbers. */ - static fetchAllPullRequests(pullRequestNumbers: number[]): Promise { + static fetchAllPullRequests(pullRequestNumbers: number[], repo: string = CONST.APP_REPO): Promise { const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate( this.octokit.pulls.list, { owner: CONST.GITHUB_OWNER, - repo: CONST.APP_REPO, + repo, state: 'all', sort: 'created', direction: 'desc', diff --git a/.github/scripts/buildActions.sh b/.github/scripts/buildActions.sh index 4227fed80328..3d2de54c31f4 100755 --- a/.github/scripts/buildActions.sh +++ b/.github/scripts/buildActions.sh @@ -20,7 +20,6 @@ declare -r GITHUB_ACTIONS=( "$ACTIONS_DIR/getAndroidRolloutPercentage/getAndroidRolloutPercentage.ts" "$ACTIONS_DIR/getArtifactInfo/getArtifactInfo.ts" "$ACTIONS_DIR/getDeployPullRequestList/getDeployPullRequestList.ts" - "$ACTIONS_DIR/getGraphiteString/getGraphiteString.ts" "$ACTIONS_DIR/getPreviousVersion/getPreviousVersion.ts" "$ACTIONS_DIR/getPullRequestDetails/getPullRequestDetails.ts" "$ACTIONS_DIR/getPullRequestIncrementalChanges/getPullRequestIncrementalChanges.ts" diff --git a/.github/tsconfig.json b/.github/tsconfig.json index 24af89937997..21c47da16943 100644 --- a/.github/tsconfig.json +++ b/.github/tsconfig.json @@ -1,13 +1,9 @@ { "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", + "module": "node16", + "moduleResolution": "node16", "target": "ES2023", "types": ["node"], - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "downlevelIteration": true, - "baseUrl": ".", "paths": { "@assets/*": ["../assets/*"], "@components/*": ["../src/components/*"], diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml index 14e1fc066ea7..8a692a8daf3e 100644 --- a/.github/workflows/buildAdHoc.yml +++ b/.github/workflows/buildAdHoc.yml @@ -145,10 +145,6 @@ jobs: with: ref: ${{ inputs.APP_REF }} - - name: Download Artifact - # v7 - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 - - name: Publish links to apps for download on Expensify/App PR if: ${{ inputs.APP_PR_NUMBER != '' }} uses: ./.github/actions/javascript/postTestBuildComment diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index 16b945cc61ec..8049d9457457 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -51,7 +51,7 @@ on: jobs: build: name: Build iOS HybridApp - runs-on: macos-15-xlarge + runs-on: blacksmith-12vcpu-macos-latest env: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }} diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index 14179a254757..4f77fc84a7a2 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -279,7 +279,7 @@ jobs: channel: '#deployer', attachments: [{ color: 'good', - text: `๐Ÿ’ Cherry-pick to *${{ inputs.TARGET }}* successful\nPR: ${{ inputs.PULL_REQUEST_URL || '(version bump only)' }}\nDeploy workflow: ${{ steps.findDeployRun.outputs.DEPLOY_RUN_MESSAGE }}` + text: `๐Ÿ’ Cherry-pick to *${{ inputs.TARGET }}* successfully started\nPR: ${{ inputs.PULL_REQUEST_URL || '(version bump only)' }}\nDeploy workflow: ${{ steps.findDeployRun.outputs.DEPLOY_RUN_MESSAGE }}` }] } env: @@ -444,3 +444,20 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + - name: "Announce CP cancellation in #deployer" + uses: 8398a7/action-slack@1750b5085f3ec60384090fb7c52965ef822e869e + if: ${{ cancelled() }} + with: + status: custom + custom_payload: | + { + channel: '#deployer', + attachments: [{ + color: 'warning', + text: `๐Ÿ’ Cherry-pick to *${{ inputs.TARGET }}* was cancelled\nPR: ${{ inputs.PULL_REQUEST_URL || '(version bump only)' }}\nWorkflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}` + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index ca547e41d5de..34c1fb9702a6 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -7,6 +7,8 @@ permissions: on: pull_request_target: types: [opened, ready_for_review] + pull_request_review: + types: [submitted] concurrency: group: claude-review-${{ github.event.pull_request.html_url }} @@ -14,18 +16,41 @@ concurrency: jobs: validate: + if: github.event_name == 'pull_request_target' uses: ./.github/workflows/contributorValidationGate.yml with: PR_NUMBER: ${{ github.event.pull_request.number }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} + checkCPlusApproval: + if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' + runs-on: blacksmith-2vcpu-ubuntu-2404 + outputs: + IS_CPLUS: ${{ steps.check.outputs.IS_CPLUS }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + + - name: Check Contributor+ membership + id: check + uses: ./.github/actions/composite/isContributorPlus + with: + USERNAME: ${{ github.event.review.user.login }} + OS_BOTIFY_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + review: - needs: [validate] + needs: [validate, checkCPlusApproval] if: | - needs.validate.outputs.IS_AUTHORIZED == 'true' + !cancelled() && github.event.pull_request.draft != true && !contains(github.event.pull_request.title, 'Revert') + && ( + (github.event_name == 'pull_request_target' && needs.validate.outputs.IS_AUTHORIZED == 'true') + || (github.event_name == 'pull_request_review' && needs.checkCPlusApproval.outputs.IS_CPLUS == 'true') + ) runs-on: blacksmith-2vcpu-ubuntu-2404 env: PR_NUMBER: ${{ github.event.pull_request.number }} @@ -57,7 +82,7 @@ jobs: run: | "$GITHUB_WORKSPACE/.github/scripts/extractAllowedRules.sh" \ "$GITHUB_WORKSPACE/.claude/skills/coding-standards/rules" \ - "$GITHUB_WORKSPACE/.claude/allowed-rules.txt" + "$RUNNER_TEMP/allowed-rules.txt" - name: Load code review JSON schema id: schema @@ -82,6 +107,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} STRUCTURED_OUTPUT: ${{ steps.code-review.outputs.structured_output }} + ALLOWED_RULES_FILE: ${{ runner.temp }}/allowed-rules.txt run: | if [ -z "$STRUCTURED_OUTPUT" ]; then echo "::error::Claude Code Action returned empty structured output" diff --git a/.github/workflows/createNewVersion.yml b/.github/workflows/createNewVersion.yml index 401a26be9883..db4768edc5d9 100644 --- a/.github/workflows/createNewVersion.yml +++ b/.github/workflows/createNewVersion.yml @@ -41,7 +41,7 @@ on: jobs: createNewVersion: - runs-on: macos-latest + runs-on: blacksmith-6vcpu-macos-latest outputs: NEW_VERSION: ${{ steps.bumpVersion.outputs.NEW_VERSION }} steps: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f165e79ad8e4..36e31a3d2ca9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -315,7 +315,7 @@ jobs: iosUploadTestflight: name: Upload iOS to TestFlight needs: [prep, iosBuild] - runs-on: macos-15-xlarge + runs-on: blacksmith-12vcpu-macos-latest if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} env: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer @@ -395,7 +395,7 @@ jobs: iosSubmit: name: Submit iOS for production rollout needs: [prep, iosBuild, iosUploadTestflight] - runs-on: macos-15-xlarge + runs-on: blacksmith-12vcpu-macos-latest if: ${{ always() && !cancelled() && github.ref == 'refs/heads/production' && needs.iosBuild.result != 'failure' && needs.iosUploadTestflight.result != 'failure' }} env: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer diff --git a/.github/workflows/deployBlockerInvestigation.yml b/.github/workflows/deployBlockerInvestigation.yml index bd07fd4458a8..0618ef144267 100644 --- a/.github/workflows/deployBlockerInvestigation.yml +++ b/.github/workflows/deployBlockerInvestigation.yml @@ -77,4 +77,4 @@ jobs: github_token: ${{ secrets.OS_BOTIFY_TOKEN }} prompt: "/investigate-deploy-blocker ISSUE_URL: ${{ env.ISSUE_URL }}" claude_args: | - --allowedTools "Task,Glob,Grep,Read,Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue edit:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(removeDeployBlockerLabel.sh:*)" + --allowedTools "Task,Glob,Grep,Read,Write,Bash(gh issue view:*),Bash(gh issue comment:*),Bash(gh issue edit:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh pr diff:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(removeDeployBlockerLabel.sh:*)" diff --git a/.github/workflows/react-compiler-compliance.yml b/.github/workflows/react-compiler-compliance.yml index 06cbf075e871..13b38271e702 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -5,7 +5,7 @@ on: pull_request: types: [opened, synchronize] branches-ignore: [staging, production] - paths: ["**.tsx"] + paths: ["**.ts", "**.tsx"] concurrency: group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-react-compiler-compliance diff --git a/.github/workflows/remote-build-ios.yml b/.github/workflows/remote-build-ios.yml index 22726e6976ad..7cbf0b44f738 100644 --- a/.github/workflows/remote-build-ios.yml +++ b/.github/workflows/remote-build-ios.yml @@ -18,7 +18,7 @@ concurrency: jobs: build: - runs-on: ${{ github.repository_owner == 'Expensify' && 'macos-15-xlarge' || 'macos-15' }} + runs-on: ${{ github.repository_owner == 'Expensify' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }} env: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer strategy: @@ -45,6 +45,12 @@ jobs: with: IS_HYBRID_BUILD: ${{ matrix.is_hybrid_build && 'true' || 'false' }} + - name: Setup Ruby + # v1.229.0 + uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 + with: + bundler-cache: true + - name: Checkout specific Mobile-Expensify PR if: ${{ matrix.is_hybrid_build && github.event.inputs.mobile_expensify_pr }} run: | diff --git a/.github/workflows/sendReassurePerfData.yml b/.github/workflows/sendReassurePerfData.yml deleted file mode 100644 index a8c043ea9648..000000000000 --- a/.github/workflows/sendReassurePerfData.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Send Reassure Performance Tests to Graphite - -on: - push: - branches: [main] - paths-ignore: [docs/**, help/**, contributingGuides/**, jest/**] - -jobs: - perf-tests: - if: ${{ github.actor != 'OSBotify' }} - runs-on: blacksmith-4vcpu-ubuntu-2404 - steps: - - name: Checkout - # v6 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - fetch-depth: 0 - - - name: Setup NodeJS - uses: ./.github/actions/composite/setupNode - - - name: Install dependencies - run: npm install - - - name: Run performance testing script - shell: bash - run: | - set -e - NODE_OPTIONS=--experimental-vm-modules npx reassure --baseline - - - name: Get merged pull request - id: getMergedPullRequest - uses: ./.github/actions/composite/getMergedPullRequest - with: - github_token: ${{ github.token }} - - - name: Test connectivity to stats.expensify.com server - run: nc -zv stats.expensify.com 3003 - - - name: Get and save graphite string - id: saveGraphiteString - uses: ./.github/actions/javascript/getGraphiteString - with: - PR_NUMBER: ${{ steps.getMergedPullRequest.outputs.number }} - - - name: Send graphite data - run: echo -e "${{ steps.saveGraphiteString.outputs.GRAPHITE_STRING }}" | nc -q0 stats.expensify.com 3003 diff --git a/.github/workflows/syncVersions.yml b/.github/workflows/syncVersions.yml index 92e5f893f274..5dd210f290b3 100644 --- a/.github/workflows/syncVersions.yml +++ b/.github/workflows/syncVersions.yml @@ -10,7 +10,7 @@ on: jobs: syncVersions: - runs-on: macos-latest + runs-on: blacksmith-6vcpu-macos-latest steps: - name: Check out # v6 @@ -36,32 +36,56 @@ jobs: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} SETUP_AS_APP: false - - name: Update submodule to latest Mobile-Expensify main - run: git submodule update --remote + - name: Record submodule gitlink and update Mobile-Expensify to latest main + id: submoduleRemote + run: | + RECORDED_SHA=$(git rev-parse HEAD:Mobile-Expensify) + echo "Submodule commit recorded on App: $RECORDED_SHA" + echo "RECORDED_SHA=$RECORDED_SHA" >> "$GITHUB_OUTPUT" + + git submodule update --remote + + ACTUAL_SHA=$(git -C Mobile-Expensify rev-parse HEAD) + echo "Mobile-Expensify after remote update: $ACTUAL_SHA" + echo "ACTUAL_SHA=$ACTUAL_SHA" >> "$GITHUB_OUTPUT" - - name: Check current versions + - name: Check sync state (versions + submodule pointer) id: checkVersions run: | APP_VERSION=$(jq -r .version package.json) ME_VERSION=$(jq -r .meta.version Mobile-Expensify/app/config/config.json) + RECORDED_SHA="${{ steps.submoduleRemote.outputs.RECORDED_SHA }}" + ACTUAL_SHA="${{ steps.submoduleRemote.outputs.ACTUAL_SHA }}" + echo "E/App version: $APP_VERSION" echo "Mobile-Expensify version: $ME_VERSION" echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_OUTPUT" echo "ME_VERSION=$ME_VERSION" >> "$GITHUB_OUTPUT" - - if [ "$APP_VERSION" == "$ME_VERSION" ]; then - echo "::notice::โœ… Versions are already in sync: $APP_VERSION" + + if [[ "$RECORDED_SHA" != "$ACTUAL_SHA" ]]; then + echo "::warning::โš ๏ธ Submodule pointer is behind Mobile-Expensify main (recorded $RECORDED_SHA, latest $ACTUAL_SHA)" + fi + + if [[ "$APP_VERSION" == "$ME_VERSION" && "$RECORDED_SHA" == "$ACTUAL_SHA" ]]; then + echo "::notice::โœ… Versions and submodule are in sync ($APP_VERSION @ $ACTUAL_SHA)" echo "IN_SYNC=true" >> "$GITHUB_OUTPUT" + echo "NEED_FULL_VERSION_SYNC=false" >> "$GITHUB_OUTPUT" else - echo "::warning::โš ๏ธ Versions are out of sync - E/App: $APP_VERSION, Mobile-Expensify: $ME_VERSION" echo "IN_SYNC=false" >> "$GITHUB_OUTPUT" + if [[ "$APP_VERSION" != "$ME_VERSION" ]]; then + echo "::warning::โš ๏ธ Versions are out of sync - E/App: $APP_VERSION, Mobile-Expensify: $ME_VERSION" + echo "NEED_FULL_VERSION_SYNC=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Versions match ($APP_VERSION) but App must record the latest submodule commit" + echo "NEED_FULL_VERSION_SYNC=false" >> "$GITHUB_OUTPUT" + fi fi - name: Determine target version - if: steps.checkVersions.outputs.IN_SYNC != 'true' + if: steps.checkVersions.outputs.IN_SYNC != 'true' && steps.checkVersions.outputs.NEED_FULL_VERSION_SYNC == 'true' id: targetVersion run: | - if [ -n "${{ inputs.TARGET_VERSION }}" ]; then + if [[ -n "${{ inputs.TARGET_VERSION }}" ]]; then echo "Using provided target version: ${{ inputs.TARGET_VERSION }}" echo "VERSION=${{ inputs.TARGET_VERSION }}" >> "$GITHUB_OUTPUT" else @@ -71,31 +95,31 @@ jobs: fi - name: Setup Node - if: steps.checkVersions.outputs.IN_SYNC != 'true' + if: steps.checkVersions.outputs.IN_SYNC != 'true' && steps.checkVersions.outputs.NEED_FULL_VERSION_SYNC == 'true' uses: ./.github/actions/composite/setupNode - name: Sync E/App to target version - if: steps.checkVersions.outputs.IN_SYNC != 'true' + if: steps.checkVersions.outputs.IN_SYNC != 'true' && steps.checkVersions.outputs.NEED_FULL_VERSION_SYNC == 'true' run: | TARGET="${{ steps.targetVersion.outputs.VERSION }}" echo "::notice::Syncing E/App to version $TARGET" - + # Update version using npm (this updates package.json and package-lock.json) npm --no-git-tag-version version "$TARGET" - + # Extract version components for native file updates SHORT_VERSION="${TARGET%-*}" # e.g., "9.3.11" from "9.3.11-48" BUILD_NUMBER="${TARGET#*-}" # e.g., "48" from "9.3.11-48" CF_VERSION="${SHORT_VERSION}.${BUILD_NUMBER}" # e.g., "9.3.11.48" - + # Pad build number components for Android version code (prefix 10 for E/App) IFS='.' read -r MAJOR MINOR PATCH <<< "$SHORT_VERSION" ANDROID_VERSION_CODE="10$(printf '%02d' "$MAJOR")$(printf '%02d' "$MINOR")$(printf '%02d' "$PATCH")$(printf '%02d' "$BUILD_NUMBER")" - + echo "Updating Android build.gradle with versionName=$TARGET, versionCode=$ANDROID_VERSION_CODE" sed -i '' "s/versionName \"[0-9.-]*\"/versionName \"$TARGET\"/" android/app/build.gradle sed -i '' "s/versionCode [0-9]*/versionCode $ANDROID_VERSION_CODE/" android/app/build.gradle - + echo "Updating iOS plists with CFBundleShortVersionString=$SHORT_VERSION, CFBundleVersion=$CF_VERSION" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $SHORT_VERSION" ios/NewExpensify/Info.plist /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $CF_VERSION" ios/NewExpensify/Info.plist @@ -103,15 +127,15 @@ jobs: /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $CF_VERSION" ios/NotificationServiceExtension/Info.plist /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $SHORT_VERSION" ios/ShareViewController/Info.plist /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $CF_VERSION" ios/ShareViewController/Info.plist - + # Commit version changes git add package.json package-lock.json android/app/build.gradle ios/*/Info.plist git commit -m "Update version to $TARGET (sync recovery)" - + # Update submodule reference git add Mobile-Expensify git commit -m "Update Mobile-Expensify submodule version to $TARGET (sync recovery)" - + # Push changes if ! git push origin main; then echo "::warning::Push failed, attempting rebase..." @@ -119,21 +143,61 @@ jobs: git rebase origin/main git push origin main fi - + echo "::notice::โœ… Successfully synced E/App to version $TARGET" + - name: Bump Mobile-Expensify submodule only + if: steps.checkVersions.outputs.IN_SYNC != 'true' && steps.checkVersions.outputs.NEED_FULL_VERSION_SYNC != 'true' + run: | + EXPECTED_SHA="${{ steps.submoduleRemote.outputs.ACTUAL_SHA }}" + CURRENT_SHA=$(git -C Mobile-Expensify rev-parse HEAD) + if [[ "$CURRENT_SHA" != "$EXPECTED_SHA" ]]; then + echo "::error::Mobile-Expensify checkout ($CURRENT_SHA) does not match expected main ($EXPECTED_SHA)" + exit 1 + fi + + echo "::notice::Recording Mobile-Expensify submodule at $CURRENT_SHA (versions already matched ${{ steps.checkVersions.outputs.APP_VERSION }})" + git add Mobile-Expensify + git commit -m "Bump Mobile-Expensify submodule to latest main ($CURRENT_SHA)" + + if ! git push origin main; then + echo "::warning::Push failed, attempting rebase..." + git fetch origin main + git rebase origin/main + git submodule update --remote + git add Mobile-Expensify + if ! git diff --staged --quiet; then + git commit -m "Bump Mobile-Expensify submodule to latest main after rebase ($(git -C Mobile-Expensify rev-parse HEAD))" + fi + git push origin main + fi + + echo "::notice::โœ… Submodule pointer updated on App main" + - name: Verify sync completed + id: verifySync if: steps.checkVersions.outputs.IN_SYNC != 'true' run: | APP_VERSION=$(jq -r .version package.json) ME_VERSION=$(jq -r .meta.version Mobile-Expensify/app/config/config.json) - - if [ "$APP_VERSION" != "$ME_VERSION" ]; then + + if [[ "$APP_VERSION" != "$ME_VERSION" ]]; then echo "::error::Sync failed! Versions still don't match" echo "::error::E/App: $APP_VERSION, Mobile-Expensify: $ME_VERSION" exit 1 fi - echo "::notice::โœ… Versions verified: $APP_VERSION" + + git -C Mobile-Expensify fetch origin + + RECORDED=$(git rev-parse HEAD:Mobile-Expensify) + REMOTE_SHA=$(git -C Mobile-Expensify rev-parse origin/main) + if [[ "$RECORDED" != "$REMOTE_SHA" ]]; then + echo "::error::Submodule on App main ($RECORDED) still differs from Mobile-Expensify origin/main ($REMOTE_SHA)" + exit 1 + fi + + echo "POST_SYNC_APP_VERSION=$APP_VERSION" >> "$GITHUB_OUTPUT" + echo "::notice::โœ… Verified versions ($APP_VERSION) and submodule match Mobile-Expensify main ($RECORDED)" - name: Announce sync in Slack if: steps.checkVersions.outputs.IN_SYNC != 'true' @@ -146,7 +210,7 @@ jobs: channel: '#deployer', attachments: [{ color: "good", - text: `โœ… Version sync completed. E/App and Mobile-Expensify are now both at version ${{ steps.targetVersion.outputs.VERSION }}` + text: `โœ… Version sync completed. E/App and Mobile-Expensify are at ${{ steps.verifySync.outputs.POST_SYNC_APP_VERSION }}; submodule matches Mobile-Expensify main.` }] } env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04ae8701a626..182e124cc948 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,10 +31,6 @@ jobs: - name: Setup Node uses: ./.github/actions/composite/setupNode - - name: Get number of CPU cores - id: cpu-cores - uses: SimenB/github-actions-cpu-cores@31e91de0f8654375a21e8e83078be625380e2b18 - - name: Cache Jest cache id: cache-jest-cache # v5.0.1 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 9a079da783db..80fc2d0148bd 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -34,7 +34,7 @@ jobs: # - git diff is used to see the files that were added on this branch # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main # - wc counts the words in the result of the intersection - count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) + count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js' '__mocks__/*.js' '.storybook/*.js' 'assets/*.js' 'config/*.js' 'jest/*.js' 'scripts/*.js' 'tests/*.js' '.github/libs/*.js' '.github/scripts/*.js' ':!src/libs/SearchParser/*.js' ':!config/babel/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) if [ "$count_new_js" -gt "0" ]; then echo "ERROR: Found new JavaScript files in the project; use TypeScript instead." exit 1 diff --git a/.github/workflows/verifyParserFiles.yml b/.github/workflows/verifyParserFiles.yml index 91fc8c5952bd..56884ac89ee6 100644 --- a/.github/workflows/verifyParserFiles.yml +++ b/.github/workflows/verifyParserFiles.yml @@ -10,7 +10,7 @@ on: jobs: verify: if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' - runs-on: macos-latest + runs-on: blacksmith-6vcpu-macos-latest steps: - name: Checkout # v6 diff --git a/.github/workflows/verifyPodfile.yml b/.github/workflows/verifyPodfile.yml index 2ff18af1caa6..b95b435f694d 100644 --- a/.github/workflows/verifyPodfile.yml +++ b/.github/workflows/verifyPodfile.yml @@ -12,7 +12,7 @@ on: jobs: verify: if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' - runs-on: macos-latest + runs-on: blacksmith-6vcpu-macos-latest steps: - name: Checkout # v6 diff --git a/.gitignore b/.gitignore index 79175c075a16..6775d4e4fe81 100644 --- a/.gitignore +++ b/.gitignore @@ -162,5 +162,8 @@ modules/*/lib/ .playwright-output/ .playwright-mcp/ +# agent-device +agent-device-output/ + # cspell cache .cspellcache diff --git a/.ruby-version b/.ruby-version index a0891f563f38..47b322c971c3 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.4 +3.4.1 diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ece74ca71aa1..19a350b23af6 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -17,9 +17,6 @@ import './fonts.css'; Onyx.init({ keys: ONYXKEYS, - initialKeyStates: { - [ONYXKEYS.NETWORK]: {isOffline: false}, - }, }); IntlStore.load(CONST.LOCALES.EN); diff --git a/CLAUDE.md b/CLAUDE.md index 56cbf058019e..a4027066d5ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,6 +194,7 @@ The skill provides guidance on: 1. **Prettier**: Run `npx prettier --write ` on every file you modified. This is mandatory - CI will reject unformatted code. 2. **ESLint**: Run `npx eslint --max-warnings=0` to catch lint errors early. 3. **TypeScript**: Run `npm run typecheck-tsgo` after changes that may affect typing (types, interfaces, or function signatures). It is ~10x faster and usually stricter than tsc. CI validates with `npm run typecheck` (tsc), which remains the required merge gate. +4. **React Compiler**: If you added new React components/hooks or modified existing ones, run `npm run react-compiler-compliance-check check-changed` to verify they compile with React Compiler. This applies the same rules as CI: new components/hooks must compile, and existing compiled files must not regress. See `contributingGuides/REACT_COMPILER.md` for details and common fixes. ### Testing - **Unit Tests**: Jest with React Native Testing Library @@ -277,6 +278,9 @@ npm run web ### Browser Testing Use the `/playwright-app-testing` skill to test and debug the App in a browser. Use this skill after making frontend changes to verify your work, or when the user requests testing. +### Mobile Device Testing +Use the `/agent-device` skill to drive the App on iOS and Android (simulators or real devices) for interactive testing, performance profiling, bug reproduction, and device-specific debugging. Requires `npm install -g agent-device` - the skill's pre-flight check will surface the install instruction if missing. + ## Architecture Decisions ### React Native New Architecture diff --git a/Gemfile b/Gemfile index 2c89f021d804..ae5ba068cb20 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version -ruby ">= 3.3.4" +ruby ">= 3.4.1" gem "cocoapods", "= 1.16.2" gem 'activesupport', '>= 7.2.3.1' @@ -19,3 +19,4 @@ gem 'bigdecimal' gem 'logger' gem 'benchmark' gem 'mutex_m' +gem 'nkf' diff --git a/Gemfile.lock b/Gemfile.lock index 9e3c96f1c6e0..8a1ddf25dcff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -260,6 +260,7 @@ GEM nap (1.1.0) naturally (2.3.0) netrc (0.11.0) + nkf (0.2.0) openssl (3.3.1) optparse (0.8.1) os (1.1.4) @@ -331,12 +332,13 @@ DEPENDENCIES fastlane-plugin-firebase_app_distribution logger mutex_m + nkf openssl (>= 3.3.1) xcodeproj (~> 1.27) xcpretty (~> 0) RUBY VERSION - ruby 3.3.4p94 + ruby 3.4.1p0 BUNDLED WITH 2.6.9 diff --git a/Mobile-Expensify b/Mobile-Expensify index 08ff3fc6bc03..7639cf734a06 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 08ff3fc6bc036b9db717157fcfaaa72dc7b18bb8 +Subproject commit 7639cf734a06b7e3f1a0a0fcd3179c851fc34e69 diff --git a/README.md b/README.md index 3bc69752ef74..a46e1b31cc5a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ For detailed setup instructions for each platform, see the following guides: * **๐Ÿ“ฑ iOS Development**: [iOS Setup Instructions](contributingGuides/SETUP_IOS.md) * **๐Ÿค– Android Development**: [Android Setup Instructions](contributingGuides/SETUP_ANDROID.md) +**Optional AI-assisted mobile testing:** If you use Claude Code, the [`/agent-device` skill](.claude/skills/agent-device/SKILL.md) drives iOS and Android simulators or devices for interactive testing, debugging, and performance profiling. Requires `npm install -g agent-device`. + ## General Troubleshooting 1. If you are having issues with **_Getting Started_**, please reference [React Native's Documentation](https://reactnative.dev/docs/environment-setup) 2. If you are running into CORS errors like (in the browser dev console) diff --git a/android/app/build.gradle b/android/app/build.gradle index de29aeeb61de..bf40aab20755 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009035807 - versionName "9.3.58-7" + versionCode 1009036203 + versionName "9.3.62-3" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 33e0e0d45ab5..47abf4ac73a1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -167,6 +167,7 @@ \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__newuser.svg b/assets/images/simple-illustrations/simple-illustration__newuser.svg new file mode 100644 index 000000000000..2b8bec249866 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__newuser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index 66094c956fac..a7e8f5cad00e 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,10 +1,9 @@ require('dotenv').config(); +const BaseReactCompilerConfig = require('./config/babel/reactCompilerConfig'); + const ReactCompilerConfig = { - target: '19', - environment: { - enableTreatRefLikeIdentifiersAsRefs: true, - }, + ...BaseReactCompilerConfig, sources: (filename) => !filename.includes('tests/') && !filename.includes('node_modules/'), }; @@ -131,6 +130,9 @@ const metro = { production: { plugins: [['transform-remove-console', {exclude: ['error', 'warn']}]], }, + test: { + plugins: ['@babel/plugin-transform-dynamic-import'], + }, }, }; diff --git a/config/babel/reactCompilerConfig.js b/config/babel/reactCompilerConfig.js new file mode 100644 index 000000000000..7f084769d768 --- /dev/null +++ b/config/babel/reactCompilerConfig.js @@ -0,0 +1,16 @@ +/** + * Shared React Compiler configuration used across: + * - babel.config.js (build pipeline, extends with `sources` filter) + * - eslint-plugin-react-compiler-compat (lint-time analysis) + * - react-compiler-compliance-check (CI and local checking) + * + * Intentionally omits `sources` since that's only relevant for the Babel build pipeline. + */ +const ReactCompilerConfig = { + target: '19', + environment: { + enableTreatRefLikeIdentifiersAsRefs: true, + }, +}; + +module.exports = ReactCompilerConfig; diff --git a/contributingGuides/INTERACTION_MANAGER.md b/contributingGuides/INTERACTION_MANAGER.md new file mode 100644 index 000000000000..08008b0b42ad --- /dev/null +++ b/contributingGuides/INTERACTION_MANAGER.md @@ -0,0 +1,67 @@ +# InteractionManager Migration + +## Why + +`InteractionManager` is being removed from React Native. We currently maintain a patch to keep it working, but that's a temporary measure and upstream libraries will also drop support over time. + +Rather than keep patching, we're replacing `InteractionManager.runAfterInteractions` with purpose-built alternatives that are more precise. + +## Current state + +`runAfterInteractions` is used across the codebase for a wide range of reasons: waiting for navigation transitions, deferring work after modals close, managing input focus, delaying scroll operations, and many other cases that are hard to classify. + +## The problem + +`runAfterInteractions` is a global queue with no granularity. This made it a convenient catch-all, but the intent behind each call is often unclear. Many usages exist simply because it "just worked" as a timing workaround, not because it was the right tool for the job. + +This makes the migration non-trivial: you have to understand *what each call is actually waiting for* before you can pick the right replacement. + +## The approach + +**TransitionTracker** is the backbone. It tracks navigation transitions explicitly, so other APIs can hook into transition lifecycle without relying on a global queue. + +On top of TransitionTracker, existing APIs gain transition-aware callbacks: + +- Navigation methods accept `afterTransition` โ€” a callback that runs after the triggered navigation transition completes +- Navigation methods accept `waitForTransition` โ€” the call waits for all ongoing transitions to finish before navigating +- Keyboard methods accept `afterTransition` โ€” a callback that runs after the keyboard transition completes +- `useConfirmModal` hook's `showConfirmModal` returns a Promise that resolves **after the modal close transition completes**, so any work awaited after it naturally runs post-transition โ€” no explicit `afterTransition` callback needed + +This makes the code self-descriptive: instead of a generic `runAfterInteractions`, each call site says exactly what it's waiting for and why. + +> **Note:** `TransitionTracker.runAfterTransitions` is an internal primitive. Application code should use the higher-level APIs (`Navigation`, `useConfirmModal`, etc.) rather than importing TransitionTracker directly. + +## How +The migration is split into 9 issues. Current status of the migration can be found in the parent Github issue [here](https://github.com/Expensify/App/issues/71913). + +## Primitives comparison + +For reference, here's how the available timing primitives compare: + +### `requestAnimationFrame` (rAF) + +- Fires **before the next paint** (~16ms at 60fps) +- Guaranteed to run every frame if the thread isn't blocked +- Use for: UI updates that need to happen on the next frame (scroll, layout measurement, enabling a button after a state flush) + +### `requestIdleCallback` + +- Fires when the runtime has **idle time** โ€” no pending frames, no urgent work +- May be delayed indefinitely if the main thread stays busy +- Accepts a `timeout` option to force execution after a deadline +- Use for: Non-urgent background work (Pusher subscriptions, search API calls, contact imports) + +### `InteractionManager.runAfterInteractions` (legacy โ€” do not use) + +- React Native-specific. Fires after all **ongoing interactions** (animations, touches) complete +- Tracks interactions via `createInteractionHandle()` โ€” anything that calls `handle.done()` unblocks the queue +- In practice, this means "run after the current navigation transition finishes" +- Problem: it's a global queue with no granularity โ€” you can't say "after _this specific_ transition" + +### Summary + +| | Timing | Granularity | Platform | +| ---------------------- | ------------------------- | ------------------------- | --------------------- | +| `rAF` | Next frame (~16ms) | None โ€” just "next paint" | Web + RN | +| `requestIdleCallback` | When idle (unpredictable) | None โ€” "whenever free" | Web + RN (polyfilled) | +| `runAfterInteractions` | After animations finish | Global โ€” all interactions | RN only | diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 3473e569991e..a810cd592b60 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -31,6 +31,7 @@ The navigation in the app is built on top of the `react-navigation` library. To - [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters) - [How to add a new dynamic route](#how-to-add-a-new-dynamic-route) - [Migrating from backTo to dynamic routes](#migrating-from-backto-to-dynamic-routes) + - [Backward compatibility for changed paths](#backward-compatibility-for-changed-paths) - [How to remove backTo from URL (Legacy)](#how-to-remove-backto-from-url) - [Separating routes for each screen instance](#separating-routes-for-each-screen-instance) - [Generating state from a path](#generating-state-from-a-path) @@ -931,6 +932,46 @@ If you are migrating an existing flow that uses `backTo` (or multiple static rou For the legacy approach (e.g. separating routes per screen instance) and edge cases like RHP underlay, see [How to remove backTo from URL](#how-to-remove-backto-from-url) and [Separating routes for each screen instance](#separating-routes-for-each-screen-instance). +### Backward compatibility for changed paths + +When migrating a static route to a dynamic route, the resulting URL often changes because dynamic routes build the target path on top of the entry screen's URL. Here is a concrete example: + +**Static routes** โ€” the entry screen and the target screen have independent, unrelated paths: + +| | Path | +|---|---| +| Entry screen | `/r/:reportID/details` | +| Target screen | `/r/:reportID/settings/name` | + +The target path `/r/:reportID/settings/name` stands on its own; it does not contain the entry screen's `/details` segment. + +**Dynamic routes** โ€” the target URL is formed by appending a suffix to the entry screen's URL: + +| | Path | +|---|---| +| Entry screen (base path) | `/r/:reportID/details` | +| Dynamic suffix | `settings/name` | +| Target screen | `/r/:reportID/details/settings/name` | + +Because the suffix `settings/name` is appended to `/r/:reportID/details`, the resulting URL now includes the entry screen's path as a prefix. The old static URL (`/r/:reportID/settings/name`) no longer matches the new dynamic one (`/r/:reportID/details/settings/name`). + +If the new path does not match the old one, you **must** add a redirect mapping to [`src/libs/Navigation/linkingConfig/OldRoutes.ts`](../src/libs/Navigation/linkingConfig/OldRoutes.ts) so that bookmarks, shared links, and browser history still work. + +Each entry in `OldRoutes.ts` maps an old path pattern to the new path. Use `*` as a wildcard for dynamic segments and `$1`, `$2`, etc. in the replacement to reference captured segments: + +```ts +const oldRoutes: Record = { + '/r/*/settings/name': '/r/$1/details/settings/name', + '/workspaces/*/accounting/*/card-reconciliation/account': '/workspaces/$1/accounting/$2/card-reconciliation/account-reconciliation-settings', +}; +``` + +- A `*` in the **middle** of a pattern matches a single path segment (everything except `/`). +- A `*` at the **end** of a pattern matches the entire remaining path (including `/`). +- `$1`, `$2`, ... in the replacement string correspond to the captured wildcards in order. + +After adding a mapping, add test cases in [`tests/navigation/getMatchingNewRouteTest.ts`](../tests/navigation/getMatchingNewRouteTest.ts) to verify that the old path redirects to the new one and that query parameters are preserved. + ## How to remove backTo from URL **Preferred approach:** For new flows where a screen is reused across multiple entry points, use [Dynamic Routes](#dynamic-routes) and the [Migrating from backTo to Dynamic Routes](#migrating-from-backto-to-dynamic-routes) guide instead of `backTo` or multiple static routes. @@ -1100,7 +1141,7 @@ In Expensify, we use an extended implementation of this function because: - In case of opening the RHP, appropriate screens should be pushed to the navigation to be displayed below the overlay. A guide on how to set up a good screen for RHP can be found [here](#how-to-set-a-correct-screen-below-the-rhp). - When opening the settings of a specific workspace, the workspace list needs to be pushed to the state. - When the `backTo` parameter is in the URL, we need to build a state also for the screen we want to return to. (`backTo` parameter is deprecated, more information can be found [here](#how-to-remove-backto-from-url)) -- For dynamic routes, state is built from the current path and [entryScreens](#entry-screens-access-control) access control. +- For dynamic routes, when the page is refreshed the navigation state only contains the deepest screen. To make back navigation work, all intermediate dynamic screens must be inserted into the state in the correct order so the app knows which screen to return to. Here are examples how the state is generated based on route: @@ -1214,6 +1255,77 @@ As you can see after opening the workspace settings of the specific workspace, w In the above example, we can see that when building a state from a link leading to a screen in RHP, screens that appear below the overlay are also built. +- `settings/profile/address/country?country=US` + +```json +{ + "stale": false, + "type": "stack", + "key": "stack-key-7", + "index": 1, + "routes": [ + { + "name": "SettingsSplitNavigator", + "state": { + "stale": false, + "type": "stack", + "key": "stack-key-8", + "index": 1, + "routes": [ + { + "name": "Settings_Root", + "key": "Settings_Root-key" + }, + { + "name": "Settings_Profile", + "key": "Settings_Profile-key" + } + ] + }, + "key": "SettingsSplitNavigator-key" + }, + { + "name": "RightModalNavigator", + "state": { + "stale": false, + "type": "stack", + "key": "stack-key-9", + "index": 0, + "routes": [ + { + "name": "Settings", + "state": { + "stale": false, + "type": "stack", + "key": "stack-key-10", + "index": 1, + "routes": [ + { + "name": "Settings_Address", + "path": "/settings/profile/address", + "key": "Settings_Address-key" + }, + { + "name": "Dynamic_Address_Country", + "path": "/settings/profile/address/country?country=US", + "params": { + "country": "US" + }, + "key": "Dynamic_Address_Country-key" + } + ] + }, + "key": "Settings-key" + } + ] + }, + "key": "RightModalNavigator-key" + } + ] +} +``` +Since `country` is a dynamic suffix, the `Dynamic_Address_Country` screen is layered on top of the static `Settings_Address` screen in the initial state, ensuring correct back navigation after a refresh. + ## Setting the correct screen underneath RHP RHP screens can usually be opened from a specific central screen. Of course there are cases where one RHP screen can be used in different tabs. However, most often one RHP screen has a specific central screen assigned underneath. diff --git a/contributingGuides/NETWORK_STATE_DETECTION.md b/contributingGuides/NETWORK_STATE_DETECTION.md new file mode 100644 index 000000000000..0b91a1f69d82 --- /dev/null +++ b/contributingGuides/NETWORK_STATE_DETECTION.md @@ -0,0 +1,214 @@ +# Network State Detection + +## Overview + +The app uses a two-layer detection model to determine connectivity status. Each layer feeds into a central **hard stop** state machine that decides whether the app is offline. When a hard stop is active, the app pauses outgoing requests and shows the offline UI. Recovery relies on NetInfo's built-in reachability polling (`isInternetReachable`) and the FailureTracker detecting a successful request. Once connectivity is confirmed, the app clears the hard stop and reconnects. + +## Architecture Diagram + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Layer 1: OS Radio โ”‚ NetInfo isConnected +โ”‚ (NetworkState) โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ +โ”‚ Layer 2: Sustained โ”‚ FailureTrackerโ”‚ NetworkState โ”‚โ”€โ”€โ”€โ”€ isOffline โ”€โ”€โ–ถ listeners (UI, SQ) +โ”‚ Failures โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ (hard stop) โ”‚ +โ”‚ (FailureTracking MW) โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ–ฒ + โ”‚ onReachabilityRestored + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ NetInfo listener โ”‚โ”€โ”€โ”€โ”€ isInternetReachable + โ”‚ (reachability โ”‚ transitions โ†’true + โ”‚ polling) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Reconnect โ”‚โ”€โ”€โ”€โ”€ openApp / reconnectApp + โ”‚ โ”‚โ”€โ”€โ”€โ”€ flush SequentialQueue + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## The Hard Stop Model + +A **hard stop** means the app considers itself offline. When at least one trigger is active, the hard stop is ON. When all triggers are cleared, the hard stop is OFF. + +Five triggers can activate a hard stop: + +| Trigger | Source | Meaning | +|---|---|---| +| `noRadioActive` | OS radio detection | Device has no network interface (airplane mode, WiFi off) | +| `internetUnreachable` | NetInfo reachability polling | `api/Ping` failed โ€” server unreachable despite connected radio | +| `sustainedFailuresActive` | Failure tracker | Requests have been failing consistently | +| `shouldForceOffline` | Debug tool | Manually forced offline via TestToolMenu | +| `simulatedOffline` | Test tool | Poor connection simulator toggling offline randomly | + +When a hard stop activates: +1. `NetworkState` notifies all subscribers (via `subscribe()`) +2. `SequentialQueue` guards against processing while offline (reads `getIsOffline()` synchronously) +3. Components using `useNetwork()` re-render with `isOffline: true` + +When the hard stop clears: +1. `NetworkState` notifies all subscribers +2. `Reconnect.ts` detects the offlineโ†’online transition and calls `SequentialQueue.flush()` +3. Components using `useNetwork()` re-render with `isOffline: false` + +## Layer 1: OS Radio Detection + +**File:** `src/libs/NetworkState.ts` + +This layer uses `@react-native-community/netinfo` to detect whether the device has an active network interface. The NetInfo listener lives inside `NetworkState.ts` โ€” there is no separate module. + +- A module-level Onyx connection to `SESSION` triggers `configureAndSubscribe()` whenever accountID changes +- The NetInfo listener reads `state.isConnected` and calls `setHasRadio()` internally +- When `isConnected` is `false` โ†’ `setHasRadio(false)` โ†’ activates the `noRadioActive` hard stop +- When `isConnected` returns to `true` โ†’ `setHasRadio(true)` โ†’ clears the `noRadioActive` hard stop +- Detects: airplane mode, WiFi disabled, no cellular signal +- Does **not** determine actual server reachability โ€” a device can be connected to WiFi but have no internet + +### Reachability tracking + +The NetInfo listener tracks `isInternetReachable` transitions in both directions: + +- **any non-`false` โ†’ `false`** (`trueโ†’false`, `nullโ†’false`, `undefinedโ†’false`) โ€” `api/Ping` failed. Sets the `internetUnreachable` hard stop. Only `falseโ†’false` is skipped (already offline). This covers cold start with no internet (`nullโ†’false` after the initial indeterminate event), post-recovery resets, and normal onlineโ†’offline transitions. Without this, an idle user would never see the offline indicator because no API requests are failing. +- **`false` โ†’ `true`** and **`null` โ†’ `true`** โ€” `api/Ping` succeeded after a previous failure. Triggers `onReachabilityRestored()` which clears all hard stops and fires reconnect listeners. +- **`undefined` โ†’ `true`** โ€” the initial event on subscribe, delivering current state. This is **not** treated as a recovery to prevent duplicate `openApp()`/`reconnectApp()` calls on boot. + +**Platform behavior:** + +We configure `useNativeReachability: false` so that NetInfo uses JS fetch polling (`api/Ping`) on **all platforms** instead of trusting native OS reachability. This aligns behavior across web and mobile. NetInfo's default polling intervals apply (60s when reachable, 5s when unreachable). + +### Why `useNativeReachability: false` is required + +With `useNativeReachability: false`, NetInfo determines `isInternetReachable` by polling `api/Ping` via a real `fetch()` request โ€” making it a genuine request outcome, consistent with the design principle that "request outcomes are the authority." + +If `useNativeReachability` were set to `true`, NetInfo would use native OS reachability heuristics instead of JS polling. The `isInternetReachable` value would no longer represent a real request outcome and should NOT be used as an offline trigger. The `internetUnreachable` hard stop depends on this configuration. + +How `isConnected` vs `isInternetReachable` are determined per platform (with `useNativeReachability: false`): + +| Platform | `isConnected` source | `isInternetReachable` source | +|---|---|---| +| Web | `navigator.onLine` | JS `fetch(api/Ping)` | +| iOS | `SCNetworkReachability` flags | JS `fetch(api/Ping)` | +| Android | `NetworkCapabilities` transport type | JS `fetch(api/Ping)` | + +Note: Android's native module computes its own `isInternetReachable` via `NET_CAPABILITY_VALIDATED`, but the JS `InternetReachability.update()` gate discards it when `useNativeReachability: false`. iOS doesn't send `isInternetReachable` from native at all. See the NetInfo source files `internetReachability.ts`, `nativeModule.web.ts`, `ConnectivityReceiver.java`, and `RNCNetInfo.mm` for implementation details. + +## Layer 2: Sustained Failure Detection + +**Files:** `src/libs/FailureTracker.ts`, `src/libs/Middleware/FailureTracking.ts` + +This layer detects connectivity loss through request outcomes, catching cases where the OS reports a connection but the server is unreachable (e.g., captive portal, DNS failure, server outage). + +### FailureTracking Middleware + +The middleware observes every API response: + +- Any resolved response (server responded at all) โ†’ calls `recordSuccess()` +- `FAILED_TO_FETCH` error โ†’ calls `recordFailure()` (DNS failure, no internet, network timeout) +- `EXPENSIFY_SERVICE_INTERRUPTED` error โ†’ calls `recordFailure()` (server down: 500/502/504/520) +- All other errors (4xx, throttling, etc.) โ†’ **not tracked** as connectivity failures + +### FailureTracker + +Counts consecutive failures and applies a dual threshold: + +1. **Count threshold:** at least `SUSTAINED_FAILURE_THRESHOLD_COUNT` failures (default: 3) +2. **Time threshold:** at least `SUSTAINED_FAILURE_WINDOW_MS` elapsed since the first failure (default: 10s) + +Both thresholds must be met simultaneously to trigger a sustained failure hard stop. This prevents brief transient errors from being misidentified as connectivity loss. + +One successful request resets everything โ€” it proves the server is reachable and clears the `sustainedFailuresActive` flag. + +## Central State Machine + +**File:** `src/libs/NetworkState.ts` + +`NetworkState` is the single source of truth for offline status. It holds module-level boolean flags and uses a subscriber pattern โ€” components use `useNetwork()` (backed by `useSyncExternalStore`) and `SequentialQueue` subscribes via `subscribe()`. Because the state is module-level (not persisted in Onyx), each browser tab detects connectivity independently. This is intentional โ€” each tab has its own network conditions and should evaluate them on its own. + +``` +hasRadio โ€” set by NetInfo listener (Layer 1), inverted: !hasRadio = noRadio hard stop +internetUnreachable โ€” set by NetInfo listener when isInternetReachable transitions to false (any non-falseโ†’false) +sustainedFailuresActive โ€” set by FailureTracker (Layer 2) +shouldForceOffline โ€” set by debug tools (Onyx NETWORK key) +simulatedOffline โ€” set by poor connection simulator +``` + +### Core logic + +`getIsOffline()` derives the offline status: + +```typescript +const offline = !hasRadio || internetUnreachable || sustainedFailuresActive || shouldForceOffline || simulatedOffline; +``` + +`updateState()` notifies all subscribers when the state changes. `Reconnect.ts` subscribes and calls `SequentialQueue.flush()` on offlineโ†’online transitions. `SequentialQueue` reads `getIsOffline()` synchronously for its guard checks but does not own the transition subscription. + +### Recovery flow + +`onReachabilityRestored()`: +1. Sets `hasRadio = true` and `sustainedFailuresActive = false`, resets FailureTracker counters +2. Calls `updateState()` (which notifies subscribers and clears the hard stop) +3. Calls `notifyReconnectListeners()` โ€” `Reconnect.ts` subscribes to this and triggers app data sync + +### App foreground handling + +`Reconnect.ts` registers an `AppStateMonitor.addBecameActiveListener` callback: +- If in hard stop โ†’ calls `NetworkState.refresh()` which triggers `NetInfo.refresh()` (bypasses stale `isInternetReachable` cache, see NetInfo issue #326) +- Always โ†’ calls `reconnect()` to catch up on missed Pusher events + +## Recovery & Reconnect + +**File:** `src/libs/actions/Reconnect.ts` + +Subscribes to `NetworkState.onReachabilityConfirmed()` and `AppStateMonitor.addBecameActiveListener()`. Handles data synchronization: + +- Skips reconnection if no active session (`currentAccountID` is undefined) +- If `isLoadingApp` is true โ†’ calls `App.openApp()` (full initial load) +- Otherwise โ†’ calls `App.reconnectApp(lastUpdateIDAppliedToClient)` (incremental sync) +- Flushes `SequentialQueue` to send any pending write requests + +## Thundering Herd Protection + +When the server recovers after an outage, many clients detect reachability at roughly the same time. The queue does not add an artificial delay before flushing because the architecture has three layers of natural backoff: + +1. **Polling jitter** โ€” each client's NetInfo polls `api/Ping` on its own 5-second cycle, so clients discover recovery at different times (up to 5 seconds of natural spread). +2. **Per-request exponential backoff** โ€” if the server is still overloaded when a client flushes, `RequestThrottle` applies jittered exponential backoff (10โ€“100 ms initial, doubling up to 30 s cap) on each failed request. +3. **Re-triggering hard stop** โ€” if enough requests fail after recovery (3 failures over 10 seconds), `FailureTracker` puts the client back into a hard stop, preventing it from hammering the server further. + +## Configuration Constants + +All values are defined in `src/CONST/index.ts` under `CONST.NETWORK`: + +| Constant | Value | Description | +|---|---|---| +| `SUSTAINED_FAILURE_THRESHOLD_COUNT` | `3` | Minimum failures before triggering sustained failure hard stop | +| `SUSTAINED_FAILURE_WINDOW_MS` | `10000` (10s) | Minimum elapsed time from first failure to trigger hard stop | + +## Debug Tools + +Two debug options are available via the TestToolMenu (accessible in dev builds): + +- **`shouldForceOffline`**: Forces the app into a hard stop. Flows through Onyx โ†’ `NetworkState.setForceOffline()`. Useful for testing offline UX patterns. +- **`shouldSimulatePoorConnection`**: Randomly toggles the app between online and offline every 2โ€“5 seconds. Handled in `NetworkState.ts`. Useful for testing flaky network behavior. + +## Key Files Reference + +| File | Role | +|---|---| +| `src/libs/NetworkState.ts` | Central hard stop state machine, NetInfo configuration/subscription, OS radio detection, and reachability tracking | +| `src/libs/FailureTracker.ts` | Counts failures, triggers sustained failure hard stop via listener pattern | +| `src/libs/Middleware/FailureTracking.ts` | Middleware that observes request outcomes and feeds FailureTracker | +| `src/libs/actions/Reconnect.ts` | Subscribes to reachability + foreground events, syncs app data after recovery | +| `src/libs/Network/SequentialQueue.ts` | Write request queue, reads `getIsOffline()` synchronously for guard checks | +| `src/libs/actions/Network.ts` | Onyx actions for debug flags (forceOffline, simulatePoorConnection) | +| `src/hooks/useNetwork.ts` | Hook for components โ€” uses `useSyncExternalStore` with `NetworkState.subscribe()` | + +## Relationship to Offline UX Patterns + +This document covers **how** the app detects connectivity changes and determines offline status. For **how features respond** to being offline (optimistic updates, blocking forms, full-page blocking), see [Offline UX Patterns](philosophies/OFFLINE.md). diff --git a/contributingGuides/PERFORMANCE.md b/contributingGuides/PERFORMANCE.md index d8eb4b865441..7758ae7551be 100644 --- a/contributingGuides/PERFORMANCE.md +++ b/contributingGuides/PERFORMANCE.md @@ -282,7 +282,7 @@ Since our codebase is very complex, it results in a large DOM tree rendered for One of the most common issues is related to modals, popovers, and tooltips โ€” elements that may appear on the screen. The problem is that they are usually present in the DOM tree even when initially invisible. Because of this, the initial render time of a screen may increase, ultimately slowing down the app. -The solution is better control of invisible elements, making sure they are not included in the first render. This can be done, e.g., by a simple `return null`, smart usage of `lazy loading`, the `useTransition` hook, or the `` component. +The solution is better control of invisible elements, making sure they are not included in the first render. This can be done, e.g., by a simple `return null`, smart usage of `lazy loading`, the `useTransition` hook, or the [``](/src/components/NavigationDeferredMount.tsx) component. `` gates a heavy subtree behind navigation transition completion via `TransitionTracker`, rendering a cheap `placeholder` until the nav animation has finished, then mounting its `children` inside `startTransition` โ€” ideal for heavy subtrees (e.g. report headers, page-level secondary actions) mounted during navigation transitions that pull many `useOnyx` subscriptions or heavy hooks but don't need to be interactive on first render. Another issue worth mentioning is unnecessary code execution, especially for elements that are never shown on a specific platform. In theory, we separate the logic between platforms by using index.tsx/index.native.tsx files, but sometimes platform-specific logic may slip in, causing unnecessary execution. For example, this may happen when logic specific to a wide layout (applicable only for web) is included. @@ -294,6 +294,7 @@ Examples: - [PopoverWithMeasuredContent optimization for mobile](https://github.com/Expensify/App/pull/68223) - returns early to avoid unnecessary calculations - [Reduce confirm modal initial render count](https://github.com/Expensify/App/pull/67518) - returns early to reduce first load cost - [Do not render PopoverMenu until it gets opened](https://github.com/Expensify/App/pull/67877) - adds a wrapper to control if `PopoverMenu` should be rendered +- [Defer mount of MoneyReportHeaderSecondaryActions](https://github.com/Expensify/App/pull/88522) - introduces `NavigationDeferredMount` and uses it to defer the "More" dropdown subtree (20+ `useOnyx` subscriptions) until the navigation transition completes # Proposing Performance Improvements diff --git a/contributingGuides/REACT_COMPILER.md b/contributingGuides/REACT_COMPILER.md index 997b07d04dae..0f479e840b86 100644 --- a/contributingGuides/REACT_COMPILER.md +++ b/contributingGuides/REACT_COMPILER.md @@ -4,86 +4,55 @@ [React Compiler](https://react.dev/learn/react-compiler) is a tool designed to enhance the performance of React applications by automatically memoizing components that lack optimizations. -At Expensify, we are early adopters of this tool and aim to fully leverage its capabilities. +React Compiler is enabled in both the webpack (web) and metro (mobile) build pipelines via `babel-plugin-react-compiler`. -## React Compiler compliance checker +## React Compiler CI check -We provide a script, `scripts/react-compiler-compliance-check.ts`, which checks for "Rules of React" compliance locally and enforces these in PRs adding or changing React code through a CI check. +A CI check runs on every PR that modifies `.ts` or `.tsx` files. It uses `@babel/core`'s `transformSync` with `babel-plugin-react-compiler` to check whether each changed file's components and hooks compile successfully. -### What it does +### What the CI check enforces -Runs `react-compiler-healthcheck` in verbose mode, parses output, and summarizes which files compiled and which failed, including file, line, column, and reason. It can: +The check enforces two rules: -- Check all files or a specific file/glob -- Check only files changed relative to a base branch -- Optionally generate a machine-readable report `react-compiler-report.json` -- Exit with non-zero code when failures are found (useful for CI) +1. **New files must compile.** If you add a new file containing React components or hooks, they must all compile with React Compiler. This prevents new Rules of React violations from entering the codebase. +2. **No regressions.** If a file already compiles on `main` and your PR breaks that, the check fails. This prevents introducing violations into files that were previously compliant. -### Usage +The check does **not** fail if: +- A file has no React components or hooks (utilities, types, constants are silently skipped) +- A file was already failing to compile on `main` and still fails on your branch (not a regression) -> [!NOTE] -> This script uses `origin` as the base remote by default. If your GH remote is named differently, use the `--remote ` flag. +### What to do when the check fails -#### Check entire codebase or a specific file/glob +The CI output shows which files failed, with the compiler error reason, file path, and line number for each failure. Look for lines like: -```bash -npm run react-compiler-compliance-check check # Check all files -npm run react-compiler-compliance-check check src/path/Component.tsx # Check specific file -npm run react-compiler-compliance-check check "src/**/*.tsx" # Check glob pattern ``` - -#### Check only changed files (against main) - -```bash -npm run react-compiler-compliance-check check-changed +FAILED src/components/MyComponent.tsx (new file must compile) + src/components/MyComponent.tsx:42:8: Hooks must always be called in a consistent order... ``` -#### Generate a detailed report (saved as `./react-compiler-report.json`) - -You can use the `--report` flag with both of the above commands: - -```bash -npm run react-compiler-compliance-check check --report -npm run react-compiler-compliance-check check-changed --report -``` - -#### Additional flags - -**Filter by diff changes (`--filterByDiff`)** +The error messages come directly from React Compiler and describe which [Rule of React](https://react.dev/reference/rules) was violated. See the ["How to fix a particular problem?"](#how-to-fix-a-particular-problem) section below for common fixes. -Only check files that have been modified in the current diff. This is useful when you want to focus on files that have actual changes: +### Local usage -```bash -npm run react-compiler-compliance-check check --filterByDiff -npm run react-compiler-compliance-check check-changed --filterByDiff -``` - -**Print successful compilations (`--printSuccesses`)** - -By default, the script only shows compilation failures. Use this flag to also display files that compiled successfully: +You can run the same check locally before pushing: ```bash -npm run react-compiler-compliance-check check --printSuccesses -npm run react-compiler-compliance-check check-changed --printSuccesses -``` - -**Custom report filename (`--reportFileName`)** +# Check specific files, directories, or glob patterns +npm run react-compiler-compliance-check check src/components/Foo.tsx +npm run react-compiler-compliance-check check src/components/ +npm run react-compiler-compliance-check check "src/hooks/**/*.ts" -Specify a custom filename for the generated report instead of the default `react-compiler-report.json`: +# Check only files changed relative to main (same as CI) +npm run react-compiler-compliance-check check-changed -```bash -npm run react-compiler-compliance-check check --report --reportFileName my-custom-report.json -npm run react-compiler-compliance-check check-changed --report --reportFileName my-custom-report.json +# Show detailed output including files that compiled or were skipped +npm run react-compiler-compliance-check check --verbose src/components/ ``` -**Custom remote name (`--remote`)** - -By default, the script uses `origin` as the base remote. If your GitHub remote is named differently, specify it with this flag: +#### Flags -```bash -npm run react-compiler-compliance-check check-changed --remote upstream -npm run react-compiler-compliance-check check --filterByDiff --remote my-remote -``` +- `--verbose` -- Show detailed output including skipped files and files that compiled successfully. +- `--remote ` -- Git remote name for the base branch (default: `origin`). ## How to fix a particular problem? diff --git a/contributingGuides/SETUP_ANDROID.md b/contributingGuides/SETUP_ANDROID.md index 570dd00c99a2..c5374fb2692d 100644 --- a/contributingGuides/SETUP_ANDROID.md +++ b/contributingGuides/SETUP_ANDROID.md @@ -73,7 +73,8 @@ If you haven't done any intentional edits outside of `src/` (like adding new dep - Changes applied to Javascript will be applied automatically, any changes to native code will require a recompile ### Expensify Employees -If you are an Expensify employee and want to point the emulator to your local VM, follow [this](https://stackoverflow.com/c/expensify/questions/7699) +- To point the **Development Emulator** at your local VM, follow [these steps](https://stackoverflow.com/c/expensify/questions/7699). +- You must configure the local dev email account; follow [these instructions](https://stackoverflowteams.com/c/expensify/questions/23537/23538#23538). ## Enabling Prebuilt React Native Artifacts diff --git a/contributingGuides/SETUP_IOS.md b/contributingGuides/SETUP_IOS.md index 4254a722a627..566822c14b44 100644 --- a/contributingGuides/SETUP_IOS.md +++ b/contributingGuides/SETUP_IOS.md @@ -80,7 +80,8 @@ For an M1 Mac, read this [Stack Overflow post](https://stackoverflow.com/questio If you want to run the app on an actual physical iOS device, please follow the instructions [here](https://github.com/Expensify/App/blob/main/contributingGuides/HOW_TO_BUILD_APP_ON_PHYSICAL_IOS_DEVICE.md). ### Expensify Employees -If you are an Expensify employee and want to point the emulator to your local VM, follow [this](https://stackoverflow.com/c/expensify/questions/7699) +- To point the **Development Simulator** at your local VM, follow [these steps](https://stackoverflow.com/c/expensify/questions/7699). +- You must configure the local dev email account; follow [these instructions](https://stackoverflowteams.com/c/expensify/questions/23537/23538#23538). ## Push Notifications Setup diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md index 98e59ab86a76..103da4c15d6a 100644 --- a/contributingGuides/STYLE.md +++ b/contributingGuides/STYLE.md @@ -571,9 +571,7 @@ When you change the function id argument type to allow `undefined`, check if it ```diff function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) { - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { - canEvict: false, - }); + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); - const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1']; + const parentReportAction = parentReportActions?.[report?.parentReportActionID]; ``` diff --git a/contributingGuides/philosophies/OFFLINE.md b/contributingGuides/philosophies/OFFLINE.md index f984f85c016c..740e94a706cb 100644 --- a/contributingGuides/philosophies/OFFLINE.md +++ b/contributingGuides/philosophies/OFFLINE.md @@ -11,6 +11,10 @@ * [UX Pattern Flowchart](#ux-pattern-flow-chart) - [Answering Questions on the Flow Chart](#answering-questions-on-the-flow-chart) +## How Offline State Is Detected + +This document covers UX patterns for handling offline state. For the underlying architecture of how the app detects connectivity changes (hard stop model, failure tracking, recovery probes), see [Network State Detection](../NETWORK_STATE_DETECTION.md). + ## Motivation & Philosophy Understanding the offline behavior of our app is vital to becoming a productive contributor to the Expensify codebase. Our mission is to support our users in every possible environment, and often our app is used in places where a stable internet connection is not guaranteed. diff --git a/contributingGuides/philosophies/ONYX-DATA-MANAGEMENT.md b/contributingGuides/philosophies/ONYX-DATA-MANAGEMENT.md index 24131557c591..6bc1f3819864 100644 --- a/contributingGuides/philosophies/ONYX-DATA-MANAGEMENT.md +++ b/contributingGuides/philosophies/ONYX-DATA-MANAGEMENT.md @@ -35,17 +35,7 @@ Different platforms come with varying storage capacities and Onyx has a way to g **To flag a key as safe for removal:** - Add the key to the `evictableKeys` option in `Onyx.init(options)` -- Implement `canEvict` in the Onyx config for each component subscribing to a key -- The key will only be deleted when all subscribers return `true` for `canEvict` - -Example: -```js -Onyx.init({ - evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], -}); - -const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {canEvict: !isActiveReport}); -``` +- A least recently accessed key will only be deleted when an Onyx operation retries after failing. ## Onyx Derived Values diff --git a/cspell.json b/cspell.json index ac25b59fc7cd..f8a27db58de0 100644 --- a/cspell.json +++ b/cspell.json @@ -218,6 +218,7 @@ "endcapture", "enddate", "endfor", + "endgroup", "enroute", "entityid", "Entra", @@ -648,6 +649,7 @@ "RPID", "RRGGBB", "rstrip", + "recyclerview", "RTER", "s3uqn2oe4m85tufi6mqflbfbuajrm2i3", "SAASPASS", @@ -885,6 +887,7 @@ "Crรฉdit", "Danske", "Depรณsitos", + "effectful", "Erste", "Gรฉnรฉrale", "Geral", @@ -908,7 +911,9 @@ "Wintrust", "Zรผrcher", "CARDFROZEN", - "CARDUNFROZEN" + "CARDUNFROZEN", + "backgrounded", + "Kolkata" ], "ignorePaths": [ "src/languages/de.ts", diff --git a/docs/Hidden/UK-and-EU-Expensify-Card.md b/docs/Hidden/UK-and-EU-Expensify-Card.md index 93ac7368a97c..d5c3ac3bd721 100644 --- a/docs/Hidden/UK-and-EU-Expensify-Card.md +++ b/docs/Hidden/UK-and-EU-Expensify-Card.md @@ -13,7 +13,7 @@ REMOVE noindex: true AND sitemap: false FROM YAML HEADER BEFORE PUBLISHING! # Set up the Expensify Card in the UK and EU -The Expensify Card is available for companies in the United Kingdom and select European countries. This guide walks you through eligibility and how to set up and start using the card in GBP or EUR. +The Expensify Card is available for companies in the United Kingdom and select European countries. This guide walks you through eligibility and how to set up and start using the card in GBP or EUR. Workspace Admins can issue virtual Expensify Cards to members and manage spending in GBP or EUR. Physical cards will be available soon. @@ -33,34 +33,27 @@ If your company hasnโ€™t yet connected a GBP or EUR business bank account, [foll The Expensify Card can be used **anywhere Visa is accepted**. -However, only companies registered in the following 20 UK and EU countries can enable the Expensify Card for their Workspace: - -- Belgium -- Denmark -- Estonia -- Finland -- Gibraltar -- Greece -- Ireland -- Latvia -- Liechtenstein -- Lithuania -- Luxembourg -- Malta -- Netherlands -- Poland -- Romania -- Slovakia -- Slovenia -- Spain -- Sweden +Companies registered in the following countries can enable the Expensify Card for their Workspaces: + +- Belgium +- Denmark +- Finland +- Gibraltar +- Ireland +- Latvia +- Lithuania +- Luxembourg +- Netherlands +- Poland +- Spain +- Sweden - United Kingdom ## What are the pre-requisites to set up the Expensify Card in the UK and EU -To set up the Expensify Card in the UK and EU, you must: +To set up the Expensify Card in the UK and EU, you must: -1. Have a GBP or EUR bank account connected. To connect one, [Enable Global Reimbursement](https://help.expensify.com/articles/new-expensify/wallet-and-payments/Enable-Global-Reimbursement). +1. Have a GBP or EUR bank account connected. To connect one, [Enable Global Reimbursement](https://help.expensify.com/articles/new-expensify/wallet-and-payments/Enable-Global-Reimbursement). 2. Complete the **[Know Your Customer (KYC)](https://launch-workflow.trulioo.com/68223577c5dae52a6ec9347f)** and **[Know Your Business (KYB)](https://launch-workflow.trulioo.com/681a7cc4e65d2e48f19de9c3)** forms. 3. Share the last 90 days of statements from your connected GBP or EUR bank account with Concierge so they can assess your eligible limit @@ -70,9 +63,9 @@ To set up the Expensify Card in the UK and EU, you must: 1. From navigation tabs (on the left on Web, on the bottom on Mobile), choose **Workspaces > [Workspace Name] > More features**. 2. In the **Spend** section, enable **Expensify Card**. -3. So that we can assess your request, reach out to Concierge and provide the last 90 days of statements from the connected UK or EU bank account +3. So that we can assess your request, reach out to Concierge and provide the last 90 days of statements from the connected UK or EU bank account -Once enabled, **Expensify Card** will appear in the navigation tabs. +Once enabled, **Expensify Card** will appear in the navigation tabs. --- @@ -88,7 +81,7 @@ Once enabled, **Expensify Card** will appear in the navigation tabs. 1. From navigation tabs (on the left on Web, on the bottom on Mobile), choose **Workspaces > [Workspace Name] > Expensify Card** 2. Click **Issue new card**. -3. Select the employee the card should be issued to. +3. Select the employee the card should be issued to. 4. Choose **Virtual**. 5. Pick a limit type: - **Smart limit** โ€“ Spend up to a threshold before needing approval @@ -110,7 +103,7 @@ We use the [Visa FX calculator](https://www.visa.co.uk/support/consumer/travel-s ## Does the Expensify Card support Visa Secure for online transactions? -Yes, the Expensify Card protects online transactions with Visa Secure. Please review the [Visa Secure FAQs]({{site.url}}/assets/Files/Visa-secure-faq-expensify.pdf) for more information. +Yes, the Expensify Card protects online transactions with Visa Secure. Please review the [Visa Secure FAQs]({{site.url}}/assets/Files/Visa-secure-faq-expensify.pdf) for more information. ## How do I get help with the Expensify Card in the UK and EU? diff --git a/docs/articles/Unlisted/Concierge-in-Expensify.md b/docs/articles/Unlisted/Concierge-in-Expensify.md index 04409b815bc7..92d98622a217 100644 --- a/docs/articles/Unlisted/Concierge-in-Expensify.md +++ b/docs/articles/Unlisted/Concierge-in-Expensify.md @@ -34,7 +34,7 @@ You can reach Concierge by email at concierge@expensify.com or directly in the a - **Expensify Classic (web):** Click the chat icon in the navigation tabs on the left. - **Expensify Classic (mobile):** Tap the chat icon in the upper right corner. -- **New Expensify:** Press the big green **+** button, select **New Chat**, and type *Concierge* into the composer box. +- **New Expensify:** Press the **+** button, select **New Chat**, and type *Concierge* into the composer box. # Common Concierge use cases diff --git a/docs/articles/expensify-classic/expenses/Troubleshoot-SmartScan-Issues.md b/docs/articles/expensify-classic/expenses/Troubleshoot-SmartScan-Issues.md new file mode 100644 index 000000000000..6344666db97c --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Troubleshoot-SmartScan-Issues.md @@ -0,0 +1,101 @@ +--- +title: Troubleshoot SmartScan Issues +description: Fix common SmartScan problems, including failed, stuck and incorrect scans. +keywords: [Expensify Classic, SmartScan, scan failed, receipt not scanning, illegible receipt, blurry receipt, stuck scanning, SmartScan error, scan not working, SmartScan troubleshooting, SCANREADY, wrong amount, wrong merchant, wrong date, SmartScan incorrect] +internalScope: Audience is all Expensify members. Covers troubleshooting SmartScan failures, image quality tips, and what to do when SmartScan returns incorrect data. Does not cover how to attach, replace, or crop receipts. +--- + +# Troubleshoot SmartScan Issues + +SmartScan reads receipt images and automatically fills in expense details like the amount, date, and merchant. If SmartScan fails, gets stuck, or scans incorrectly, use this guide to identify the cause and fix the issue. + +[Learn how to SmartScan a receipt](/articles/expensify-classic/expenses/Add-an-expense#add-an-expense-with-smartscan). + +--- + +## Why SmartScan fails to complete a scan + +SmartScan requires a readable date and total amount on the receipt. If either of these cannot be detected, the scan will fail. + +Common reasons include: + + - The receipt does not include a date or total + - The date or total is cut off or cropped out + - The image is blurry or out of focus + - The receipt is faded or low contrast + - The text is obscured by shadows, glare, or folds + +If SmartScan cannot confidently read these required fields, the scan will fail and will show the message: "Receipt scanning failed. Enter details manually". + +--- + +## How to fix when SmartScan fails to complete a scan + +SmartScan may fail if the receipt image is unreadable, or if the image is missing a clear expense date or amount. + +To retry: + +**On web:** + +1. In the navigation tabs on the left, go to **Expenses**. +2. Click the expense with the failed scan to open it. +3. Drag and drop a receipt image onto the window to replace the existing receipt. + +**On mobile:** + +1. Tap the hamburger menu in the top-left corner. +2. Tap **Expenses**. +3. Tap the expense with the failed scan to open it. +4. Tap the receipt image. +5. Tap **Retake**. + +Once a new receipt image is added, SmartScan will run again. + +--- + +## How to troubleshoot stuck or delayed scans + +SmartScan usually completes within a few seconds, but delays can happen. + +- **Wait a few minutes**. Scans may take longer during high-traffic periods. +- **Check your internet connection**. SmartScan requires an active connection to process. If you were offline when you took the photo, the scan will begin once you reconnect. +- **Check the photo library on your device**. If SmartScan fails to process the images, it will be saved to the photo library on your device. + +If the scan is still stuck after several minutes, you can retry SmartScan with a new photo to trigger a fresh scan. + +--- + +## How to fix incorrect scans + +SmartScan extracts data automatically, but it may misread certain receipts โ€” especially handwritten receipts, faded ink, or unusual formats. + +To fix an incorrectly scanned merchant name, date or amount you can edit the expense manually. [Learn how to edit expenses](/articles/expensify-classic/expenses/Edit-expenses). + +--- + +## How to take better receipt photos for SmartScan + +Improving image quality helps prevent most SmartScan issues. + +- Lay the receipt flat on a contrasting surface (e.g., a dark receipt on a light table). +- Capture the full receipt and make sure all edges, the total, date, and merchant name are visible. +- Avoid shadows and glare. Hold your phone directly above the receipt and use even lighting. +- Keep the camera steady - blurry images are the most common cause of scan failures. +- Avoid folded or crumpled receipts. Flatten the receipt before photographing it. +- Use the in-app camera. The Expensify camera is optimized for receipt scanning. + +--- + +# FAQ + +## Why does SmartScan keep failing on the same receipt? + +The receipt image may be too low quality for SmartScan to process. Try taking a new photo with better lighting and ensure the full receipt is in frame. If it continues to fail, the receipt may be too faded, damaged, or in a non-standard format โ€” in that case, enter the expense details manually instead. + +## What file types does SmartScan support? + +SmartScan works with common image formats (JPG, PNG) and PDF files. For best results on mobile, use the in-app camera to take a photo directly. + +## SmartScan got the currency wrong. How do I fix it? + +Open the expense, tap the **Amount** field, and change the currency manually. This can happen with receipts from international merchants or receipts that don't clearly display a currency symbol. diff --git a/docs/articles/expensify-classic/settings/Avoiding-common-scams.md b/docs/articles/expensify-classic/settings/Avoiding-common-scams.md index 2a72d315808a..1292f98ef53b 100644 --- a/docs/articles/expensify-classic/settings/Avoiding-common-scams.md +++ b/docs/articles/expensify-classic/settings/Avoiding-common-scams.md @@ -2,14 +2,16 @@ title: Avoiding common scams description: Learn how to recognize and avoid common phishing scams that pretend to be from Expensify, including fake phone calls and emails asking for your Magic Code. keywords: [Expensify scam, phishing, Magic Code, security, login code, avoid fraud, suspicious email, fake call, fraud prevention] +internalScope: Audience is all Expensify members. Covers identifying and responding to scams involving Magic Codes. Does not cover account recovery or dispute workflows. --- -
-# How to avoid scams pretending to be from Expensify +# Avoiding common scams Some scammers try to impersonate Expensify by sending fake emails or calling you directly, often asking for your **Magic Code** (your login code). These scams are designed to trick you into giving up access to your Expensify account. -This guide explains what these scams look like, how to spot them, and what to do if you think you've been targeted. +This article explains how these scams work, how to recognize them, and what actions to take. + +**Note:** Expensify will never call you asking for login codes, passwords, or payment information. If you receive such a call, it's a scam. --- @@ -21,21 +23,17 @@ The **Magic Code** is a one-time login code Expensify emails or texts to you whe --- -## Common scams to watch out for - -Here are the most common types of scams we've seen targeting Expensify members: - -### 1. Phone call scams requesting your Magic Code +## How to recognize phone call scams pretending to be Expensify - The scammer claims to be from Expensify and needs your Magic Code to verify your account. - They may spoof a legitimate phone number to make it seem more convincing. - Some scammers may create a sense of urgency (e.g., โ€œWe detected suspicious activity on your account.โ€) -**If someone calls you asking for your Magic Code, hang up immediately.** +**If you receive this type of call, hang up.** --- -### 2. Phishing emails and texts requesting you use their link to login to Expensify +## How to recognize phishing emails or texts pretending to be Expensify - Emails and text messages often look like theyโ€™re from Expensify or Visa, but the sender address is likely not from an expensify.com email address. - These emails and text messages may have links that lead to a fake login page, prompt you to reset your password, or ask you to reply with your Magic Code. @@ -45,14 +43,28 @@ Here are the most common types of scams we've seen targeting Expensify members: --- -## How to stay safe - -Hereโ€™s how you can protect your Expensify account: +## How to protect your Expensify account - **Never share your Magic Code** โ€” not over the phone, not by email, not via text. - **Donโ€™t click suspicious links** โ€” always verify the senderโ€™s email address. -- **Use 2FA** (two-factor authentication) to add an extra layer of security. -- **If in doubt, message Concierge** directly from within the Expensify app. +- **Use two-factor authentication (2FA)** - this adds an extra layer of security to your account. +- **If in doubt, message Concierge** - reach out directly from within the Expensify app. + +[Learn how to enable two-factor authentication](/articles/expensify-classic/settings/Two-Factor-Authentication). + +--- + +## What to do if you receive a Magic Code you didn't request + +If you receive a Magic Code email but didnโ€™t try to log in, this does not necessarily mean your account has been compromised. It can happen for a variety of reasons, such as someone mistyping an email address. + +Here's what to do: + +1. **Do not share the code with anyone** โ€” simply ignore or delete the email. +2. **Do not click any links** in the email. +3. **Consider enabling [two-factor authentication](/articles/expensify-classic/settings/Two-Factor-Authentication)** for extra account security. + +**Note:** You do not need to lock your account just because you received an unexpected Magic Code. Only lock your account if you believe someone has gained access to your email account or if you shared your Magic Code with someone. --- @@ -60,9 +72,9 @@ Hereโ€™s how you can protect your Expensify account: If you accidentally gave someone your Magic Code, act fast: -1. **[Lock your Expensify account](https://help.expensify.com/articles/expensify-classic/settings/Report-Suspicious-Activity)** immediately to block unauthorized access. +1. **[Lock your Expensify account](/articles/expensify-classic/settings/Report-Suspicious-Activity)** immediately to block unauthorized access. 2. **Contact Concierge** from a safe device and explain the situation. -3. **[Enable two-factor authentication](https://help.expensify.com/articles/expensify-classic/settings/Two-Factor-Authentication)** to protect your account going forward. +3. **[Enable two-factor authentication](/articles/expensify-classic/settings/Two-Factor-Authentication)** to protect your account going forward. --- @@ -70,7 +82,7 @@ If you accidentally gave someone your Magic Code, act fast: If you believe your account has been targeted or compromised: -1. **[Lock your Expensify account](https://help.expensify.com/articles/expensify-classic/settings/Report-Suspicious-Activity)** immediately to block unauthorized access. +1. **[Lock your Expensify account](/articles/expensify-classic/settings/Report-Suspicious-Activity)** immediately to block unauthorized access. 2. **Message Concierge immediately** via [email](mailto:concierge@expensify.com), or from within the Expensify app (web or mobile). 3. Let us know if youโ€™ve received a suspicious message or phone call. 4. Our team will work to secure your account. @@ -90,9 +102,12 @@ Expensify uses Magic Codes instead of passwords to simplify login while keeping Legitimate Expensify emails always come from an address ending in **@expensify.com** or **@team.expensify.com**. If youโ€™re unsure, donโ€™t click any links โ€” just log into the Expensify app directly and message Concierge. +## I received a Magic Code I didn't request. Is my account compromised? + +No. Receiving an unrequested Magic Code does not mean your account is compromised. It most likely means someone entered your email address by mistake when trying to log in. As long as you don't share the code with anyone, your account is safe. You can simply ignore or delete the email. If you want extra protection, enable [two-factor authentication](/articles/expensify-classic/settings/Two-Factor-Authentication). + ## Can Expensify call me? Expensify will **never** call you asking for login codes, passwords, or payment information. If you receive such a call, it's a scam. -
diff --git a/docs/articles/expensify-classic/settings/Close-Your-Expensify-Account.md b/docs/articles/expensify-classic/settings/Close-Your-Expensify-Account.md new file mode 100644 index 000000000000..2ec983871e28 --- /dev/null +++ b/docs/articles/expensify-classic/settings/Close-Your-Expensify-Account.md @@ -0,0 +1,91 @@ +--- +title: Close Your Expensify Account +description: Learn how to close your Expensify account. +keywords: [Expensify Classic, close account, delete account, cancel account, delete Expensify account, cancel Expensify, deactivate account, how to delete Expensify, how to cancel Expensify] +internalScope: Audience is all members. Covers how to close an Expensify account and resolve common errors that block account closure. Does not cover how to delete workspaces or how to remove members from the domain. +--- + +# Close Your Expensify Account + +Closing an Expensify account permanently deletes all unsubmitted expenses and personal data tied to the account. + +Instead of closing your account, you can downgrade to a free account to retain access to your historical data. [Learn how to downgrade to a free account](/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription#downgrading-to-a-free-account). + +**Note:** If you are the Billing Owner of a Workspace on an Annual Subscription, you canโ€™t close your account directly. You may be able to downgrade to a free account if you meet eligibility criteria, or you must transfer billing ownership before closing your account. + +--- + +## Who can close an Expensify account + +Any member can close their own Expensify account, if: + - They have a verified account + - They are not a member of a controlled domain. + - They are not the Billing Owner of a Workspace + - They do not have any outstanding balance due on their Expensify account. + - They do not have a bank account which is currently locked. + +--- + +## How to close your Expensify account + +**On Web:** +1. Click **Settings > Account** +2. Click **Profile** +1. Click **Settings > Account**. +2. Click **Profile**. +3. Scroll to the bottom of the page and click **Close Account**. + +**Mobile:** +1. Tap the **Menu (โ‰ก)** at the top left. +2. Tap **Settings**. +1. Tap the hamburger menu in the top-left corner. +4. Confirm the closure using your phone or email. + +If you don't see the option to close your account, your account may be domain-controlled. Contact your Domain Admin and ask them to [remove you as a domain member](/articles/expensify-classic/domains/Domain-Members#remove-a-domain-member). + +--- + +## What happens when you close your Expensify account + +All unsubmitted expenses and personal data tied to the account are deleted. + +Expenses shared with others, such as expenses submitted on a Workspace, may be retained for regulatory compliance. Submitted expenses remain visible to approvers and Admins on the Workspace where the expenses were submitted. + +--- + +# FAQ + +## Why do I see the message "Outstanding balance" when closing my account? + +This error occurs when you have an outstanding balance on your Expensify account. To proceed with closing your account, you must first pay the balance owed. [Learn how to add a payment method and retry billing](/articles/expensify-classic/expensify-billing/Out-of-Date-Billing#update-payment-and-retry-billing). + +## Why do I see the message "Shared workspaces" when closing my account? + +This error occurs when you are the Billing Owner for Workspaces that are shared with other members. To proceed with closing your account, you must first delete the workspaces or have another Workspace Admin take ownership. + +[Learn how to delete a Workspace](/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription#downgrading-to-a-free-account). +[Learn how to transfer billing ownership](/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account#transfer-billing-ownership). + +## Why do I see the message "Primary domain admin" when closing my account? + +This error occurs when you are set as the Primary Contact on a domain. To proceed with closing your account, you must first set a new Primary Contact on the domain. [Learn how to set the Primary Contact on a domain](/articles/expensify-classic/domains/Domain-Members). + +## Why do I see the message "Last domain admin" when closing my account? + +This error occurs when you are only remaining admin on a domain. To proceed with closing your account, you must first add another domain admin, or reset the domain. +This error occurs when you are the only remaining admin on a domain. To proceed with closing your account, you must first add another domain admin, or reset the domain. +[Learn how to promote a domain member to admin](/articles/expensify-classic/domains/Domain-Members#promote-a-domain-member-to-admin). + +[Learn how to reset a domain](/articles/expensify-classic/domains/Claim-And-Verify-A-Domain). + +## Why do I see the message "Expensify Card bank account owner" when closing my account? + +This error occurs when you are the owner of the bank account that your company uses to settle the Expensify Card. To proceed with closing your account, [message Concierge](https://new.expensify.com/concierge). + +## Can I close my account if I own a Workspace on an Annual Subscription? + +If you are a Workspace Billing Owner on an Annual Subscription, you must first either: +- Downgrade to a free account (if eligible), or +- Transfer billing ownership to another Workspace Admin. + +Learn how to [submit a downgrade request](/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription#annual-subscription) or [transfer Workspace billing ownership](/articles/expensify-classic/expensify-billing/Change-Plan-Or-Subscription#annual-subscription). diff --git a/docs/articles/expensify-classic/settings/Close-or-reopen-account.md b/docs/articles/expensify-classic/settings/Close-or-reopen-account.md deleted file mode 100644 index 9e89ea9c1eef..000000000000 --- a/docs/articles/expensify-classic/settings/Close-or-reopen-account.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: Close or Reopen an Account -description: Learn how to close or reopen an Expensify account. -keywords: [Expensify Classic, close account, reopen account] ---- - -Closing an Expensify account deletes the data tied to it. However, transactions shared with others, including approved and reimbursed company expenses, remain visible to the Admins on the workspace where those reports were submitted. Some transaction records may also be retained for legal compliance. - -If you need to leave a **Company Workspace** but want to keep your data: -- Ask the **Workspace Admin** to remove you from the workspace. -- If you own the workspace, you can delete it. - -Once removed from the workspace, the account will downgrade to a free account, and you will retain access to your historical data. - ---- - -# Close Account - -**On Web:** -1. Hover over **Settings > Account > Profile**. -2. Scroll down and click **Close Account**. -3. Confirm the closure using your phone or email. - -If you don't see this option, your account may be domain-controlled. To close it, contact your **Domain Admin**. - -**Mobile:** -1. Tap the **menu** in the top left. -2. Tap **Settings**. -3. Scroll to the **Others** section and tap **Close Account**. -4. Confirm the closure using your phone or email. - -It may be domain-controlled if you don't see the option to close your account. In that case, you can contact your **Domain Admin** to close it. - ---- - -# Reopen a Closed Account - -1. Sign in to your web account with your email or phone number. -2. Choose **Reopen Account**. -3. Open your email and click the link from Expensify. - -Your account is open, and you can recreate workspaces, adjust settings, and access shared historical data. - -Any unshared data or reports are permanently deleted when the account is closed. - ---- - -# Close a Domain Member's Account (Domain Admin Only) - -1. Hover over **Settings**, then click **Domains**. -2. Select the domain name. -3. Click the **Domain Members** tab. -4. Select the email address of the account to close. -5. Click **Close**. - ---- - -# FAQ - -## Why can't I close my account? -If you encounter an error or the **Close Account** button is unavailable, check the following: - -- **Under a Validated Domain**: A **Domain Admin** must remove your account from the domain first. -- **Sole Domain Admin**: Assign a new **Domain Admin** before closing your account. -- **Workspace Billing Owner with an Annual Subscription**: Downgrade or transfer billing ownership first. -- **Company Workspace Owner**: Assign a new **Workspace Owner** first. -- **Outstanding Balance**: Settle your balance before closing the account. -- **Preferred Exporter for a Workspace Integration**: Assign a new **Preferred Exporter** first. -- **Verified Business Account (Locked)**: Unlock your account. -- **Verified Business Account (Outstanding Balance)**: Pay the balance before closing. -- **Unverified Account**: Verify your account before closing. - -## How can I reopen a domain member's account? - -A domain admin can reopen closed accounts under **Settings > Domain Members > Entering the employee's email address > Clicking Invite**. - diff --git a/docs/articles/new-expensify/chat/Create-a-New-Chat.md b/docs/articles/new-expensify/chat/Create-a-New-Chat.md index 919ca7eae4c7..9fa2c2aed152 100644 --- a/docs/articles/new-expensify/chat/Create-a-New-Chat.md +++ b/docs/articles/new-expensify/chat/Create-a-New-Chat.md @@ -25,7 +25,7 @@ Expensify Chat is an instant messaging system that helps you converse with peopl ## Start a Group Chat -1. Click the green **+** button in the navigation tabs and select **Start Chat**. +1. Click the **+** button in the navigation tabs and select **Start Chat**. 2. Enter the name, email, or phone number of each participant and select **Add to group**. - Group participants are listed with a green checkmark. 3. Click or tap **Next**. @@ -38,7 +38,7 @@ Expensify Chat is an instant messaging system that helps you converse with peopl ## Start a Chat Room -1. Click the green **+** button in the navigation tabs and select **Start Chat**. +1. Click the **+** button in the navigation tabs and select **Start Chat**. 2. Click or tap the **#Room** tab at the top. 3. Enter a unique name for the room. 4. (Optional) Add a room description. diff --git a/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md b/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md index 3a03fe6eb2ad..8c9386ecb357 100644 --- a/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md +++ b/docs/articles/new-expensify/concierge-ai/Concierge-Basics.md @@ -23,8 +23,8 @@ All rolled into one! You can chat with Concierge from anywhere in the app: -- **On web:** Press the **+** button at the bottom left corner of the screen, select **Start Chat**, and type **Concierge** into the composer box. -- **On mobile:** Tap **Create** at the bottom of your screen, select **Start Chat**, and type **Concierge **into the composer box. +- **On web:** Click the **Concierge** icon at the top right corner of the page. +- **On mobile:** Tap **Account** > **Help** and tap **Concierge** to navigate to the Concierge chat. You can also contact Concierge by: - Email: **concierge@expensify.com** diff --git a/docs/articles/new-expensify/concierge-ai/Support-Agent.md b/docs/articles/new-expensify/concierge-ai/Support-Agent.md index 592c1e048941..e180a8c75bba 100644 --- a/docs/articles/new-expensify/concierge-ai/Support-Agent.md +++ b/docs/articles/new-expensify/concierge-ai/Support-Agent.md @@ -17,7 +17,8 @@ Anyone with an Expensify account can contact Concierge for helpโ€”whether you're You can ask Concierge for help from anywhere in Expensify: -- **Web:** Click the chat icon in the lower-right corner +- **Account:** Go to **Account** > **Help** and select **Concierge**. +- **Web:** Click the chat icon in the top-right corner - **Mobile:** Tap the hamburger menu in the top-left corner, then tap **Concierge** - **Workspace chat:** Mention Concierge in a chat room (e.g., `#admins`) - **Report or expense threads:** Ask a question in the thread diff --git a/docs/articles/new-expensify/connect-credit-cards/Configure-Company-Card-Settings.md b/docs/articles/new-expensify/connect-credit-cards/Configure-Company-Card-Settings.md index 13b285bf7966..569ce2a49aef 100644 --- a/docs/articles/new-expensify/connect-credit-cards/Configure-Company-Card-Settings.md +++ b/docs/articles/new-expensify/connect-credit-cards/Configure-Company-Card-Settings.md @@ -31,6 +31,9 @@ You can manage the following options: **Note:** This setting only applies to transactions imported after the setting is enabled. +- **Assign new cards** (direct feeds only) + Reconnects to your bank to refresh the card list so newly issued cards appear in the assignment UI. + - **Remove card feed** Remove the card feed from the workspace and unassign all cards. If the feed is not connected to another workspace, it is permanently deleted. diff --git a/docs/articles/new-expensify/connect-credit-cards/Connect-Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Connect-Personal-Cards.md new file mode 100644 index 000000000000..bba95525d299 --- /dev/null +++ b/docs/articles/new-expensify/connect-credit-cards/Connect-Personal-Cards.md @@ -0,0 +1,66 @@ +--- +title: Connect a personal card +description: Learn how to connect a new personal credit card to your Expensify account to automatically import transactions. +keywords: [New Expensify, personal cards, connect card, Wallet, Plaid, bank connection, card import, credit card, New Expensify, oAuth, add card] +internalScope: Audience is members who want to connect a new personal credit card. Covers the card connection flow including country selection, bank selection, oAuth and Plaid connections, company card warnings, and free card limits. Does not cover managing existing cards or company card programs. +--- + +# Connect a personal card + +Connect your personal credit card to Expensify to automatically import transactions, eliminating the need to manually create expenses or scan receipts. Expensify supports over 10,000 banks across the United States, Canada, the United Kingdom, and the European Union. + +--- + +## Who can connect a personal card + +Anyone with an Expensify account can connect up to two personal cards for free. To connect more than two personal cards, you'll need a paid workspace (Collect plan or above). + +--- + +## How to connect a personal card + +1. Click the navigation tabs (on the left on web, on the bottom on mobile), then go to **Account > Wallet**. +2. Tap **Add personal card**. +3. Select your country from the country selector. +4. If you selected **United States**, choose your bank from the bank selector. + - If your bank is listed, you'll be prompted to connect via your bank's secure login (oAuth). + - If your bank is not listed, select **Other** to connect via Plaid. +5. Follow the prompts to log into your bank and authorize the connection. + +Once your card is connected, it will appear under **Assigned cards** in the **Wallet**, and transactions will begin importing automatically. + +--- + +## What to do if you see a company card warning + +If you're a member of a Workspace or domain that has a company card feed, Expensify will ask you to confirm that the card you're adding is a personal card. + +- **If you're an employee:** You'll see options to ask your admin if you should be assigned a company card, or continue adding a personal card. +- **If you're an admin:** You'll see options to confirm this is a company card (which redirects you to the company card connection flow) or proceed with adding it as a personal card. + +--- + +## What happens when you reach the free card limit + +If you're not on a paid workspace and try to add more than two personal cards, you'll see an upgrade page prompting you to create a Collect workspace. + +If you proceed with the upgrade: + +1. A Collect workspace is created for your account. +2. You'll see a success page with options to add company cards or additional personal cards. + +--- + +# FAQ + +## How can I check if my bank or card issuer is supported? + +Expensify supports most major banks and card issuers worldwide. [Learn how to check which banks are supported in Expensify](/articles/new-expensify/connect-credit-cards/Check-Supported-Banks) + +## How many personal cards can I connect for free? + +You can connect up to two personal cards for free. To add more, you'll need a paid workspace on the Collect plan or above. + +## How do I manage a personal card after connecting it? + +Once connected, your card appears in **Account > Wallet** under **Assigned cards**. [Learn how to manage your personal cards](/articles/new-expensify/connect-credit-cards/Personal-Cards). diff --git a/docs/articles/new-expensify/connect-credit-cards/Import-Personal-Card-Transactions-From-a-Spreadsheet.md b/docs/articles/new-expensify/connect-credit-cards/Import-Personal-Card-Transactions-From-a-Spreadsheet.md new file mode 100644 index 000000000000..6162db1c885c --- /dev/null +++ b/docs/articles/new-expensify/connect-credit-cards/Import-Personal-Card-Transactions-From-a-Spreadsheet.md @@ -0,0 +1,70 @@ +--- +title: Import personal card transactions from a spreadsheet +description: Learn how Members can manually import personal card transactions using a spreadsheet in Wallet. +keywords: [New Expensify, import personal card, upload file, import spreadsheet, CSV, TXT, XLS, XLSX, Wallet, card feed, reimbursable, bring your own card, BYOC] +internalScope: Audience is all members. Covers how to import, update, and delete personal card transactions via CSV. Does not cover company cards or Plaid connections. +--- + +# Import personal card transactions from a spreadsheet + +If your bank isn't supported by a direct connection, you can still import personal card transactions into Expensify using a spreadsheet file. This allows you to track and submit expenses without connecting your bank account. + +If your bank is supported, you can connect your account to automatically import transactions. [Learn how to manage personal cards](/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards). + +--- + +## Who can import personal card transactions from a spreadsheet + +Anyone can import personal card transactions using a spreadsheet file in their account. + +--- + +## How to import personal card transactions from a spreadsheet + +1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Wallet**. +2. In the **Assigned Cards** section, choose **Import transactions**. +3. Enter a display name and configure the currency, reimbursable state, and amount sign direction. +3. Enter a display name and configure the currency, reimbursable state, and amount sign direction. +5. Map your spreadsheet columns to the required fields (**Date**, **Merchant**, **Amount**). +6. Click **Import**. + +--- + +## What happens after you import personal card transactions from a spreadsheet + +- Imported transactions appear as **Unreported** expenses. +- You can edit, categorize, and submit these expenses on a report. +- Imported transactions are available on both web and mobile. + +[Learn how to create and submit a report](/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports). + +--- + +# FAQ + +## What spreadsheet file formats are accepted for personal card imports? + +You can import the following file types: +- .CSV +- .TXT +- .XLS +- .XLSX + +## What columns are required for personal card spreadsheet imports? + +Your file must include at least the following columns: +- Date +- Amount +- Merchant + +## What happens if I use the same column twice when mapping fields? + +Youโ€™ll see an error message and wonโ€™t be able to proceed until the issue is resolved. + +## Can I import additional transactions to the same card? + +Yes. You can simply repeat the import process. + +## Can I delete personal card spreadsheet imports? + +You cannot delete the imported file, but you can delete the individual expenses that were created. [Learn how to delete expenses](/articles/new-expensify/reports-and-expenses/How-to-Delete-Expenses). diff --git a/docs/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards.md b/docs/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards.md index 35a4ff4fd172..6ee204c53804 100644 --- a/docs/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards.md +++ b/docs/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards.md @@ -7,15 +7,15 @@ internalScope: Audience is members with personal credit cards already connected # Manage Personal Cards -Expensify lets you view and manage your personal credit cards in one place, making it easier to track spending, submit reimbursable expenses, and stay organized for tax time. +Expensify lets you manage your personal credit cards in one place using **Wallet**. From there, you can review card details, track spending, and use transactions for expense reporting. -If you previously connected a personal card in Expensify Classic, you can now manage that card directly in Expensify on both web and mobile. +If you donโ€™t have a card connected yet, [learn how to connect a personal card](/articles/new-expensify/connect-credit-cards/Connect-Personal-Cards). --- ## Who can manage personal cards -You can manage personal cards if you have a personal credit card that was already connected to your account. You can manage these cards on both web and mobile, alongside any company cards on your account. +Anyone with a personal credit card connected to their Expensify account can manage it in **Wallet**. --- @@ -23,25 +23,27 @@ You can manage personal cards if you have a personal credit card that was alread 1. Click the navigation tabs (on the left on web, on the bottom on mobile), then select **Account > Wallet**. 2. Under **Assigned cards**: - - Personal cards imported from Expensify Classic will appear here. + - Personal cards will appear in the **Personal** section. - You'll see the card name, bank icon, and last 4 digits. -3. Tap the personal card you want to manage. If you have both company and personal cards, you'll see them separated into **Company cards** and **Personal cards** sections. -![Tap a personal card to open card details]({{site.url}}/assets/images/personal-card-01.png){:width="100%"} +![Tap a personal card to open card details]({{site.url}}/assets/images/ExpensifyHelp-FixPersonalCards.png){:width="100%"} --- -## What you can do with personal cards +## What you can manage on a personal card in Wallet -You can: -- View all personal cards imported from Expensify Classic -- Automatically import transactions from your linked personal cards -- Generate IRS-compliant eReceipts for eligible USD purchases -- Update personal card settings -- Fix broken card connections -- Filter expenses by card in the **Expenses** tab on the **Reports** page +You can manage the following settings for a personal card: + +- Rename the card with a custom nickname +- Turn on **Mark transactions as reimbursable** for imported transactions +- View the last successful transaction import +- Update the transaction start date for imports +- View imported transactions from the card +- Manually refresh the card to import recent transactions +- Fix connection issues if the card stops syncing +- Remove the card from your account --- @@ -53,10 +55,9 @@ You can: - Rename the card - Update the card to pull in new transactions (if not CSV-imported) - Unassign the card if it's no longer needed - - Toggle **Mark transactions as reimbursable** - - The reimbursable setting applies only to **new** transactions and is turned on by default. + - Toggle **Mark transactions as reimbursable** to mark future transactions as reimbursable. -**Note:** Unassigning a personal card permanently deletes any unreported expenses or expenses on draft reports from that card. +**Note:** Removing a personal card permanently deletes any unreported expenses or expenses on draft reports from that card. ![Update personal card settings from the card details page]({{site.url}}/assets/images/personal-card-02.png){:width="100%"} @@ -112,9 +113,6 @@ You can tap the hyperlinked text in the violation to go directly to the **Card d # FAQ -## Can I add another personal card? - -Not yet. Only cards connected to your account in Expensify Classic are available to manage in New Expensify. Support for adding new personal cards will be available in a future update. ## Can I change the reimbursable setting for past transactions? diff --git a/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection.md b/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection.md index e178dc196a16..2ff2b8877be2 100644 --- a/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection.md +++ b/docs/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection.md @@ -61,6 +61,17 @@ Some banks have specific requirements for successful connections: # FAQ +## Why don't newly issued cards appear in the assignment list? + +Direct feeds cache the card list when first connected. If your bank issues new cards after the initial setup, they won't appear automatically. To refresh the card list: + +1. Go to **Workspaces > [Workspace Name] > Company cards**. +2. Click **Settings** in the top-right corner. +3. Click **Assign new cards**. +4. Complete the bank re-authentication to pull the latest cards from your bank. + +New cards will then be available for assignment. + ## How do I fix a broken company card feed connection? If your company card feed is broken, you can fix it from the **Time Sensitive** section on **Home**. Click **Fix** to restore the connection. [Learn how to fix a broken company card feed connection](/articles/new-expensify/connect-credit-cards/Fix-a-broken-Company-Card-Feed-Connection). diff --git a/docs/articles/new-expensify/connections/quickbooks-desktop/Connect-to-QuickBooks-Desktop.md b/docs/articles/new-expensify/connections/quickbooks-desktop/Connect-to-QuickBooks-Desktop.md index 6b80a7dc4248..8f5b7cf931d4 100644 --- a/docs/articles/new-expensify/connections/quickbooks-desktop/Connect-to-QuickBooks-Desktop.md +++ b/docs/articles/new-expensify/connections/quickbooks-desktop/Connect-to-QuickBooks-Desktop.md @@ -1,10 +1,12 @@ --- -title: QuickBooks Desktop +title: Connect to QuickBooks Desktop description: Easily connect Expensify to QuickBooks Desktop for streamlined expense management and accounting. -keywords: [New Expensify, QuickBooks Desktop, Web Connector, accounting integration, expense management] +keywords: [New Expensify, QuickBooks Desktop, Web Connector, accounting integration, expense management, reuse existing connection] +internalScope: Audience is Workspace Admins. Covers connecting QuickBooks Desktop to Expensify and reusing existing connections, does not cover configuring export or coding settings. order: 1 --- +# Connect to QuickBooks Desktop QuickBooks Desktop is accounting software designed for small and medium-sized businesses. Connecting Expensify to QuickBooks Desktop streamlines your expense management and accounting workflows. @@ -12,7 +14,25 @@ To connect QuickBooks Desktop to Expensify, you must log in as an Admin in Quick --- -# Step 1: Start the Setup in Expensify +## How to reuse an existing QuickBooks Desktop connection + +If you already have QuickBooks Desktop connected to another workspace, you can reuse that existing connection instead of setting up a new one. + +1. From the navigation tabs (on the left on web, and at the bottom on mobile), select **Workspaces** +2. Choose the workspace you want to connect to +3. Click **More features** in the left-hand menu +4. In the **Integrate** section, toggle on **Accounting** +5. Click **Accounting** in the left-hand menu +6. Click **Set up** next to QuickBooks Desktop +7. Select the workspace with the existing QuickBooks Desktop connection you want to reuse + +The connection and its settings will be imported to your new workspace. + +To create a new connection instead, click **Create new connection** and follow the steps below. + +--- + +## How to create a new QuickBooks Desktop connection 1. From the navigation tabs (on the left on web, and at the bottom on mobile), select **Workspaces** 2. Choose the workspace you want to connect to @@ -27,7 +47,7 @@ To connect QuickBooks Desktop to Expensify, you must log in as an Admin in Quick --- -# Step 2: Install and Launch the Web Connector +## How to install and launch the Web Connector for QuickBooks Desktop 1. Download the Web Connector and follow the installation instructions 2. Open the Web Connector @@ -37,7 +57,7 @@ To connect QuickBooks Desktop to Expensify, you must log in as an Admin in Quick --- -# Step 3: Authorize and Finalize the Connection +## How to authorize and finalize the QuickBooks Desktop connection 1. In QuickBooks Desktop, select **Yes, always allow access, even when QuickBooks is not running**, then click **Continue** 2. Click **OK**, then click **Yes** @@ -70,6 +90,10 @@ Expensify follows [Intuitโ€™s service discontinuation policy](https://quickbooks Yes. You can install one connector per company file. Multiple connectors can run from the same computer. Just ensure you're logged into the correct company file before syncing. +## Can I reuse an existing QuickBooks Desktop connection on a new workspace? + +Yes. If you already have QuickBooks Desktop connected to another workspace where you are a Workspace Admin, Expensify will offer to reuse that existing connection when you set up QuickBooks Desktop on a new workspace. + ## Can I export negative expenses? Yes, in general. However: diff --git a/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md b/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md index 424dc6074642..d82167d0dd4b 100644 --- a/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md +++ b/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md @@ -1,7 +1,7 @@ --- title: Using Your Expensify Card in New Expensify description: Learn how to activate, use, and manage your physical and virtual Expensify Card, including Smart Limits, notifications, and troubleshooting. -keywords: [New Expensify, Expensify Card, Smart Limit, activate card, virtual card, card notifications, card declined, eReceipts] +keywords: [New Expensify, Expensify Card, Smart Limit, activate card, virtual card, card notifications, card declined, eReceipts, freeze card, unfreeze card] --- Once you receive your Expensify Visaยฎ Commercial Card, you can activate it and start using it immediately. When you use your Expensify Card, expenses are automatically created, and SmartScanned receipts are matched to the corresponding transactions. For most purchases, excluding lodging, Expensify also generates IRS-compliant eReceipts. If your organization doesn't require itemized receipts, you can rely on eReceipts to meet documentation requirements. @@ -96,6 +96,30 @@ For more details on adding a card to Apple or Android Pay, see this [guide](http --- +# How to freeze or unfreeze your Expensify Card + +Need to temporarily pause spending? You can freeze your Expensify Card at any time - without canceling or reissuing it. + +Freezing your card: +- Immediately blocks new purchases on the card +- Keeps your card number, CVV, and expiration date intact +- Can be reversed at any time + +## To freeze or unfreeze your card + +1. From the navigation tabs (on the left on web, and at the bottom on mobile), go to **Account > Wallet > Expensify Card**. +2. Click your Expensify Card to view its details. +3. Click **Freeze card** or **Unfreeze card**, depending on the card's current state. +4. Click **Confirm** in the dialog that appears. + +Once frozen: +- You'll see a **Frozen** badge and a scarf graphic over your card +- All new transactions will be declined until you unfreeze it + +**Note:** If your workspace admin froze the card, you'll need to contact them directly via Expensify Chat to request that they unfreeze it. + +--- + # FAQ ## What if I havenโ€™t received my Expensify Card? diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md index 50cce6d5703f..cfa03397d57c 100644 --- a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md @@ -97,6 +97,31 @@ If a card reaches its expiration date, it automatically deactivates and declines --- +# How to freeze or unfreeze an Expensify Card + +As a Workspace Admin, you can freeze or unfreeze any card in your workspace without needing to cancel or reissue it. + +Freezing a card is helpful if: +- You suspect misuse or a policy violation +- You want to temporarily pause employee spending +- You're enforcing a department-wide budget freeze + +To freeze or unfreeze a card: + +1. Go to **Settings > Workspaces > [Workspace Name] > Expensify Card**. +2. Click a card from the list to open its details pane. +3. Click **Freeze card** or **Unfreeze card**. +4. Confirm the action in the popup modal. + +Once frozen: +- The card immediately stops working for purchases +- You'll see a note below the card indicating who froze the card and when +- The employee sees the same message and cannot unfreeze the card themselves + +All freeze/unfreeze actions are automatically posted to the workspace chat. + +--- + # FAQ ## What kind of bank account is required? diff --git a/docs/articles/new-expensify/expensify-card/View-and-Reconcile-Expensify-Card-Expenses.md b/docs/articles/new-expensify/expensify-card/View-and-Reconcile-Expensify-Card-Expenses.md index 5203b149e94f..63b121f896d1 100644 --- a/docs/articles/new-expensify/expensify-card/View-and-Reconcile-Expensify-Card-Expenses.md +++ b/docs/articles/new-expensify/expensify-card/View-and-Reconcile-Expensify-Card-Expenses.md @@ -19,7 +19,7 @@ Workspace Admins can reconcile Expensify Card expenses when the Expensify Card i ## How to View Expensify Card Withdrawals -1. Click the navigation tabs (on the left on web, on the bottom on mobile) and go to **Reports**. +1. Click the navigation tabs (on the left on web, on the bottom on mobile) and go to **Spend**. 2. In the **Reconciliation** section, select **Expensify Card**. 3. Choose the **Withdrawn** filter to select a date range (for example, **Last month**). 4. Review the list of withdrawals. Each row represents a single settlement withdrawal from your bank account. diff --git a/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md b/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md index fded0c08ce86..54933cbe6763 100644 --- a/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md +++ b/docs/articles/new-expensify/getting-started/Expensify-Home-Overview.md @@ -1,8 +1,8 @@ --- title: Expensify Home Overview description: Learn what youโ€™ll see on Home and how it helps you understand what needs attention, get oriented, and stay informed when you sign in. -keywords: [New Expensify, Expensify Home, Home, Home tab, For you, Discover, Announcements, time-sensitive, urgent alerts, Expensify Card fraud, broken bank connection, assigned cards, Expensify Card limit, available limit, available balance, Spend over time, spending chart, insights] -internalScope: Audience is all Expensify members. Covers what Home is and what appears on it, including the For you, Spend over time, Discover, Announcements, Time-sensitive, and Assigned cards sections. Does not cover how to complete actions like submitting, approving, paying, exporting, or fixing expenses. +keywords: [New Expensify, Expensify Home, Home, Home tab, For you, Discover, Announcements, time-sensitive, urgent alerts, Expensify Card fraud, broken bank connection, assigned cards, Expensify Card limit, available limit, available balance, Spend over time, spending chart, insights, Getting started, onboarding checklist] +internalScope: Audience is all Expensify members. Covers what Home is and what appears on it, including the For you, Spend over time, Discover, Announcements, Time-sensitive, Getting started, and Assigned cards sections. Does not cover how to complete actions like submitting, approving, paying, exporting, or fixing expenses. --- @@ -33,6 +33,7 @@ Home includes: - **Time-sensitive alerts** (when applicable) - **For you** - **Spend over time** (when applicable) +- **Getting started** (when applicable) - **Discover** - **Announcements** - **Assigned cards** (when applicable) @@ -73,9 +74,22 @@ If thereโ€™s nothing that needs action, the **For you** section may appear empty The **Spend over time** section displays a line chart showing your workspace spending trends over time. -This section is available to Workspace Admins, Auditors, and approvers on paid workspaces that have transaction data. Click **View** to open the full **Spend over time** chart on the Search page. +This section is available to Workspace Admins, Auditors, and approvers on paid workspaces that have expenses across at least two time periods (e.g., two or more months). Click **View** to open the full **Spend over time** chart on the Search page. -If you don't hold one of these roles or your workspace has no transactions yet, this section won't appear. +If you don't hold one of these roles, or your workspace doesn't have expenses across at least two time periods, this section won't appear. + +--- + +## How the Getting started section works on Home + +The **Getting started** section shows a checklist of key setup steps to help Workspace Admins configure their workspace during onboarding. + +This section appears only if: + + - The workspace is on a free trial + - The workspace owner selected **manage my team** during setup + +If these conditions arenโ€™t met, the section wonโ€™t appear. --- @@ -85,6 +99,8 @@ The **Discover** section helps you get familiar with things you can do in Expens This section includes a short demo that introduces key areas of the app and shows how different parts fit together. This can be helpful when youโ€™re getting started or returning after some time away. +Once youโ€™ve watched the demo, the **Discover** section is automatically hidden from Home. + --- ## How the Announcements section works on Home @@ -107,7 +123,7 @@ Use this section to quickly check how much you can spend before making a purchas ## What is Home for? -Home helps you get oriented when you sign in. It shows whether anything needs attention, highlights whatโ€™s new, and points you to the right placeโ€”like Inbox or Reportsโ€”if you need to take action. +Home helps you get oriented when you sign in. It shows whether anything needs attention, highlights whatโ€™s new, and points you to the right placeโ€”like Inbox or Spendโ€”if you need to take action. ## Is Home the same as Inbox? @@ -127,7 +143,15 @@ The **Time-sensitive** section only appears when there is an urgent issue or lim ## Why donโ€™t I see the Spend over time section? -The **Spend over time** section only appears if you are a Workspace Admin, Auditor, or approver on a paid workspace that has existing transactions. If you don't hold one of these roles, or your workspace has no transactions yet, this section won't be visible. +The **Spend over time** section only appears if you are a Workspace Admin, Auditor, or approver on a paid workspace that has expenses across at least two time periods (e.g., two or more months). If you don't hold one of these roles, or your workspace doesn't have enough data to display a trend, this section won't be visible. + +## Why donโ€™t I see the Discover section? + +The **Discover** section only appears until youโ€™ve watched the demo. Once youโ€™ve completed it, the section is automatically hidden from Home. + +## Why donโ€™t I see the Getting started section? + +The **Getting started** section only appears if you selected the **manage my team** intent during onboarding, you are within 60 days of your free trial start date, and you have an active paid workspace. If any of these conditions are not met, the section wonโ€™t be visible. ## Why donโ€™t I see the Assigned cards section? diff --git a/docs/articles/new-expensify/getting-started/Free-Features-in-Expensify.md b/docs/articles/new-expensify/getting-started/Free-Features-in-Expensify.md index ea3ee7cfbeb8..a97870cac0c6 100644 --- a/docs/articles/new-expensify/getting-started/Free-Features-in-Expensify.md +++ b/docs/articles/new-expensify/getting-started/Free-Features-in-Expensify.md @@ -20,8 +20,8 @@ Anyone with an Expensify account can use the free features โ€” no workspace requ ## Where to find free features in Expensify -- **Web:** Use the navigation tabs on the left to access **Inbox**, **Reports**, or **Account**. -- **Mobile:** Use the navigation tabs on the bottom to access **Inbox**, **Reports**, or **Account**. +- **Web:** Use the navigation tabs on the left to access **Inbox**, **Spend**, or **Account**. +- **Mobile:** Use the navigation tabs on the bottom to access **Inbox**, **Spend**, or **Account**. --- @@ -94,7 +94,7 @@ For purchases without receipts, enter expenses by hand. Export your data for taxes, budgeting, or sharing with an accountant. ### Web -1. Click **Reports** > **Expenses**. +1. Click **Spend** > **Expenses**. 2. Tick the boxes of the expenses you want to export. 3. Click **Selected** at the top. 4. Choose **Export** from the dropdown menu. diff --git a/docs/articles/new-expensify/getting-started/How-to-Access-Expensify-Training.md b/docs/articles/new-expensify/getting-started/How-to-Access-Expensify-Training.md index bbd161d1c8d2..b2f8948ce6ed 100644 --- a/docs/articles/new-expensify/getting-started/How-to-Access-Expensify-Training.md +++ b/docs/articles/new-expensify/getting-started/How-to-Access-Expensify-Training.md @@ -80,7 +80,7 @@ Youโ€™ll learn how to: Prefer to explore on your own? -The navigation tour provides a guided overview of New Expensify and explains where to find core sections such as Inbox, Reports, and Settings. +The navigation tour provides a guided overview of New Expensify and explains where to find core sections such as Inbox, Spend, and Settings. Use this tour if you want a high-level introduction to: @@ -111,10 +111,10 @@ Learn how Concierge supports you in Expensify. Step-by-step instructions for creating and submitting expenses. ๐Ÿ‘‰ [Create an Expense](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Create-an-Expense) -**Get started with the Reports page** +**Get started with the Spend page** Understand how reports are organized and managed. -๐Ÿ‘‰ [Getting Started with the Reports Page](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Reports-Page) +๐Ÿ‘‰ [Getting Started with the Spend page](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Reports-Page) **How to avoid common scams** diff --git a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md index ec9162dade4c..332cd6666ee5 100644 --- a/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md +++ b/docs/articles/new-expensify/getting-started/Navigating-New-Expensify.md @@ -1,13 +1,13 @@ --- title: Navigating New Expensify -description: Learn how to move around New Expensify using the navigation tabs, including Home, Inbox, Reports, Workspaces, and Account on web and mobile. -keywords: [New Expensify, navigation tabs, navigation, Home tab, Inbox tab, Reports tab, Workspaces tab, Account tab, Receipt button, Create button, workspace filter] +description: Learn how to move around New Expensify using the navigation tabs, including Home, Inbox, Spend, Workspaces, and Account on web and mobile. +keywords: [New Expensify, navigation tabs, navigation, Home tab, Inbox tab, Spend tab, Workspaces tab, Account tab, Receipt button, Create button, workspace filter] internalScope: Audience is all Expensify members. Covers how to navigate Expensify using the navigation tabs and primary action buttons. Does not explain the detailed behavior of the Home tab sections. --- # Navigating New Expensify -New Expensify uses a consistent navigation layout across web and mobile so you can quickly move between Home, Inbox, Reports, Workspaces, and Account settings. +New Expensify uses a consistent navigation layout across web and mobile so you can quickly move between Home, Inbox, Spend, Workspaces, and Account settings. This guide explains where to find each tab and what itโ€™s used for. @@ -24,7 +24,7 @@ Tabs include: - **Home** - **Inbox** -- **Reports** +- **Spend** - **Workspaces** - **Account** @@ -34,7 +34,7 @@ Tabs include: The **Home** tab is the first screen you see when you sign in. -Use Home to get oriented and see whether anything needs attention. From the Home tab, you can navigate to Inbox, Reports, or Workspaces as needed. +Use Home to get oriented and see whether anything needs attention. From the Home tab, you can navigate to Inbox, Spend, or Workspaces as needed. For a full breakdown of what appears on Home, see the **Expensify Home Overview** article. @@ -53,15 +53,15 @@ Use Inbox to: --- -# What the Reports tab is used for +# What the Spend tab is used for -**Reports** is where you view and filter expense reports. +**Spend** is the main place to search and act on expenses and expense reports. -From Reports, you can: +From **Spend**, you can: -- View all reports you have access to -- Apply filters -- Narrow results using the **Workspace filter** inside **Filters** +- Open **Explore** and choose **Reports** (expense reports), **Expenses**, or **Chats** +- Use **To-do** shortcuts such as **Submit**, **Approve**, **Pay**, and **Export** +- Apply filters and narrow results using the **Workspace filter** inside **Filters** --- @@ -126,7 +126,7 @@ If youโ€™re not sure where to go: - Use **Home** to see what needs attention. - Use **Inbox** to manage conversations and approvals. -- Use **Reports** to view or filter expense reports. +- Use **Spend** to view or filter expense reports. - Use **Workspaces** to manage workspace settings. - Use **Account** to update personal settings or subscription details. diff --git a/docs/articles/new-expensify/getting-started/Track-Personal-Expenses.md b/docs/articles/new-expensify/getting-started/Track-Personal-Expenses.md index 3f361cac45ab..435388befb37 100644 --- a/docs/articles/new-expensify/getting-started/Track-Personal-Expenses.md +++ b/docs/articles/new-expensify/getting-started/Track-Personal-Expenses.md @@ -75,7 +75,7 @@ Other ways to SmartScan receipts include: Learn more about [creating expenses](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Create-an-Expense) -View expenses in your Expense chat or the **Reports** page. [Export to CSV](https://help.expensify.com/articles/new-expensify/expenses-and-payments/Search-and-Download-Expenses) for tax or accountant use. +View expenses in your Expense chat or the **Spend** page. [Export to CSV](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses) for tax or accountant use. --- diff --git a/docs/articles/new-expensify/getting-started/Understanding-the-For-you-Section.md b/docs/articles/new-expensify/getting-started/Understanding-the-For-you-Section.md index ccdcc6b98c5a..81ddcfe38562 100644 --- a/docs/articles/new-expensify/getting-started/Understanding-the-For-you-Section.md +++ b/docs/articles/new-expensify/getting-started/Understanding-the-For-you-Section.md @@ -9,7 +9,7 @@ internalScope: Audience is all Expensify members. Covers how the For you section The **For you** section shows items that currently need your attention. Each item links you to the report or view where you can take action. -For you helps you see what needs attention at a glance, without having to search through Inbox or Reports. +**For you** helps you see what needs attention at a glance, without having to search through Inbox or Spend. --- @@ -64,7 +64,7 @@ The items you see in **For you** depend on what actions youโ€™re responsible for ## Where each For you item takes you -When you select an item in **For you**, youโ€™re taken directly to Reports, filtered to show the items that need that action. +When you select an item in **For you**, youโ€™re taken directly to **Spend**, filtered to show the items that need that action. For example: - **Submit** takes you to reports ready to submit @@ -91,9 +91,9 @@ If youโ€™re responsible for more than one type of action, such as approving and An item only appears when a report is waiting specifically on you. If the report is still in progress or waiting on someone else, it wonโ€™t appear in For you. -## Does the For you section replace Inbox or Reports? +## Does the For you section replace Inbox or Spend? -No. For you helps you spot what needs attention and takes you to the right place. All work is still completed in Reports. +No. For you helps you spot what needs attention and takes you to the right place. All work is still completed in Spend. ## Why donโ€™t all my reports appear in the For you section? diff --git a/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md b/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md index 225ded238bf2..983939ccddff 100644 --- a/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md +++ b/docs/articles/new-expensify/insights/How-to-Use-Insights-in-Expensify.md @@ -26,7 +26,7 @@ Insights always respect workspace permissions. You will only see data youโ€™re a You can access Insights on both web and mobile. -1. Click **Reports** in the navigation tabs (on the left on web, or on the bottom on mobile). +1. Click **Spend** in the navigation tabs (on the left on web, or on the bottom on mobile). 2. Scroll to **Insights**. 3. Select an Insight to open the report. diff --git a/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md b/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md index 913b210201e0..c9b3e838d26e 100644 --- a/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md +++ b/docs/articles/new-expensify/insights/View-the-Top-Categories-report.md @@ -30,10 +30,10 @@ The Top Categories report is available to all workspace members on web and mobil ## Where to find the Top Categories report **Web:** -Click **Reports** in the left navigation, then click **Top categories** under the **Insights** section +Click **Spend** in the left navigation, then click **Top categories** under the **Insights** section **Mobile:** -Tap **Reports** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top categories**. +Tap **Spend** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top categories**. --- @@ -66,11 +66,11 @@ The Top Categories report is a suggested search template. You cannot save change To create and save a custom report: -1. Go to the **Reports** or **Expenses** tab. +1. Go to the **Spend** or **Expenses** tab. 2. Use filters to adjust the category grouping or timeframe. 3. Apply filters and click **Save search** to save your custom report. -[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#How-to-use-Reports-search-query-commands) +[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#how-to-use-spend-search-query-commands) --- @@ -110,7 +110,7 @@ The Top Categories report canโ€™t be exported with its grouped totals or summary To export expenses: -1. Go to the **Reports** or **Expenses** tab. +1. Go to the **Spend** or **Expenses** tab. 2. Use filters to group by category and apply the same timeframe. 3. Expand the category group, select the expenses, and click **Export to CSV**. diff --git a/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md b/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md index 056ed4fc623e..38ecb798dbb3 100644 --- a/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md +++ b/docs/articles/new-expensify/insights/View-the-Top-Merchants-report.md @@ -29,10 +29,10 @@ The Top Merchants report is available to all workspace members on web and mobile ## Where to find the Top Merchants report **Web:** -Click **Reports** in the left navigation, then click **Top merchants** under the **Insights** section +Click **Spend** in the left navigation, then click **Top merchants** under the **Insights** section **Mobile:** -Tap **Reports** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top merchants**. +Tap **Spend** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top merchants**. --- @@ -65,11 +65,11 @@ The Top Merchants report is a suggested search template. You cannot save changes To create and save a custom report: -1. Go to the **Reports** or **Expenses** tab. +1. Go to the **Spend** or **Expenses** tab. 2. Use filters to adjust the merchant grouping or timeframe. 3. Apply filters and click **Save search** to save your custom report. -[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#How-to-use-Reports-search-query-commands) +[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#how-to-use-spend-search-query-commands) --- @@ -111,7 +111,7 @@ The Top Merchants report can't be exported with its grouped totals or summary da To export expenses: -1. Go to the **Reports** or **Expenses** tab. +1. Go to the **Spend** or **Expenses** tab. 2. Use filters to group by merchant and apply the same timeframe. 3. Expand the merchant group, select the expenses, and click **Export to CSV**. diff --git a/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md b/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md index 3b8161b3baac..568ab0b5514d 100644 --- a/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md +++ b/docs/articles/new-expensify/insights/View-the-Top-Spenders-report.md @@ -30,10 +30,10 @@ The Top Spenders report is available to Workspace Admins, Approvers, and Auditor ## Where to find the Top Spenders report **Web:** -Use the navigation tabs on the left and select **Reports**, then click **Top spenders** under the **Insights** section. +Use the navigation tabs on the left and select **Spend**, then click **Top spenders** under the **Insights** section. **Mobile:** -Tap **Reports** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top spenders**. +Tap **Spend** from the navigation tabs on the bottom, then tap the hamburger menu in the top-right corner. Under **Insights**, tap **Top spenders**. --- @@ -63,11 +63,11 @@ Yes - you can adjust filters like date range, workspace, or employee to explore To create and save a custom report: -1. Go to the **Reports** or **Expenses** tab. +1. Go to the **Spend** or **Expenses** tab. 2. Use filters to adjust grouping and timeframes. 3. Apply filters and save your custom search. -[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#How-to-use-Reports-search-query-commands). +[Learn how to create custom reports](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify#how-to-use-spend-search-query-commands). --- @@ -105,7 +105,7 @@ The report will update instantly to reflect your selected view. Not directly โ€” the Top Spenders report canโ€™t be exported with its grouped totals or summary data. However, if you expand each group to reveal the individual expenses, you can then select those expenses and use Export to CSV to download the raw data. -1. Go to the **Reports** tab. +1. Go to the **Spend** tab. 2. Apply filters to create your own view. 3. Click **Export to CSV**. diff --git a/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md b/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md new file mode 100644 index 000000000000..e94bd615ceb8 --- /dev/null +++ b/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md @@ -0,0 +1,78 @@ +--- +title: Accounting Search Shortcuts +description: Use the Accounting search shortcuts on the Spend page to review expense status, prepare for export, and reconcile transactions with your accounting records. +keywords: [New Expensify, accounting expensify, reconcile expenses, awaiting export, cash accruals, card accruals, card statements, bank reconciliation, month-end close, search shortcut, accounting shortcut] +internalScope: Audience is Workspace Admins. Covers using the Accounting section to review expense states and support reconciliation workflows. Does not cover exporting or performing reconciliation in detail. +--- + +# Accounting Search Shortcuts + +The **Accounting** search shortcuts on the **Spend** page bring together the expense views you need to reconcile your records and close your books. It helps you identify whatโ€™s ready to export, what still needs attention, and what should be matched against statements or bank data. + +Workspace Admins can use these shortcuts to validate totals, track liabilities, and ensure your expenses align with accounting records before and after export. + +--- + +## Who can use Accounting search shortcuts + +Workspace Admins with: +- Approvals turned on +- Either reimbursements enabled or at least one card feed connected + +--- + +## How to use the Accounting search shortcuts + +1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Spend**. +2. In the **Accounting** section, choose a search shortcut based on what you need to review. +3. Use filters like **Date**, **Feed**, or **Posted** to refine your view. +4. Review the **Total** in the footer to understand the amount for that view. + +Once youโ€™ve reviewed the expenses, you can export them to a CSV if you need to share or work with the data outside of Expensify. Learn how to [export expenses to a CSV](/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses#how-to-export-multiple-expenses). + +--- + +## What each Accounting search shortcut shows you + +- **Awaiting export**: Reports that are approved and ready to be exported to your accounting system. +- **Cash accruals**: Reimbursable expenses that are not yet approved and may need to be accrued. +- **Card accruals**: Imported card expenses that are not yet approved and may need to be accrued. +- **Card statements**: Credit card expenses grouped by statement periods to help you reconcile against your card statement. +- **Bank reconciliation**: Reimbursed reports and Expensify Card settlement withdrawals to help you match transactions with your bank data. + +--- + +## What happens after you review expenses with Accounting search shortcuts + + - You can identify reports that are ready to export to your accounting system. + - You can account for unapproved expenses as accruals before closing a period. + - You can verify imported credit card expense totals against statements and bank data. + - You can ensure your records are complete before and after reconciliation. + +--- + +# FAQ + +## Why donโ€™t I see all Accounting search shortcuts? + +Some search shortcuts only appear when the required features are enabled: + + - **Cash accruals** requires reimbursements + - **Card accruals** and **Card statements** require a card feed + - **Bank reconciliation** requires a validated business bank account. + +If a feature isnโ€™t enabled, its shortcut wonโ€™t appear. + +## Can I export expenses from the Accounting search shortcuts? + +Yes. You can export expenses from any shortcut to a CSV file if you need to use the data outside of Expensify. + + +## Why does the total change when I apply filters? + +The **Total** reflects only the expenses shown in your current shortcut. Changing filters like **Date**, **Feed,** or **Posted** will update the total based on those results. + +## Can I filter Card accruals by feed? + +Yes. If you have more than one card feed, a **Feed** filter will appear so you can review one account at a time. + diff --git a/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md b/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md index c0f2f0c22039..372d5a12942d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md +++ b/docs/articles/new-expensify/reports-and-expenses/Adding-Attendees.md @@ -2,35 +2,60 @@ title: Adding Attendees description: Learn how to add attendees to your expenses and resolve category limit errors by splitting the cost across participants. keywords: [New Expensify, add attendees, expense attendees, split expense, category limit, attendee allocation] +internalScope: Audience is all members. Covers adding attendees to an expense. Does not cover category limit policies member management. --- -You can add attendees to an expense to indicate who participated in a meal, meeting, or event. This is especially helpful when your company has per-person category limits, as Expensify can split the cost evenly across all attendees to help avoid violations. +# Adding Attendees + +Adding attendees to an expense shows who participated in a meal, meeting, or event. Expensify automatically splits the total cost across all attendees, making it easier to track per-person spending and stay within company limits. --- -# Add Attendees to an Expense +## Who can add attendees to an expense -1. From the navigation tabs (on the left on web, and at the bottom on mobile), head to **Reports > Expenses**. -2. Open the expense and click the **Attendees** caret to expand attendee options. -3. Select participants from your **Recents**, **Contacts**, or use the **Search bar** to find someone by name or email. -4. To add someone new, manually enter their name or email. Theyโ€™ll be saved to your Contacts for future use. -5. Click on each attendee to add them. A checkmark (โœ…) will show whoโ€™s been selected. -6. Click **Save**. +All members can add attendees to their own expenses. --- -# Resolve Category Limit Errors by Adding Attendees +## How to add attendees to an expense -If an expense exceeds a category limit set by your admin, you can resolve the issue by adding attendees. +1. From the navigation tabs (on the left on web, and at the bottom on mobile), go to **Spend > Expenses**. +2. Open the expense and click **Attendees**. +3. Select attendees from **Recents**, **Contacts**, or enter a name, email, or phone number. +4. Select each attendee to add a checkmark next to their name. +5. Click **Save**. -1. Click the **Attendees** caret to open the attendee selector. -2. Add the attendees involved in the expense. -3. Expensify will automatically split the total cost across all attendees. -4. Once added, a dot will appear between the **Attendees** field and the **per-person amount**, making it easy for approvers to verify the distribution. +The selected attendees are added to the expense. --- -# Audit Trail for Attendees +## What happens after you add attendees to an expense + + - The total expense is automatically split evenly across all attendees + - The per-attendee amount updates instantly on the expense + - If your Workspace has per-attendee category limits, adding attendees can reduce the per-attendee amount and help resolve limit violations + - Approvers can review attendee details during the approval process + - All attendee changes are recorded in the expense history for auditing + +--- + +# How to view attendees on a report + +When any expense in a report has attendees, the report automatically shows two additional columns: + +- **Attendees:** displays the attendees added to each expense. +- **Per attendee:** shows the total amount divided equally among all attendees. + +These columns appear in both the report and search results. + +--- + +# FAQ + +## Is there an audit trail for attendee changes? + +Yes. Expensify automatically records all attendee updates in the expense history. These system records cannot be edited. -๐Ÿ•ต๏ธ **Note:** Expensify automatically records attendee changes on the expense report. These system messages cannot be edited, ensuring a clear and secure history of all attendee updates. +## Can I edit attendees on an expense before it's submitted? +Yes. Open the expense, click **Attendees**, update the list, and click **Save**. diff --git a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md index 499c39696978..5b29d9ab882d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Approve-Expenses.md @@ -1,127 +1,147 @@ --- title: Approve Expenses -description: Approve, hold, and unapprove submitted expenses -keywords: [New Expensify, approve expenses, hold expenses, unapprove report, workspace approval workflow, expense approval, reimburse expenses, pending expense, Expensify Card, expense status, expense settings] +description: Approve, hold, reject, and unapprove submitted expenses +keywords: [New Expensify, approve expenses, hold expenses, unapprove report, reject report, reject expense, workspace approval workflow, expense approval, reimburse expenses, pending expense, Expensify Card, expense status, expense settings] +internalScope: Audience is Workspace approvers and admins. Covers approving and managing submitted expenses and reports. Does not cover creating or submitting expenses. --- -Expenses can be created through manual entry, tracking distance, or scanning a receipt. They can be submitted to an individual or to a workspace. For steps on creating and submitting expenses, check out [Create an Expense](https://help.expensify.com/articles/new-expensify/expenses-and-payments/Create-an-expense). +# Approve Expenses ---- - -# Expenses Sent to an Individual (Not a Workspace) +When an expense report is submitted on a Workspace with an approval workflow configured, it must be approved before it can be paid. To set an approval workflow on your Workspace, [learn how to add approvals](/articles/new-expensify/workspaces/Add-Approvals). -When an expense is submitted to an individual (rather than on a workspace), it won't go through an approval process. It only needs to be paid. See [Pay an Expense](https://help.expensify.com/articles/new-expensify/expenses-and-payments/Pay-an-expense) for the payment steps. +When an expense report is submitted to an individual (rather than on a workspace), it won't go through an approval process. It only needs to be paid. [Learn how to pay an expense](/articles/new-expensify/expenses-and-payments/Pay-an-expense). --- -# Expenses Submitted on a Workspace +## What actions can be taken on expenses and reports submitted on a Workspace -When an expense is submitted on a workspace with an approval workflow configured, it must be approved before it can be paid. +On a submitted report, the approver can: -**For each expense report, you can:** - -- **Approve**: Confirm the expense is valid and ready to pay. +- **Approve**: Confirm the report is valid and ready to pay. - **Unapprove**: Return the report to its previous state for additional edits. +- **Reject**: Return the report to the submitter or a previous approver. -**For each expense on a report, you can:** -- **Hold**: Temporarily delay approval of the individual expense if more information is needed. -- **Reject**: Remove the expense from the report and send back to the submitter with a reason. The rejected expense can be marked as resolved and resubmitted by the submitter. +On any expense submitted on a report, the approver can: -**To set up an approval workflow:** +- **Hold**: Temporarily delay approval of the individual expense if more information is needed. +- **Reject**: Remove the expense from the report and send it back to the submitter with a reason. The rejected expense can be marked as resolved and resubmitted. -1. In the navigation tabs (on the left on web, and at the bottom on mobile), go to **Workspaces > [Workspace Name] > Workflows**. -2. Create a custom workflow to route expenses to the appropriate approvers. +**Note:** On reports with only one expense, you cannot reject a single expense. Instead, the entire report must be rejected. --- -# Approve an Expense +## How to review and approve a report -1. Youโ€™ll receive an in-app and email notification when an expense is submitted. -2. Click the notification to review the expense in Expensify, or you can find the expense in your **Inbox**. -3. Review details like the receipt, amount, and description. -4. Click **Approve**. +When a report is submitted to you for approval it will appear in the **For you** section on **Home**, and under **Spend** in the **Expense reports** section, under **Needs approval**. -**Note:** If the transaction is still pending (e.g., an Expensify Card or SmartScan expense), you must wait until the transaction posts before approving. +To review and approve a report submitted to you for approval: ---- +1. Click the report to open it. +2. Review details like the receipt, amount, and description. +3. Click **Approve** at the top of the report. -# Change Approver +--- -**To add an approver:** +## How to add an approver to a report -1. From the Report, open the **More** dropdown at the top of the expense. +1. From the Report, choose **More**. 2. Select **Change approver**. 3. Select **Add approver**. -4. Select the approver and save. -5. The approver you selected is now the current approver, and the prescribed workflow will continue after their approval. +4. Choose an additional approver to add to the report. +5. Click **Save**. -**To bypass approvers:** +The approver you added is now the current approver, and the original workflow will continue after their approval. -1. From the Report, open the **More** dropdown at the top of the expense. +--- + +## How to bypass an approver on a report + +Workspace admins can bypass the approval workflow on a report to final approve it themselves. + +1. From the Report, choose **More**. 2. Select **Change approver**. 3. Select **Bypass approvers**. 4. You are now the final approver, and the prescribed workflow has been bypassed. +**Note:** Only Workspace admins can bypass the prescribed approval workflow. + --- -# Hold an Expense +## How to hold an expense -**To place an expense on hold:** +1. In the navigation tabs (on the left on web, and at the bottom on mobile), go to **Spend > All expenses**. +2. Locate the expense you want to hold. +3. Click the expense to open it. +4. Click **More** at the top of the expense. +5. Select **Hold** and enter a reason. -1. In the navigation tabs (on the left on web, and at the bottom on mobile), head to **Reports > Expenses**. -2. Locate the expense using the search bar or filters. -3. Click **View**. -4. Open the **More** dropdown at the top of the expense. -5. Select **Hold** and enter a reason (this will be added to the report). +To take an expense off hold, follow the same steps but select **Unhold**. -**To take an expense off hold to approve:** +--- -1. In the navigation tabs (on the left on web, and at the bottom on mobile), head to **Reports > Expenses**. -2. Locate the held expense using the search bar or filters. -3. Click **View**. -4. Open the **More** dropdown at the top of the expense. -5. Select **Unhold**. -6. Then follow the steps above to **Approve**. +## How to unapprove a report -**Note:** Held expenses cannot be paid until they are approved. +1. In the navigation tabs (on the left on web, and at the bottom on mobile), go to **Spend > All reports**. +2. Locate the Approved report you want to unapprove. +3. Click the report to open it. +4. Click **More** at the top of the report. +5. Select **Unapprove**. + +Unapproving a report returns it to the Outstanding state. The last approver will be notified and can then revise or reject expenses on the report. + +**Note:** Only Approved reports can be unapproved. Paid and Done reports cannot be unapproved. --- -# Unapprove a report +## How to reject a report -1. In the navigation tabs (on the left on web, and at the bottom on mobile), head to **Reports > Reports**. -2. Locate the report using the search bar or filters. -3. Click **View**. -4. Open the **More** dropdown at the top of the report. -5. Select **Unapprove**. +As the assigned approver, you can reject an entire expense report to return it to the submitter or a previous approver while keeping the report's expense grouping intact. + +1. In the navigation tabs (on the left on web, and at the bottom on mobile), go to **Spend > Needs approval**. +2. Locate the Outstanding report you want to reject. +3. Click the report to open it. +4. Click **More** at the top of the report. +5. Select **Reject**. +6. Enter a comment to explain why you will not approve the report. +7. If the report passed through previous approvers, choose who the report should be rejected back to for review. +8. Click **Reject report** to confirm. -Unapproving a report returns it to the Processing state. The last approver will be notified and can then revise or reject expenses on the report. +## What happens after a report is rejected -**Note:** Paid reports cannot be unapproved. If the expense was already exported to accounting software, unapproving it may cause reconciliation issues. Be sure to remove the exported data before approving the expense again. +- **Rejected to the submitter**: The report moves back to Draft. The submitter must fix any issues and manually resubmit โ€” rejected reports are skipped during scheduled submit. +- **Rejected to a previous approver**: The report stays Outstanding and prior approvals are preserved, so it won't restart the entire approval workflow. --- -# Reject an expense +## How to reject an expense -1. In the navigation tabs (on the left on web, and at the bottom on mobile), head to **Reports > Expenses**. -2. Locate the expense using the search bar or filters. +1. In the navigation tabs (on the left on web, and at the bottom on mobile), head to **Spend > All expenses**. +2. Locate the expense you want to reject using the search bar or filters. 3. Click **View**. 4. Open the **More** dropdown at the top of the expense. 5. Select **Reject** and enter a reason (this will be added to the report). -The rejected expense will be removed from the report, and the submitter will be notified. The rejection reason will be added to the expense. The expense can later be marked as resolved and resubmitted for approval. +The rejected expense will be removed from the report, and the submitter will be notified. The rejection reason will be added to the expense, and it can later be marked as resolved and resubmitted for approval. --- - # FAQ -## Why is an employee expense showing as pending? -Expensify Card expenses show as pending until the merchant posts them. This can take 1โ€“3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). +## Why can't I action a pending expense? +Expensify Card expenses show as pending until the merchant posts them. This can take 1โ€“3 business days. Hotel or rental car holds may take longer (up to 31 days for hotels). Only posted expenses can be approved. + +## Why canโ€™t I see Bypass approvers? +Only Workspace admins can bypass the prescribed approval workflow. If **Prevent Self-Approval** is enabled, an admin cannot bypass approvals to approve their own report. + +## Whatโ€™s the difference between rejecting a report and rejecting an expense? + +Rejecting a report sends the entire report back while keeping all expenses grouped together. Rejecting an expense removes only that expense from the report and sends it back to the submitter. + +## Why canโ€™t I unapprove a report? ## What are expense reports? -Expense reports group multiple expenses into one batch for review or payment. Draft reports collect new expenses automatically. You can check the status of an expense under **Reports > Expenses**. +Expense reports group multiple expenses into one batch for review or payment. Draft reports collect new expenses automatically. You can check the status of an expense under **Spend > Expenses**. -## Why canโ€™t I see โ€œBypass approversโ€? -Only workspace admins can bypass the prescribed approval workflow. If โ€œPrevent Self-Approvalโ€ is enabled, an admin cannot bypass approvals to approve their own report. +## What happens after I approve a report? +The report moves to the next approver in the workflow. If you are the final approver, the report becomes **Approved** and is ready for payment. diff --git a/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md b/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md index d30532e625aa..ef0ffd95d764 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses.md @@ -57,6 +57,14 @@ You can crop a receipt image to remove unnecessary areas or focus on specific de The cropped image will replace the original receipt on the expense. +## How to rotate a receipt + +You can rotate a receipt to correct its orientation. Rotation is available for uploaded or scanned image receipts and for PDF receipts on web or mobile web. PDF rotation is not available in the native mobile app. + +1. Open the expense and click on the receipt image or PDF. +2. Click **Rotate**. +3. Each click rotates the receipt 90ยฐ counter-clockwise. + --- ## When to Use Report Comments for Supporting Documentation @@ -112,7 +120,7 @@ Only the expense creator or a Workspace Admin can attach a receipt an expense. No. Receipts in the comments section are considered supplemental and are not included in audits, exports, or downloadable reports. ## Can I crop or rotate an eReceipt? -No. The **Crop** and **Rotate** buttons are only available for image receipts that you uploaded or scanned. eReceipts cannot be cropped or rotated. +No. eReceipts cannot be cropped or rotated. The **Crop** button is available for uploaded or scanned image receipts only. The **Rotate** button is available for image receipts and PDF receipts on web or mobile web. PDF rotation is not available in the native mobile app. ## Can I attach both the card and itemized restaurant receipts? Yes. Attach the **card receipt** to the expense itself, and upload the **itemized receipt** in the report comments as supporting documentation. diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-an-Expense.md b/docs/articles/new-expensify/reports-and-expenses/Create-an-Expense.md index 97b1dce71401..a80706ed1dc2 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Create-an-Expense.md +++ b/docs/articles/new-expensify/reports-and-expenses/Create-an-Expense.md @@ -114,7 +114,7 @@ The total amount will be calculated automatically using the hourly rate set for ## How to bulk upload multiple receipts (web only) -1. On the web, go to the **Reports** page or the **Home** page. +1. On the web, go to the **Spend** page or the **Home** page. 2. Drag up to 30 PDF or image files (under 10MB each) into Expensify. 3. Follow the prompts to assign workspaces, recipients, and coding. 4. Once the expenses are ready, click **Create expenses**. diff --git a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md index 8a69b68895b8..3c3c554baf0f 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md @@ -42,7 +42,7 @@ To create an expense using distance between the starting and ending locations of - Distance - Amount - Date - - (Optional) Add a description, category, or tag. + - Other optional fields 6. Select **Create expense**. --- @@ -59,7 +59,7 @@ To create an expense by inputting a distance manually: - Distance - Amount - Date - - (Optional) Add a description, category, tag or receipt. + - Other optional fields 6. Select **Create expense**. --- @@ -98,15 +98,15 @@ If you are creating expenses on a Workspace: [Learn how to manage distance rates as a Workspace Admin](/articles/new-expensify/reports-and-expenses/Managing-Distance-Rates) -### Distance expenses created for personal tracking +### Distance expenses created outside of a Workspace -If you're creating expenses outside of a Workspace: +If you're creating a distance expense outside of a Workspace (e.g., in a DM chat or Your space): -- Expensify sets a default rate based on your payment currency. - - Example: For USD, the rate is based on the current IRS reimbursement rate and uses miles. - - Distance default rates are updated annually. - -**Note:** Only Workspace Admins can set a custom distance rate. It's not possible to set a custom distance rate for personal expenses outside of a Workspace. +- **DM chats:** Expensify sets a default rate based on your payment currency (e.g., the current IRS rate for USD). It is not possible to set a custom distance rate for these expenses. +- **Your space (tracking for personal use):** + - **If you have a default Workspace:** The distance rates from your default Workspace are automatically applied. You can tap the **Rate** field to choose a different rate from that Workspace. + - **If you have multiple Workspaces but no default:** You'll be prompted to select a Workspace first, and then you can choose from that Workspace's available rates. + - **If you don't have a group Workspace:** Expensify sets a default rate based on your payment currency (e.g., the current IRS rate for USD). When you tap the **Rate** field, you'll be guided through creating a Workspace with a custom distance rate. --- @@ -133,7 +133,7 @@ Yes! You can edit the expense before it is approved. To learn how to edit an exp ## Can I update the Distance expense unit or rate? -The distance unit and rate can only be updated by a Workspace Admin on the Workspace. It is not possible to adjust the distance rate or unit at the expense level. +The distance unit and rate can only be updated by a Workspace Admin on the Workspace. It is not possible to adjust the distance rate or unit at the expense level. ## What happens if a Distance expense is moved to a different Workspace? diff --git a/docs/articles/new-expensify/reports-and-expenses/Duplicate-a-Report.md b/docs/articles/new-expensify/reports-and-expenses/Duplicate-a-Report.md new file mode 100644 index 000000000000..a43e9302b0a0 --- /dev/null +++ b/docs/articles/new-expensify/reports-and-expenses/Duplicate-a-Report.md @@ -0,0 +1,78 @@ +--- +title: Duplicate a Report +description: Learn how to create a copy of an existing report and its expenses in Expensify using Duplicate report. +keywords: [New Expensify, duplicate report, copy report, create copy of report, duplicate expenses] +internalScope: Audience is all members. Covers how to duplicate an entire report and its non-card expenses. Does not cover duplicating individual expenses or duplicate detection. +--- + +# Duplicate a Report + +You can create a copy of an existing report and all of its non-card expenses using **Duplicate report**. This creates a new report with copies of all non-card expenses from the original, including each expense's merchant, amount, category, tags, and tax. Expense dates are set to today and receipt images are not copied. + +This is useful when you need to recreate a similar set of expenses such as recurring monthly reports. + +If you only need to duplicate specific expenses, [learn how to duplicate an expense](/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense). + +--- + +## Who can duplicate a report + +Any member who created a report can duplicate it. **Duplicate report** is available for reports in any status, including Draft, Processing, Approved, Done, and Paid. + +--- + +## How to duplicate a report + +1. In the navigation tabs (on the left on web, and on the bottom on mobile), go to **Spend** > **Reports**. +2. Locate the report you want to duplicate. +3. Click the report to open it. +4. Select **More**. +5. Choose **Duplicate report**. + +A new report is created and you are taken to it automatically. + +**Note:** You can only duplicate reports that you created. You can't duplicate other members' reports. + +--- + +## What happens after you duplicate a report + +A new report is created with the name **"Copy of [Original report name]"**. + +- If you still have access to the Workspace the original report is on, the new report is created on that workspace. +- If you no longer have access to that Workspace, the new report is created on your primary Workspace. + +Each non-card expense on the original report is copied to the new report with the following details: + +- Merchant +- Amount and currency +- Category +- Tags +- Tax + +The following details are **not** copied: + +- Date is set to today instead of the original date +- Receipt images are not included on the duplicate expenses +- Card expenses (company cards and Expensify Cards) are not copied +- Per diem and distance expenses are not copied when the new report is created in a different Workspace than the original + +--- + +# FAQ + +## Can I duplicate multiple reports at once? + +No. Reports must be duplicated one at a time. + +## Are card expenses included in the duplicate? + +No. Only non-card expenses are copied. Expenses from company cards or Expensify Cards are skipped. + +## Does the duplicate include receipt images? + +No. Receipt images are not copied to the duplicate expenses. You will need to attach new receipts if required. + +## Why is the Duplicate report option not showing in the More menu? + +**Duplicate report** only appears on reports that you created. If you are viewing someone else's report, you will not see this option. diff --git a/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md b/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md index f76bd63be9bf..cab15663f4cf 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md +++ b/docs/articles/new-expensify/reports-and-expenses/Expense-and-Report-Actions.md @@ -48,9 +48,11 @@ Report actions guide the submitter, approver, and payer regarding the report's s | **Mark as exported** | On approved/paid reports when integrated to an accounting system| Admin, Exporter | Marks the report as manually exported for tracking purposes | | **Export to [accounting system]** | When an accounting system is connected | Admin, Exporter | Sends the report to an external system such as Xero or QuickBooks Online | | **Duplicate expense** | On non-card expenses in any state | Submitter | Creates a copy of the expense on your primary workspace with the same details. Date is set to today and receipts are not copied | +| **Duplicate report** | On reports the submitter owns, in any state | Submitter | Creates a copy of the report and its non-card expenses. Date is set to today and receipts are not copied | | **Download** | Any report state | All roles | Downloads a copy of the report as a PDF | | **Print** | Any report state | All roles | Opens the report in a printable format and triggers the browser's print dialog | | **Submit** | For draft reports | Submitter, Admin (on behalf of submitter) | Kicks off report approval workflow | +| **Reject** | On Outstanding reports | Assigned approver | Returns the entire report to the submitter or a previous approver with a required reason. The report moves to Draft (if rejected to submitter) or stays Outstanding (if rejected to a previous approver) | | **Approve** | For outstanding reports | Admin | Skips current approver | | **View details** | Any report | All roles | Opens details view with options to share, pin, view members | | **Cancel payment** | After payment is initiated, but before the payment has been processed, or always for a manual payment | Payer | Cancels pending payment | diff --git a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Reports-Page.md b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md similarity index 84% rename from docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Reports-Page.md rename to docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md index e027bb821293..3a5f18511a59 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Reports-Page.md +++ b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md @@ -1,10 +1,10 @@ --- -title: Getting Started with the Reports Page -description: Learn how to use the Reports page in New Expensify to view, filter, and manage your expense data using report previews, tables, filters, and smart suggestions. -keywords: [New Expensify, reports page, report preview, report table, filters, smart suggestions, expense management, add expenses, bulk actions, date range, search, expenses missing, reports navigation] +title: Getting Started with the Spend Page +description: Learn how to use the Spend page in New Expensify to view, filter, and manage your expense data using report previews, tables, filters, and smart suggestions. +keywords: [New Expensify, Spend page, report preview, report table, filters, smart suggestions, expense management, add expenses, bulk actions, date range, search, expenses missing, Spend navigation] --- -The Reports page in New Expensify gives you a complete view of your submitted, approved, and reimbursed expensesโ€”all in one place. This guide shows you how to use report previews, tables, filters, and smart suggestions to track, manage, and take action on your expenses more efficiently. +The Spend page in New Expensify gives you a complete view of your submitted, approved, and reimbursed expensesโ€”all in one place. This guide shows you how to use report previews, tables, filters, and smart suggestions to track, manage, and take action on your expenses more efficiently. --- @@ -78,7 +78,7 @@ Filters help you narrow down expenses or reports so you can find exactly what yo - **Add unreported expenses to a report**: Use filters to find unreported expenses, and then add them to a report with just a few clicks. - **Quickly search and locate specific reports using filters** - On the **Reports** page, filter by: + On the **Spend** page, filter by: - **Workspace** - **Submitter** - **Report status** (e.g., Open, Approved, Reimbursed) @@ -89,9 +89,9 @@ You can filter your **expenses** and **reports** using dropdowns, search bars, a - **Expenses:** can be filtered by `Date`, `Merchant`, `Category`, `Tag`, `Amount`, `Reimbursable`, `Billable`, `Status` (e.g. Unreported) -- **Reports:** can be filtered by `Submitter`, `Workspace`, `Report Status` (e.g. Draft, Outstanding, Approved, Paid), `Date` +- **Reports**: can be filtered by `Submitter`, `Workspace`, `Report Status` (e.g. Draft, Outstanding, Approved, Paid), `Date` -To view other available filters, click into the **search box** on the Reports or Expenses page. +To view other available filters, click into the **search box** on the **Spend** page while viewing **Reports** or **Expenses** under **Explore**. ## How Filtering Works @@ -127,8 +127,8 @@ Smart suggestions recommend the next best actionโ€”like submitting a report or a # FAQ -## Can I export reports from the Reports page? -Yes! You can [export filtered reports to CSV](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses) using the export option at the top of the Reports page. +## Can I export reports from the Spend page? +Yes! You can [export filtered reports to CSV](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses) using the export option at the top of the Spend page. ## Do smart suggestions work on mobile? Yes, smart suggestions also appear in the mobile Inbox and draft reports. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Delete-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Delete-Expenses.md index 11718f257d0d..f3881b22eb00 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Delete-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Delete-Expenses.md @@ -23,7 +23,7 @@ Most expenses can be deleted directly. If an expense is on a submitted report or ## How to delete multiple expenses -1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Reports** > **Expenses**. +1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Spend** > **Expenses**. 2. Select the expenses you want to delete. 3. Choose **Selected**, then **Delete**. @@ -33,7 +33,7 @@ Most expenses can be deleted directly. If an expense is on a submitted report or ## How to delete an expense from a report -1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Reports** > **Expenses**. +1. In the navigation tabs (on the left on web, on the bottom on mobile) select **Spend** > **Expenses**. 2. Open the report that contains the expense. 3. Select the expense you want to delete. 4. Choose **Selected**, then **Delete**. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md index 34140158fce1..9bbddd6c8864 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Duplicate-an-Expense.md @@ -1,23 +1,28 @@ --- title: How to Duplicate an Expense -description: Learn how to create a copy of an existing expense in Expensify using the Duplicate option. -keywords: [New Expensify, duplicate expense, copy expense, duplicate cash expense, duplicate distance expense, duplicate per diem expense, create copy of expense] -internalScope: Audience is expense submitters. Covers how to duplicate a single cash, distance, or per diem expense. Does not cover duplicate detection, resolving flagged duplicates, or merging expenses. +description: Learn how to create a copy of one or more expenses in Expensify using Duplicate expense. +keywords: [New Expensify, duplicate expense, copy expense, duplicate cash expense, duplicate distance expense, duplicate per diem expense, create copy of expense, bulk duplicate, duplicate multiple expenses] +internalScope: Audience is all members. Covers how to duplicate a single expense and how to bulk-duplicate multiple expenses from the Expenses tab or a report. Does not cover duplicate detection, resolving flagged duplicates, or merging expenses. --- # How to duplicate an expense -You can create a copy of an existing expense using **Duplicate expense**. This generates a new expense on your primary workspace with the same details as the original, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. +You can create a copy of one or more expenses using **Duplicate expense**. This generates new expenses on your primary workspace with the same details as the originals, including the merchant, amount, category, tags, and tax. The date is set to today and receipt images are not copied. -This is useful when you need to create a similar expense quickly without re-entering all the details manually. +This is useful when you need to create similar expenses quickly without re-entering all the details manually. You can duplicate a single expense or select multiple expenses and duplicate them all at once. -**Note:** Card expenses cannot be duplicated. Only cash, distance, and per diem expenses support duplication. +**Note:** Card expenses cannot be duplicated. Only cash, distance, and per diem expenses are eligible to be duplicated. + + --- ## Who can duplicate an expense -Any expense submitter can duplicate their own expenses. **Duplicate expense** is available for cash, distance, and per diem expenses in any status, including Unreported, Draft, Processing, Approved, and Paid. +Anyone can duplicate their own expenses. **Duplicate expense** is available for cash, distance, and per diem expenses in any status, including Unreported, Draft, Processing, Approved, and Paid. Card expenses (company cards and Expensify Cards) cannot be duplicated. @@ -25,36 +30,45 @@ Card expenses (company cards and Expensify Cards) cannot be duplicated. ## How to duplicate a single expense -1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Reports > Expenses** -1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Reports > Expenses**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Spend > Expenses** +2. Click to open the eligible expense you want to duplicate. 3. Select **More**. -4. Choose **Duplicate expense**.The **Duplicate expense** menu item will briefly change to **Duplicated** to confirm the action. +4. Choose **Duplicate expense**. -The duplicate expense is created on your primary workspace with the original expense's merchant, amount, category, tags, tax, and billable or reimbursable status. The date is set to today and receipt images are not included. +![Select an Expense > More > Duplicate expense]({{site.url}}/assets/images/ExpensifyHelp-DuplicateExpense_01.png){:width="100%"} + +--- + +## How to duplicate multiple expenses at once + +You can select multiple eligible expenses and duplicate them all in one action. + +**On web:** + +1. In the navigation tabs on the left, click **Spend > Expenses**. +2. Select two or more cash, distance, or per diem expenses that you created. +3. Click **Selected**, then **Duplicate expenses**. + +**On mobile** + +1. In the navigation tabs on the bottom, tap **Spend > Expenses**. +2. Long-press an expense, then tap **Select** to enter selection mode. +3. Select two or more cash, distance, or per diem expenses that you created. +4. Tap **Selected**, then **Duplicate expenses**. + +Each selected expense is duplicated individually. The same rules apply as for single duplication โ€” card expenses, scanning expenses, per diem expenses without dates, and expenses you did not submit are excluded from selection. --- ## What happens after you duplicate an expense -A new expense is created on your primary workspace with the following details copied from the original: - -- Merchant -- Amount and currency -- Category -- Tags -- Tax -- Billable and reimbursable status -- Attendees -- Description - -The following details are not copied: - -- **Date** is set to today instead of the original date -- **Receipt images** are not included on the duplicate +The duplicate expense is created on your primary workspace with the original expense's merchant, amount, category, tags, tax, and billable or reimbursable status. The date is set to today and receipt images are not included. If the original expense's coding (such as categories or tags) is not available on your primary workspace, the duplicate expense will still be created. You will be prompted to correct the coding on the expense. @@ -70,27 +84,19 @@ When this restriction applies, Expensify will display a message explaining that # FAQ -## Can I duplicate multiple expenses at once? - -No, you canโ€™t duplicate multiple expenses at once. Expenses must be duplicated one at a time. - ## Can I duplicate a card expense? No. Expenses from company cards or Expensify Cards cannot be duplicated. Only cash, distance, and per diem expenses support duplication. - - ## Does the duplicated expense include the receipt image? No. Receipt images are not copied to the duplicate expense. You will need to attach a new receipt if one is required. -## Why is the Duplicate option not showing in the More menu? +## Why is Duplicate not showing in the menu? -**Duplicate expense** only appears when the expense is a cash, distance, or per diem expense that you submitted. It will not appear if: +**Duplicate expense** only appears when the expense is a cash, distance, or per diem expense that you created. It will not appear if: -- The expense is from a company card or Expensify Card +- The expense was created by someone else. +- The expense is imported from a company card or Expensify Card - A receipt is currently being scanned - For per diem expenses, if start or end dates are missing diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md index 5c82aba1bf2b..acc2eb8b1d5f 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md @@ -16,7 +16,7 @@ If youโ€™re trying to export a specific type of data, use the guides below: ## How to export a single expense -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Reports** > **Expenses**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend** > **Expenses**. 2. Open the expense you want to export. 3. Select **More**. 4. Select **Export**. @@ -26,7 +26,7 @@ You'll receive the exported CSV file in a message from Concierge. ## How to export multiple expenses -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Reports** > **Expenses**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend** > **Expenses**. 2. Select the checkbox next to each expense you want to export, or use the top checkbox to select all. 3. Select **Selected** at the top. 4. Select **Export**. @@ -58,7 +58,7 @@ If your Workspace Admin has created custom export templates, you can select one ## Why don't I see certain export templates? -Report-level templates (such as All Data - Report Level Export) only appear when exporting from the Reports page with full reports selected. When exporting individual expenses from the Expenses page, only expense-level templates are available. +Report-level templates (such as All Data - Report Level Export) only appear when exporting from **Spend > Reports** page with full reports selected. When exporting individual expenses from the Expenses page, only expense-level templates are available. ## The data looks wrong in Excel. How can I fix it? diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md index d3fd4a50149a..7565d0709388 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md @@ -2,12 +2,12 @@ title: How to Export Reports description: Learn how to export reports to CSV or download them as PDFs. keywords: [New Expensify, export reports, CSV export, PDF download, download report, report export template, bulk export reports, report data] -internalScope: Audience is all members. Covers exporting reports from the Reports page to CSV using export templates (individually and in bulk), and downloading individual reports as PDF. Does not cover exporting individual expenses from the Expenses page (see Export-Expenses.md), exporting to an accounting integration, or company card reconciliation. +internalScope: Audience is all members. Covers exporting reports to CSV using export templates (individually and in bulk), and downloading individual reports as PDF. Does not cover exporting individual expenses from the Expenses page (see Export-Expenses.md), exporting to an accounting integration, or company card reconciliation. --- # How to export reports -Export reports from the Reports page to a CSV file using export templates, and download individual reports as PDFs. +Export reports to a CSV file using export templates, and download individual reports as PDFs. If youโ€™re trying to export a specific type of data, use the guides below: @@ -17,7 +17,7 @@ If youโ€™re trying to export a specific type of data, use the guides below: ## How to export a single report to CSV -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Reports**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend > Reports**. 2. Open the report you want to export. 3. Select **More**. 4. Select **Export**. @@ -27,7 +27,7 @@ You'll receive the exported CSV file in a message from Concierge. ## How to export multiple reports -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Reports**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend > Reports**. 2. Select the checkbox next to each report you want to export, or use the top checkbox to select all. 3. Select **Selected** at the top. 4. Select **Export**. @@ -45,10 +45,10 @@ Expensify offers pre-built export templates, or you can build your own custom ex - **Custom templates** - Any custom template created by you or your Workspace Admin, if available. **Note** Currently, it's not possible to build custom export templates on New Expensify, they can only be created on Expensify Classic. However, once built they will be available on New Expensify when exporting reports. [Learn how to build a custom export template in Expensify Classic](/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports#create-a-custom-export-template). + ## How to download a report as a PDF -# How to download a report as a PDF -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Reports**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend > Reports**. 2. Open the report you want to download. 3. Select **More**. 4. Select **Download as PDF**. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md index 7ff96232d8c6..f338fca60aac 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md @@ -35,10 +35,7 @@ Within the report, duplicate expenses will show the label **Potential duplicate* --- - +![Inbox > Chat with red dot]({{site.url}}/assets/images/ExpensifyHelp-ResolveDuplicates_03.png){:width="100%"} --- @@ -56,10 +53,9 @@ Design request:https://github.com/Expensify/Expensify/issues/614048 --- - +![expense with Review Duplicates at the top]({{site.url}}/assets/images/ExpensifyHelp-ResolveDuplicates_04.png){:width="100%"} + +--- ## What happens after you resolve a flagged duplicate @@ -73,9 +69,11 @@ The hold is removed from the expense. Depending on your choice: # FAQ ## Can I edit a duplicate expense after resolving it? + Yes, you can edit a duplicate expense as long as it is in the Unreported, Draft or Outstanding state. ## Will two SmartScanned receipts from the same day with the same amount be flagged? + Yes, unless: - The expenses were split from a single expense. - They were imported from a credit card. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md index f4a445529ae2..2612f7138b48 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses.md @@ -54,7 +54,7 @@ Create new page describing expense icons and what they mean. WIP here: https://g Use this method when you already see both duplicate expenses on the Expenses page. -1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Reports > Expenses**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Spend > Expenses**. 2. Select two expenses to merge. 3. Choose **Selected**, then select **Merge**. 4. If both expenses have receipts, select the one you want to keep. @@ -67,7 +67,7 @@ Use this method when you already see both duplicate expenses on the Expenses pag Use this method when both expenses are inside the same report. -1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Reports** +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Spend > Reports** 2. Click the report to open it. 3. Select the two expenses you want to merge 4. Choose **Selected**, then select **Merge**. @@ -77,14 +77,13 @@ Use this method when both expenses are inside the same report. --- - +![expenses selected within a report > Selected button > Merge highlighted]({{site.url}}/assets/images/ExpensifyHelp-ResolveDuplicates_01){:width="100%"} + +--- ## How to merge duplicate expenses from an individual expense -1. In the navigation tabs (on the left on web, and at the bottom on mobile), select **Reports > Expenses** +1. In the navigation tabs (on the left on web, and at the bottom on mobile), select **Spend > Expenses** 2. Click on the expense you want to merge to open it. 3. Choose **More**, then select **Merge**. 4. Choose one eligible expense to merge with, then select **Continue**. @@ -94,10 +93,7 @@ Design request:https://github.com/Expensify/Expensify/issues/614048 --- - +![one individual expense > More > Merge > one eligible expense selected]({{site.url}}/assets/images/ExpensifyHelp-ResolveDuplicates_02){:width="100%"} --- diff --git a/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md b/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md index a548ae3a9c51..3ab387573d47 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md +++ b/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md @@ -46,7 +46,7 @@ You can move expenses to a different report, create a new report, or remove them **To move or remove all expenses from a report** -1. In the left side tabs, choose **Reports** > **Reports** +1. In the left side tabs, choose **Spend** > **Reports** 2. Click the report with the expense(s) you want to move. 3. Choose the green **selected** button > **Move expense(s)**. 4. Choose a destination report, select **Create report** to create a new report, or select **Remove from report**. @@ -90,6 +90,8 @@ The table displays: - Date - Merchant - Category +- Attendees (if any expense has attendees) +- Per attendee (if any expense has attendees) - Amount - Workspace violations (if applicable) diff --git a/docs/articles/new-expensify/reports-and-expenses/Per-Diem-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Per-Diem-Expenses.md index 724b8cf9e4f8..84ac15bf2580 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Per-Diem-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Per-Diem-Expenses.md @@ -12,7 +12,7 @@ Per diem expenses let you claim daily allowances for meals, lodging, and inciden **To create a per diem expense:** -1. In the navigation tabs, click the green **+** button and click **Create expense**. +1. In the navigation tabs, click the **+** button and click **Create expense**. 2. If your workspace supports Per Diem, you'll have the option to choose **Per Diem**. 3. Choose the **workspace** you want to create under (if you're in more than one). 4. Choose the **destination** (country or region where the Per Diem applies). diff --git a/docs/articles/new-expensify/reports-and-expenses/Reconcile-Reimbursements.md b/docs/articles/new-expensify/reports-and-expenses/Reconcile-Reimbursements.md index cf1d1753e168..c255ed6abaec 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Reconcile-Reimbursements.md +++ b/docs/articles/new-expensify/reports-and-expenses/Reconcile-Reimbursements.md @@ -19,7 +19,7 @@ Workspace Admins can reconcile reimbursements when Payments is enabled on the Wo ## How to View Reimbursements -1. Click the navigation tabs (on the left on web, on the bottom on mobile) and go to **Reports**. +1. Click the navigation tabs (on the left on web, on the bottom on mobile) and go to **Spend**. 2. In the **Reconciliation** section, select **Reimbursements**. 3. Choose the **Withdrawn** filter to select a date range (for example, **Last month**). 4. Review the list of reimbursements. Each row represents a reimbursement for a single report and shows the total amount. diff --git a/docs/articles/new-expensify/reports-and-expenses/Reimbursement-Failure-Reasons.md b/docs/articles/new-expensify/reports-and-expenses/Reimbursement-Failure-Reasons.md index 3e766468c1d3..bbbbaf05335e 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Reimbursement-Failure-Reasons.md +++ b/docs/articles/new-expensify/reports-and-expenses/Reimbursement-Failure-Reasons.md @@ -150,10 +150,8 @@ Review the FAQ in [Connect a Personal Bank Account](https://help.expensify.com/a If you're a Workspace Admin, follow these steps: -1. Go to the **Reports** tab. - - On web: Select **Reports** from the navigation tabs on the left. - - On mobile: Tap **Reports** in the navigation tabs on the bottom. -2. Open the returned report (status: **Approved**). +1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Spend > Reports**. +2. Click the returned **Approved** report to open it. 3. Review the failure message in the report chat. 4. Confirm the member has corrected their bank details. 5. Click **Pay** to retry the reimbursement. @@ -166,17 +164,13 @@ Payments aren't retried automatically -- you must manually click **Pay** after t If your reimbursement failed, follow these steps: -1. Open the report from the **Reports** tab. - - On web: Select **Reports** from the navigation tabs on the left. - - On mobile: Tap **Reports** in the navigation tabs on the bottom. -2. Review the failure message in the report chat. -3. Go to **Settings**. - - On web: Select **Settings** from the navigation tabs on the left. - - On mobile: Tap your profile picture, then tap **Settings**. -4. Select **Wallet**. -5. Remove your existing bank connection. -6. Add your bank account again using the exact name and correct account details. -7. Let your Workspace Admin know once your information is updated. +1.In the navigation tabs (on the left on web, on the bottom on mobile) go to **Spend > Reports**. +2. Click the report to open it. +3. Review the failure message in the report chat. +4. Go to **Account > Wallet.** +6. Remove your existing bank connection. +7. Add your bank account again using the exact name and correct account details. +8. Let your Workspace Admin know once your information is updated. --- diff --git a/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md index 361067118800..850c468f396a 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Search-and-Download-Expenses.md @@ -19,7 +19,7 @@ Expensify offers flexible options to search and export expenses and reports in v ## Web -1. In the navigation tabs (on the left on web, and at the bottom on mobile) on the left, select **Reports > Expense Reports**. +1. In the navigation tabs (on the left on web, and at the bottom on mobile) on the left, select **Spend** > **Reports**. 2. Check the box next to the expenses or reports you want to export, or use the top checkbox to select all. 3. Click **Selected** at the top. 4. Select **Export** from the dropdown. @@ -35,7 +35,7 @@ Youโ€™ll receive a message from **Concierge** with the exported file. ## Mobile -1. In the navigation tabs (on the left on web, and at the bottom on mobile) at the bottom, select **Reports > Expense Reports**. +1. In the navigation tabs (on the left on web, and at the bottom on mobile) at the bottom, select **Spend** > **Reports**. 2. Tap the three-line icon in the top-right corner. 3. Choose between **Reports** or **Expenses**. 4. Check the box next to the items you want to export, or use the top checkbox to select all. diff --git a/docs/articles/new-expensify/reports-and-expenses/Statement-Matching-and-Reconciliation.md b/docs/articles/new-expensify/reports-and-expenses/Statement-Matching-and-Reconciliation.md index e895f4c09927..8cba52374972 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Statement-Matching-and-Reconciliation.md +++ b/docs/articles/new-expensify/reports-and-expenses/Statement-Matching-and-Reconciliation.md @@ -19,7 +19,7 @@ Workspace Admins whose workspace has at least one company card feed connected ca ## How to Reconcile Company Card Transactions Against a Statement -1. Click the navigation tabs (on the left on web, on the bottom on mobile) and go to **Reports**. +1. Click the navigation tabs (on the left on web, on the bottom on mobile) and go to **Spend**. 2. In the **Reconciliation** section, select **Card statements**. 3. Choose the **Feed** filter to select the card feed or account you want to reconcile. 4. Choose the **Posted** filter to select the date range that matches your statement period. diff --git a/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md index a7b75bd511f0..917bd85a53cf 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md @@ -59,7 +59,7 @@ Other ways to SmartScan receipts include: # Locate and Manage Tracked Expenses -After creating an expense, go to **Reports > Expenses** to locate the expense. Use the filters to search if the expense isn't immediately visible. +After creating an expense, go to **Spend > Expenses** to locate the expense. Use the filters to search if the expense isn't immediately visible. Click into the expense to: - **Submit it to someone** โ€“ Send it for approval or processing. @@ -73,7 +73,7 @@ Click into the expense to: ## Can I edit an expense after tracking it? -Yes! Open the expense from **Reports > Expenses** or your expense chat, then click into it to edit any field. +Yes! Open the expense from **Spend > Expenses** or your expense chat, then click into it to edit any field. ## Why canโ€™t I find my expense? diff --git a/docs/articles/new-expensify/reports-and-expenses/Troubleshoot-SmartScan-Issues.md b/docs/articles/new-expensify/reports-and-expenses/Troubleshoot-SmartScan-Issues.md new file mode 100644 index 000000000000..daa1557ea500 --- /dev/null +++ b/docs/articles/new-expensify/reports-and-expenses/Troubleshoot-SmartScan-Issues.md @@ -0,0 +1,95 @@ +--- +title: Troubleshoot SmartScan Issues +description: Fix common SmartScan problems, including failed, stuck and incorrect scans. +keywords: [New Expensify, SmartScan, scan failed, receipt not scanning, illegible receipt, blurry receipt, stuck scanning, SmartScan error, scan not working, SmartScan troubleshooting, SCANREADY, wrong amount, wrong merchant, wrong date, SmartScan incorrect] +internalScope: Audience is all Expensify members. Covers troubleshooting SmartScan failures, image quality tips, and what to do when SmartScan returns incorrect data. Does not cover how to attach, replace, or crop receipts. +--- + +# Troubleshoot SmartScan Issues + +SmartScan reads receipt images and automatically fills in expense details like the amount, date, and merchant. If SmartScan fails, gets stuck, or scans incorrectly, use this guide to identify the cause and fix the issue. + +For help attaching or cropping receipt images, learn how to [attach and edit receipts on expenses](/articles/new-expensify/reports-and-expenses/Attach-and-edit-receipts-on-expenses). + +--- + +## Why SmartScan fails to complete a scan + +SmartScan requires a readable date and total amount on the receipt. If either of these cannot be detected, the scan will fail. + +Common reasons include: + + - The receipt does not include a date or total + - The date or total is cut off or cropped out + - The image is blurry or out of focus + - The receipt is faded or low contrast + - The text is obscured by shadows, glare, or folds + +If SmartScan cannot confidently read these required fields, the scan will fail and will show the message: "Receipt scanning failed. Enter details manually". + +--- + +## How to fix when SmartScan fails to complete a scan + +SmartScan may fail if the receipt image is unreadable, or if the image is missing a clear expense date or amount. + +To retry SmartScan: + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Spend > Expenses**. +2. Click to open the expense with the failed scan. It will show a red dot indicator with the message "receipt scanning failed". +3. Click the receipt image. +4. Click **Replace**. +5. Take a photo or upload a receipt image file from your device. + +Once a new receipt image is added, SmartScan will run again. + +--- + +## How to troubleshoot stuck or delayed scans + +SmartScan usually completes within a few seconds, but delays can happen. + +- **Wait a few minutes**. Scans may take longer during high-traffic periods. +- **Check your internet connection**. SmartScan requires an active connection to process. If you were offline when you took the photo, the scan will begin once you reconnect. +- **Check the photo library on your device**. If SmartScan fails to process the images, it will be saved to the photo library on your device. + +If the scan is still stuck after several minutes, you can retry SmartScan with a new photo to trigger a fresh scan. + +--- + +## How to fix incorrect scans + +SmartScan extracts data automatically, but it may misread certain receipts โ€” especially handwritten receipts, faded ink, or unusual formats. + +If the details of the expense are wrong, you can: + - [Ask Concierge to fix it for you](/articles/new-expensify/concierge-ai/What-Concierge-Can-Do#what-actions-concierge-can-take-on-expenses), or + - [Edit the expense manually](/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report#who-can-edit-or-modify-expenses-in-a-report). + +--- + +## How to take better receipt photos for SmartScan + +Improving image quality helps prevent most SmartScan issues. + +- Lay the receipt flat on a contrasting surface (e.g., a dark receipt on a light table). +- Capture the full receipt and make sure all edges, the total, date, and merchant name are visible. +- Avoid shadows and glare. Hold your phone directly above the receipt and use even lighting. +## How to take better receipt photos for SmartScan +- Avoid folded or crumpled receipts. Flatten the receipt before photographing it. +- Use the in-app camera. The Expensify camera is optimized for receipt scanning. + +--- + +# FAQ + +## Why does SmartScan keep failing on the same receipt? + +The receipt image may be too low quality for SmartScan to process. Try taking a new photo with better lighting and ensure the full receipt is in frame. If it continues to fail, the receipt may be too faded, damaged, or in a non-standard format โ€” in that case, enter the expense manually instead. + +## What file types does SmartScan support? + +SmartScan works with common image formats (JPG, PNG) and PDF files. For best results on mobile, use the in-app camera to take a photo directly. + +## SmartScan got the currency wrong. How do I fix it? + +Open the expense, tap the **Amount** field, and change the currency manually. This can happen with receipts from international merchants or receipts that don't clearly display a currency symbol. diff --git a/docs/articles/new-expensify/reports-and-expenses/Unapproved-Spend.md b/docs/articles/new-expensify/reports-and-expenses/Unapproved-Spend.md deleted file mode 100644 index 999e99b807a6..000000000000 --- a/docs/articles/new-expensify/reports-and-expenses/Unapproved-Spend.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: Unapproved Spend -description: See all unapproved cash and card spend in one place so you can close the books without missing anything. -keywords: [unapproved spend, unapproved cash, unapproved card, accounting, liabilities, month-end close, New Expensify, workspace admin, approvals, reimbursements, card feed] ---- - -Unapproved expenses that havenโ€™t been recorded yet can create hidden liabilities. The Unapproved Spend views bring them together in one place so you can post accruals, follow up with submitters, and keep your general ledger accurate. - -# Unapproved Spend - -The Unapproved Spend section includes two views: **Unapproved Cash** and **Unapproved Card**. Both show expenses that are waiting for approval and have not yet been posted to your books, so you can take action before closing the period. - ---- - -## Who can use the Unapproved Spend view -Workspace Admins with: -- Approvals turned on -- Either reimbursements enabled or at least one card feed connected - ---- - -## How to get there -Go to: -- **Reports > Monthly accrual > Unapproved cash** โ€” reimbursable expenses paid out-of-pocket -- **Reports > Monthly accrual > Unapproved card** โ€” expenses from connected card feeds - -These views come with filters already applied: - -**Unapproved Cash** -`type:expense group-by:from status:drafts,outstanding reimbursable:yes` - -**Unapproved Card** -`type:expense group-by:from status:drafts,outstanding feed:"Your Bank"` - - ---- - -## What youโ€™ll see -- **Grouped view:** Expenses are grouped by submitter, with a subtotal for each person. -- **Helper filters:** If you have more than one card feed, a **Feed** filter appears so you can focus on one account at a time. -- **Total footer:** The total at the bottom shows the amount to accrue, without needing to export first. -- **Pre-filtered results:** Cash shows only unapproved reimbursable expenses; Card shows only unapproved card expenses, so nothing gets overlooked. - -**Note:** If your company uses cash basis accounting, you may not need to accrue these amounts. - ---- - -## Steps to post accruals -1. Open the relevant **Unapproved** view. -2. (Optional) Adjust the **Date** or **Posted** filters to match your period cut-off. -3. Review the **Total** in the footer โ€” this is the amount to accrue. -4. Click **More > Export** if you need a detailed CSV. -5. Message submitters directly from the list to clear items before close. - ---- - -# FAQ - -## Why track unapproved spend? -Because even if itโ€™s not approved yet, itโ€™s still money your business owes. Seeing it all in one place helps you stay on top of liabilities. - -## Can I group unapproved expenses by submitter? -Yes. Both Unapproved Cash and Unapproved Card group expenses by submitter so you can see who has unapproved items and the per-person subtotal. - -## What if I donโ€™t see the Monthly accrual section on the Reports page? -Youโ€™ll need to be a Workspace Admin with approvals turned on, plus either reimbursements or at least one card feed connected. If your workspace has only cash, only **Unapproved cash** appears under Monthly accrual; if it has only card feeds, only **Unapproved card** appears. - -## Can I filter unapproved card expenses by feed? -Yes. If you have more than one card feed, a **Feed** filter will appear so you can review one account at a time. - diff --git a/docs/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions.md b/docs/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions.md index bae7dcafc8cc..488faf0d7432 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions.md +++ b/docs/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions.md @@ -15,7 +15,7 @@ Each report status reflects a different stage in the reporting workflow: - **Draft**: The report is a draft. You can still add or remove expenses. - **Submitted**: The report is awaiting approval from another person. - **Outstanding**: The report is in progressโ€”under review, held, or partially completed. -- **Approved**: The report is approved and ready for payment or export. This is the final status for reports containing only non-reimbursable (e.g. company card) expenses. +- **Approved**: The report is approved and ready for payment or export. - **Paid**: The report has been paid. All actions are complete. - **Done**: The report is locked. No further edits or deletions can be made. This is the final status for non-approvable reports. @@ -33,6 +33,7 @@ The grey **More** button is always visible in the report header. Tap it to acces - Hold / Unhold - Unapprove +- Duplicate report - Download as CSV - **Print** - Change Workspace diff --git a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Shortcuts.md b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Shortcuts.md new file mode 100644 index 000000000000..c9c4b5363ada --- /dev/null +++ b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Shortcuts.md @@ -0,0 +1,63 @@ +--- +title: Use Search Shortcuts +description: Quickly find and take action on expense reports using role-based search shortcuts on the Spend page. +keywords: [New Expensify, search, shortcut, Spend page, expense reports filters, submit, approve, pay, in bulk, multiple reports] +internalScope: Audience is all members. Covers using search shortcuts on the Spend page. Does not cover advanced search operators or saved searches. +--- + +# Use Search Shortcuts + +Search shortcuts on the **Spend** page apply pre-built filters that help you quickly find expense reports that need action. These shortcuts are automatically tailored to your role and show only the reports that require your attention. + +To create your own custom searches with advanced filtering, [learn how to use search operators](/articles/new-expensify/reports-and-expenses/Using-search-operators). + +--- + +## Who can use search shortcuts + +All members can use search shortcuts, but what you see depends on your role: + + - **Submitters:** See **Drafts** when reports are ready to submit + - **Approvers:** See **Needs approval** when reports require review + - **Workspace Admins:** See **Ready to pay** when reports are approved and reimbursements are enabled + +--- + +## How to use search shortcuts + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Spend**. +2. In the **Expense reports** section, choose a search shortcut. + +Shortcuts include: + +- **Drafts** โ€“ Reports you need to submit +- **Needs approval** โ€“ Reports awaiting your review +- **Ready to pay** โ€“ Reports ready for reimbursement + +Workspace Admins will see additional search shortcuts for reconciliation in the Accounting section. [Learn how to reconcile expenses with Accounting](/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts). + +--- + +## What happens after you use search shortcuts + + - The selected shortcut opens a filtered list of reports + - You can take action directly (submit, approve, or pay) + - Results update automatically as reports are processed + +These shortcuts help you process reports in bulk without manually creating filters. + +--- + +# FAQ + +## Can I use search shortcuts across multiple workspaces? + +Yes. Search shortcuts on the **Spend** page surface reports across multiple workspaces. Within the results, you can use **Filters** to refine the results. + +## Do search shortcuts update automatically? + +Yes. Search shortcuts update in real time as reports are submitted, approved, paid, or exported. + +## Can I customize the search shortcuts? + +No. Search shortcuts are automatically generated. To create custom searches, [learn how to use search operators](/articles/new-expensify/reports-and-expenses/Using-search-operators). diff --git a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md index 376a7dfe7a43..b04e608d5470 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md +++ b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md @@ -1,44 +1,42 @@ --- -title: Using Reports in New Expensify -description: Learn how to use Reports in New Expensify to search, filter, customize columns, and save reports for expenses, invoices, trips, and chats. -keywords: [Reports, New Expensify, report filters, search commands, custom columns, saved reports, group expenses, invoices, expenses, chats, trips, reimbursement tracking, view expenses, customize report view, reporting table columns] +title: Using Spend in New Expensify +description: Learn how to use Spend in New Expensify to search, filter, customize columns, and save searches for expenses, invoices, trips, and chats. +keywords: [Spend, New Expensify, report filters, search commands, custom columns, saved searches, group expenses, invoices, expenses, chats, trips, reimbursement tracking, view expenses, customize report view, reporting table columns] ---
-Use the **Reports** tab in New Expensify to view and manage expenses, invoices, trips, chats, and more. You can search with keywords, apply filters, customize columns, and save searches to reuse later. +Use the **Spend** tab in New Expensify to view and manage expenses, invoices, trips, chats, and more. You can search with keywords, apply filters, customize columns, and save searches to reuse later. --- -# Where to find Reports in New Expensify +# Where to find Spend in New Expensify -- **Web:** Go to the **Reports** tab in the navigation on the left. -- **Mobile:** Tap **Reports** in the navigation tabs at the bottom. +- **Web:** Go to the **Spend** tab in the navigation on the left. +- **Mobile:** Tap **Spend** in the navigation tabs at the bottom. -You can also open **Reports** by clicking the magnifying glass icon in the top-right corner. +You can also open **Spend** by clicking the magnifying glass icon in the top-right corner. --- -# What you can view in Reports +# What you can view in Spend -Reports includes the following types of data: +Under **Spend**, the **Explore** section includes **Reports**, **Expenses**, and **Chats**. Choose **Reports** to view and filter expense reports; choose **Expenses** or **Chats** for those lists. From Spend you can also search other types (invoices, trips, tasks): -- **Expenses** -- **Expense Reports** -- **Chats** -- **Invoices** -- **Trips** -- **Tasks** +- **Reports** โ€” expense reports +- **Expenses** โ€” individual expenses +- **Chats** โ€” conversations +- **Invoices**, **Trips**, and **Tasks** โ€” from Spend search Each type has its own filters and search options. --- -# How to filter Reports +# How to filter in Spend Filter reports by type, status, category, or date to find specific information. -1. Go to the **Reports** tab. +1. Go to the **Spend** tab. 2. Enter a keyword in the search bar (optional). 3. Choose a type tab - **Expense**, **Expense Report**, **Chat**, **Invoice**, **Trip**, or **Task**. 4. Click **Filters**. @@ -48,16 +46,16 @@ Filter reports by type, status, category, or date to find specific information. - **From:** You, or someone else 6. Click **View Results** to see the filtered results. -![Reports page showing available report filtering options]({{site.url}}/assets/images/Filter-reports.png){:width="100%"} +![Spend page showing available report filtering options]({{site.url}}/assets/images/Filter-reports.png){:width="100%"} --- -## How to customize the Reports column view +## How to customize the Spend column view -On the web, you can customize which columns appear when viewing **Reports** or **Expenses**. This helps surface key details and tailor the view to your workflow. +On the web, you can customize which columns appear when viewing **Reports** or **Expenses** under **Explore**. This helps surface key details and tailor the view to your workflow. To customize columns: -1. Go to the **Reports** tab on the left (web only). +1. Go to the **Spend** tab on the left (web only). 2. Run a search or apply filters if needed. 3. Click the **Columns** icon (next to **Filters**). 4. Use the checkboxes to show or hide specific columns. @@ -72,7 +70,7 @@ You can choose from a wide range of columns, including: - **Workspace** โ€“ The workspace the report belongs to - **Action** โ€“ Shows available actions like approve or reject -To view the full list of available columns, click the **Columns** icon in the **Reports** tab. +To view the full list of available columns, click the **Columns** icon in the **Spend** tab. --- @@ -88,7 +86,7 @@ To group expenses: Your selected layout will be remembered and applied to other reports you view. -# How to use Reports search query commands +# How to use Spend search query commands Use search commands in the search bar to run advanced queries based on specific fields. @@ -143,7 +141,7 @@ Each report type has specific filters and search tools for finding the informati - Search by merchant, category, or tag - Helpful for tracking unsubmitted or reimbursed expenses -## Expense Reports +## Reports - Filter by status: Draft, Outstanding, Approved, Paid, or Done - Search by submitter or workspace @@ -184,7 +182,7 @@ Filters are enabled incrementally. If a filter isnโ€™t visible, ensure that your Expensify ensures cross-platform consistency where possible. However, certain features (like customizable columns) are only available on web for larger screen layouts. ## How do I find unsubmitted reports? -1. Go to the **Reports** tab. +1. Go to the **Spend** tab. 2. Select **Expense** from the Type filter. 3. Apply the **Draft** filter to view all unsubmitted reports. diff --git a/docs/articles/new-expensify/reports-and-expenses/Using-Search-on-the-Reports-Page.md b/docs/articles/new-expensify/reports-and-expenses/Using-Search-on-the-Reports-Page.md deleted file mode 100644 index 71e4a2652e09..000000000000 --- a/docs/articles/new-expensify/reports-and-expenses/Using-Search-on-the-Reports-Page.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Using Search on the Reports Page -description: Learn how role-based search simplifies submitting, approving, or paying expense reports in New Expensify. -keywords: [New Expensify, search, Submit, Approve, Pay, To-do, LHN, batch processing, filters, expense report actions] ---- - - -Using the search function on the reports page helps you quickly take action on reports you need to submit, approve, or pay, all tailored to your role in a workspace. - ---- - -# Search on the Reports Page - -**Navigation: In the navigation tabs (on the left on web, and at the bottom on mobile), select Reports > Expense Reports > To-do**. - -The **To-do** section surfaces key report actions so you can: -- Submit expenses waiting on you -- Approve reports that need your review -- Pay approved reports ready for reimbursement -- Export finalized reports to accounting platforms - -These shortcuts are personalized based on your role and workspace configuration, helping you process reports in batches without manually building advanced searches. - -![An image of the Search function on the Reports page on New Expensify]({{site.url}}/assets/images/export.png){:width="100%"} - ---- - -# How to Use Search Shortcuts - -Search options appear under the **To-do** section in the navigation tabs (on the left on web, and at the bottom on mobile). - -Depending on your role, youโ€™ll see shortcuts like: -- **Submit** โ€“ Reports youโ€™ve drafted and need to submit. -- **Approve** โ€“ Reports awaiting your review. -- **Pay** โ€“ Reports ready for reimbursement. -- **Export** โ€“ Reports ready for accounting export (e.g., to QuickBooks). - -Admins may also see: -- **Monthly accrual** -- Unapproved cash, Unapproved card. -- **Reconciliation** -- Card statements, Expensify Card, Reimbursements. - ---- - -# Member Views and Access - -## All members -- Can access **Reports**, **Expenses**, and **Chats (Inbox tab)** from the **Explore** section. -- If no reports exist, the To-do section will prompt you to create one. - -## Employees (Submitters) -- See **Submit** when they have unsubmitted reports. -- See a confirmation message (e.g., โ€œAll caught up!โ€) when nothing remains. - -## Approvers -- See **Approve** for reports awaiting their review. -- See a message like โ€œNothing left to reviewโ€ when done. - -## Admins (Payments) -- See **Pay** for reports ready to reimburse. -- See a celebratory message (e.g., โ€œAll reports paid!โ€) when cleared. - -### Admins (Exports) -- See **Export** for reports eligible for accounting export (e.g., to QuickBooks Online). -- Filter uses `exported:never` to show only unexported reports. - ---- - -# Best Practices - -- Start with the **To-do** section to prioritize daily report actions. -- Save your favorite filters or searches for reuse. -- Use advanced search only when needed for specific cases. - ---- - -# FAQ - -## What if I don't see the Submit, Approve, Pay or Export options? - -These search shortcuts only appear when: -- You have reports that require those actions. -- You have the correct workspace permissions (e.g., Admin, Approver). - -## Can I use these searches across multiple workspaces? - -Not yet. The **Workspace filter** is currently single-select. Multi-workspace support is planned for a future update. - -## What is the difference between Explore and To-do? - -- **Explore** = General browsing (e.g., Expenses, Chats) -- **To-do** = Personalized actions based on your role (e.g., Submit, Approve, Pay, Export) - diff --git a/docs/articles/new-expensify/reports-and-expenses/Using-search-operators.md b/docs/articles/new-expensify/reports-and-expenses/Using-search-operators.md index 8c7e3f955a2b..82a481ded9d2 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Using-search-operators.md +++ b/docs/articles/new-expensify/reports-and-expenses/Using-search-operators.md @@ -1,18 +1,17 @@ --- -title: "How to use search operators in Expensify" -description: "Learn how to use advanced search filters, comparisons, and groupings to find exactly what you need across expenses, chats, reports, and more." -keywords: "search operators, filters, search rules, expense search, report search, chat filters, advanced search, group-by, search syntax" +title: How to use search operators in Expensify +description: Learn how to use advanced search filters, comparisons, and groupings to find exactly what you need across expenses, chats, reports, and more. +keywords: [New Expensify, search operators, filters, search rules, expense search, report search, chat filters, advanced search, group-by, view, chart, search syntax] +internalScope: Audience is all Expensify users. Covers search operator syntax for filtering, grouping, and chart views. Does not cover saved search management or Search page UI navigation. --- -
- The search operator framework lets you quickly filter, sort, and group items like expenses, chats, reports, and tasks using powerful text-based rules. This guide walks you through the supported syntax, available filters, and usage tips. --- # Who can use search operators in Expensify -Anyone can use search operators when filtering data in features like Expenses, Reports, Chats, and Tasks. This is especially helpful for Workspace Admins, accountants, and finance teams looking to analyze or export targeted data. +Anyone can use search operators when filtering data in features like expenses, reports, chats, and tasks. This is especially helpful for Workspace Admins, accountants, and finance teams looking to analyze or export targeted data. --- @@ -101,12 +100,12 @@ type:task assignee:"Charlie Brown" status:outstanding --- -# Available filters for grouping and currency conversion +## How to use grouping, chart views, and currency conversion filters -Use `group-by:` to analyze data by dimension, and `group-currency:` to normalize totals. +Use `group-by:` to analyze data by dimension, `view:` to choose how grouped results are displayed, and `group-currency:` to normalize totals. ``` -group-by:merchant group-currency:USD +group-by:category view:bar group-currency:USD ``` Supported groupings include: @@ -123,6 +122,19 @@ Supported groupings include: - `group-by:quarter` - Group by fiscal quarter - `group-by:year` - Group by calendar year +## How to choose a chart view for grouped results + +When using `group-by:`, you can add `view:` to control the visualization type. If you save a grouped search with a specific `view:`, that choice is preserved in the saved search label. + +Supported views: + +- `view:table` - Display grouped results as a table (default) +- `view:bar` - Display grouped results as a bar chart +- `view:pie` - Display grouped results as a pie chart +- `view:line` - Display grouped results as a line chart + +> **Note:** The `view:` operator only applies when `group-by:` is also used. Without `group-by:`, the `view:` value is ignored. + --- # FAQ @@ -139,4 +151,3 @@ If the search operator isnโ€™t recognized, the system will ignore it and return Only use quotes for values that include spaces or exact phrases, like `description:"client lunch"` or `in:"#general"`. -
diff --git a/docs/articles/new-expensify/settings/Account-Settings.md b/docs/articles/new-expensify/settings/Account-Settings.md index 00fe74ac27bd..fc82f3cfdd24 100644 --- a/docs/articles/new-expensify/settings/Account-Settings.md +++ b/docs/articles/new-expensify/settings/Account-Settings.md @@ -128,9 +128,12 @@ Your photo helps teammates identify you easily in chats, reports, and notificati ## Will changing my language affect reports? No. It only changes the language of your user interface, not the content of your reports. -## Can I disable all Expensify notifications? +## Can I disable all Expensify app notifications? Yes. From the navigation tabs, go to **Account > Preferences** and toggle off both update and sound notifications. +## Can I disable email notifications for reports that I approve for reimbursement? +No. To comply with regulations in several US states, Expensify is required to send you an email whenever you send money to another individual. + ## Will theme preferences sync across devices? Yes! Your chosen theme applies across web, mobile, and desktop versions of Expensify. diff --git a/docs/articles/new-expensify/settings/Avoiding-common-scams.md b/docs/articles/new-expensify/settings/Avoiding-common-scams.md index a1a78b8759fe..fa64e148024a 100644 --- a/docs/articles/new-expensify/settings/Avoiding-common-scams.md +++ b/docs/articles/new-expensify/settings/Avoiding-common-scams.md @@ -2,16 +2,17 @@ title: Avoiding common scams description: Learn how to recognize and avoid common phishing scams that pretend to be from Expensify, including fake phone calls and emails asking for your Magic Code. keywords: [Expensify scam, phishing, Magic Code, security, login code, avoid fraud, suspicious email, fake call, fraud prevention] +internalScope: Audience is all Expensify members. Covers identifying and responding to scams involving Magic Codes. Does not cover account recovery or dispute workflows. --- -
- -# How to avoid scams pretending to be from Expensify +# Avoiding common scams Some scammers try to impersonate Expensify by sending fake emails or calling you directly, often asking for your **Magic Code** (your login code). These scams are designed to trick you into giving up access to your Expensify account. -This guide explains what these scams look like, how to spot them, and what to do if you think you've been targeted. +This article explains how these scams work, how to recognize them, and what actions to take. + +**Note:** Expensify will never call you asking for login codes, passwords, or payment information. If you receive such a call, it's a scam. --- @@ -23,21 +24,17 @@ The **Magic Code** is a one-time login code Expensify emails or texts to you whe --- -## Common scams to watch out for - -Here are the most common types of scams we've seen targeting Expensify members: - -### 1. Phone call scams requesting your Magic Code +## How to recognize phone call scams pretending to be Expensify - The scammer claims to be from Expensify and needs your Magic Code to verify your account. - They may spoof a legitimate phone number to make it seem more convincing. - Some scammers may create a sense of urgency (e.g., โ€œWe detected suspicious activity on your account.โ€) -**If someone calls you asking for your Magic Code, hang up immediately.** +**If you receive this type of call, hang up.** --- -### 2. Phishing emails and texts requesting you use their link to login to Expensify +## How to recognize phishing emails or texts pretending to be Expensify - Emails and text messages often look like theyโ€™re from Expensify or Visa, but the sender address is likely not from an expensify.com email address. - These emails and text messages may have links that lead to a fake login page, prompt you to reset your password, or ask you to reply with your Magic Code. @@ -47,24 +44,38 @@ Here are the most common types of scams we've seen targeting Expensify members: --- -## How to stay safe - -Hereโ€™s how you can protect your Expensify account: +## How to protect your Expensify account - **Never share your Magic Code** โ€” not over the phone, not by email, not via text. - **Donโ€™t click suspicious links** โ€” always verify the senderโ€™s email address. -- **Use 2FA** (two-factor authentication) to add an extra layer of security. -- **If in doubt, message Concierge** directly from within the Expensify app. +- **Use two-factor authentication (2FA)** - this adds an extra layer of security to your account. +- **If in doubt, message Concierge** - reach out directly from within the Expensify app. + +[Learn how to enable two-factor authentication](/articles/new-expensify/settings/Two-Factor-Authentication#how-to-enable-two-factor-authentication). + +--- + +## What to do if you receive a Magic Code you didn't request + +If you receive a Magic Code email but didnโ€™t try to log in, this does not necessarily mean your account has been compromised. It can happen for a variety of reasons, such as someone mistyping an email address. + +Here's what to do: + +1. **Do not share the code with anyone** โ€” simply ignore or delete the email. +2. **Do not click any links** in the email. +3. **Consider enabling [two-factor authentication](https://help.expensify.com/articles/new-expensify/settings/Two-Factor-Authentication)** for extra account security. + +**Note:** You do not need to lock your account just because you received an unexpected Magic Code. Only lock your account if you believe someone has gained access to your email account or if you shared your Magic Code with someone. --- ## What to do if you shared your Magic Code -If you accidentally gave someone your Magic Code, act fast: +If you accidentally gave someone your Magic Code, act fast to: -1. **[Lock your Expensify account](https://help.expensify.com/articles/expensify-classic/settings/Report-Suspicious-Activity)** immediately to block unauthorized access. +1. **[Lock your Expensify account](/articles/expensify-classic/settings/Report-Suspicious-Activity)** immediately to block unauthorized access. 2. **Contact Concierge** from a safe device and explain the situation. -3. **[Enable two-factor authentication](https://help.expensify.com/articles/new-expensify/settings/Two-Factor-Authentication)** to protect your account going forward. +3. **[Enable two-factor authentication](/articles/new-expensify/settings/Two-Factor-Authentication)** to protect your account going forward. --- @@ -72,7 +83,7 @@ If you accidentally gave someone your Magic Code, act fast: If you believe your account has been targeted or compromised: -1. **[Lock your Expensify account](https://help.expensify.com/articles/expensify-classic/settings/Report-Suspicious-Activity)** immediately to block unauthorized access. +1. **[Lock your Expensify account](/articles/expensify-classic/settings/Report-Suspicious-Activity)** immediately to block unauthorized access. 2. **Message Concierge immediately** via [email](mailto:concierge@expensify.com), or from within the Expensify app (web or mobile). 3. Let us know if youโ€™ve received a suspicious message or phone call. 4. Our team will work to secure your account. @@ -92,8 +103,11 @@ Expensify uses Magic Codes instead of passwords to simplify login while keeping Legitimate Expensify emails always come from an address ending in **@expensify.com** or **@team.expensify.com**. If youโ€™re unsure, donโ€™t click any links โ€” just log into the Expensify app directly and message Concierge. +## I received a Magic Code I didn't request. Is my account compromised? + +No. Receiving an unrequested Magic Code does not mean your account is compromised. It most likely means someone entered your email address by mistake when trying to log in. As long as you don't share the code with anyone, your account is safe. You can simply ignore or delete the email. If you want extra protection, enable [two-factor authentication](/articles/new-expensify/settings/Two-Factor-Authentication). + ## Can Expensify call me? Expensify will **never** call you asking for login codes, passwords, or payment information. If you receive such a call, it's a scam. -
diff --git a/docs/articles/new-expensify/settings/Close-Account.md b/docs/articles/new-expensify/settings/Close-Account.md deleted file mode 100644 index 362fcce39999..000000000000 --- a/docs/articles/new-expensify/settings/Close-Account.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: Close Account -description: Close an Expensify account and learn how to resolve common issues that prevent account deletion. -keywords: [New Expensify, close account, delete Expensify account, remove user, deactivate account, cancel account, terminate] ---- - -Closing your Expensify account will permanently delete associated personal data. However, expenses shared with others, such as approved or reimbursed company expenses, will remain visible to those users. Expensify may also retain certain records to comply with legal requirements. - ---- - -# How to Close Your Account - -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Account > Security**. -2. Under **Security options**, click **Close account**. -3. Follow the prompts and answer the required questions. -4. Click **Close Account** to confirm. - ---- - -# Troubleshooting: Canโ€™t Close Your Account? - -If you're unable to close your account, itโ€™s likely due to one of the following reasons: - -- **You're under a validated domain**: A Domain Admin must remove you from the domain. -- **You're the only Domain Admin**: Assign a new Domain Admin before proceeding. -- **You're the billing owner on an annual subscription**: Downgrade or transfer billing to another user first. -- **You're a workspace owner**: Assign a new owner to the workspace. -- **Outstanding balance**: Settle any unpaid invoices. -- **You're the Preferred Exporter for an integration**: Assign a new Preferred Exporter before closing. -- **Your verified business account is locked**: Resolve the lock by contacting support. -- **Your verified business account has a balance due**: Make a payment to continue. -- **Your account is unverified**: You must verify the account before it can be closed. - -If you're still having trouble, reach out to **Concierge** for additional assistance. - diff --git a/docs/articles/new-expensify/settings/Close-Your-Expensify-Account.md b/docs/articles/new-expensify/settings/Close-Your-Expensify-Account.md new file mode 100644 index 000000000000..d8a4de53ef06 --- /dev/null +++ b/docs/articles/new-expensify/settings/Close-Your-Expensify-Account.md @@ -0,0 +1,68 @@ +--- +title: Close Your Expensify Account +description: Learn how to close your Expensify account. +keywords: [New Expensify, close account, delete Expensify account, delete account, remove user, deactivate account, cancel account, cancel Expensify, terminate, how to delete Expensify, how to cancel Expensify] +internalScope: Audience is all members. Covers how to close an Expensify account and resolve common errors that block account closure. Does not cover how to delete workspaces or how to remove members from the domain. +--- + +# Close Your Expensify Account + +Closing an Expensify account permanently deletes all unsubmitted expenses and personal data tied to the account. + +Instead of closing your account, you can downgrade to a free account to retain access to your historical data. [Learn how to downgrade to a free account](/articles/new-expensify/billing-and-subscriptions/Changing-Your-Workspace-Plan#how-to-downgrade-an-annual-subscription-to-a-free-account). + +**Note:** If you are the Billing Owner of a Workspace on an Annual Subscription, you canโ€™t close your account directly. You may be able to downgrade to a free account if you meet eligibility criteria, or you must transfer billing ownership before closing your account. + +--- + +## Who can close an Expensify account + +Any member can close their own Expensify account, if: + - They have a verified account + - They are not a member of a controlled domain. + - They are not the Billing Owner of a Workspace + - They do not have any outstanding balance due on their Expensify account. + - They do not have a bank account which is currently locked. + +--- + +## How to close your Expensify account + +1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Account > Security**. +2. Under **Security options**, click **Close account**. +3. Follow the prompts and answer the required questions. +4. Click **Close Account** to confirm. + +If you don't see the option to close your account, your account may be domain-controlled. Contact your Domain Admin and ask them to [deactivate your account](/articles/new-expensify/domains/Manage-Domain-Members#how-to-close-or-deactivate-a-domain-members-account). + +--- + +## What happens when you close your Expensify account + +All unsubmitted expenses and personal data tied to the account are deleted. + +Expenses shared with others, such as expenses submitted on a Workspace, may be retained for regulatory compliance. Submitted expenses remain visible to approvers and Admins on the Workspace where the expenses were submitted. + +--- + +# FAQ + +## Can I close my account if I have an outstanding balance? + +No. To proceed with closing your account, you must first pay the balance owed. [Learn how to add a payment method](/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription#how-to-update-your-expensify-billing-card). + +## Can I close my account if I own workspaces shared with other members? + +No. To proceed with closing your account, you must first delete any workspaces shared with other members, or have another Workspace Admin take ownership of them. + +[Learn how to delete a Workspace](/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription#how-to-delete-a-workspace-in-expensify). +[Learn how to transfer billing ownership](/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription#how-to-transfer-billing-ownership-in-expensify)). +## Can I close my account if I am the Primary Contact on a domain? +No. To proceed with closing your account, you must first set a new Primary Contact on the domain. You can set a new Primary Contact on a domain under **Domain admins > Settings > Primary Contact**. + + +## Can I close my account if I own a Workspace on an Annual Subscription? + +- [Downgrade to a free account](a/rticles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription#how-to-request-early-cancellation-of-an-annual-subscription) (if eligible), or +- [Transfer billing ownership to another Workspace Admin](/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription#how-to-transfer-billing-ownership-in-expensify). + diff --git a/docs/articles/new-expensify/settings/Personal-Karma.md b/docs/articles/new-expensify/settings/Personal-Karma.md index 40d8ebd650a7..7201d18ff105 100644 --- a/docs/articles/new-expensify/settings/Personal-Karma.md +++ b/docs/articles/new-expensify/settings/Personal-Karma.md @@ -8,15 +8,28 @@ keywords: [New Expensify, personal karma, donations, expensify.org, save the wor Personal Karma lets you automatically donate a small percentage of your monthly added expenses to [Expensify.org](https://www.expensify.org/about). -For every $500 in expenses you add, $1 is donated to a related Expensify.org fund. Donations are calculated monthly and charged to the billing card on file. Youโ€™ll receive a donation receipt by email after each charge. +For every $500 in expenses you add, $1 is donated to a related Expensify.org fund. Donations are calculated monthly and charged to the billing card on file. You'll receive a donation receipt by email after each charge. --- -## How to enable or disable Personal Karma +## How to enable Personal Karma -1. In the navigation tabs (on the left on Web, at the bottom on Mobile), choose **Account**. -2. Select **Save the World** -3. Toggle **Personal Karma** to enable or disable it. +1. In the navigation tabs (on the left on web, at the bottom on mobile), choose **Account**. +2. Select **Save the World**. +3. Toggle **Enable Personal Karma** on. + +If you don't have a payment card on file, you'll be prompted to add one before Personal Karma can be enabled. If you dismiss the payment card prompt without adding a card, the toggle will revert to disabled. + +Once enabled, your billing card details are displayed below the toggle. + +--- + +## How to disable Personal Karma + +1. In the navigation tabs (on the left on web, at the bottom on mobile), choose **Account**. +2. Select **Save the World**. +3. Toggle **Enable Personal Karma** off. +4. A confirmation prompt will ask if you want to stop donating to Expensify.org. Select **Disable** to confirm, or **Cancel** to keep Personal Karma enabled. --- diff --git a/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md index 4bc142f64d17..88f06b43fcb4 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Connect-a-Business-Bank-Account.md @@ -1,107 +1,133 @@ --- -title: Connect a Business Bank Account -description: How to connect a business bank account to New Expensify in supported currencies (USD, CAD, GBP, EUR, AUD). -keywords: [New Expensify, business bank account, verified business bank account, send reimbursement, micro transactions, test deposits, verification amounts, bank verification, share bank account, share business bank account] +title: Connect a US Business Bank Account +description: Learn how to connect and verify a US business bank account in Expensify for reimbursements, Expensify Card payments, and invoice payouts. +keywords: [New Expensify, business bank account, connect bank account, verified business bank account, reimbursement, Expensify Card, Plaid, Wallet, workspace payments, link bank account, ACH] +internalScope: Audience is Workspace Admins. Covers connecting a new US business bank account from a workspace or Wallet, and linking an existing one to a workspace for reimbursements. Does not cover validating test transactions, sharing, unsharing, or unlocking a business bank account. --- -
+# Connect a US Business Bank Account -You can connect a business bank account in **New Expensify** using the following supported currencies: **USD, CAD, GBP, EUR, and AUD**. +You can connect a US business bank account from **Wallet**, or from a Workspace if you are a Workspace Admin. Once connected, you can use the bank account for reimbursements, Expensify Card payments, and invoice payouts. -Follow the steps below to connect your business bank account and enable payment features. +This article covers connecting US bank accounts only. If your business bank account is based outside the United States, [learn how to connect an international business bank account](/articles/new-expensify/wallet-and-payments/Enable-Global-Reimbursement). --- -# How to connect and verify a business bank account +## Who can connect a US business bank account -## Step 1: Enable Make or Track Payments +- Any member can connect a US business bank account in **Wallet**. +- Workspace Admins can connect a business bank account to a Workspace. +- The member who sets up the account and all beneficial owners must have a physical US address, a US photo ID, and a US SSN. -1. Click **Workspaces** in the navigation tabs. -2. Navigate to **[Workspace Name] > More Features**. -3. Click **Enable Workflows**. -4. A **Workflows** setting will appear in the left-hand menu. -5. Click **Workflows** and enable **Make or Track Payments**. +To connect a business bank account, the Workspace must have Workflows enabled. [Learn how to enable Workflows](/articles/new-expensify/workspaces/Workspace-Workflows#enable-workflows). -![Enable workflows from the more features page]({{site.url}}/assets/images/Help-ConnectBusinessBankAccount-1.png){:width="100%"} +--- -![Workflows screen with Connect Bank Account button]({{site.url}}/assets/images/Help-ConnectBusinessBankAccount-2.png){:width="100%"} +## How to add a new US business bank account in Wallet + +1. In the navigation tabs (on the left on web, on the bottom on mobile), click **Account > Wallet**. +2. Click **Add bank account**. +3. Select **Make payments**. +4. Choose the country where your bank is located. +5. Select **Log into your bank** (preferred) or **Connect manually**. +6. Complete the identity verification, company details, and Beneficial Owner steps described below. + + --- -## Step 2: Connect your business bank account +## How to connect a new US business bank account in a Workspace -1. Click **Connect Bank Account**. -2. Select either **Connect online with Plaid (preferred)** or **Connect manually**. -3. Enter your bank details. +1. In the navigation tabs (on the left on web, on the bottom on mobile)**,** click **Workspace > [Workspace name]**. +2. Select **Workflows**. +3. Enable **Payments**. +4. In the **Payments** section, select **Add bank account**. +5. Select **Log into your bank** (preferred) or **Connect manually**. +6. Enter your bank details. +7. Complete the identity verification, company details, and Beneficial Owner steps described below. -**Note:** If your business bank account already exists in Expensify, an admin with access can **share it with you** โ€” no need to complete the setup process again. [Learn how to share a business bank account](https://help.expensify.com/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account). +If your business bank account already exists in Expensify, an admin with access can share it with you instead. Learn how to [share a business bank account](/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account). ---- + -## Step 3: Verify your identity with a U.S.-issued photo ID +![Workflows screen with Connect Bank Account button]({{site.url}}/assets/images/Help-ConnectBusinessBankAccount-2.png){:width="100%"} + +--- -After entering your personal details, youโ€™ll be prompted to complete identity verification: -1. Upload a photo of the **front and back** of your ID (**cannot be a photo of an existing image**). -2. Use your device to take a **selfie** and record a **short video** of yourself. +## How to link an existing business bank account to a workspace -**Your ID must be:** -- Issued in the **US** -- Current (**expiration date must be in the future**) +If you already have a verified business bank account, you can share it to another workspace without needing to add it again. [Learn how to share a business bank account](/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account). --- -## Step 4: Add company details for bank account verification +## How to verify your identity with a US-issued photo ID + +After entering your personal details, complete identity verification: + +1. Upload a photo of the **front and back** of your ID. The photo cannot be a photo of an existing image. +2. Take a **selfie** and record a **short video** of yourself. -Add your company details, including: -- **Company Address:** Must be a **physical** US location (**PO Boxes or mail drop addresses will be flagged for review and may delay verification**). -- **Tax Identification Number (TIN):** Assigned by the **IRS**. -- **Company Website:** Required to access most **Expensify payment features**. -- **Industry Classification Code:** Find the list of codes [here](https://www.census.gov/naics/?input=software&year=2022). +Your ID must be issued in the US with an expiration date in the future. --- -## Step 5: Add Beneficial Owner details (if applicable) +## How to add company details for bank account verification -1. Check the appropriate box under **Beneficial Owner**: - - A **Beneficial Owner** is an **individual** who owns **25% or more** of the business. - - If no individual owns **25% or more**, leave both boxes **unchecked**. -2. Accept the agreement terms and verify that all details are **true and accurate**. +Provide the following company information: + +1. **Company address** โ€” Must be a physical US address. PO Boxes or mail drop addresses will be flagged for review and may delay verification. +2. **Tax Identification Number (TIN)** โ€” Assigned by the IRS. +3. **Company website** โ€” Required to access most Expensify payment features. +4. **Industry Classification Code** โ€” Find the list of codes at [census.gov/naics](https://www.census.gov/naics/?input=software&year=2022). --- -## Step 6: Wait for documentation review +## How to add Beneficial Owner details -In some cases, Concierge may request additional documents (e.g., business address verification or a bank letter). If this happens, Concierge will follow up directly. +1. Check the appropriate box under **Beneficial Owner**. A Beneficial Owner is an individual who owns 25% or more of the business. If no individual owns 25% or more, leave both boxes unchecked. +2. Accept the agreement terms and verify that all details are true and accurate. --- -## Step 7: Watch for the test transaction message +## What to expect after submitting your bank account details -Once your account is nearly ready, youโ€™ll see the message: **โ€œYour bank account is almost set up!โ€** +After you submit your details: -This means Expensify has sent **three test transactions** (usually arriving within 1โ€“2 business days). Youโ€™ll need these to complete setup. +1. **Documentation review** โ€” In some cases, Concierge may request additional documents such as business address verification or a bank letter. Concierge will follow up directly if needed. +2. **Test transactions** โ€” Once your account is nearly ready, you'll see the message **"Your bank account is almost set up!"** This means Expensify has sent three test transactions, which usually arrive within 1โ€“2 business days. +3. **Validation** โ€” Enter the three exact amounts to complete setup. Learn how to [validate a business bank account](/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account). --- -## Step 8: Validate the test transactions +## How business bank accounts appear in Wallet + +Once connected, your business bank account appears in **Account > Wallet**. If you have both personal and business bank accounts, Wallet separates them into **Personal bank accounts** and **Business bank accounts** sections. -1. Return to **Settings > Workspaces > [Workspace Name] > Workflows > Payments**. -2. Click **Enter test transactions**. -3. Input the **three exact amounts** and click **Validate**. +Partially set-up accounts display an **Action required** badge. Click the account row to resume setup from where you left off. -Once validated, your **verified business bank account** is fully connected and ready to use. + --- # FAQ -## What are the general requirements for adding a business bank account? -To add a **business bank account** for **ACH reimbursements (US) or Expensify Cards**: -- Enter a **physical address** for yourself, any **Beneficial Owner** (if applicable), and the **business**. -- **US photo ID, address, and SSN** are required for all individuals associated with the account. +## What are the general requirements for adding a US business bank account? + +To add a business bank account for ACH reimbursements or the Expensify Card, enter a physical address for yourself, any Beneficial Owner (if applicable), and the business. A US photo ID, US address, and US SSN are required for all individuals associated with the account. -## Are there certain industries or businesses for which Expensify cannot process automatic, in-app payments? -Yes, Expensify cannot process direct payments for businesses in the following industries: +## Which industries does Expensify not support for direct payments? + +Expensify cannot process direct payments for businesses in the following industries: - Security Brokers & Dealers - Dating & Escort Services @@ -119,28 +145,33 @@ Yes, Expensify cannot process direct payments for businesses in the following in - NFT (non-fungible token) services ## What is a Beneficial Owner? -A **Beneficial Owner** is an **individual** who owns **25% or more** of the business. If no individual meets this threshold, you do **not** need to list a **Beneficial Owner**. + +A Beneficial Owner is an individual who owns 25% or more of the business. If no individual meets this threshold, you do not need to list a Beneficial Owner. ## What if my business is owned by another company? -Please only check the Beneficial Owner box if an individual owns 25% or more of the business. -## Why canโ€™t I input my address or upload my ID? -All individuals associated with the account must have a **US address, US photo ID, and a US SSN**. If you do not meet these requirements, an **admin who qualifies** should add the bank account. +Only check the Beneficial Owner box if an individual owns 25% or more of the business. + +## Why can't I input my address or upload my ID? + +All individuals associated with the account must have a US address, US photo ID, and a US SSN. If you do not meet these requirements, an admin who qualifies should add the bank account instead. ## Why am I asked for documents when adding my bank account? -Expensify follows **federal regulations (BSA / AML laws)** and anti-fraud measures. If automatic verification fails, we may request manual verification (e.g., address verification, bank ownership letter, etc.). Contact **Concierge** for assistance. -## I donโ€™t see all three microtransactions I need to validate my bank account. What should I do? -Wait **two business days**. If still missing, contact your **bank** and ask them to whitelist our **ACH IDs:** +Expensify follows federal regulations (BSA/AML laws) and anti-fraud measures. If automatic verification fails, Expensify may request manual verification such as address verification or a bank ownership letter. Contact Concierge for assistance. + +## What should I do if I don't see all three test transactions? + +Wait two business days. If the transactions still haven't arrived, contact your bank and ask them to whitelist the following ACH IDs: + - **1270239450** - **4270239450** - **2270239450** -Expensifyโ€™s **ACH Originator Name**: **Expensify**. +ACH Originator Name: **Expensify** -Once completed, contact **Concierge** to re-trigger the microtransactions. +Once completed, contact Concierge to re-trigger the test transactions. -## Is my data safe? -Yes! Expensify uses **bank-level security measures** and is **tested daily** by **McAfee**. Learn more about our security policies [here](https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security). +## How does Expensify protect my data? -
+Expensify uses bank-level security measures. Learn more about [Expensify's security policies](/articles/new-expensify/settings/Encryption-and-Data-Security). diff --git a/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md b/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md index 88f39633706b..53e7bf264226 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md +++ b/docs/articles/new-expensify/wallet-and-payments/Pay-Expenses.md @@ -3,7 +3,6 @@ title: How to Pay Expenses in New Expensify description: Learn how to pay personal or workspace expenses in New Expensify using connected bank accounts or your Wallet. keywords: [New Expensify, pay expenses, reimburse teammates, reimburse friends, personal payments, business payments, connected bank account, Wallet, pay report] --- -
Paying expenses in New Expensify is fast and easyโ€”whether you're reimbursing a teammate through a Workspace or sending money to a friend personally. You can pay using a connected business bank account or your Wallet. @@ -17,13 +16,13 @@ To pay Workspace expenses, youโ€™ll first need to: ## Where to Find Workspaces Expenses to Pay -- On web, go to the **navigation tabs on the left** and select **Reports > To-do > Pay** -- On mobile, go to the **navigation tabs on the bottom** and select **Reports > To-do > Pay** +- On web, go to the **navigation tabs on the left** and select **Spend > Reports > To-do > Pay** +- On mobile, go to the **navigation tabs on the bottom** and select **Spend > Reports > To-do > Pay** - Or, tap the notification you received via email, push, or text ## How to Pay Workspace Expenses -1. Open the expense report from the **Reports** tab or the notification. +1. Open the expense report from **Spend > Reports** or from the notification. 2. Tap **Pay** to continue. 3. Choose your payment method: - Use your connected business bank account @@ -42,13 +41,13 @@ To send a personal payment, you must first [set up your Wallet](https://help.exp ## Where to find Personal Expenses to Pay -- On web, go to the **navigation tabs on the left** and select **Reports > To-do > Pay** -- On mobile, go to the **navigation tabs on the bottom** and select **Reports > To-do > Pay** +- On web, go to the **navigation tabs on the left** and select **Spend > Reports > To-do > Pay** +- On mobile, go to the **navigation tabs on the bottom** and select **Spend > Reports > To-do > Pay** - Or, click the notification you received ## How to Pay a Personal Expense -1. Open the personal expense from your notification or the **Reports** tab. +1. Open the personal expense from your notification or **Spend > Reports**. 2. Tap **Pay** to proceed. 3. Choose your payment method: - Pay from your Wallet (personal bank account) @@ -80,4 +79,10 @@ You can reimburse Workspace expenses using business bank accounts in the followi **Note:** Personal expenses paid with the Wallet can only be paid in **USD**. +--- + +## How do I pay a report with only non-reimbursable expenses? + +Reports containing only non-reimbursable expenses (e.g., company card charges) cannot be paid via ACH through Expensify. Instead, select **Mark as paid** from the **Pay** button dropdown to close the report. +
diff --git a/docs/articles/new-expensify/wallet-and-payments/Reimbursement-Payment-Methods.md b/docs/articles/new-expensify/wallet-and-payments/Reimbursement-Payment-Methods.md index b6abc4e6ba92..54d8c83d44b2 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Reimbursement-Payment-Methods.md +++ b/docs/articles/new-expensify/wallet-and-payments/Reimbursement-Payment-Methods.md @@ -18,6 +18,8 @@ Workspace Admins can choose between two reimbursement methods when paying an app The payment method is selected when the Workspace Admin clicks **Pay** on an approved report. +**Note:** For reports containing only non-reimbursable expenses (e.g. company card expenses), direct reimbursement is not available. The only payment option is **Pay elsewhere**. + [Learn about report statuses and actions](https://help.expensify.com/articles/new-expensify/reports-and-expenses/Understanding-Report-Statuses-and-Actions). --- @@ -51,7 +53,7 @@ The steps to send reimbursement are the same for ACH reimbursement, global reimb To pay one report: -1. From the navigation tabs (on the left on web, and at the bottom on mobile) choose **Reports**. +1. From the navigation tabs (on the left on web, and at the bottom on mobile) choose **Spend > Reports**. 2. Under **To-do**, select **Pay**. 3. On the report you wish to reimburse, select **Pay**. 4. Choose a payment method: @@ -69,7 +71,7 @@ Workspace Admins can also pay multiple reports at the same time. To pay reports in bulk: -1. From the navigation tabs (on the left on web, and at the bottom on mobile) choose **Reports**. +1. From the navigation tabs (on the left on web, and at the bottom on mobile) choose **Spend > Reports**. 2. Under **To-do**, select **Pay**. 3. Select the checkbox next to each Approved report you want to pay. 4. Select **Selected**. @@ -128,6 +130,10 @@ Expensify does not issue checks. Workspace Admins can select **Pay elsewhere** a ACH reimbursement applies to US-based USD payments. Global reimbursement supports international payments and multiple currencies. +## Why is direct reimbursement not available for some reports? + +If a report contains only non-reimbursable expenses (e.g. company card expenses), direct reimbursement (ACH or global reimbursement) is not offered. Only **Pay elsewhere** is available, because non-reimbursable expenses are paid by the company directly and do not require reimbursement to the employee. + ## When is Pay elsewhere used? Common scenarios for using Pay elsewhere include: diff --git a/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md index 804c196690f8..f39790376d5d 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Share-a-Business-Bank-Account.md @@ -1,74 +1,61 @@ --- title: Share a Business Bank Account -description: Workspace Admins can now share access to a business bank account with other admins โ€” no need to set it up from scratch. -keywords: [share business bank account, reimbursements, Wallet, Workspace Admin, Settings > Wallet, pending validation, Expensify business bank] +description: Share a verified business bank account with other Workspace Admins so they can use it without repeating setup. +keywords: [New Expensify, share business bank account, shared bank, Workspace Admin, verified bank account, bank account access, ACH sharing, shared access, Wallet] +internalScope: Audience is Workspace Admins with a verified business bank account. Covers sharing a verified business bank account with other admins. Does not cover initial bank account setup, validation, unsharing, or unlocking. --- -
+# Share a Business Bank Account -# Sharing a business bank account in Expensify +If you've already added and verified a business bank account, you can share it with other Workspace Admins. This allows them to use the account without repeating the full setup process. -If youโ€™ve already added a business bank account, you can share access to that account with other Workspace Admins. This eliminates the need to repeat the full setup process. - -## Who can share a business bank account in Expensify +--- -Only **Workspace Admins** can share an existing business bank account. You can only share accounts that have been verified. +## Who can share a business bank account -## Where to find the business bank account sharing option +- Only **Workspace Admins** can share a business bank account. +- You can only share **verified** business bank accounts. +- You can only share with admins of Workspaces where you are also an admin. -Go to the navigation tabs and select **Settings > Wallet**. +--- ## How to share a business bank account -To share an existing account with other admins: - -1. Open **Settings > Wallet**. -2. Click the **three-dot menu (โ‹ฎ)** next to the bank account you want to share. +1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Account > Wallet**. +2. In the **Bank accounts** section, find the verified business bank account and select the three dots **(โ‹ฎ)** next to the account. 3. Select **Share**. -4. On the Share screen: - - The list shows only Workspace Admins from workspaces where you also have admin access. -5. Select the admins you want to share with, then click **Share**. -6. Youโ€™ll see a confirmation screen once the share is successful. +4. Select the admins you want to share the account with. +5. Click **Share**. +6. You'll see a confirmation once the process is complete. -**Note:** Youโ€™ll only see Admins of workspaces where youโ€™re also an admin in the list. +**Note:** You can only share with Workspace Admins from Workspaces where you also have admin access. ![Open the wallet page]({{site.url}}/assets/images/Help-ShareBankAccount-1.png){:width="100%"} -![Open the three dot menu next to the bank account and tap share]({{site.url}}/assets/images/Help-ShareBankAccount-2.png){:width="100%"} +![Open the three dots next to the bank account and tap share]({{site.url}}/assets/images/Help-ShareBankAccount-2.png){:width="100%"} ![Select the admins you want to share with]({{site.url}}/assets/images/Help-ShareBankAccount-3.png){:width="100%"} ![Close the confirmation screen once the share is successful]({{site.url}}/assets/images/Help-ShareBankAccount-4.png){:width="100%"} -## What happens after sharing a business bank account - -- The shared-to admins will receive a Concierge message letting them know the account was shared. -- Theyโ€™ll now see the account in their **Wallet**. - -**For US accounts:** -- The shared account appears with an **Action required** badge. -- The shared-to admin will need to **validate** the account by entering test transactions. -- After validation, they can begin using it. - -**For non-US accounts:** -- The shared account is ready to use immediately โ€” no test transactions required. - -## How business bank account sharing works when changing the payer +--- -When changing the **Authorized Payer** in a Workspace: +## What happens after you share a business bank account -- If you have access but the new payer doesn't: - - You'll be prompted to share the bank account from the Authorized Payer screen. - - The account shares immediately when you confirm. - - A confirmation screen appears after successful sharing. +The receiving admin must validate their copy (required for US accounts) before using the account. -- If your own copy of the bank account is pending validation, and you try to assign a new payer who doesnโ€™t have access: - - Youโ€™ll see an error prompting you to **validate your account first** by entering the test transactions before changing payers. +- The selected admins receive a Concierge message notifying them the account was shared. +- The account appears in their **Wallet**. -- If **neither** you nor the new selected payer has access: - - Youโ€™ll be shown an error telling you to ask the current payer to share the account. +**For US accounts:** +- The account shows an **Action required** badge +- The admin must validate the account with the test transactions before using it. +- After validation, the account is ready to use + +**For non-US accounts:** +- The shared account is ready to use immediately โ€” no validation is required. -This process ensures you can only set an Authorized Payer who already has bank account access or receives access during the payer change. +[Learn how to validate a business bank account](/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account). --- @@ -76,33 +63,22 @@ This process ensures you can only set an Authorized Payer who already has bank a ## Why share a business bank account? -Sharing saves time. You donโ€™t have to redo the full verification process, and it makes it easier to: -- Change the workspace payer -- Set up new Workspaces -- Enable reimbursements without delay +Sharing saves time by avoiding repeated setup. It also helps when changing the workspace payer or enabling reimbursements quickly. ## Can I share the same bank account with multiple admins? -Yes. You can share a bank account with multiple admins at once. +Yes. You can select multiple Workspace Admins when sharing. -## Why donโ€™t I see the Share option? +## Why don't I see the Share option? -Youโ€™ll only see **Share** if the bank account is verified and there is at least one eligible Workspace Admin you havenโ€™t shared with yet. +The **Share** option only appears if: +- The account is verified +- There is at least one eligible Workspace Admin you havenโ€™t shared it with ## What if the other admin already has a copy of the account? -If an admin already has access to the bank account, they wonโ€™t appear in the share list. +They wonโ€™t appear in the list of admins you can share with. ## Can a shared admin share the bank account with others? -Yes โ€” once an admin has validated the account (if required), they can share the bank account with other admins. - -## Can I share a bank account during the payer change process? - -Yes. If the new payer doesnโ€™t already have access, youโ€™ll be prompted to share the bank account before confirming the payer change. This ensures the new payer can reimburse expenses without delay. - -## What happens if I try to set a payer but neither of us has bank account access? - -Youโ€™ll be shown an error and prompted to ask the current payer to share the account with one of you before proceeding. - -
+Yes. Once an admin has validated the account (if required), they can share it with other admins. diff --git a/docs/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account.md index c07317107ed1..d4a3013fd425 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Unlock-a-Business-Bank-Account.md @@ -1,70 +1,90 @@ --- title: Unlock a Business Bank Account -description: Learn how to resolve issues with a locked business bank account in Expensify. -keywords: [New Expensify, unlock bank account, reimbursements, direct debit, ACH, bank rejected withdrawal, retry debit] +description: Request an unlock for a locked business bank account in Expensify and restore reimbursements and payments. +keywords: [New Expensify, locked bank account, unlock bank account, reimbursements failed, ACH rejected, bank error, fix bank account, Expensify Card locked, ACH company ID, unlock payment method] +internalScope: Audience is Workspace Admins with a locked business bank account. Covers requesting an unlock and preventing future lockouts. Does not cover initial bank account setup, validation, sharing, or unsharing. --- -When you reimburse a report, Expensify initiates a withdrawal from your connected business bank account. If your bank rejects this withdrawalโ€”due to **insufficient funds**, **missing authorization**, or **direct debit restrictions**โ€”your account will be **locked** until the issue is resolved. +# Unlock a Business Bank Account + +If your business bank account is locked, Expensify pauses reimbursements and payments to prevent failed debits. Youโ€™ll need to request an unlock before the account can be used again. --- -# Why is the business bank account locked? +## Why your business bank account is locked -Common reasons include: +Bank accounts may be locked after a failed debit attempt or a bank-side restriction. Common reasons include: -- Your bank account is not enabled for **ACH direct debits** -- There were **insufficient funds** available during a reimbursement attempt +- Insufficient funds at the time of reimbursement - Your bank blocked the debit attempt +- The bank account isn't enabled for ACH direct debits + +When a bank account is locked, youโ€™ll see a **Locked** badge on the account in: -When this occurs, Expensify pauses reimbursements and locks the verified account as a precaution. + - **Settings > Wallet** in the **Bank accounts** section + - **Workspaces > [Workspace Name] > Workflows** in the **Payments** section --- -# How to request an unlock +## How to unlock a business bank account -To unlock your account: +1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Account > Wallet**. +2. Click the bank account with the **Locked** badge. +3. Click **Fix** or **Unlock account**. +4. Send the message to Concierge to request the unlock. -1. Go to **Workspaces > [Workspace Name] > Bank Account**. -2. Click the **Fix** button next to the locked account. +Concierge will walk you through the next steps to unlock the account, or confirm when your account is ready to use. -This sends a request to the Expensify support team. A Concierge agent will contact you with the next steps. + -> **Note:** Unlocking a bank account may take several business days due to ACH processing timelines and return periods. +--- -# When will the account unlock? +## What happens after you request to unlock a business bank account -After we receive confirmation from your bank that a debit has successfully settled, your business bank account will unlock automatically and reimbursements will resume. +Unlocking is tied to your bank's processing timeline: -> **Note:** If a reimbursement debit fails, we must wait for the bank to return the attempt before another retry can be triggered. We cannot manually initiate a new debit while a previous one is still processing. +1. Expensify must wait for the failed debit attempt to fully return from your bank. +2. Once returned, Concierge will notify you. +3. You confirm whether you'd like Expensify to retry the debit. +4. After a successful debit, the account unlocks automatically. -Once the previous attempt is returned by the bank, Concierge will notify you in your chat. At that point, youโ€™ll be asked to confirm whether youโ€™d like us to retry the debit. Once confirmed, weโ€™ll proceed with the next attempt. +Expensify cannot manually retry debits until the bank returns the original attempt. --- -# Enable direct debit with your bank +## How to prevent your business bank account from locking again + +Ask your bank to allow ACH debits from the following originators: -To ensure your bank allows direct debit from Expensify, share the following ACH details with your financial institution: +**For reimbursements via Expensify:** +- ACH Company IDs: 1270239450, 4270239450, 2270239450 +- ACH Originator Name: Expensify -### For reimbursements via Expensify: +**For bill payments via Stripe:** +- ACH Company IDs: 1800948598, 4270465600 +- ACH Originator Name: expensify.com -- **ACH Company IDs**: - `1270239450`, `4270239450`, `2270239450` -- **ACH Originator Name**: `Expensify` +**For international reimbursements via CorPay:** +- ACH Company IDs: 1522304924, 2522304924 +- ACH Originator Name: Cambridge Global Payments -### For bill payments via Stripe: +--- -- **ACH Company IDs**: - `1800948598`, `4270465600` -- **ACH Originator Name**: `expensify.com` -- [Stripe ACH ID Reference](https://support.stripe.com/questions/ach-direct-debit-company-ids-for-stripe) +# FAQ -### For international reimbursements via CorPay: +## Will I be notified when my bank account is locked? -- **ACH Company IDs**: - `1522304924`, `2522304924` -- **ACH Originator Name**: `Cambridge Global Payments` +Yes. Youโ€™ll see a **Locked** badge in **Wallet** and **Workflows**, and Concierge will message you. ---- +## Can I delete a locked bank account? + +No. Locked accounts can't be deleted until they're resolved and unlocked. + +## Why hasnโ€™t my account unlocked yet? -Once your bank has approved these IDs and your account is reviewed, Concierge will notify you when it is unlocked and reimbursements can resume. +The failed debit may still be processing with your bank. Concierge will notify you as soon as it can be retried. diff --git a/docs/articles/new-expensify/wallet-and-payments/Unshare-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Unshare-a-Business-Bank-Account.md index b46a450e03ef..20f48dff1431 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Unshare-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Unshare-a-Business-Bank-Account.md @@ -1,48 +1,50 @@ --- -title: Unshare a business bank account -description: Learn how to unshare a business bank account from other admins in New Expensify to revoke access instantly. -keywords: [unshare business bank account, remove bank access, New Expensify Wallet, Workspace Admin, revoke bank permission, Expensify Card settlement] +title: Unshare a Business Bank Account +description: Remove another member's access to a shared business bank account in Expensify. +keywords: [New Expensify, unshare business bank account, revoke access, remove admin, Wallet, bank access, shared business account, remove shared access] +internalScope: Audience is members with a shared business bank account. Covers revoking shared access to a business bank account. Does not cover initial bank account setup, validation, sharing, or unlocking. --- -
-# Unshare a business bank account +# Unshare a Business Bank Account -If other Workspace Admins have access to your business bank account in Expensify, you can unshare it at any time to revoke their access. Unsharing access is especially helpful when someone changes roles or leaves the company. +If youโ€™ve shared a business bank account with other members, you can remove their access at any time. -Unsharing the account removes it from the other admin's Wallet immediately and prevents that admin from using it to reimburse reports, pay bills, or use it with the Expensify Card. We'll still complete any payments in process that the admin issued. +Unsharing removes the account from the other memberโ€™s Wallet immediately. Any payments already in progress will still complete. --- ## Who can unshare a business bank account -To unshare a business bank account, you must: -- Be a **Workspace Admin**. -- Have an active business bank account in your Wallet that at least one other admin can access. +Any member who has a validated business bank account in their **Wallet** that is shared with another member can unshare it. --- -## Where to find the unshare business bank account option +## How to unshare a business bank account -1. Select **Wallet** from the navigation tabs on the left. -2. Choose your business bank account and click the **More** (three-dot) menu. +1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Account > Wallet**. +2. In the **Bank accounts** section, find the verified business bank account and select the three dots **(โ‹ฎ)** next to the account. 3. Select **Unshare**. +4. Select the members you want to remove. +5. Click **Unshare**. +6. Click **Confirm** + +You'll see a confirmation once the account has been unshared. --- -## How to unshare a business bank account +## What happens after you unshare a business bank account + + - The account is removed from the unshared member's **Wallet** immediately. + - The unshared member can no longer use the account for reimbursements, bill payments, or Expensify Card settlement. + - The unshared member receives a message from Concierge notifying them of the change. -1. Go to **Wallet** and open the **More** menu (three dots) on your business bank account. -2. Click **Unshare**. -3. A list of all admins with access to that bank account will appear. -4. Click **Unshare** next to the admin you want to remove. -5. Confirm in the pop-up modal. -6. That admin will immediately lose access and receive a Concierge message letting them know. +**Note:** Any payments already in progress before the bank account was unshared will still complete. -**Note:** Admins with access can unshare the business bank account from anyone else โ€” even the person who originally added it. +-- ![Open the wallet page]({{site.url}}/assets/images/Help-UnshareBankAccount-1.png){:width="100%"} -![Open the three dot menu next to the bank account and tap unshare]({{site.url}}/assets/images/Help-UnshareBankAccount-2.png){:width="100%"} +![Open the three dots next to the bank account and tap unshare]({{site.url}}/assets/images/Help-UnshareBankAccount-2.png){:width="100%"} ![Tap unshare for the admin you want to unshare from]({{site.url}}/assets/images/Help-UnshareBankAccount-3.png){:width="100%"} @@ -50,12 +52,11 @@ To unshare a business bank account, you must: --- -## Unsharing when the account is set as Expensify Card settlement account +## What to do if you canโ€™t unshare a business bank account -If the admin's copy of the bank account is currently set as the **Expensify Card settlement account**, you'll see an error message and won't be able to unshare from that admin. +If the bank account is set as an Expensify Card settlement account for that member, you wonโ€™t be able to unshare it. -To resolve this: -- Click **Reach out to Concierge** in the error message to get help switching the settlement account. +To resolve this, click **Reach out to Concierge** in the error message. Concierge will help switch the settlement account so you can proceed. --- @@ -63,18 +64,16 @@ To resolve this: ## Can I unshare a bank account I didnโ€™t originally add? -Yes. As long as your copy of the business bank account is active, you can unshare it from any admin โ€” including the original sharer. - -## Will the other admin be notified when I unshare the bank account? - -Yes. Theyโ€™ll receive a message from Concierge letting them know they no longer have access to the bank account. - -## What happens to the unshared bank account? +Yes. As long as your copy of the business bank account is active, you can unshare it from any member. -The business bank account is removed from the admin's Wallet. The admin can no longer use the account for any payments or reimbursement features. We'll still complete any payments in process that the admin issued beforehand. +## Can a bank account be unshared with the original account owner? -
+Yes. Any member with access can unshare the bank account from another member, including the original account owner. +## Will the unshared member be notified when I unshare the bank account? +Yes. They'll receive a message from Concierge letting them know they no longer have access. +## What happens to processing reimbursements when a bank account is unshared? +Processing payments will still complete when the account is unshared. diff --git a/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md b/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md index cca49ccf3512..cca943d087af 100644 --- a/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md +++ b/docs/articles/new-expensify/wallet-and-payments/Validate-a-Business-Bank-Account.md @@ -1,48 +1,70 @@ --- title: Validate a Business Bank Account -description: Finish connecting your business bank account by verifying test transactions in Expensify. -keywords: [New Expensify, bank account verification, test deposits, ACH validation, business bank account, workspace payments] +description: Enter test transaction amounts to complete validation of a business bank account in Expensify. +keywords: [New Expensify, validate bank account, test deposits, ACH validation, business bank verification, confirm test transactions, micro-transactions, pending bank account] +internalScope: Audience is all members with a pending business bank account. Covers entering test transactions to validate a bank account. Does not cover initial bank account setup, sharing, unsharing, or unlocking. --- -Once your business bank account connection is approved in Expensify, youโ€™ll receive a series of **test deposits** to confirm the connection. This step is required before you can start using the account for reimbursements or payments. +# Validate a Business Bank Account + +After your business bank account is approved, Expensify sends three test transactions to confirm ownership. Youโ€™ll also receive a message from Concierge that says โ€œYour bank account is almost set up!โ€. + +The three transactions (two small withdrawals and one small deposit) typically arrive within oneโ€“two business days. Once they appear in your bank account, enter the exact amounts in Expensify to complete validation and start using the account for reimbursements or payments. + +--- + +## Who can validate a business bank account + +Any member who has a **Pending** business bank account in **Wallet** can validate it using the test transactions. --- -# How to Validate Your Bank Account +## When can a business bank account be validated + +After your business bank account is approved: + - Expensify sends three test transactions to your bank account - two small withdrawals and one small deposit. + - When the test transactions are sent, Concierge will send a message to notify you that says "Your bank account is almost set up!". + - The test transactions will arrive 1-2 days after sending. + +## How to validate a business bank account with test transactions -1. Go to **Workspaces > [Workspace Name] > Bank Account**. -2. Review the account status: - - **Verifying** โ€“ Expensify requested more info. Check your email for the next steps. - - **Pending** โ€“ Your account is ready for validation. Continue with the steps below. -3. Within **1โ€“2 business days**, Expensify will send **three test transactions** to your business bank account. -4. Return to **Workspaces > [Workspace Name] > Bank Account**. -5. Enter the exact **transaction amounts** when prompted. +When you see the three test amounts post to your bank account: -Once submitted correctly, your account will be fully validated and ready to use. +1. In the navigation tabs (on the left on web, on the bottom on mobile) go to **Account > Wallet**. +2. In the **Bank accounts** section, click the business bank account with the **Pending** badge. +3. Enter all three amounts exactly as they appear. +4. Click **Validate**. + +Once the values are confirmed, your business bank account is fully connected and ready to use. --- -# FAQ +## What to do if test transactions don't arrive + +If the test deposits don't appear after two business days: + +1. Confirm that your bank account supports ACH direct debits. +2. Share the following ACH details with your bank: + - **ACH Company IDs:** 1270239450, 4270239450, 2270239450 + - **ACH Originator Name:** Expensify +3. If still missing, contact Concierge to re-trigger the test transactions. -## How Many Test Deposits Will I Receive? +--- + +# FAQ -Youโ€™ll see **three transactions**: -- **Two small withdrawals** -- **One small deposit** +## How many test transactions will I receive? -These are temporary and used to confirm your ownership of the account. +Three total: two small withdrawals and one small deposit. These are temporary and used only for verification. -## What If I Donโ€™t See the Test Transactions? +## What happens if I enter the wrong amounts? -If the test deposits donโ€™t appear after two business days: +Youโ€™ll be prompted to try again. Multiple failed attempts may temporarily lock validation. -- Your bank account may not support **ACH direct debits** yet. -- Provide your bank with the following information to enable ACH access: +## Can I skip validation if the account was shared with me? - - **ACH Company IDs**: - `1270239450`, `4270239450`, `2270239450` - - **ACH Originator Name**: - `Expensify` +No. US bank accounts require validation by entering test transactions. Some non-US accounts may not require this step. -Still not seeing them? Contact **Expensify Support via Concierge**, and weโ€™ll help investigate. +## What if I donโ€™t have access to the bank account? +Another member with access to the bank account must complete the validation. diff --git a/docs/articles/new-expensify/workspaces/Track-Taxes.md b/docs/articles/new-expensify/workspaces/Track-Taxes.md index 0c2f3d281c70..90984a2e1fe6 100644 --- a/docs/articles/new-expensify/workspaces/Track-Taxes.md +++ b/docs/articles/new-expensify/workspaces/Track-Taxes.md @@ -17,7 +17,7 @@ To enable tax tracking in a workspace: 1. In the **navigation tabs** on the left (web) or bottom (mobile), click Workspaces. 2. Click your **workspace name**. 3. Click **More Features**. -4. Under the **Spend** section, toggle on **Taxes**. +4. Under the **Reports** section, toggle on **Taxes**. Once enabled, a new **Taxes** section will appear in the left-hand menu. @@ -80,7 +80,7 @@ Expenses sometimes have more than one tax applied to them (for example, in Canad To handle multiple tax rates, you can create a new tax rate that combines both into a single rate. For example, if you have a GST of 5% and PST of 7%, you can add them together and create a new tax rate of 12%. -You can generate a CSV from the Reports page containing all the expense information, including the split-out taxes, by going to the Reports tab, clicking **Export To**, and selecting **Tax Report**. +You can generate a CSV from the Reports page containing all the expense information, including the split-out taxes, by going to the **Spend > Reports**, selecting the report. licking **Export To**, and selecting **Tax Report**. ## How do I handle the taxes for a receipt that includes more than one tax rate? @@ -89,6 +89,17 @@ If your receipt includes more than one tax rate, there are two ways you can hand - Many tax authorities do not require reporting tax amounts by rate; therefore, you can apply the highest rate on the receipt and then modify the tax amount on the receipt if necessary. Please check with your tax advisor to determine if this approach suits you. - Alternatively, you can apply each specific tax rate by splitting the expense by the applicable costs for each rate. To do this, open the expense and click **Split Expense**. Then, apply the correct tax rate to each. +## What happens if an expense with tax is moved to a workspace where tax tracking is disabled? + +If an expense that includes tax data is moved or merged into a workspace that does not have tax tracking enabled, the tax fields (**Tax** and **Tax amount**) will still appear on the expense with a "Tax no longer valid" violation. + +To resolve this, tap the **Tax** or **Tax amount** field on the expense. An alert will appear explaining that tax tracking is disabled on the workspace. You can then choose to: + +- **Delete tax** โ€” Removes the tax rate and tax amount from the expense. +- **Cancel** โ€” Keeps the tax data on the expense. + +Alternatively, you can enable tax tracking on the workspace to edit the tax details directly. + ## What if my workspace has multiple tax rates? You'll have the option to change the tax rate from within the expense as needed. diff --git a/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md b/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md index c0702e14ad2f..d8ea2244fe57 100644 --- a/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md +++ b/docs/articles/travel/booking-travel/Arrange-Travel-for-Another-User.md @@ -11,7 +11,7 @@ After you're assigned the Travel Arranger role, you can arrange travel for anoth ## Where to find Expensify Travel -Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. If you donโ€™t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/booking-travel/Book-Rail-Travel.md b/docs/articles/travel/booking-travel/Book-Rail-Travel.md index b7e1c5e88afd..711d54c0b471 100644 --- a/docs/articles/travel/booking-travel/Book-Rail-Travel.md +++ b/docs/articles/travel/booking-travel/Book-Rail-Travel.md @@ -10,7 +10,7 @@ Book train tickets directly in Expensify Travel. This guide covers how to search ## Where to find Expensify Travel -Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. If you donโ€™t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md b/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md index b8754f782a59..343f14e69267 100644 --- a/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md +++ b/docs/articles/travel/booking-travel/Book-Travel-for-a-Guest.md @@ -11,7 +11,7 @@ Any member with guest booking permission can book travel for someone who doesn ## Where to find Expensify Travel -Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. If you donโ€™t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/booking-travel/Book-a-Flight.md b/docs/articles/travel/booking-travel/Book-a-Flight.md index bf1183009815..07ba2885be33 100644 --- a/docs/articles/travel/booking-travel/Book-a-Flight.md +++ b/docs/articles/travel/booking-travel/Book-a-Flight.md @@ -11,7 +11,7 @@ Book flights easily with Expensify Travel. This guide walks you through finding ## Where to find Expensify Travel -Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. If you donโ€™t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. @@ -65,7 +65,7 @@ Yes! The receipt is SmartScanned and added to an expense report once the flight Yes โ€” but it depends on your fare and the airlineโ€™s policy. If changes are made through support, a $25 booking change fee applies. To modify or cancel a booking: -1. Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +1. Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. 2. In the window that opens, click the **Trips** tab. 3. Find your trip and click **Modify or Cancel** diff --git a/docs/articles/travel/booking-travel/Book-a-Hotel.md b/docs/articles/travel/booking-travel/Book-a-Hotel.md index ea35d842b2b1..b3957baa9faa 100644 --- a/docs/articles/travel/booking-travel/Book-a-Hotel.md +++ b/docs/articles/travel/booking-travel/Book-a-Hotel.md @@ -10,7 +10,7 @@ Book hotels easily with Expensify Travel. This guide covers how to search for ho ## Where to find Expensify Travel -Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. If you donโ€™t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. @@ -59,7 +59,7 @@ You can cancel or modify the booking if the hotelโ€™s policy allows. Cancellation policies are shown during booking. Changes made through support may incur a fee of up to $25. To modify or cancel a booking: -1. Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +1. Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. 2. In the window that opens, click the **Trips** tab. 3. Find your trip and click **Modify or Cancel** diff --git a/docs/articles/travel/booking-travel/Book-a-Rental-Car.md b/docs/articles/travel/booking-travel/Book-a-Rental-Car.md index a20ec6449aaf..944ac6bba680 100644 --- a/docs/articles/travel/booking-travel/Book-a-Rental-Car.md +++ b/docs/articles/travel/booking-travel/Book-a-Rental-Car.md @@ -10,7 +10,7 @@ Book rental cars quickly and easily through Expensifyโ€™s integrated travel port ## Where to find Expensify Travel -Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. If you donโ€™t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/company-setup/Adding-Supplier-Programs.md b/docs/articles/travel/company-setup/Adding-Supplier-Programs.md index 9690c7b84630..45dfc5fafa79 100644 --- a/docs/articles/travel/company-setup/Adding-Supplier-Programs.md +++ b/docs/articles/travel/company-setup/Adding-Supplier-Programs.md @@ -11,7 +11,7 @@ Travel admins can add supplier programs to ensure corporate discounts and direct ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. @@ -38,7 +38,7 @@ Under the **Air** tab, there are two configuration types: **Corporate discount c **Corporate discount codes** -1. Click the green **+** button. +1. Click the **+** button. 2. Fill out the required fields: - **Vendor name** - **Company name** diff --git a/docs/articles/travel/company-setup/Central-Billing.md b/docs/articles/travel/company-setup/Central-Billing.md index 89adfe4b11ae..8176218c55f0 100644 --- a/docs/articles/travel/company-setup/Central-Billing.md +++ b/docs/articles/travel/company-setup/Central-Billing.md @@ -10,7 +10,7 @@ Central Billing allows Workspace Admins to set up a company card for Expensify T ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md index 02546e16929c..ddffe6fa8c86 100644 --- a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md +++ b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md @@ -11,7 +11,7 @@ Travel admins can set up booking restrictions and apply them based on user profi ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Configuring-Email-Notifications.md b/docs/articles/travel/company-setup/Configuring-Email-Notifications.md index 616b9facdfb1..8b98b77d4098 100644 --- a/docs/articles/travel/company-setup/Configuring-Email-Notifications.md +++ b/docs/articles/travel/company-setup/Configuring-Email-Notifications.md @@ -11,7 +11,7 @@ Travel admins can configure custom email notification rules to keep key stakehol ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Creating-Departments.md b/docs/articles/travel/company-setup/Creating-Departments.md index 816d31c6cbea..f8afe616eb34 100644 --- a/docs/articles/travel/company-setup/Creating-Departments.md +++ b/docs/articles/travel/company-setup/Creating-Departments.md @@ -11,7 +11,7 @@ Departments help travel admins group employees together and apply travel policie ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Donโ€™t see the travel feature?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Importing-Corporate-Rates.md b/docs/articles/travel/company-setup/Importing-Corporate-Rates.md index 2368cbf8842c..7837e1bfe7c7 100644 --- a/docs/articles/travel/company-setup/Importing-Corporate-Rates.md +++ b/docs/articles/travel/company-setup/Importing-Corporate-Rates.md @@ -11,7 +11,7 @@ Corporate hotel rates can be added to Expensify Travel with help from your Accou ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/company-setup/Managing-Users.md b/docs/articles/travel/company-setup/Managing-Users.md index deb216cbf837..07990388a101 100644 --- a/docs/articles/travel/company-setup/Managing-Users.md +++ b/docs/articles/travel/company-setup/Managing-Users.md @@ -11,7 +11,7 @@ Travel admins can view, edit, and configure traveler profiles under the **Users* ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/event-management/Booking-Event-Travel.md b/docs/articles/travel/event-management/Booking-Event-Travel.md index f5f46e8cf485..48b704cb6188 100644 --- a/docs/articles/travel/event-management/Booking-Event-Travel.md +++ b/docs/articles/travel/event-management/Booking-Event-Travel.md @@ -24,7 +24,7 @@ Before booking travel, make sure: To get started: -- **In New Expensify:** Click the green **+** button in the bottom-left corner, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner, then select **Book travel**. - **In Classic Expensify:** Go to **Travel** in the left-hand menu, then choose **Book or manage travel**. **Havenโ€™t enabled travel yet?** diff --git a/docs/articles/travel/event-management/Booking-Travel-for-Event-Attendees.md b/docs/articles/travel/event-management/Booking-Travel-for-Event-Attendees.md index f5f46e8cf485..48b704cb6188 100644 --- a/docs/articles/travel/event-management/Booking-Travel-for-Event-Attendees.md +++ b/docs/articles/travel/event-management/Booking-Travel-for-Event-Attendees.md @@ -24,7 +24,7 @@ Before booking travel, make sure: To get started: -- **In New Expensify:** Click the green **+** button in the bottom-left corner, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner, then select **Book travel**. - **In Classic Expensify:** Go to **Travel** in the left-hand menu, then choose **Book or manage travel**. **Havenโ€™t enabled travel yet?** diff --git a/docs/articles/travel/event-management/Travel-Event-Management.md b/docs/articles/travel/event-management/Travel-Event-Management.md index 871c976f98ae..c6d28d1fdff6 100644 --- a/docs/articles/travel/event-management/Travel-Event-Management.md +++ b/docs/articles/travel/event-management/Travel-Event-Management.md @@ -11,7 +11,7 @@ Plan and coordinate group travel in one place with Expensify Classic Events. Thi ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/event-management/View-Event-As-Employee-or-Guest.md b/docs/articles/travel/event-management/View-Event-As-Employee-or-Guest.md index daed19160df9..80d5ed2c59c9 100644 --- a/docs/articles/travel/event-management/View-Event-As-Employee-or-Guest.md +++ b/docs/articles/travel/event-management/View-Event-As-Employee-or-Guest.md @@ -12,7 +12,7 @@ If youโ€™ve been invited to a company event, you can view event details and book ## How to Access the Travel Tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** diff --git a/docs/articles/travel/getting-started/Expensify-Travel-Walkthrough.md b/docs/articles/travel/getting-started/Expensify-Travel-Walkthrough.md deleted file mode 100644 index f5258da749eb..000000000000 --- a/docs/articles/travel/getting-started/Expensify-Travel-Walkthrough.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Expensify Travel Walkthrough -description: Learn the most common terms and abbreviations used in the travel industry to better understand your bookings and travel policies. -keywords: [travel terms, travel abbreviations, business travel glossary, common travel lingo, travel vocabulary, industry jargon] ---- - - -Check out a video of how Expensify Travel works below: - -{% include video.html src="https://customer-dtm7nf3ai7b93j1q.cloudflarestream.com/b81eabafe1391ab06901ddc19dcda161" %} diff --git a/docs/articles/travel/getting-started/How-to-Get-Expensify-Travel-Support-from-an-Agent.md b/docs/articles/travel/getting-started/How-to-Get-Expensify-Travel-Support-from-an-Agent.md index 9b242a7d7924..a8b917f02fa7 100644 --- a/docs/articles/travel/getting-started/How-to-Get-Expensify-Travel-Support-from-an-Agent.md +++ b/docs/articles/travel/getting-started/How-to-Get-Expensify-Travel-Support-from-an-Agent.md @@ -22,7 +22,7 @@ Anyone with access to Expensify Travel can contact travel agent support. ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Expensify Classic:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Loyalty-Programs.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Loyalty-Programs.md index 807ac8018c0f..71493d01f4fc 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Loyalty-Programs.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Loyalty-Programs.md @@ -11,7 +11,7 @@ Add your loyalty programs for air, hotel, car, and rail travel to earn rewards a ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Your-Personal-Information.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Your-Personal-Information.md index 717dd6e24c44..0dc5adb14b25 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Your-Personal-Information.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-Your-Personal-Information.md @@ -11,7 +11,7 @@ Set up your personal travel profile to customize your Expensify Travel expense a ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Company-Card-or-Personal-Card-for-Travel.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Company-Card-or-Personal-Card-for-Travel.md index e7c470932d64..f55b6669f7d9 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Company-Card-or-Personal-Card-for-Travel.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Company-Card-or-Personal-Card-for-Travel.md @@ -11,7 +11,7 @@ Add a personal or company credit card to your travel profile so you can quickly ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Secondary-Approver.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Secondary-Approver.md index dee624d0bf49..66181ccb2607 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Secondary-Approver.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Secondary-Approver.md @@ -11,7 +11,7 @@ Add a secondary approver to your travel profile so that bookings can move forwar ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Travel-Arranger.md b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Travel-Arranger.md index 0ce193cb456a..8de2d8b155f1 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Travel-Arranger.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Adding-a-Travel-Arranger.md @@ -11,7 +11,7 @@ Assign travel arrangers to your profile so designated teammates can book trips o ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/getting-started/setting-up-your-profile/Setting-Travel-Preferences.md b/docs/articles/travel/getting-started/setting-up-your-profile/Setting-Travel-Preferences.md index 1e02a490e0e6..86c9a2686172 100644 --- a/docs/articles/travel/getting-started/setting-up-your-profile/Setting-Travel-Preferences.md +++ b/docs/articles/travel/getting-started/setting-up-your-profile/Setting-Travel-Preferences.md @@ -11,7 +11,7 @@ Set your personal travel preferences for flights, hotels, cars, and trains so th ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md b/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md index 6fe1dadd231a..3a3eabfd12ee 100644 --- a/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md +++ b/docs/articles/travel/managing-trips/Canceling-Travel-Arrangements.md @@ -11,7 +11,7 @@ You can cancel flights, hotels, cars, or rail bookings directly from your trip v ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/managing-trips/Creating-Trips.md b/docs/articles/travel/managing-trips/Creating-Trips.md index 520430ffb426..81f6ded313e1 100644 --- a/docs/articles/travel/managing-trips/Creating-Trips.md +++ b/docs/articles/travel/managing-trips/Creating-Trips.md @@ -11,7 +11,7 @@ Create a trip in Expensify to bundle all your flights, hotels, and car rentals i ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/managing-trips/Editing-Travel-Arrangements.md b/docs/articles/travel/managing-trips/Editing-Travel-Arrangements.md index b5c7ed39926c..1fbe4cf49fa9 100644 --- a/docs/articles/travel/managing-trips/Editing-Travel-Arrangements.md +++ b/docs/articles/travel/managing-trips/Editing-Travel-Arrangements.md @@ -11,7 +11,7 @@ Make changes to your travel bookings directly in Expensify without needing to co ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/managing-trips/View-Upcoming-and-Past-Trips.md b/docs/articles/travel/managing-trips/View-Upcoming-and-Past-Trips.md index 48c5b1ac068a..7d9c086e1d96 100644 --- a/docs/articles/travel/managing-trips/View-Upcoming-and-Past-Trips.md +++ b/docs/articles/travel/managing-trips/View-Upcoming-and-Past-Trips.md @@ -11,7 +11,7 @@ Use the **Trips** dropdown in the travel tool to view and manage upcoming, compl ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/travel-analytics/Overview-of-Analytics.md b/docs/articles/travel/travel-analytics/Overview-of-Analytics.md index ec3ff78ebe99..3c557ce5c732 100644 --- a/docs/articles/travel/travel-analytics/Overview-of-Analytics.md +++ b/docs/articles/travel/travel-analytics/Overview-of-Analytics.md @@ -11,7 +11,7 @@ Expensify Travel analytics give travelers, approvers, and admins real-time visib ## Where to find Expensify Travel -Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. If you donโ€™t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/travel-analytics/Traveler-Safety.md b/docs/articles/travel/travel-analytics/Traveler-Safety.md index 75f09e6333ad..ca447a06dde1 100644 --- a/docs/articles/travel/travel-analytics/Traveler-Safety.md +++ b/docs/articles/travel/travel-analytics/Traveler-Safety.md @@ -8,7 +8,7 @@ Expensify Travel supports your companyโ€™s duty of care obligations by giving ad ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel admins can access the duty of care and safety tool. diff --git a/docs/articles/travel/travel-analytics/Viewing-Carbon-Emissions.md b/docs/articles/travel/travel-analytics/Viewing-Carbon-Emissions.md index 02648b4f127c..bc3fbb640ad3 100644 --- a/docs/articles/travel/travel-analytics/Viewing-Carbon-Emissions.md +++ b/docs/articles/travel/travel-analytics/Viewing-Carbon-Emissions.md @@ -11,7 +11,7 @@ Use the Analytics dashboard in Expensify to monitor CO2 emissions from travel ac ## How to access the travel tool -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/travel-policy/Approving-Travel.md b/docs/articles/travel/travel-policy/Approving-Travel.md index 9031bc9854b4..b18231b6de1a 100644 --- a/docs/articles/travel/travel-policy/Approving-Travel.md +++ b/docs/articles/travel/travel-policy/Approving-Travel.md @@ -10,7 +10,7 @@ Approving travel is easy and flexible, giving approvers full control over how an ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/travel-policy/Configuring-Car-Settings.md b/docs/articles/travel/travel-policy/Configuring-Car-Settings.md index bbc57a673c22..32448fbe0b4f 100644 --- a/docs/articles/travel/travel-policy/Configuring-Car-Settings.md +++ b/docs/articles/travel/travel-policy/Configuring-Car-Settings.md @@ -8,7 +8,7 @@ Set rental car preferences that balance traveler convenience with company policy ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. **Need to enable travel still?** Reach out to your Account Manager or Concierge to schedule a travel demo and get it enabled for your account. diff --git a/docs/articles/travel/travel-policy/Configuring-Flight-Settings.md b/docs/articles/travel/travel-policy/Configuring-Flight-Settings.md index 1b4978ec812a..017136e6e416 100644 --- a/docs/articles/travel/travel-policy/Configuring-Flight-Settings.md +++ b/docs/articles/travel/travel-policy/Configuring-Flight-Settings.md @@ -11,7 +11,7 @@ Set clear, customizable flight rules to control how travelers book airfare in Ex ## Where to find Expensify Travel -Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. If you donโ€™t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/travel-policy/Configuring-Hotel-Settings.md b/docs/articles/travel/travel-policy/Configuring-Hotel-Settings.md index 63853e7fa069..d337c7093fea 100644 --- a/docs/articles/travel/travel-policy/Configuring-Hotel-Settings.md +++ b/docs/articles/travel/travel-policy/Configuring-Hotel-Settings.md @@ -10,7 +10,7 @@ Customize hotel booking policies to guide travelers toward compliant, cost-effec ## Where to find Expensify Travel -Tap the green โž• **Create** button at the bottom of your screen, then choose **Book travel**. +Tap the **โž• Create** button at the bottom of your screen, then choose **Book travel**. If you donโ€™t see **Book travel**, ask a Workspace Admin to [enable Expensify Travel](https://help.expensify.com/articles/travel/company-setup/Enable-Travel-on-a-Workspace) on the workspace. diff --git a/docs/articles/travel/travel-policy/Configuring-Rail-Settings.md b/docs/articles/travel/travel-policy/Configuring-Rail-Settings.md index 11760b499f92..307ca322b067 100644 --- a/docs/articles/travel/travel-policy/Configuring-Rail-Settings.md +++ b/docs/articles/travel/travel-policy/Configuring-Rail-Settings.md @@ -8,7 +8,7 @@ Streamline rail travel by defining booking rules based on class, cost, and timin ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel Admins can create or manage travel policies in Expensify Travel. diff --git a/docs/articles/travel/travel-policy/Deleting-a-Travel-Policy.md b/docs/articles/travel/travel-policy/Deleting-a-Travel-Policy.md index d3f6a0dc23f1..d4a7e0101c12 100644 --- a/docs/articles/travel/travel-policy/Deleting-a-Travel-Policy.md +++ b/docs/articles/travel/travel-policy/Deleting-a-Travel-Policy.md @@ -8,7 +8,7 @@ Remove outdated or unused travel policies to keep your Expensify Travel setup or ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel Admins can create or manage travel policies in Expensify Travel. diff --git a/docs/articles/travel/travel-policy/Multi-Policy-Configurations.md b/docs/articles/travel/travel-policy/Multi-Policy-Configurations.md index ec72c4f014fa..bf31caefb822 100644 --- a/docs/articles/travel/travel-policy/Multi-Policy-Configurations.md +++ b/docs/articles/travel/travel-policy/Multi-Policy-Configurations.md @@ -9,7 +9,7 @@ Set up multiple travel policies to manage different booking rules, traveler type ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel Admins can create or manage travel policies in Expensify Travel. diff --git a/docs/articles/travel/travel-policy/Setting-Approval-Types-and-Adding-Members.md b/docs/articles/travel/travel-policy/Setting-Approval-Types-and-Adding-Members.md index 1c614f9ad466..63bd0077936b 100644 --- a/docs/articles/travel/travel-policy/Setting-Approval-Types-and-Adding-Members.md +++ b/docs/articles/travel/travel-policy/Setting-Approval-Types-and-Adding-Members.md @@ -11,7 +11,7 @@ Expensify Travel policies give admins powerful tools to control booking behavior ## How to access Expensify Travel -- **In New Expensify:** Click the green **+** button in the bottom-left corner of your screen, then select **Book travel**. +- **In New Expensify:** Click the **+** button in the bottom-left corner of your screen, then select **Book travel**. - **In Classic Expensify:** Click **Travel** in the left-hand menu, then select **Book or manage travel**. Note: Only Travel Admins can create or manage travel policies in Expensify Travel. diff --git a/docs/assets/images/ExpensifyHelp-DuplicateExpense_01.png b/docs/assets/images/ExpensifyHelp-DuplicateExpense_01.png new file mode 100644 index 000000000000..95174b7cf5f5 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-DuplicateExpense_01.png differ diff --git a/docs/assets/images/ExpensifyHelp-FixPersonalCards.png b/docs/assets/images/ExpensifyHelp-FixPersonalCards.png new file mode 100644 index 000000000000..b5c668925c98 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-FixPersonalCards.png differ diff --git a/docs/assets/images/ExpensifyHelp-ResolveDuplicates_01.png b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_01.png new file mode 100644 index 000000000000..bb657b74ee7f Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_01.png differ diff --git a/docs/assets/images/ExpensifyHelp-ResolveDuplicates_02.png b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_02.png new file mode 100644 index 000000000000..c1d53ace72b5 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_02.png differ diff --git a/docs/assets/images/ExpensifyHelp-ResolveDuplicates_03.png b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_03.png new file mode 100644 index 000000000000..5fbecc06e2fe Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_03.png differ diff --git a/docs/assets/images/ExpensifyHelp-ResolveDuplicates_04.png b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_04.png new file mode 100644 index 000000000000..7dfd2267daea Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ResolveDuplicates_04.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index 2ce209d94217..4e07e7e9710c 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -963,8 +963,15 @@ https://help.expensify.com/articles/new-expensify/reports-and-expenses/Delete-Ex https://help.expensify.com/articles/new-expensify/reports-and-expenses/Duplicate-Detection,https://help.expensify.com/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses https://help.expensify.com/articles/new-expensify/reports-and-expenses/Merging-expenses,https://help.expensify.com/articles/new-expensify/reports-and-expenses/How-to-Merge-Expenses https://help.expensify.com/articles/new-expensify/reports-and-expenses/How-to-prevent-duplicate-expenses,https://help.expensify.com/articles/new-expensify/reports-and-expenses/Why-Expenses-Duplicate +https://help.expensify.com/articles/travel/getting-started/Expensify-Travel-Walkthrough,https://help.expensify.com/travel/hubs/getting-started/ https://help.expensify.com/articles/new-expensify/connect-credit-cards/Assign-and-Manage-Cards,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Assign-Company-Cards https://help.expensify.com/articles/new-expensify/connect-credit-cards/Commercial-feeds,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Set-up-a-Commercial-Card-Feed-Connection https://help.expensify.com/articles/new-expensify/connect-credit-cards/Company-Card-Settings,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Configure-Company-Card-Settings https://help.expensify.com/articles/new-expensify/connect-credit-cards/Direct-feeds,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Set-up-a-Direct-Company-Card-Feed-Connection https://help.expensify.com/articles/new-expensify/connect-credit-cards/Personal-Cards,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Manage-Personal-Cards +https://help.expensify.com/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Reports-Page,https://help.expensify.com/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page +https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Search-on-the-Reports-Page,https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Search-on-the-Spend-Page +https://help.expensify.com//articles/expensify-classic/settings/Close-or-reopen-account, https://help.expensify.com//articles/expensify-classic/settings/Close-Your-Expensify-Account +https://help.expensify.com/articles/new-expensify/settings/Close-Account, https://help.expensify.com/articles/new-expensify/settings/Close-Your-Expensify-Account +https://help.expensify.com/articles/new-expensify/reports-and-expenses/Unapproved-Spend,https://help.expensify.com/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts +https://help.expensify.com/articles/new-expensify/reports-and-expenses/Using-Search-on-the-Spend-Page,https://help.expensify.com/articles/new-expensify/reports-and-expenses/Use-Search-Shortcuts diff --git a/eslint-plugin-react-compiler-compat/index.mjs b/eslint-plugin-react-compiler-compat/index.mjs index aca2fc6d1eff..2a1a60070583 100644 --- a/eslint-plugin-react-compiler-compat/index.mjs +++ b/eslint-plugin-react-compiler-compat/index.mjs @@ -13,6 +13,10 @@ */ import {transformSync} from '@babel/core'; import _ from 'lodash'; +import {createRequire} from 'module'; + +const require = createRequire(import.meta.url); +const ReactCompilerConfig = require('../config/babel/reactCompilerConfig'); // Rules that are entirely unnecessary when React Compiler successfully compiles // all functions in a file. Add more rules here as needed. @@ -24,15 +28,6 @@ const RULES_SUPPRESSED_BY_REACT_COMPILER = new Set(['react/jsx-no-constructed-co // about genuinely missing dependencies. const EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN = /\buseCallback\(\) Hook\b|\buseMemo\(\) Hook\b/; -// Mirror the compiler config from babel.config.js (excluding `sources`, -// which is only relevant for the Babel build pipeline, not for our analysis). -const ReactCompilerConfig = { - target: '19', - environment: { - enableTreatRefLikeIdentifiersAsRefs: true, - }, -}; - // Per-file compilation results, populated in preprocess, consumed in postprocess. const compilationResults = new Map(); diff --git a/eslint.config.mjs b/eslint.config.mjs index eccc93e73678..5612462ff07f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -446,6 +446,7 @@ const config = defineConfig([ ], '@typescript-eslint/prefer-find': 'off', '@typescript-eslint/prefer-includes': 'off', + '@typescript-eslint/prefer-optional-chain': 'off', '@typescript-eslint/prefer-nullish-coalescing': [ 'error', { diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 67fde2417ba4..803d3a370dc9 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.3.58 + 9.3.62 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.3.58.7 + 9.3.62.3 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 9ca81e5a82e1..5a9d08cce2c6 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.3.58 + 9.3.62 CFBundleVersion - 9.3.58.7 + 9.3.62.3 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile b/ios/Podfile index 1d225fb991d1..9cb2feda298d 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -47,21 +47,21 @@ def __apply_Xcode_14_3_RC_post_install_workaround(installer) end end -# Pods does not derive :debug type from NewExpensify's debug configurations so we need to set it manually before react-native-post-install. -# Based on this value react-native-post-install will set the correct GCC_PREPROCESSOR_DEFINITIONS e.g. ENABLE_HERMES_DEBUGGER -def __set_debug_type_for_debug_configurations(installer) - installer.pods_project.build_configurations.each do |config| - if config.name.downcase.include?("debug") - installer.pods_project.add_build_configuration(config.name, :debug) - end - end -end - # Configure Mapbox before installing dependencies pre_install do |installer| $RNMapboxMaps.pre_install(installer) end +# Declare project globally so all targets inherit the same build configurations +project 'NewExpensify', + 'DebugDevelopment' => :debug, + 'DebugAdHoc' => :debug, + 'DebugProduction' => :debug, + 'ReleaseDevelopment' => :release, + 'ReleaseAdHoc' => :release, + 'ReleaseProduction' => :release, + 'Debug' => :debug + target 'NewExpensify' do use_expo_modules! post_integrate do |installer| @@ -73,15 +73,6 @@ target 'NewExpensify' do end permissions_path = '../node_modules/react-native-permissions/ios' - project 'NewExpensify', - 'DebugDevelopment' => :debug, - 'DebugAdHoc' => :debug, - 'DebugProduction' => :debug, - 'ReleaseDevelopment' => :release, - 'ReleaseAdHoc' => :release, - 'ReleaseProduction' => :release, - 'Debug' => :debug - config = use_native_modules!(['npx', 'rock', 'config', '-p', 'ios']) # Flags change depending on the env values. @@ -89,6 +80,8 @@ target 'NewExpensify' do # ENV Variable enables/disables TurboModules ENV['RCT_NEW_ARCH_ENABLED'] = '1'; + # ENV Variable enables/disables Hermes V1 + ENV['RCT_HERMES_V1_ENABLED'] = '1'; use_react_native!( @@ -101,7 +94,6 @@ target 'NewExpensify' do # Configure Mapbox after installation $RNMapboxMaps.post_install(installer) - __set_debug_type_for_debug_configurations(installer) # https://github.com/facebook/react-native/blob/main/scripts/react_native_pods.rb#L197-L202 react_native_post_install( installer, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c572b275ca6f..5aa81f6a21ac 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -308,20 +308,41 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - group-ib-fp (2.6.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine - RCT-Folly + - RCT-Folly/Fabric - RCTRequired - RCTTypeSafety - - React-Codegen - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - GTMAppAuth (4.1.1): - AppAuth/Core (~> 1.7) - GTMSessionFetcher/Core (< 4.0, >= 3.3) - GTMSessionFetcher/Core (3.5.0) - GzipSwift (5.1.1) - - hermes-engine (0.14.0): - - hermes-engine/Pre-built (= 0.14.0) - - hermes-engine/Pre-built (0.14.0) + - hermes-engine (250829098.0.4): + - hermes-engine/Pre-built (= 250829098.0.4) + - hermes-engine/Pre-built (250829098.0.4) - libavif/core (0.11.1) - libavif/libdav1d (0.11.1): - libavif/core @@ -383,7 +404,7 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - - NitroModules (0.29.4): + - NitroModules (0.35.0): - boost - DoubleConversion - fast_float @@ -467,7 +488,6 @@ PODS: - React-RCTText (= 0.83.1) - React-RCTVibration (= 0.83.1) - React-callinvoker (0.83.1) - - React-Codegen (0.1.0) - React-Core (0.83.1): - boost - DoubleConversion @@ -3773,7 +3793,7 @@ PODS: - SocketRocket - Turf - Yoga - - RNNitroSQLite (9.2.0): + - RNNitroSQLite (9.6.0): - boost - DoubleConversion - fast_float @@ -4427,7 +4447,6 @@ SPEC REPOS: - Plaid - PromisesObjC - PusherSwift - - React-Codegen - SDWebImage - SDWebImageAVIFCoder - SDWebImageSVGCoder @@ -4497,7 +4516,7 @@ EXTERNAL SOURCES: :path: "../node_modules/group-ib-fp" hermes-engine: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" - :tag: hermes-v0.14.0 + :tag: hermes-v250829098.0.4 lottie-react-native: :path: "../node_modules/lottie-react-native" NitroModules: @@ -4784,11 +4803,11 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 0dfca1a4b534d123de3945e28f77869d10d0d600 GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - group-ib-fp: 03efaa63a9996535431452a213ed5c7642576c55 + group-ib-fp: 42bc98218d2b4b18eac9a2dd0156a3074e0df082 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa - hermes-engine: 0711ccb14bd615969ef611bc6c2483ea2ed3b09e + hermes-engine: b39ec807040f5a775de027a4a9647c0f4222c6ef libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -4799,7 +4818,7 @@ SPEC CHECKSUMS: MapboxMaps: f87023cf0d72b180b40ea0b6fb4b2d7db6b73b71 MapboxMobileEvents: d044b9edbe0ec7df60f6c2c9634fe9a7f449266b nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - NitroModules: 8bffd214aa360baba0f2c5718fe0acff5ef94763 + NitroModules: f8c2cc3025e4550aee15ff77c525622bf98e774a NWWebSocket: b4741420f1976e1dff4da3edad00c401e4f1d769 Onfido: 65454f91d10758193c857fd149417f6efbea84c5 onfido-react-native-sdk: bb8cfd9198e2e97978461d969497d18b37e45ca7 @@ -4815,7 +4834,6 @@ SPEC CHECKSUMS: RCTTypeSafety: 27927d0ca04e419ed9467578b3e6297e37210b5c React: 4bc1f928568ad4bcfd147260f907b4ea5873a03b React-callinvoker: 87f8728235a0dc62e9dc19b3851c829d9347d015 - React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a React-Core: 76bed73b02821e5630e7f2cb2e82432ee964695d React-CoreModules: 752dbfdaeb096658aa0adc4a03ba6214815a08df React-cxxreact: b6798528aa601c6db66e6adc7e2da2b059c8be74 @@ -4912,7 +4930,7 @@ SPEC CHECKSUMS: RNLiveMarkdown: 589e31da57c2cb6d98b37ac68ae8b0b256247172 RNLocalize: 05e367a873223683f0e268d0af9a8a8e6aed3b26 rnmapbox-maps: 392ac61c42a9ff01a51d4c2f6775d9131b5951fb - RNNitroSQLite: fb251387cfbee73b100cd484a3c886fda681b3b5 + RNNitroSQLite: a9b5965d511ed6e99ce903380e64934d043a0d2c RNPermissions: 518f0a0c439acc74e2b9937e0e7d29e5031ae949 RNReactNativeHapticFeedback: 5f1542065f0b24c9252bd8cf3e83bc9c548182e4 RNReanimated: fbcb7fd8da5b0b088401542c58fb5d266388f1cf @@ -4920,7 +4938,7 @@ SPEC CHECKSUMS: RNSentry: f73f4da92e4c20841ab16e1fa22fc289bc2f9f4e RNShare: 1c1fde2c4134b9cf220ffebbd6df9c414036d382 RNSVG: 74eb75bd44d62ba9969941e80d8f9832971c681f - RNWorklets: 01efdd402d236a13651ea5ea5437ca85a44e7afa + RNWorklets: b2d54014a57a29d3c5848283069360d982880e60 SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c @@ -4934,6 +4952,6 @@ SPEC CHECKSUMS: VisionCamera: 30b358b807324c692064f78385e9a732ce1bebfe Yoga: 33b53536a0500d039f2cd78caf1d5d68712d3af7 -PODFILE CHECKSUM: ff148a26470031497393c45c4ba773f18f371221 +PODFILE CHECKSUM: b5c5984c2431ca6aefe88307d041b5640a6661dd COCOAPODS: 1.16.2 diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 49b7db4f0e80..b4edca493389 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.3.58 + 9.3.62 CFBundleVersion - 9.3.58.7 + 9.3.62.3 NSExtension NSExtensionAttributes diff --git a/jest/setup.ts b/jest/setup.ts index 9255404f5d68..2d7834eb5f18 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -262,7 +262,7 @@ jest.mock('../src/components/Icon/IllustrationLoader.ts', () => ({ })); jest.mock( - '@components/FlatList/InvertedFlatList/RenderTaskQueue', + '@components/FlatList/RenderTaskQueue', () => class SyncRenderTaskQueue { private handler: (info: unknown) => void = () => {}; @@ -288,7 +288,7 @@ jest.mock('@libs/prepareRequestPayload/index.native.ts', () => ({ for (const key of Object.keys(data)) { const value = data[key]; - if (value === undefined) { + if (value === undefined || value === null) { continue; } diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts index c9ef9afc5d98..cf6a137be8e3 100644 --- a/jest/setupAfterEnv.ts +++ b/jest/setupAfterEnv.ts @@ -1,9 +1,53 @@ import '@testing-library/react-native'; +import type {KeyboardEventName} from 'react-native'; +import {Keyboard} from 'react-native'; import Onyx from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; jest.useRealTimers(); +// Patch Keyboard.addListener to return a subscription object with .remove() so that +// @react-navigation/bottom-tabs useIsKeyboardShown hook doesn't crash on cleanup. +if (Keyboard && typeof Keyboard.addListener === 'function') { + const originalAddListener = Keyboard.addListener.bind(Keyboard); + Keyboard.addListener = ((event: KeyboardEventName, callback: () => void) => { + const subscription = originalAddListener(event, callback); + if (!subscription || typeof subscription.remove !== 'function') { + return {remove: jest.fn()}; + } + return subscription; + }) as typeof Keyboard.addListener; +} + +// This mock must live in setupAfterEnv (not setupFiles) because @shopify/flash-list/jestSetup, +// imported in setup.ts, registers its own measureLayout mock. Placing ours here ensures it +// runs after FlashList's setup and takes precedence. +jest.mock( + '@shopify/flash-list/dist/recyclerview/utils/measureLayout', + () => + ({ + ...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'), + measureParentSize: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 400, + })), + measureFirstChildLayout: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 400, + })), + measureItemLayout: jest.fn().mockImplementation(() => ({ + x: 0, + y: 0, + width: 300, + height: 75, + })), + }) as Record, +); + // Auto-initialize Onyx for tests. // Tests that already call Onyx.init() in their own beforeAll will safely re-configure Onyx โ€” // the second init() just re-runs initStoreValues and re-resolves the already-resolved deferred task. diff --git a/modules/ExpensifyNitroUtils/android/src/main/java/com/margelo/nitro/utils/HybridAppStartTimeModule.kt b/modules/ExpensifyNitroUtils/android/src/main/java/com/margelo/nitro/utils/HybridAppStartTimeModule.kt index ddde08906f00..5ba9df67cdd5 100644 --- a/modules/ExpensifyNitroUtils/android/src/main/java/com/margelo/nitro/utils/HybridAppStartTimeModule.kt +++ b/modules/ExpensifyNitroUtils/android/src/main/java/com/margelo/nitro/utils/HybridAppStartTimeModule.kt @@ -2,14 +2,15 @@ package com.margelo.nitro.utils import android.content.Context import com.margelo.nitro.NitroModules +import androidx.core.content.edit class HybridAppStartTimeModule : HybridAppStartTimeModuleSpec() { override val memorySize: Long = 16L - override fun recordAppStartTime() { + fun recordAppStartTime() { val context = NitroModules.applicationContext ?: return val sharedPreferences = context.getSharedPreferences("AppStartTime", Context.MODE_PRIVATE) - sharedPreferences.edit().putLong("AppStartTime", System.currentTimeMillis()).apply() + sharedPreferences.edit { putLong("AppStartTime", System.currentTimeMillis()) } } override val appStartTime: Double diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtils+autolinking.cmake b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtils+autolinking.cmake index 90c9d6cab0e4..b09226ad9a7f 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtils+autolinking.cmake +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtils+autolinking.cmake @@ -2,7 +2,7 @@ # ExpensifyNitroUtils+autolinking.cmake # This file was generated by nitrogen. DO NOT MODIFY THIS FILE. # https://github.com/mrousavy/nitro -# Copyright ยฉ 2026 Marc Rousavy @ Margelo +# Copyright ยฉ Marc Rousavy @ Margelo # # This is a CMake file that adds all files generated by Nitrogen @@ -13,6 +13,12 @@ # include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/ExpensifyNitroUtils+autolinking.cmake) # ``` +# Define a flag to check if we are building properly +add_definitions(-DBUILDING_EXPENSIFYNITROUTILS_WITH_GENERATED_CMAKE_PROJECT) + +# Enable Raw Props parsing in react-native (for Nitro Views) +add_definitions(-DRN_SERIALIZABLE_STATE) + # Add all headers that were generated by Nitrogen include_directories( "../nitrogen/generated/shared/c++" @@ -34,12 +40,9 @@ target_sources( ../nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp ) -# Define a flag to check if we are building properly -add_definitions(-DBUILDING_EXPENSIFYNITROUTILS_WITH_GENERATED_CMAKE_PROJECT) - # From node_modules/react-native/ReactAndroid/cmake-utils/folly-flags.cmake # Used in node_modules/react-native/ReactAndroid/cmake-utils/ReactNative-application.cmake - target_compile_definitions( +target_compile_definitions( ExpensifyNitroUtils PRIVATE -DFOLLY_NO_CONFIG=1 -DFOLLY_HAVE_CLOCK_GETTIME=1 diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtils+autolinking.gradle b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtils+autolinking.gradle index f199d0712989..f65bb1cd4a6b 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtils+autolinking.gradle +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtils+autolinking.gradle @@ -2,7 +2,7 @@ /// ExpensifyNitroUtils+autolinking.gradle /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// /// This is a Gradle file that adds all files generated by Nitrogen diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp index 00c1e6a59825..f328c82635bd 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.cpp @@ -2,7 +2,7 @@ /// ExpensifyNitroUtilsOnLoad.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #ifndef BUILDING_EXPENSIFYNITROUTILS_WITH_GENERATED_CMAKE_PROJECT @@ -22,33 +22,49 @@ namespace margelo::nitro::utils { int initialize(JavaVM* vm) { + return facebook::jni::initialize(vm, []() { + ::margelo::nitro::utils::registerAllNatives(); + }); +} + +struct JHybridContactsModuleSpecImpl: public jni::JavaClass { + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModule;"; + static std::shared_ptr create() { + static auto constructorFn = javaClassStatic()->getConstructor(); + jni::local_ref javaPart = javaClassStatic()->newObject(constructorFn); + return javaPart->getJHybridContactsModuleSpec(); + } +}; +struct JHybridAppStartTimeModuleSpecImpl: public jni::JavaClass { + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModule;"; + static std::shared_ptr create() { + static auto constructorFn = javaClassStatic()->getConstructor(); + jni::local_ref javaPart = javaClassStatic()->newObject(constructorFn); + return javaPart->getJHybridAppStartTimeModuleSpec(); + } +}; + +void registerAllNatives() { using namespace margelo::nitro; using namespace margelo::nitro::utils; - using namespace facebook; - - return facebook::jni::initialize(vm, [] { - // Register native JNI methods - margelo::nitro::utils::JHybridAppStartTimeModuleSpec::registerNatives(); - margelo::nitro::utils::JHybridContactsModuleSpec::registerNatives(); - - // Register Nitro Hybrid Objects - HybridObjectRegistry::registerHybridObjectConstructor( - "ContactsModule", - []() -> std::shared_ptr { - static DefaultConstructableObject object("com/margelo/nitro/utils/HybridContactsModule"); - auto instance = object.create(); - return instance->cthis()->shared(); - } - ); - HybridObjectRegistry::registerHybridObjectConstructor( - "AppStartTimeModule", - []() -> std::shared_ptr { - static DefaultConstructableObject object("com/margelo/nitro/utils/HybridAppStartTimeModule"); - auto instance = object.create(); - return instance->cthis()->shared(); - } - ); - }); + + // Register native JNI methods + margelo::nitro::utils::JHybridAppStartTimeModuleSpec::CxxPart::registerNatives(); + margelo::nitro::utils::JHybridContactsModuleSpec::CxxPart::registerNatives(); + + // Register Nitro Hybrid Objects + HybridObjectRegistry::registerHybridObjectConstructor( + "ContactsModule", + []() -> std::shared_ptr { + return JHybridContactsModuleSpecImpl::create(); + } + ); + HybridObjectRegistry::registerHybridObjectConstructor( + "AppStartTimeModule", + []() -> std::shared_ptr { + return JHybridAppStartTimeModuleSpecImpl::create(); + } + ); } } // namespace margelo::nitro::utils diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.hpp index f6235b868cbb..28c454ec157e 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/ExpensifyNitroUtilsOnLoad.hpp @@ -2,24 +2,33 @@ /// ExpensifyNitroUtilsOnLoad.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #include +#include #include namespace margelo::nitro::utils { + [[deprecated("Use registerNatives() instead.")]] + int initialize(JavaVM* vm); + /** - * Initializes the native (C++) part of ExpensifyNitroUtils, and autolinks all Hybrid Objects. - * Call this in your `JNI_OnLoad` function (probably inside `cpp-adapter.cpp`). + * Register the native (C++) part of ExpensifyNitroUtils, and autolinks all Hybrid Objects. + * Call this in your `JNI_OnLoad` function (probably inside `cpp-adapter.cpp`), + * inside a `facebook::jni::initialize(vm, ...)` call. * Example: * ```cpp (cpp-adapter.cpp) * JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { - * return margelo::nitro::utils::initialize(vm); + * return facebook::jni::initialize(vm, []() { + * // register all ExpensifyNitroUtils HybridObjects + * margelo::nitro::utils::registerNatives(); + * // any other custom registrations go here. + * }); * } * ``` */ - int initialize(JavaVM* vm); + void registerAllNatives(); } // namespace margelo::nitro::utils diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp index 2789f76a1836..2dccb42fa544 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContact.hpp @@ -2,7 +2,7 @@ /// JContact.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -78,7 +78,11 @@ namespace margelo::nitro::utils { */ [[maybe_unused]] static jni::local_ref fromCpp(const Contact& value) { - return newInstance( + using JSignature = JContact(jni::alias_ref, jni::alias_ref, jni::alias_ref>, jni::alias_ref>, jni::alias_ref); + static const auto clazz = javaClassStatic(); + static const auto create = clazz->getStaticMethod("fromCpp"); + return create( + clazz, value.firstName.has_value() ? jni::make_jstring(value.firstName.value()) : nullptr, value.lastName.has_value() ? jni::make_jstring(value.lastName.value()) : nullptr, value.phoneNumbers.has_value() ? [&]() { @@ -86,7 +90,8 @@ namespace margelo::nitro::utils { jni::local_ref> __array = jni::JArrayClass::newArray(__size); for (size_t __i = 0; __i < __size; __i++) { const auto& __element = value.phoneNumbers.value()[__i]; - __array->setElement(__i, *JStringHolder::fromCpp(__element)); + auto __elementJni = JStringHolder::fromCpp(__element); + __array->setElement(__i, *__elementJni); } return __array; }() : nullptr, @@ -95,7 +100,8 @@ namespace margelo::nitro::utils { jni::local_ref> __array = jni::JArrayClass::newArray(__size); for (size_t __i = 0; __i < __size; __i++) { const auto& __element = value.emailAddresses.value()[__i]; - __array->setElement(__i, *JStringHolder::fromCpp(__element)); + auto __elementJni = JStringHolder::fromCpp(__element); + __array->setElement(__i, *__elementJni); } return __array; }() : nullptr, diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp index 2cf3d29547bf..1ed3380932ce 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JContactFields.hpp @@ -2,7 +2,7 @@ /// JContactFields.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -41,22 +41,21 @@ namespace margelo::nitro::utils { [[maybe_unused]] static jni::alias_ref fromCpp(ContactFields value) { static const auto clazz = javaClassStatic(); - static const auto fieldFIRST_NAME = clazz->getStaticField("FIRST_NAME"); - static const auto fieldLAST_NAME = clazz->getStaticField("LAST_NAME"); - static const auto fieldPHONE_NUMBERS = clazz->getStaticField("PHONE_NUMBERS"); - static const auto fieldEMAIL_ADDRESSES = clazz->getStaticField("EMAIL_ADDRESSES"); - static const auto fieldIMAGE_DATA = clazz->getStaticField("IMAGE_DATA"); - switch (value) { case ContactFields::FIRST_NAME: + static const auto fieldFIRST_NAME = clazz->getStaticField("FIRST_NAME"); return clazz->getStaticFieldValue(fieldFIRST_NAME); case ContactFields::LAST_NAME: + static const auto fieldLAST_NAME = clazz->getStaticField("LAST_NAME"); return clazz->getStaticFieldValue(fieldLAST_NAME); case ContactFields::PHONE_NUMBERS: + static const auto fieldPHONE_NUMBERS = clazz->getStaticField("PHONE_NUMBERS"); return clazz->getStaticFieldValue(fieldPHONE_NUMBERS); case ContactFields::EMAIL_ADDRESSES: + static const auto fieldEMAIL_ADDRESSES = clazz->getStaticField("EMAIL_ADDRESSES"); return clazz->getStaticFieldValue(fieldEMAIL_ADDRESSES); case ContactFields::IMAGE_DATA: + static const auto fieldIMAGE_DATA = clazz->getStaticField("IMAGE_DATA"); return clazz->getStaticFieldValue(fieldIMAGE_DATA); default: std::string stringValue = std::to_string(static_cast(value)); diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.cpp index b4eb2b05e7d7..3556e2cc22ef 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.cpp @@ -2,7 +2,7 @@ /// JHybridAppStartTimeModuleSpec.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #include "JHybridAppStartTimeModuleSpec.hpp" @@ -13,37 +13,41 @@ namespace margelo::nitro::utils { - jni::local_ref JHybridAppStartTimeModuleSpec::initHybrid(jni::alias_ref jThis) { - return makeCxxInstance(jThis); + std::shared_ptr JHybridAppStartTimeModuleSpec::JavaPart::getJHybridAppStartTimeModuleSpec() { + auto hybridObject = JHybridObject::JavaPart::getJHybridObject(); + auto castHybridObject = std::dynamic_pointer_cast(hybridObject); + if (castHybridObject == nullptr) [[unlikely]] { + throw std::runtime_error("Failed to downcast JHybridObject to JHybridAppStartTimeModuleSpec!"); + } + return castHybridObject; } - void JHybridAppStartTimeModuleSpec::registerNatives() { - registerHybrid({ - makeNativeMethod("initHybrid", JHybridAppStartTimeModuleSpec::initHybrid), - }); + jni::local_ref JHybridAppStartTimeModuleSpec::CxxPart::initHybrid(jni::alias_ref jThis) { + return makeCxxInstance(jThis); } - size_t JHybridAppStartTimeModuleSpec::getExternalMemorySize() noexcept { - static const auto method = javaClassStatic()->getMethod("getMemorySize"); - return method(_javaPart); + std::shared_ptr JHybridAppStartTimeModuleSpec::CxxPart::createHybridObject(const jni::local_ref& javaPart) { + auto castJavaPart = jni::dynamic_ref_cast(javaPart); + if (castJavaPart == nullptr) [[unlikely]] { + throw std::runtime_error("Failed to cast JHybridObject::JavaPart to JHybridAppStartTimeModuleSpec::JavaPart!"); + } + return std::make_shared(castJavaPart); } - void JHybridAppStartTimeModuleSpec::dispose() noexcept { - static const auto method = javaClassStatic()->getMethod("dispose"); - method(_javaPart); + void JHybridAppStartTimeModuleSpec::CxxPart::registerNatives() { + registerHybrid({ + makeNativeMethod("initHybrid", JHybridAppStartTimeModuleSpec::CxxPart::initHybrid), + }); } // Properties double JHybridAppStartTimeModuleSpec::getAppStartTime() { - static const auto method = javaClassStatic()->getMethod("getAppStartTime"); + static const auto method = _javaPart->javaClassStatic()->getMethod("getAppStartTime"); auto __result = method(_javaPart); return __result; } // Methods - void JHybridAppStartTimeModuleSpec::recordAppStartTime() { - static const auto method = javaClassStatic()->getMethod("recordAppStartTime"); - method(_javaPart); - } + } // namespace margelo::nitro::utils diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp index 135fb2db00d7..91fc411edbd0 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridAppStartTimeModuleSpec.hpp @@ -2,7 +2,7 @@ /// HybridAppStartTimeModuleSpec.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -18,32 +18,33 @@ namespace margelo::nitro::utils { using namespace facebook; - class JHybridAppStartTimeModuleSpec: public jni::HybridClass, - public virtual HybridAppStartTimeModuleSpec { + class JHybridAppStartTimeModuleSpec: public virtual HybridAppStartTimeModuleSpec, public virtual JHybridObject { public: - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec;"; - static jni::local_ref initHybrid(jni::alias_ref jThis); - static void registerNatives(); - - protected: - // C++ constructor (called from Java via `initHybrid()`) - explicit JHybridAppStartTimeModuleSpec(jni::alias_ref jThis) : - HybridObject(HybridAppStartTimeModuleSpec::TAG), - HybridBase(jThis), - _javaPart(jni::make_global(jThis)) {} + struct JavaPart: public jni::JavaClass { + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec;"; + std::shared_ptr getJHybridAppStartTimeModuleSpec(); + }; + struct CxxPart: public jni::HybridClass { + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridAppStartTimeModuleSpec$CxxPart;"; + static jni::local_ref initHybrid(jni::alias_ref jThis); + static void registerNatives(); + using HybridBase::HybridBase; + protected: + std::shared_ptr createHybridObject(const jni::local_ref& javaPart) override; + }; public: + explicit JHybridAppStartTimeModuleSpec(const jni::local_ref& javaPart): + HybridObject(HybridAppStartTimeModuleSpec::TAG), + JHybridObject(javaPart), + _javaPart(jni::make_global(javaPart)) {} ~JHybridAppStartTimeModuleSpec() override { // Hermes GC can destroy JS objects on a non-JNI Thread. jni::ThreadScope::WithClassLoader([&] { _javaPart.reset(); }); } public: - size_t getExternalMemorySize() noexcept override; - void dispose() noexcept override; - - public: - inline const jni::global_ref& getJavaPart() const noexcept { + inline const jni::global_ref& getJavaPart() const noexcept { return _javaPart; } @@ -53,12 +54,10 @@ namespace margelo::nitro::utils { public: // Methods - void recordAppStartTime() override; + private: - friend HybridBase; - using HybridBase::HybridBase; - jni::global_ref _javaPart; + jni::global_ref _javaPart; }; } // namespace margelo::nitro::utils diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp index 17499137b22a..812c363b0d36 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp @@ -2,7 +2,7 @@ /// JHybridContactsModuleSpec.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #include "JHybridContactsModuleSpec.hpp" @@ -28,24 +28,31 @@ namespace margelo::nitro::utils { enum class ContactFields; } namespace margelo::nitro::utils { - jni::local_ref JHybridContactsModuleSpec::initHybrid(jni::alias_ref jThis) { - return makeCxxInstance(jThis); + std::shared_ptr JHybridContactsModuleSpec::JavaPart::getJHybridContactsModuleSpec() { + auto hybridObject = JHybridObject::JavaPart::getJHybridObject(); + auto castHybridObject = std::dynamic_pointer_cast(hybridObject); + if (castHybridObject == nullptr) [[unlikely]] { + throw std::runtime_error("Failed to downcast JHybridObject to JHybridContactsModuleSpec!"); + } + return castHybridObject; } - void JHybridContactsModuleSpec::registerNatives() { - registerHybrid({ - makeNativeMethod("initHybrid", JHybridContactsModuleSpec::initHybrid), - }); + jni::local_ref JHybridContactsModuleSpec::CxxPart::initHybrid(jni::alias_ref jThis) { + return makeCxxInstance(jThis); } - size_t JHybridContactsModuleSpec::getExternalMemorySize() noexcept { - static const auto method = javaClassStatic()->getMethod("getMemorySize"); - return method(_javaPart); + std::shared_ptr JHybridContactsModuleSpec::CxxPart::createHybridObject(const jni::local_ref& javaPart) { + auto castJavaPart = jni::dynamic_ref_cast(javaPart); + if (castJavaPart == nullptr) [[unlikely]] { + throw std::runtime_error("Failed to cast JHybridObject::JavaPart to JHybridContactsModuleSpec::JavaPart!"); + } + return std::make_shared(castJavaPart); } - void JHybridContactsModuleSpec::dispose() noexcept { - static const auto method = javaClassStatic()->getMethod("dispose"); - method(_javaPart); + void JHybridContactsModuleSpec::CxxPart::registerNatives() { + registerHybrid({ + makeNativeMethod("initHybrid", JHybridContactsModuleSpec::CxxPart::initHybrid), + }); } // Properties @@ -53,13 +60,14 @@ namespace margelo::nitro::utils { // Methods std::shared_ptr>> JHybridContactsModuleSpec::getAll(const std::vector& keys) { - static const auto method = javaClassStatic()->getMethod(jni::alias_ref> /* keys */)>("getAll"); + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref> /* keys */)>("getAll"); auto __result = method(_javaPart, [&]() { size_t __size = keys.size(); jni::local_ref> __array = jni::JArrayClass::newArray(__size); for (size_t __i = 0; __i < __size; __i++) { const auto& __element = keys[__i]; - __array->setElement(__i, *JContactFields::fromCpp(__element)); + auto __elementJni = JContactFields::fromCpp(__element); + __array->setElement(__i, *__elementJni); } return __array; }()); diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp index c4977a2937c1..b140e3c9f376 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp @@ -2,7 +2,7 @@ /// HybridContactsModuleSpec.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -18,32 +18,33 @@ namespace margelo::nitro::utils { using namespace facebook; - class JHybridContactsModuleSpec: public jni::HybridClass, - public virtual HybridContactsModuleSpec { + class JHybridContactsModuleSpec: public virtual HybridContactsModuleSpec, public virtual JHybridObject { public: - static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec;"; - static jni::local_ref initHybrid(jni::alias_ref jThis); - static void registerNatives(); - - protected: - // C++ constructor (called from Java via `initHybrid()`) - explicit JHybridContactsModuleSpec(jni::alias_ref jThis) : - HybridObject(HybridContactsModuleSpec::TAG), - HybridBase(jThis), - _javaPart(jni::make_global(jThis)) {} + struct JavaPart: public jni::JavaClass { + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec;"; + std::shared_ptr getJHybridContactsModuleSpec(); + }; + struct CxxPart: public jni::HybridClass { + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/utils/HybridContactsModuleSpec$CxxPart;"; + static jni::local_ref initHybrid(jni::alias_ref jThis); + static void registerNatives(); + using HybridBase::HybridBase; + protected: + std::shared_ptr createHybridObject(const jni::local_ref& javaPart) override; + }; public: + explicit JHybridContactsModuleSpec(const jni::local_ref& javaPart): + HybridObject(HybridContactsModuleSpec::TAG), + JHybridObject(javaPart), + _javaPart(jni::make_global(javaPart)) {} ~JHybridContactsModuleSpec() override { // Hermes GC can destroy JS objects on a non-JNI Thread. jni::ThreadScope::WithClassLoader([&] { _javaPart.reset(); }); } public: - size_t getExternalMemorySize() noexcept override; - void dispose() noexcept override; - - public: - inline const jni::global_ref& getJavaPart() const noexcept { + inline const jni::global_ref& getJavaPart() const noexcept { return _javaPart; } @@ -56,9 +57,7 @@ namespace margelo::nitro::utils { std::shared_ptr>> getAll(const std::vector& keys) override; private: - friend HybridBase; - using HybridBase::HybridBase; - jni::global_ref _javaPart; + jni::global_ref _javaPart; }; } // namespace margelo::nitro::utils diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp index babbbf5289c0..11b563d2b695 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/c++/JStringHolder.hpp @@ -2,7 +2,7 @@ /// JStringHolder.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -44,7 +44,11 @@ namespace margelo::nitro::utils { */ [[maybe_unused]] static jni::local_ref fromCpp(const StringHolder& value) { - return newInstance( + using JSignature = JStringHolder(jni::alias_ref); + static const auto clazz = javaClassStatic(); + static const auto create = clazz->getStaticMethod("fromCpp"); + return create( + clazz, jni::make_jstring(value.value) ); } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt index 327fe876dbbb..ca037de30643 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/Contact.kt @@ -2,14 +2,13 @@ /// Contact.kt /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// package com.margelo.nitro.utils import androidx.annotation.Keep import com.facebook.proguard.annotations.DoNotStrip -import com.margelo.nitro.core.* /** @@ -17,25 +16,35 @@ import com.margelo.nitro.core.* */ @DoNotStrip @Keep -data class Contact +data class Contact( @DoNotStrip @Keep - constructor( - @DoNotStrip - @Keep - val firstName: String?, - @DoNotStrip - @Keep - val lastName: String?, - @DoNotStrip - @Keep - val phoneNumbers: Array?, - @DoNotStrip - @Keep - val emailAddresses: Array?, + val firstName: String?, + @DoNotStrip + @Keep + val lastName: String?, + @DoNotStrip + @Keep + val phoneNumbers: Array?, + @DoNotStrip + @Keep + val emailAddresses: Array?, + @DoNotStrip + @Keep + val imageData: String? +) { + /* primary constructor */ + + companion object { + /** + * Constructor called from C++ + */ @DoNotStrip @Keep - val imageData: String? - ) { - /* main constructor */ + @Suppress("unused") + @JvmStatic + private fun fromCpp(firstName: String?, lastName: String?, phoneNumbers: Array?, emailAddresses: Array?, imageData: String?): Contact { + return Contact(firstName, lastName, phoneNumbers, emailAddresses, imageData) + } + } } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/ContactFields.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/ContactFields.kt index 619aed2f224f..8c2c337ac373 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/ContactFields.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/ContactFields.kt @@ -2,7 +2,7 @@ /// ContactFields.kt /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// package com.margelo.nitro.utils @@ -21,4 +21,6 @@ enum class ContactFields(@DoNotStrip @Keep val value: Int) { PHONE_NUMBERS(2), EMAIL_ADDRESSES(3), IMAGE_DATA(4); + + companion object } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/ExpensifyNitroUtilsOnLoad.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/ExpensifyNitroUtilsOnLoad.kt index f454ec43e2ff..3b6c58ae83cb 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/ExpensifyNitroUtilsOnLoad.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/ExpensifyNitroUtilsOnLoad.kt @@ -2,7 +2,7 @@ /// ExpensifyNitroUtilsOnLoad.kt /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// package com.margelo.nitro.utils diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridAppStartTimeModuleSpec.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridAppStartTimeModuleSpec.kt index a03637b06ae7..f925221dd42b 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridAppStartTimeModuleSpec.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridAppStartTimeModuleSpec.kt @@ -2,7 +2,7 @@ /// HybridAppStartTimeModuleSpec.kt /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// package com.margelo.nitro.utils @@ -10,7 +10,7 @@ package com.margelo.nitro.utils import androidx.annotation.Keep import com.facebook.jni.HybridData import com.facebook.proguard.annotations.DoNotStrip -import com.margelo.nitro.core.* +import com.margelo.nitro.core.HybridObject /** * A Kotlin class representing the AppStartTimeModule HybridObject. @@ -24,31 +24,31 @@ import com.margelo.nitro.core.* "LocalVariableName", "PropertyName", "PrivatePropertyName", "FunctionName" ) abstract class HybridAppStartTimeModuleSpec: HybridObject() { - @DoNotStrip - private var mHybridData: HybridData = initHybrid() - - init { - super.updateNative(mHybridData) - } - - override fun updateNative(hybridData: HybridData) { - mHybridData = hybridData - super.updateNative(hybridData) - } - // Properties @get:DoNotStrip @get:Keep abstract val appStartTime: Double // Methods + + + // Default implementation of `HybridObject.toString()` + override fun toString(): String { + return "[HybridObject AppStartTimeModule]" + } + + // C++ backing class @DoNotStrip @Keep - abstract fun recordAppStartTime(): Unit - - private external fun initHybrid(): HybridData + protected open class CxxPart(javaPart: HybridAppStartTimeModuleSpec): HybridObject.CxxPart(javaPart) { + // C++ JHybridAppStartTimeModuleSpec::CxxPart::initHybrid(...) + external override fun initHybrid(): HybridData + } + override fun createCxxPart(): CxxPart { + return CxxPart(this) + } companion object { - private const val TAG = "HybridAppStartTimeModuleSpec" + protected const val TAG = "HybridAppStartTimeModuleSpec" } } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridContactsModuleSpec.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridContactsModuleSpec.kt index eb72823ea494..8f68b49898ea 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridContactsModuleSpec.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/HybridContactsModuleSpec.kt @@ -2,7 +2,7 @@ /// HybridContactsModuleSpec.kt /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// package com.margelo.nitro.utils @@ -10,7 +10,8 @@ package com.margelo.nitro.utils import androidx.annotation.Keep import com.facebook.jni.HybridData import com.facebook.proguard.annotations.DoNotStrip -import com.margelo.nitro.core.* +import com.margelo.nitro.core.Promise +import com.margelo.nitro.core.HybridObject /** * A Kotlin class representing the ContactsModule HybridObject. @@ -24,18 +25,6 @@ import com.margelo.nitro.core.* "LocalVariableName", "PropertyName", "PrivatePropertyName", "FunctionName" ) abstract class HybridContactsModuleSpec: HybridObject() { - @DoNotStrip - private var mHybridData: HybridData = initHybrid() - - init { - super.updateNative(mHybridData) - } - - override fun updateNative(hybridData: HybridData) { - mHybridData = hybridData - super.updateNative(hybridData) - } - // Properties @@ -44,9 +33,23 @@ abstract class HybridContactsModuleSpec: HybridObject() { @Keep abstract fun getAll(keys: Array): Promise> - private external fun initHybrid(): HybridData + // Default implementation of `HybridObject.toString()` + override fun toString(): String { + return "[HybridObject ContactsModule]" + } + + // C++ backing class + @DoNotStrip + @Keep + protected open class CxxPart(javaPart: HybridContactsModuleSpec): HybridObject.CxxPart(javaPart) { + // C++ JHybridContactsModuleSpec::CxxPart::initHybrid(...) + external override fun initHybrid(): HybridData + } + override fun createCxxPart(): CxxPart { + return CxxPart(this) + } companion object { - private const val TAG = "HybridContactsModuleSpec" + protected const val TAG = "HybridContactsModuleSpec" } } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt index 2b5330c8c4e2..accaebee2968 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/android/kotlin/com/margelo/nitro/utils/StringHolder.kt @@ -2,14 +2,13 @@ /// StringHolder.kt /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// package com.margelo.nitro.utils import androidx.annotation.Keep import com.facebook.proguard.annotations.DoNotStrip -import com.margelo.nitro.core.* /** @@ -17,13 +16,23 @@ import com.margelo.nitro.core.* */ @DoNotStrip @Keep -data class StringHolder +data class StringHolder( @DoNotStrip @Keep - constructor( + val value: String +) { + /* primary constructor */ + + companion object { + /** + * Constructor called from C++ + */ @DoNotStrip @Keep - val value: String - ) { - /* main constructor */ + @Suppress("unused") + @JvmStatic + private fun fromCpp(value: String): StringHolder { + return StringHolder(value) + } + } } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb index 997952fe1fc7..78c2c451cd92 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils+autolinking.rb @@ -2,7 +2,7 @@ # ExpensifyNitroUtils+autolinking.rb # This file was generated by nitrogen. DO NOT MODIFY THIS FILE. # https://github.com/mrousavy/nitro -# Copyright ยฉ 2026 Marc Rousavy @ Margelo +# Copyright ยฉ Marc Rousavy @ Margelo # # This is a Ruby script that adds all files generated by Nitrogen @@ -52,7 +52,7 @@ def add_nitrogen_files(spec) spec.pod_target_xcconfig = current_pod_target_xcconfig.merge({ # Use C++ 20 "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", - # Enables C++ <-> Swift interop (by default it's only C) + # Enables C++ <-> Swift interop (by default it's only ObjC) "SWIFT_OBJC_INTEROP_MODE" => "objcxx", # Enables stricter modular headers "DEFINES_MODULE" => "YES", diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Bridge.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Bridge.cpp index b4ba285734f0..bad2bd0d8e7a 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Bridge.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Bridge.cpp @@ -2,7 +2,7 @@ /// ExpensifyNitroUtils-Swift-Cxx-Bridge.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #include "ExpensifyNitroUtils-Swift-Cxx-Bridge.hpp" @@ -11,15 +11,16 @@ #include "ExpensifyNitroUtils-Swift-Cxx-Umbrella.hpp" #include "HybridAppStartTimeModuleSpecSwift.hpp" #include "HybridContactsModuleSpecSwift.hpp" +#include namespace margelo::nitro::utils::bridge::swift { // pragma MARK: std::shared_ptr - std::shared_ptr create_std__shared_ptr_HybridAppStartTimeModuleSpec_(void* _Nonnull swiftUnsafePointer) noexcept { + std::shared_ptr create_std__shared_ptr_HybridAppStartTimeModuleSpec_(void* NON_NULL swiftUnsafePointer) noexcept { ExpensifyNitroUtils::HybridAppStartTimeModuleSpec_cxx swiftPart = ExpensifyNitroUtils::HybridAppStartTimeModuleSpec_cxx::fromUnsafe(swiftUnsafePointer); return std::make_shared(swiftPart); } - void* _Nonnull get_std__shared_ptr_HybridAppStartTimeModuleSpec_(std__shared_ptr_HybridAppStartTimeModuleSpec_ cppType) noexcept { + void* NON_NULL get_std__shared_ptr_HybridAppStartTimeModuleSpec_(std__shared_ptr_HybridAppStartTimeModuleSpec_ cppType) { std::shared_ptr swiftWrapper = std::dynamic_pointer_cast(cppType); #ifdef NITRO_DEBUG if (swiftWrapper == nullptr) [[unlikely]] { @@ -31,7 +32,7 @@ namespace margelo::nitro::utils::bridge::swift { } // pragma MARK: std::function& /* result */)> - Func_void_std__vector_Contact_ create_Func_void_std__vector_Contact_(void* _Nonnull swiftClosureWrapper) noexcept { + Func_void_std__vector_Contact_ create_Func_void_std__vector_Contact_(void* NON_NULL swiftClosureWrapper) noexcept { auto swiftClosure = ExpensifyNitroUtils::Func_void_std__vector_Contact_::fromUnsafe(swiftClosureWrapper); return [swiftClosure = std::move(swiftClosure)](const std::vector& result) mutable -> void { swiftClosure.call(result); @@ -39,7 +40,7 @@ namespace margelo::nitro::utils::bridge::swift { } // pragma MARK: std::function - Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* _Nonnull swiftClosureWrapper) noexcept { + Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept { auto swiftClosure = ExpensifyNitroUtils::Func_void_std__exception_ptr::fromUnsafe(swiftClosureWrapper); return [swiftClosure = std::move(swiftClosure)](const std::exception_ptr& error) mutable -> void { swiftClosure.call(error); @@ -47,11 +48,11 @@ namespace margelo::nitro::utils::bridge::swift { } // pragma MARK: std::shared_ptr - std::shared_ptr create_std__shared_ptr_HybridContactsModuleSpec_(void* _Nonnull swiftUnsafePointer) noexcept { + std::shared_ptr create_std__shared_ptr_HybridContactsModuleSpec_(void* NON_NULL swiftUnsafePointer) noexcept { ExpensifyNitroUtils::HybridContactsModuleSpec_cxx swiftPart = ExpensifyNitroUtils::HybridContactsModuleSpec_cxx::fromUnsafe(swiftUnsafePointer); return std::make_shared(swiftPart); } - void* _Nonnull get_std__shared_ptr_HybridContactsModuleSpec_(std__shared_ptr_HybridContactsModuleSpec_ cppType) noexcept { + void* NON_NULL get_std__shared_ptr_HybridContactsModuleSpec_(std__shared_ptr_HybridContactsModuleSpec_ cppType) { std::shared_ptr swiftWrapper = std::dynamic_pointer_cast(cppType); #ifdef NITRO_DEBUG if (swiftWrapper == nullptr) [[unlikely]] { diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Bridge.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Bridge.hpp index ebcb69c0ee02..1e38d4cb4153 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Bridge.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Bridge.hpp @@ -2,7 +2,7 @@ /// ExpensifyNitroUtils-Swift-Cxx-Bridge.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -52,22 +52,13 @@ namespace margelo::nitro::utils::bridge::swift { * Specialized version of `std::shared_ptr`. */ using std__shared_ptr_HybridAppStartTimeModuleSpec_ = std::shared_ptr; - std::shared_ptr create_std__shared_ptr_HybridAppStartTimeModuleSpec_(void* _Nonnull swiftUnsafePointer) noexcept; - void* _Nonnull get_std__shared_ptr_HybridAppStartTimeModuleSpec_(std__shared_ptr_HybridAppStartTimeModuleSpec_ cppType) noexcept; + std::shared_ptr create_std__shared_ptr_HybridAppStartTimeModuleSpec_(void* NON_NULL swiftUnsafePointer) noexcept; + void* NON_NULL get_std__shared_ptr_HybridAppStartTimeModuleSpec_(std__shared_ptr_HybridAppStartTimeModuleSpec_ cppType); // pragma MARK: std::weak_ptr using std__weak_ptr_HybridAppStartTimeModuleSpec_ = std::weak_ptr; inline std__weak_ptr_HybridAppStartTimeModuleSpec_ weakify_std__shared_ptr_HybridAppStartTimeModuleSpec_(const std::shared_ptr& strong) noexcept { return strong; } - // pragma MARK: Result - using Result_void_ = Result; - inline Result_void_ create_Result_void_() noexcept { - return Result::withValue(); - } - inline Result_void_ create_Result_void_(const std::exception_ptr& error) noexcept { - return Result::withError(error); - } - // pragma MARK: std::optional /** * Specialized version of `std::optional`. @@ -80,7 +71,7 @@ namespace margelo::nitro::utils::bridge::swift { return optional.has_value(); } inline std::string get_std__optional_std__string_(const std::optional& optional) noexcept { - return *optional; + return optional.value(); } // pragma MARK: std::vector @@ -106,7 +97,7 @@ namespace margelo::nitro::utils::bridge::swift { return optional.has_value(); } inline std::vector get_std__optional_std__vector_StringHolder__(const std::optional>& optional) noexcept { - return *optional; + return optional.value(); } // pragma MARK: std::vector @@ -149,7 +140,7 @@ namespace margelo::nitro::utils::bridge::swift { private: std::unique_ptr& /* result */)>> _function; } SWIFT_NONCOPYABLE; - Func_void_std__vector_Contact_ create_Func_void_std__vector_Contact_(void* _Nonnull swiftClosureWrapper) noexcept; + Func_void_std__vector_Contact_ create_Func_void_std__vector_Contact_(void* NON_NULL swiftClosureWrapper) noexcept; inline Func_void_std__vector_Contact__Wrapper wrap_Func_void_std__vector_Contact_(Func_void_std__vector_Contact_ value) noexcept { return Func_void_std__vector_Contact__Wrapper(std::move(value)); } @@ -171,7 +162,7 @@ namespace margelo::nitro::utils::bridge::swift { private: std::unique_ptr> _function; } SWIFT_NONCOPYABLE; - Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* _Nonnull swiftClosureWrapper) noexcept; + Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept; inline Func_void_std__exception_ptr_Wrapper wrap_Func_void_std__exception_ptr(Func_void_std__exception_ptr value) noexcept { return Func_void_std__exception_ptr_Wrapper(std::move(value)); } @@ -192,8 +183,8 @@ namespace margelo::nitro::utils::bridge::swift { * Specialized version of `std::shared_ptr`. */ using std__shared_ptr_HybridContactsModuleSpec_ = std::shared_ptr; - std::shared_ptr create_std__shared_ptr_HybridContactsModuleSpec_(void* _Nonnull swiftUnsafePointer) noexcept; - void* _Nonnull get_std__shared_ptr_HybridContactsModuleSpec_(std__shared_ptr_HybridContactsModuleSpec_ cppType) noexcept; + std::shared_ptr create_std__shared_ptr_HybridContactsModuleSpec_(void* NON_NULL swiftUnsafePointer) noexcept; + void* NON_NULL get_std__shared_ptr_HybridContactsModuleSpec_(std__shared_ptr_HybridContactsModuleSpec_ cppType); // pragma MARK: std::weak_ptr using std__weak_ptr_HybridContactsModuleSpec_ = std::weak_ptr; diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Umbrella.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Umbrella.hpp index 52ea5f386373..f61ac3b6b942 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Umbrella.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtils-Swift-Cxx-Umbrella.hpp @@ -2,7 +2,7 @@ /// ExpensifyNitroUtils-Swift-Cxx-Umbrella.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtilsAutolinking.mm b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtilsAutolinking.mm index b3101413be34..dcf15dd6aba0 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtilsAutolinking.mm +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtilsAutolinking.mm @@ -2,7 +2,7 @@ /// ExpensifyNitroUtilsAutolinking.mm /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #import diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtilsAutolinking.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtilsAutolinking.swift index b402dc07c98f..5794c7097255 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtilsAutolinking.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/ExpensifyNitroUtilsAutolinking.swift @@ -2,19 +2,16 @@ /// ExpensifyNitroUtilsAutolinking.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// +import NitroModules + +// TODO: Use empty enums once Swift supports exporting them as namespaces +// See: https://github.com/swiftlang/swift/pull/83616 public final class ExpensifyNitroUtilsAutolinking { public typealias bridge = margelo.nitro.utils.bridge.swift - /** - * Creates an instance of a Swift class that implements `HybridContactsModuleSpec`, - * and wraps it in a Swift class that can directly interop with C++ (`HybridContactsModuleSpec_cxx`) - * - * This is generated by Nitrogen and will initialize the class specified - * in the `"autolinking"` property of `nitro.json` (in this case, `HybridContactsModule`). - */ public static func createContactsModule() -> bridge.std__shared_ptr_HybridContactsModuleSpec_ { let hybridObject = HybridContactsModule() return { () -> bridge.std__shared_ptr_HybridContactsModuleSpec_ in @@ -23,13 +20,10 @@ public final class ExpensifyNitroUtilsAutolinking { }() } - /** - * Creates an instance of a Swift class that implements `HybridAppStartTimeModuleSpec`, - * and wraps it in a Swift class that can directly interop with C++ (`HybridAppStartTimeModuleSpec_cxx`) - * - * This is generated by Nitrogen and will initialize the class specified - * in the `"autolinking"` property of `nitro.json` (in this case, `HybridAppStartTimeModule`). - */ + public static func isContactsModuleRecyclable() -> Bool { + return HybridContactsModule.self is any RecyclableView.Type + } + public static func createAppStartTimeModule() -> bridge.std__shared_ptr_HybridAppStartTimeModuleSpec_ { let hybridObject = HybridAppStartTimeModule() return { () -> bridge.std__shared_ptr_HybridAppStartTimeModuleSpec_ in @@ -37,4 +31,8 @@ public final class ExpensifyNitroUtilsAutolinking { return __cxxWrapped.getCxxPart() }() } + + public static func isAppStartTimeModuleRecyclable() -> Bool { + return HybridAppStartTimeModule.self is any RecyclableView.Type + } } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridAppStartTimeModuleSpecSwift.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridAppStartTimeModuleSpecSwift.cpp index e9cb5771aaa3..c5c0f33208a5 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridAppStartTimeModuleSpecSwift.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridAppStartTimeModuleSpecSwift.cpp @@ -2,7 +2,7 @@ /// HybridAppStartTimeModuleSpecSwift.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #include "HybridAppStartTimeModuleSpecSwift.hpp" diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridAppStartTimeModuleSpecSwift.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridAppStartTimeModuleSpecSwift.hpp index 8a0da8729b56..2f7cf1df7128 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridAppStartTimeModuleSpecSwift.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridAppStartTimeModuleSpecSwift.hpp @@ -2,7 +2,7 @@ /// HybridAppStartTimeModuleSpecSwift.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -47,9 +47,18 @@ namespace margelo::nitro::utils { inline size_t getExternalMemorySize() noexcept override { return _swiftPart.getMemorySize(); } + bool equals(const std::shared_ptr& other) override { + if (auto otherCast = std::dynamic_pointer_cast(other)) { + return _swiftPart.equals(otherCast->_swiftPart); + } + return false; + } void dispose() noexcept override { _swiftPart.dispose(); } + std::string toString() override { + return _swiftPart.toString(); + } public: // Properties @@ -59,12 +68,7 @@ namespace margelo::nitro::utils { public: // Methods - inline void recordAppStartTime() override { - auto __result = _swiftPart.recordAppStartTime(); - if (__result.hasError()) [[unlikely]] { - std::rethrow_exception(__result.error()); - } - } + private: ExpensifyNitroUtils::HybridAppStartTimeModuleSpec_cxx _swiftPart; diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.cpp index 4ed87fe30a92..c112a079efb9 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.cpp @@ -2,7 +2,7 @@ /// HybridContactsModuleSpecSwift.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #include "HybridContactsModuleSpecSwift.hpp" diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp index 0ec5342eccec..17fb6c17149a 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp @@ -2,7 +2,7 @@ /// HybridContactsModuleSpecSwift.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -58,9 +58,18 @@ namespace margelo::nitro::utils { inline size_t getExternalMemorySize() noexcept override { return _swiftPart.getMemorySize(); } + bool equals(const std::shared_ptr& other) override { + if (auto otherCast = std::dynamic_pointer_cast(other)) { + return _swiftPart.equals(otherCast->_swiftPart); + } + return false; + } void dispose() noexcept override { _swiftPart.dispose(); } + std::string toString() override { + return _swiftPart.toString(); + } public: // Properties diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Contact.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Contact.swift index bc66c3e46d2b..2e6df64a0eaa 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Contact.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Contact.swift @@ -2,7 +2,7 @@ /// Contact.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// import NitroModules @@ -64,135 +64,63 @@ public extension Contact { }()) } + @inline(__always) var firstName: String? { - @inline(__always) - get { - return { () -> String? in - if bridge.has_value_std__optional_std__string_(self.__firstName) { - let __unwrapped = bridge.get_std__optional_std__string_(self.__firstName) - return String(__unwrapped) - } else { - return nil - } - }() - } - @inline(__always) - set { - self.__firstName = { () -> bridge.std__optional_std__string_ in - if let __unwrappedValue = newValue { - return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) - } else { - return .init() - } - }() - } + return { () -> String? in + if bridge.has_value_std__optional_std__string_(self.__firstName) { + let __unwrapped = bridge.get_std__optional_std__string_(self.__firstName) + return String(__unwrapped) + } else { + return nil + } + }() } + @inline(__always) var lastName: String? { - @inline(__always) - get { - return { () -> String? in - if bridge.has_value_std__optional_std__string_(self.__lastName) { - let __unwrapped = bridge.get_std__optional_std__string_(self.__lastName) - return String(__unwrapped) - } else { - return nil - } - }() - } - @inline(__always) - set { - self.__lastName = { () -> bridge.std__optional_std__string_ in - if let __unwrappedValue = newValue { - return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) - } else { - return .init() - } - }() - } + return { () -> String? in + if bridge.has_value_std__optional_std__string_(self.__lastName) { + let __unwrapped = bridge.get_std__optional_std__string_(self.__lastName) + return String(__unwrapped) + } else { + return nil + } + }() } + @inline(__always) var phoneNumbers: [StringHolder]? { - @inline(__always) - get { - return { () -> [StringHolder]? in - if bridge.has_value_std__optional_std__vector_StringHolder__(self.__phoneNumbers) { - let __unwrapped = bridge.get_std__optional_std__vector_StringHolder__(self.__phoneNumbers) - return __unwrapped.map({ __item in __item }) - } else { - return nil - } - }() - } - @inline(__always) - set { - self.__phoneNumbers = { () -> bridge.std__optional_std__vector_StringHolder__ in - if let __unwrappedValue = newValue { - return bridge.create_std__optional_std__vector_StringHolder__({ () -> bridge.std__vector_StringHolder_ in - var __vector = bridge.create_std__vector_StringHolder_(__unwrappedValue.count) - for __item in __unwrappedValue { - __vector.push_back(__item) - } - return __vector - }()) - } else { - return .init() - } - }() - } + return { () -> [StringHolder]? in + if bridge.has_value_std__optional_std__vector_StringHolder__(self.__phoneNumbers) { + let __unwrapped = bridge.get_std__optional_std__vector_StringHolder__(self.__phoneNumbers) + return __unwrapped.map({ __item in __item }) + } else { + return nil + } + }() } + @inline(__always) var emailAddresses: [StringHolder]? { - @inline(__always) - get { - return { () -> [StringHolder]? in - if bridge.has_value_std__optional_std__vector_StringHolder__(self.__emailAddresses) { - let __unwrapped = bridge.get_std__optional_std__vector_StringHolder__(self.__emailAddresses) - return __unwrapped.map({ __item in __item }) - } else { - return nil - } - }() - } - @inline(__always) - set { - self.__emailAddresses = { () -> bridge.std__optional_std__vector_StringHolder__ in - if let __unwrappedValue = newValue { - return bridge.create_std__optional_std__vector_StringHolder__({ () -> bridge.std__vector_StringHolder_ in - var __vector = bridge.create_std__vector_StringHolder_(__unwrappedValue.count) - for __item in __unwrappedValue { - __vector.push_back(__item) - } - return __vector - }()) - } else { - return .init() - } - }() - } + return { () -> [StringHolder]? in + if bridge.has_value_std__optional_std__vector_StringHolder__(self.__emailAddresses) { + let __unwrapped = bridge.get_std__optional_std__vector_StringHolder__(self.__emailAddresses) + return __unwrapped.map({ __item in __item }) + } else { + return nil + } + }() } + @inline(__always) var imageData: String? { - @inline(__always) - get { - return { () -> String? in - if bridge.has_value_std__optional_std__string_(self.__imageData) { - let __unwrapped = bridge.get_std__optional_std__string_(self.__imageData) - return String(__unwrapped) - } else { - return nil - } - }() - } - @inline(__always) - set { - self.__imageData = { () -> bridge.std__optional_std__string_ in - if let __unwrappedValue = newValue { - return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) - } else { - return .init() - } - }() - } + return { () -> String? in + if bridge.has_value_std__optional_std__string_(self.__imageData) { + let __unwrapped = bridge.get_std__optional_std__string_(self.__imageData) + return String(__unwrapped) + } else { + return nil + } + }() } } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/ContactFields.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/ContactFields.swift index 9f464ce24e0a..1f7f694816da 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/ContactFields.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/ContactFields.swift @@ -2,7 +2,7 @@ /// ContactFields.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// /** diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift index a2ddc405ac6d..17206995f1f6 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift @@ -2,12 +2,11 @@ /// Func_void_std__exception_ptr.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// import NitroModules - /** * Wraps a Swift `(_ error: Error) -> Void` as a class. * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Func_void_std__vector_Contact_.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Func_void_std__vector_Contact_.swift index 1dd498ba1629..48f3bb192548 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Func_void_std__vector_Contact_.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/Func_void_std__vector_Contact_.swift @@ -2,12 +2,11 @@ /// Func_void_std__vector_Contact_.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// import NitroModules - /** * Wraps a Swift `(_ value: [Contact]) -> Void` as a class. * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridAppStartTimeModuleSpec.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridAppStartTimeModuleSpec.swift index e3b9e50858fb..97370c141ba0 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridAppStartTimeModuleSpec.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridAppStartTimeModuleSpec.swift @@ -2,10 +2,9 @@ /// HybridAppStartTimeModuleSpec.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// -import Foundation import NitroModules /// See ``HybridAppStartTimeModuleSpec`` @@ -14,7 +13,14 @@ public protocol HybridAppStartTimeModuleSpec_protocol: HybridObject { var appStartTime: Double { get } // Methods - func recordAppStartTime() throws -> Void + +} + +public extension HybridAppStartTimeModuleSpec_protocol { + /// Default implementation of ``HybridObject.toString`` + func toString() -> String { + return "[HybridObject AppStartTimeModule]" + } } /// See ``HybridAppStartTimeModuleSpec`` @@ -23,14 +29,14 @@ open class HybridAppStartTimeModuleSpec_base { public init() { } public func getCxxWrapper() -> HybridAppStartTimeModuleSpec_cxx { #if DEBUG - guard self is HybridAppStartTimeModuleSpec else { + guard self is any HybridAppStartTimeModuleSpec else { fatalError("`self` is not a `HybridAppStartTimeModuleSpec`! Did you accidentally inherit from `HybridAppStartTimeModuleSpec_base` instead of `HybridAppStartTimeModuleSpec`?") } #endif if let cxxWrapper = self.cxxWrapper { return cxxWrapper } else { - let cxxWrapper = HybridAppStartTimeModuleSpec_cxx(self as! HybridAppStartTimeModuleSpec) + let cxxWrapper = HybridAppStartTimeModuleSpec_cxx(self as! any HybridAppStartTimeModuleSpec) self.cxxWrapper = cxxWrapper return cxxWrapper } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridAppStartTimeModuleSpec_cxx.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridAppStartTimeModuleSpec_cxx.swift index 3d54beba719d..933ed8c89bee 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridAppStartTimeModuleSpec_cxx.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridAppStartTimeModuleSpec_cxx.swift @@ -2,10 +2,9 @@ /// HybridAppStartTimeModuleSpec_cxx.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// -import Foundation import NitroModules /** @@ -76,7 +75,7 @@ open class HybridAppStartTimeModuleSpec_cxx { */ public func getCxxPart() -> bridge.std__shared_ptr_HybridAppStartTimeModuleSpec_ { let cachedCxxPart = self.__cxxPart.lock() - if cachedCxxPart.__convertToBool() { + if Bool(fromCxx: cachedCxxPart) { return cachedCxxPart } else { let newCxxPart = bridge.create_std__shared_ptr_HybridAppStartTimeModuleSpec_(self.toUnsafe()) @@ -96,6 +95,14 @@ open class HybridAppStartTimeModuleSpec_cxx { return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize } + /** + * Compares this object with the given [other] object for reference equality. + */ + @inline(__always) + public func equals(other: HybridAppStartTimeModuleSpec_cxx) -> Bool { + return self.__implementation === other.__implementation + } + /** * Call dispose() on the Swift class. * This _may_ be called manually from JS. @@ -105,6 +112,14 @@ open class HybridAppStartTimeModuleSpec_cxx { self.__implementation.dispose() } + /** + * Call toString() on the Swift class. + */ + @inline(__always) + public func toString() -> String { + return self.__implementation.toString() + } + // Properties public final var appStartTime: Double { @inline(__always) @@ -114,14 +129,5 @@ open class HybridAppStartTimeModuleSpec_cxx { } // Methods - @inline(__always) - public final func recordAppStartTime() -> bridge.Result_void_ { - do { - try self.__implementation.recordAppStartTime() - return bridge.create_Result_void_() - } catch (let __error) { - let __exceptionPtr = __error.toCpp() - return bridge.create_Result_void_(__exceptionPtr) - } - } + } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift index 3456cb65982b..cd6b32d83873 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift @@ -2,10 +2,9 @@ /// HybridContactsModuleSpec.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// -import Foundation import NitroModules /// See ``HybridContactsModuleSpec`` @@ -17,20 +16,27 @@ public protocol HybridContactsModuleSpec_protocol: HybridObject { func getAll(keys: [ContactFields]) throws -> Promise<[Contact]> } +public extension HybridContactsModuleSpec_protocol { + /// Default implementation of ``HybridObject.toString`` + func toString() -> String { + return "[HybridObject ContactsModule]" + } +} + /// See ``HybridContactsModuleSpec`` open class HybridContactsModuleSpec_base { private weak var cxxWrapper: HybridContactsModuleSpec_cxx? = nil public init() { } public func getCxxWrapper() -> HybridContactsModuleSpec_cxx { #if DEBUG - guard self is HybridContactsModuleSpec else { + guard self is any HybridContactsModuleSpec else { fatalError("`self` is not a `HybridContactsModuleSpec`! Did you accidentally inherit from `HybridContactsModuleSpec_base` instead of `HybridContactsModuleSpec`?") } #endif if let cxxWrapper = self.cxxWrapper { return cxxWrapper } else { - let cxxWrapper = HybridContactsModuleSpec_cxx(self as! HybridContactsModuleSpec) + let cxxWrapper = HybridContactsModuleSpec_cxx(self as! any HybridContactsModuleSpec) self.cxxWrapper = cxxWrapper return cxxWrapper } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec_cxx.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec_cxx.swift index 80081c8acb78..e8c768395282 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec_cxx.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/HybridContactsModuleSpec_cxx.swift @@ -2,10 +2,9 @@ /// HybridContactsModuleSpec_cxx.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// -import Foundation import NitroModules /** @@ -76,7 +75,7 @@ open class HybridContactsModuleSpec_cxx { */ public func getCxxPart() -> bridge.std__shared_ptr_HybridContactsModuleSpec_ { let cachedCxxPart = self.__cxxPart.lock() - if cachedCxxPart.__convertToBool() { + if Bool(fromCxx: cachedCxxPart) { return cachedCxxPart } else { let newCxxPart = bridge.create_std__shared_ptr_HybridContactsModuleSpec_(self.toUnsafe()) @@ -96,6 +95,14 @@ open class HybridContactsModuleSpec_cxx { return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize } + /** + * Compares this object with the given [other] object for reference equality. + */ + @inline(__always) + public func equals(other: HybridContactsModuleSpec_cxx) -> Bool { + return self.__implementation === other.__implementation + } + /** * Call dispose() on the Swift class. * This _may_ be called manually from JS. @@ -105,6 +112,14 @@ open class HybridContactsModuleSpec_cxx { self.__implementation.dispose() } + /** + * Call toString() on the Swift class. + */ + @inline(__always) + public func toString() -> String { + return self.__implementation.toString() + } + // Properties diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/StringHolder.swift b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/StringHolder.swift index 5f9c0bd1b5b1..f1ccaabdc507 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/StringHolder.swift +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/ios/swift/StringHolder.swift @@ -2,7 +2,7 @@ /// StringHolder.swift /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// import NitroModules @@ -22,14 +22,8 @@ public extension StringHolder { self.init(std.string(value)) } + @inline(__always) var value: String { - @inline(__always) - get { - return String(self.__value) - } - @inline(__always) - set { - self.__value = std.string(newValue) - } + return String(self.__value) } } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/Contact.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/Contact.hpp index c30eb56e1820..cda9afb3f5f3 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/Contact.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/Contact.hpp @@ -2,7 +2,7 @@ /// Contact.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -17,6 +17,16 @@ #else #error NitroModules cannot be found! Are you sure you installed NitroModules properly? #endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif // Forward declaration of `StringHolder` to properly resolve imports. namespace margelo::nitro::utils { struct StringHolder; } @@ -31,7 +41,7 @@ namespace margelo::nitro::utils { /** * A struct which can be represented as a JavaScript object (Contact). */ - struct Contact { + struct Contact final { public: std::optional firstName SWIFT_PRIVATE; std::optional lastName SWIFT_PRIVATE; @@ -42,6 +52,9 @@ namespace margelo::nitro::utils { public: Contact() = default; explicit Contact(std::optional firstName, std::optional lastName, std::optional> phoneNumbers, std::optional> emailAddresses, std::optional imageData): firstName(firstName), lastName(lastName), phoneNumbers(phoneNumbers), emailAddresses(emailAddresses), imageData(imageData) {} + + public: + friend bool operator==(const Contact& lhs, const Contact& rhs) = default; }; } // namespace margelo::nitro::utils @@ -54,20 +67,20 @@ namespace margelo::nitro { static inline margelo::nitro::utils::Contact fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { jsi::Object obj = arg.asObject(runtime); return margelo::nitro::utils::Contact( - JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "firstName")), - JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "lastName")), - JSIConverter>>::fromJSI(runtime, obj.getProperty(runtime, "phoneNumbers")), - JSIConverter>>::fromJSI(runtime, obj.getProperty(runtime, "emailAddresses")), - JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "imageData")) + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "firstName"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "lastName"))), + JSIConverter>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "phoneNumbers"))), + JSIConverter>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "emailAddresses"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "imageData"))) ); } static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::utils::Contact& arg) { jsi::Object obj(runtime); - obj.setProperty(runtime, "firstName", JSIConverter>::toJSI(runtime, arg.firstName)); - obj.setProperty(runtime, "lastName", JSIConverter>::toJSI(runtime, arg.lastName)); - obj.setProperty(runtime, "phoneNumbers", JSIConverter>>::toJSI(runtime, arg.phoneNumbers)); - obj.setProperty(runtime, "emailAddresses", JSIConverter>>::toJSI(runtime, arg.emailAddresses)); - obj.setProperty(runtime, "imageData", JSIConverter>::toJSI(runtime, arg.imageData)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "firstName"), JSIConverter>::toJSI(runtime, arg.firstName)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "lastName"), JSIConverter>::toJSI(runtime, arg.lastName)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "phoneNumbers"), JSIConverter>>::toJSI(runtime, arg.phoneNumbers)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "emailAddresses"), JSIConverter>>::toJSI(runtime, arg.emailAddresses)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "imageData"), JSIConverter>::toJSI(runtime, arg.imageData)); return obj; } static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { @@ -75,11 +88,14 @@ namespace margelo::nitro { return false; } jsi::Object obj = value.getObject(runtime); - if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "firstName"))) return false; - if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "lastName"))) return false; - if (!JSIConverter>>::canConvert(runtime, obj.getProperty(runtime, "phoneNumbers"))) return false; - if (!JSIConverter>>::canConvert(runtime, obj.getProperty(runtime, "emailAddresses"))) return false; - if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "imageData"))) return false; + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "firstName")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "lastName")))) return false; + if (!JSIConverter>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "phoneNumbers")))) return false; + if (!JSIConverter>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "emailAddresses")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "imageData")))) return false; return true; } }; diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/ContactFields.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/ContactFields.hpp index df13271b6919..562bed803574 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/ContactFields.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/ContactFields.hpp @@ -2,7 +2,7 @@ /// ContactFields.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridAppStartTimeModuleSpec.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridAppStartTimeModuleSpec.cpp index 90c53a80a53d..a3a70baab6b5 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridAppStartTimeModuleSpec.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridAppStartTimeModuleSpec.cpp @@ -2,7 +2,7 @@ /// HybridAppStartTimeModuleSpec.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #include "HybridAppStartTimeModuleSpec.hpp" @@ -15,7 +15,6 @@ namespace margelo::nitro::utils { // load custom methods/properties registerHybrids(this, [](Prototype& prototype) { prototype.registerHybridGetter("appStartTime", &HybridAppStartTimeModuleSpec::getAppStartTime); - prototype.registerHybridMethod("recordAppStartTime", &HybridAppStartTimeModuleSpec::recordAppStartTime); }); } diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridAppStartTimeModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridAppStartTimeModuleSpec.hpp index 75b174de7a6b..21188ee4dbdf 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridAppStartTimeModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridAppStartTimeModuleSpec.hpp @@ -2,7 +2,7 @@ /// HybridAppStartTimeModuleSpec.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -48,7 +48,7 @@ namespace margelo::nitro::utils { public: // Methods - virtual void recordAppStartTime() = 0; + protected: // Hybrid Setup diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp index 3f609e5bbcb2..dec0a2adc457 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp @@ -2,7 +2,7 @@ /// HybridContactsModuleSpec.cpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #include "HybridContactsModuleSpec.hpp" diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp index c9ffa5ed3cca..cbb6831c685c 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp @@ -2,7 +2,7 @@ /// HybridContactsModuleSpec.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once diff --git a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/StringHolder.hpp b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/StringHolder.hpp index 1f400597c645..506185c82184 100644 --- a/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/StringHolder.hpp +++ b/modules/ExpensifyNitroUtils/nitrogen/generated/shared/c++/StringHolder.hpp @@ -2,7 +2,7 @@ /// StringHolder.hpp /// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. /// https://github.com/mrousavy/nitro -/// Copyright ยฉ 2026 Marc Rousavy @ Margelo +/// Copyright ยฉ Marc Rousavy @ Margelo /// #pragma once @@ -17,6 +17,16 @@ #else #error NitroModules cannot be found! Are you sure you installed NitroModules properly? #endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif @@ -27,13 +37,16 @@ namespace margelo::nitro::utils { /** * A struct which can be represented as a JavaScript object (StringHolder). */ - struct StringHolder { + struct StringHolder final { public: std::string value SWIFT_PRIVATE; public: StringHolder() = default; explicit StringHolder(std::string value): value(value) {} + + public: + friend bool operator==(const StringHolder& lhs, const StringHolder& rhs) = default; }; } // namespace margelo::nitro::utils @@ -46,12 +59,12 @@ namespace margelo::nitro { static inline margelo::nitro::utils::StringHolder fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { jsi::Object obj = arg.asObject(runtime); return margelo::nitro::utils::StringHolder( - JSIConverter::fromJSI(runtime, obj.getProperty(runtime, "value")) + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "value"))) ); } static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::utils::StringHolder& arg) { jsi::Object obj(runtime); - obj.setProperty(runtime, "value", JSIConverter::toJSI(runtime, arg.value)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "value"), JSIConverter::toJSI(runtime, arg.value)); return obj; } static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { @@ -59,7 +72,10 @@ namespace margelo::nitro { return false; } jsi::Object obj = value.getObject(runtime); - if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, "value"))) return false; + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "value")))) return false; return true; } }; diff --git a/modules/group-ib-fp/group-ib-fp.podspec b/modules/group-ib-fp/group-ib-fp.podspec index c4d6ead3f067..156ba7e656c3 100644 --- a/modules/group-ib-fp/group-ib-fp.podspec +++ b/modules/group-ib-fp/group-ib-fp.podspec @@ -1,7 +1,6 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) -folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' Pod::Spec.new do |s| s.name = "group-ib-fp" @@ -11,26 +10,12 @@ Pod::Spec.new do |s| s.license = package["license"] s.authors = package["author"] - s.platforms = { :ios => "11.0" } + s.platforms = { :ios => min_ios_version_supported } s.source = { :git => "https://group-ib.com.git", :tag => "#{s.version}" } s.source_files = "ios/*.{h,m,mm,swift}" - s.dependency "React-Core" s.vendored_frameworks = ['iOS/Frameworks/GIBMobileSdk.xcframework', 'iOS/Frameworks/FPAppsCapability.xcframework', 'iOS/Frameworks/FPCallCapability.xcframework'] - # Don't install the dependencies when we run `pod install` in the old architecture. - if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then - s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1" - s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"", - "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1", - "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" - } - s.dependency "React-Codegen" - s.dependency "RCT-Folly" - s.dependency "RCTRequired" - s.dependency "RCTTypeSafety" - s.dependency "ReactCommon/turbomodule/core" - end + install_modules_dependencies(s) end diff --git a/package-lock.json b/package-lock.json index ca9609d4b700..6789f08ad3df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.3.58-7", + "version": "9.3.62-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.3.58-7", + "version": "9.3.62-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -39,6 +39,7 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", + "@react-navigation/bottom-tabs": "7.15.5", "@react-navigation/core": "7.16.1", "@react-navigation/material-top-tabs": "7.4.19", "@react-navigation/native": "7.1.33", @@ -90,7 +91,7 @@ "react-content-loader": "^7.0.0", "react-dom": "19.2.0", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "^1.0.32", + "react-fast-pdf": "^1.0.33", "react-is": "^18.3.1", "react-map-gl": "^7.1.3", "react-native": "0.83.1", @@ -112,9 +113,9 @@ "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-launch-arguments": "^4.1.0", "react-native-localize": "^3.5.4", - "react-native-nitro-modules": "0.29.4", - "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.58", + "react-native-nitro-modules": "0.35.0", + "react-native-nitro-sqlite": "9.6.0", + "react-native-onyx": "3.0.63", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -180,7 +181,7 @@ "@rock-js/plugin-metro": "0.12.10", "@rock-js/provider-s3": "0.12.10", "@sentry/cli": "3.3.0", - "@sentry/webpack-plugin": "4.6.0", + "@sentry/webpack-plugin": "5.2.0", "@storybook/addon-a11y": "10.1.10", "@storybook/addon-docs": "10.1.10", "@storybook/addon-webpack5-compiler-babel": "4.0.0", @@ -247,7 +248,7 @@ "eslint-plugin-testing-library": "^7.11.0", "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", "glob": "^10.4.5", - "googleapis": "^144.0.0", + "googleapis": "^152.0.0", "html-webpack-plugin": "^5.5.0", "http-server": "^14.1.1", "jest": "29.7.0", @@ -260,14 +261,13 @@ "link": "^2.1.1", "memfs": "^4.6.0", "mini-css-extract-plugin": "^2.9.4", - "nitrogen": "0.29.4", + "nitrogen": "0.35.0", "onchange": "^7.1.0", "openai": "^6.16.0", "patch-package": "^8.1.0-canary.1", "peggy": "^4.0.3", "portfinder": "^1.0.34", "prettier": "3.7.4", - "react-compiler-healthcheck": "^19.0.0-beta-8a03594-20241020", "react-native-clean-project": "^4.0.0-alpha4.0", "react-refresh": "^0.14.2", "react-test-renderer": "19.2.0", @@ -281,13 +281,13 @@ "style-loader": "^3.0.0", "svgo": "^4.0.0", "time-analytics-webpack-plugin": "^0.1.17", - "ts-jest": "^29.2.5", + "ts-jest": "^29.4.9", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "tsx": "4.21.0", "type-fest": "4.35.0", - "typescript": "^5.9.2", - "typescript-eslint": "^8.45.0", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1", "wait-port": "^0.2.9", "webpack": "^5.104.1", "webpack-bundle-analyzer": "^4.5.0", @@ -2103,23 +2103,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", - "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", @@ -5425,9 +5408,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5455,9 +5438,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -5692,170 +5675,6 @@ "slugify": "^1.3.4" } }, - "node_modules/@expo/config-plugins": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-10.1.2.tgz", - "integrity": "sha512-IMYCxBOcnuFStuK0Ay+FzEIBKrwW8OVUMc65+v0+i7YFIIe8aL342l7T4F8lR4oCfhXn7d6M5QPgXvjtc/gAcw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@expo/config-types": "^53.0.5", - "@expo/json-file": "~9.1.5", - "@expo/plist": "^0.3.5", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^10.4.2", - "resolve-from": "^5.0.0", - "semver": "^7.5.4", - "slash": "^3.0.0", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "node_modules/@expo/config-plugins/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/@expo/config-plugins/node_modules/@expo/config-types": { - "version": "53.0.5", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-53.0.5.tgz", - "integrity": "sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@expo/config-plugins/node_modules/@expo/json-file": { - "version": "9.1.5", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.1.5.tgz", - "integrity": "sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" - } - }, - "node_modules/@expo/config-plugins/node_modules/@expo/plist": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.3.5.tgz", - "integrity": "sha512-9RYVU1iGyCJ7vWfg3e7c/NVyMFs8wbl+dMWZphtFtsqyN9zppGREU3ctlD3i8KUE0sCUTVnLjCWr+VeUIDep2g==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", - "xmlbuilder": "^15.1.1" - } - }, - "node_modules/@expo/config-plugins/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@expo/config-plugins/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@expo/config-plugins/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/@expo/config-plugins/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@expo/config-plugins/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@expo/config-plugins/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@expo/config-plugins/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@expo/config-types": { "version": "55.0.5", "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", @@ -5883,6 +5702,25 @@ "xml2js": "0.6.0" } }, + "node_modules/@expo/config/node_modules/@expo/require-utils": { + "version": "55.0.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.4.tgz", + "integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@expo/config/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -6043,6 +5881,21 @@ "node": ">=8" } }, + "node_modules/@expo/config/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@expo/devcert": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", @@ -7463,25 +7316,6 @@ "node": ">=8" } }, - "node_modules/@expo/require-utils": { - "version": "55.0.2", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.2.tgz", - "integrity": "sha512-dV5oCShQ1umKBKagMMT4B/N+SREsQe3lU4Zgmko5AO0rxKV0tynZT6xXs+e2JxuqT4Rz997atg7pki0BnZb4uw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@expo/schema-utils": { "version": "55.0.2", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-55.0.2.tgz", @@ -7727,18 +7561,20 @@ } }, "node_modules/@firebase/app": { - "version": "0.13.2", + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.10.tgz", + "integrity": "sha512-PlPhdtjgWUra+LImQTnXOUqUa/jcufZhizdR93ZjlQSS3ahCtDTG6pJw7j0OwFal18DQjICXfeVNsUUrcNisfA==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", + "@firebase/component": "0.7.2", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.15.0", "idb": "7.1.1", "tslib": "^2.1.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@firebase/app-check": { @@ -7908,41 +7744,6 @@ "version": "0.9.3", "license": "Apache-2.0" }, - "node_modules/@firebase/app/node_modules/@firebase/component": { - "version": "0.6.18", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app/node_modules/@firebase/logger": { - "version": "0.4.4", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app/node_modules/@firebase/util": { - "version": "1.12.1", - "hasInstallScript": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@firebase/auth": { "version": "1.9.0", "license": "Apache-2.0", @@ -8046,6 +7847,20 @@ "node": ">=18.0.0" } }, + "node_modules/@firebase/component": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.2.tgz", + "integrity": "sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/util": "1.15.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@firebase/data-connect": { "version": "0.3.0", "license": "Apache-2.0", @@ -8434,6 +8249,19 @@ "@firebase/app-types": "0.x" } }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@firebase/messaging": { "version": "0.12.16", "license": "Apache-2.0", @@ -8790,11 +8618,17 @@ } }, "node_modules/@firebase/util": { - "version": "1.9.7", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.0.tgz", + "integrity": "sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA==", + "hasInstallScript": true, "license": "Apache-2.0", "peer": true, "dependencies": { "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@firebase/vertexai": { @@ -12636,6 +12470,24 @@ } } }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.15.5", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.5.tgz", + "integrity": "sha512-wQHredlCrRmShWQ1vF4HUcLdaiJ8fUgnbaeQH7BJ7MQVQh4mdzab0IOY/4QSmUyNRB350oyu1biTycyQ5FKWMQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.10", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.1.33", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, "node_modules/@react-navigation/core": { "version": "7.16.1", "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.16.1.tgz", @@ -12677,9 +12529,9 @@ } }, "node_modules/@react-navigation/elements": { - "version": "2.9.10", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.10.tgz", - "integrity": "sha512-N8tuBekzTRb0pkMHFJGvmC6Q5OisSbt6gzvw7RHMnp4NDo5auVllT12sWFaTXf8mTduaLKNSrD/NZNaOqThCBg==", + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.14.tgz", + "integrity": "sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -12688,7 +12540,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.1.33", + "@react-navigation/native": "^7.2.2", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -13514,13 +13366,13 @@ } }, "node_modules/@sentry/babel-plugin-component-annotate": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.0.tgz", - "integrity": "sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.2.0.tgz", + "integrity": "sha512-8LbOI5Kzb5F0+7LVQPi2+zGz1iPiRRFhM+7uZ/ZQ33L9BmDOYNIy3xWxCfMw2JCuMXXaxF47XCjGmR22/B0WPg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 18" } }, "node_modules/@sentry/browser": { @@ -13540,29 +13392,28 @@ } }, "node_modules/@sentry/bundler-plugin-core": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.6.0.tgz", - "integrity": "sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.2.0.tgz", + "integrity": "sha512-+C0x4gEIJRgoMwyRFGx+TFiJ1Po2BZlT1v61+PnouiaprKL5qtZG8n5PXx/5LPLDsVjSIcXjnDrTz9aSm8SJ3w==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.18.5", - "@sentry/babel-plugin-component-annotate": "4.6.0", - "@sentry/cli": "^2.57.0", + "@sentry/babel-plugin-component-annotate": "5.2.0", + "@sentry/cli": "^2.58.5", "dotenv": "^16.3.1", "find-up": "^5.0.0", - "glob": "^9.3.2", - "magic-string": "0.30.8", - "unplugin": "1.0.1" + "glob": "^13.0.6", + "magic-string": "~0.30.8" }, "engines": { - "node": ">= 14" + "node": ">= 18" } }, "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli": { - "version": "2.58.4", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.4.tgz", - "integrity": "sha512-ArDrpuS8JtDYEvwGleVE+FgR+qHaOp77IgdGSacz6SZy6Lv90uX0Nu4UrHCQJz8/xwIcNxSqnN22lq0dH4IqTg==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.5.tgz", + "integrity": "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==", "dev": true, "hasInstallScript": true, "license": "FSL-1.1-MIT", @@ -13580,20 +13431,20 @@ "node": ">= 10" }, "optionalDependencies": { - "@sentry/cli-darwin": "2.58.4", - "@sentry/cli-linux-arm": "2.58.4", - "@sentry/cli-linux-arm64": "2.58.4", - "@sentry/cli-linux-i686": "2.58.4", - "@sentry/cli-linux-x64": "2.58.4", - "@sentry/cli-win32-arm64": "2.58.4", - "@sentry/cli-win32-i686": "2.58.4", - "@sentry/cli-win32-x64": "2.58.4" + "@sentry/cli-darwin": "2.58.5", + "@sentry/cli-linux-arm": "2.58.5", + "@sentry/cli-linux-arm64": "2.58.5", + "@sentry/cli-linux-i686": "2.58.5", + "@sentry/cli-linux-x64": "2.58.5", + "@sentry/cli-win32-arm64": "2.58.5", + "@sentry/cli-win32-i686": "2.58.5", + "@sentry/cli-win32-x64": "2.58.5" } }, "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-darwin": { - "version": "2.58.4", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.4.tgz", - "integrity": "sha512-kbTD+P4X8O+nsNwPxCywtj3q22ecyRHWff98rdcmtRrvwz8CKi/T4Jxn/fnn2i4VEchy08OWBuZAqaA5Kh2hRQ==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.5.tgz", + "integrity": "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==", "dev": true, "license": "FSL-1.1-MIT", "optional": true, @@ -13605,9 +13456,9 @@ } }, "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-arm": { - "version": "2.58.4", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.4.tgz", - "integrity": "sha512-rdQ8beTwnN48hv7iV7e7ZKucPec5NJkRdrrycMJMZlzGBPi56LqnclgsHySJ6Kfq506A2MNuQnKGaf/sBC9REA==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.5.tgz", + "integrity": "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==", "cpu": [ "arm" ], @@ -13624,9 +13475,9 @@ } }, "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-arm64": { - "version": "2.58.4", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.4.tgz", - "integrity": "sha512-0g0KwsOozkLtzN8/0+oMZoOuQ0o7W6O+hx+ydVU1bktaMGKEJLMAWxOQNjsh1TcBbNIXVOKM/I8l0ROhaAb8Ig==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.5.tgz", + "integrity": "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==", "cpu": [ "arm64" ], @@ -13643,9 +13494,9 @@ } }, "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-i686": { - "version": "2.58.4", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.4.tgz", - "integrity": "sha512-NseoIQAFtkziHyjZNPTu1Gm1opeQHt7Wm1LbLrGWVIRvUOzlslO9/8i6wETUZ6TjlQxBVRgd3Q0lRBG2A8rFYA==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.5.tgz", + "integrity": "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==", "cpu": [ "x86", "ia32" @@ -13663,9 +13514,9 @@ } }, "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-linux-x64": { - "version": "2.58.4", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.4.tgz", - "integrity": "sha512-d3Arz+OO/wJYTqCYlSN3Ktm+W8rynQ/IMtSZLK8nu0ryh5mJOh+9XlXY6oDXw4YlsM8qCRrNquR8iEI1Y/IH+Q==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.5.tgz", + "integrity": "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==", "cpu": [ "x64" ], @@ -13682,9 +13533,9 @@ } }, "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-win32-arm64": { - "version": "2.58.4", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.4.tgz", - "integrity": "sha512-bqYrF43+jXdDBh0f8HIJU3tbvlOFtGyRjHB8AoRuMQv9TEDUfENZyCelhdjA+KwDKYl48R1Yasb4EHNzsoO83w==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.5.tgz", + "integrity": "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==", "cpu": [ "arm64" ], @@ -13699,9 +13550,9 @@ } }, "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-win32-i686": { - "version": "2.58.4", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.4.tgz", - "integrity": "sha512-3triFD6jyvhVcXOmGyttf+deKZcC1tURdhnmDUIBkiDPJKGT/N5xa4qAtHJlAB/h8L9jgYih9bvJnvvFVM7yug==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.5.tgz", + "integrity": "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==", "cpu": [ "x86", "ia32" @@ -13717,9 +13568,9 @@ } }, "node_modules/@sentry/bundler-plugin-core/node_modules/@sentry/cli-win32-x64": { - "version": "2.58.4", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.4.tgz", - "integrity": "sha512-cSzN4PjM1RsCZ4pxMjI0VI7yNCkxiJ5jmWncyiwHXGiXrV1eXYdQ3n1LhUYLZ91CafyprR0OhDcE+RVZ26Qb5w==", + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.5.tgz", + "integrity": "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==", "cpu": [ "x64" ], @@ -13733,94 +13584,90 @@ "node": ">=10" } }, + "node_modules/@sentry/bundler-plugin-core/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@sentry/bundler-plugin-core/node_modules/glob": { - "version": "9.3.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", - "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "fs.realpath": "^1.0.0", - "minimatch": "^8.0.2", - "minipass": "^4.2.4", - "path-scurry": "^1.6.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@sentry/bundler-plugin-core/node_modules/magic-string": { - "version": "0.30.8", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", - "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", + "node_modules/@sentry/bundler-plugin-core/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" + "node": "20 || >=22" } }, "node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz", - "integrity": "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@sentry/bundler-plugin-core/node_modules/minipass": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", - "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/@sentry/bundler-plugin-core/node_modules/unplugin": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.0.1.tgz", - "integrity": "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==", + "node_modules/@sentry/bundler-plugin-core/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "acorn": "^8.8.1", - "chokidar": "^3.5.3", - "webpack-sources": "^3.2.3", - "webpack-virtual-modules": "^0.5.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@sentry/bundler-plugin-core/node_modules/webpack-virtual-modules": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz", - "integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==", - "dev": true, - "license": "MIT" - }, "node_modules/@sentry/cli": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.3.0.tgz", @@ -14232,57 +14079,21 @@ } }, "node_modules/@sentry/webpack-plugin": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-4.6.0.tgz", - "integrity": "sha512-i9Yy2kXCbFKlRST09fV1HsI0naJAfeXxoiUPyh5iCgSo2w7ZwEUlk0tJhupnHZzfSa3OSg01+vVNeeyLYM4tdA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-5.2.0.tgz", + "integrity": "sha512-ssV/uJK3ixf8UHBrNdLBXcnprUwppJNilbFv+19I81KTH4gVwzKXsVTMO91j6lyAXtk2mORwmEFwxZqScFfc7g==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/bundler-plugin-core": "4.6.0", - "unplugin": "1.0.1", - "uuid": "^9.0.0" + "@sentry/bundler-plugin-core": "5.2.0" }, "engines": { - "node": ">= 14" + "node": ">= 18" }, "peerDependencies": { - "webpack": ">=4.40.0" - } - }, - "node_modules/@sentry/webpack-plugin/node_modules/unplugin": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.0.1.tgz", - "integrity": "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.8.1", - "chokidar": "^3.5.3", - "webpack-sources": "^3.2.3", - "webpack-virtual-modules": "^0.5.0" - } - }, - "node_modules/@sentry/webpack-plugin/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "webpack": ">=5.0.0" } }, - "node_modules/@sentry/webpack-plugin/node_modules/webpack-virtual-modules": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz", - "integrity": "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==", - "dev": true, - "license": "MIT" - }, "node_modules/@shopify/flash-list": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.3.0.tgz", @@ -15142,6 +14953,8 @@ }, "node_modules/@sqltools/formatter": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, "node_modules/@storybook/addon-a11y": { @@ -16225,32 +16038,51 @@ } }, "node_modules/@ts-morph/common": { - "version": "0.26.1", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", + "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", "dev": true, "license": "MIT", "dependencies": { - "fast-glob": "^3.3.2", - "minimatch": "^9.0.4", - "path-browserify": "^1.0.1" + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "2.0.1", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "9.0.5", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -17136,21 +16968,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", - "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/type-utils": "8.45.0", - "@typescript-eslint/utils": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17160,9 +16991,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.45.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -17175,31 +17006,18 @@ "node": ">= 4" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/@typescript-eslint/parser": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", - "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17209,20 +17027,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", - "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.45.0", - "@typescript-eslint/types": "^8.45.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17232,18 +17050,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", - "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0" + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17254,9 +17072,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", - "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", "dev": true, "license": "MIT", "engines": { @@ -17267,21 +17085,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", - "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0", - "@typescript-eslint/utils": "8.45.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17291,27 +17109,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", - "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", "dev": true, "license": "MIT", "engines": { @@ -17323,22 +17128,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", - "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.45.0", - "@typescript-eslint/tsconfig-utils": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17348,39 +17152,52 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -17390,30 +17207,17 @@ "node": ">=10" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", - "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17423,19 +17227,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", - "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.45.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17446,13 +17250,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -18229,9 +18033,14 @@ "node": ">=4" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "license": "MIT" + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } }, "node_modules/anymatch": { "version": "3.1.3", @@ -18246,6 +18055,8 @@ }, "node_modules/app-root-path": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", "license": "MIT", "engines": { "node": ">= 6.0.0" @@ -18478,6 +18289,8 @@ }, "node_modules/ascii-table": { "version": "0.0.9", + "resolved": "https://registry.npmjs.org/ascii-table/-/ascii-table-0.0.9.tgz", + "integrity": "sha512-xpkr6sCDIYTPqzvjG8M3ncw1YOTaloWZOyrUmicoEifBEKzQzt+ooUpRpQ/AbOoJfO/p2ZKiyp79qHThzJDulQ==", "license": "MIT" }, "node_modules/assertion-error": { @@ -19165,7 +18978,9 @@ } }, "node_modules/bignumber.js": { - "version": "9.1.2", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "dev": true, "license": "MIT", "engines": { @@ -19411,6 +19226,8 @@ }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "dev": true, "license": "BSD-3-Clause" }, @@ -19621,7 +19438,7 @@ }, "node_modules/chalk": { "version": "2.4.2", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", @@ -19663,7 +19480,7 @@ }, "node_modules/chalk/node_modules/escape-string-regexp": { "version": "1.0.5", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.0" @@ -19906,83 +19723,6 @@ "node": ">=8" } }, - "node_modules/cli-highlight": { - "version": "2.1.11", - "license": "ISC", - "dependencies": { - "chalk": "^4.0.0", - "highlight.js": "^10.7.1", - "mz": "^2.4.0", - "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^6.0.0", - "yargs": "^16.0.0" - }, - "bin": { - "highlight": "bin/highlight" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, - "node_modules/cli-highlight/node_modules/ansi-styles": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cli-highlight/node_modules/chalk": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cli-highlight/node_modules/color-convert": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/cli-highlight/node_modules/color-name": { - "version": "1.1.4", - "license": "MIT" - }, - "node_modules/cli-highlight/node_modules/has-flag": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-highlight/node_modules/supports-color": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cli-spinners": { "version": "2.9.2", "license": "MIT", @@ -20069,6 +19809,8 @@ }, "node_modules/code-block-writer": { "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "dev": true, "license": "MIT" }, @@ -21476,6 +21218,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -21552,7 +21304,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -22030,6 +21784,7 @@ }, "node_modules/dotenv": { "version": "16.4.5", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -22065,6 +21820,8 @@ }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -22111,15 +21868,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/endent": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/endent/-/endent-2.1.0.tgz", @@ -22883,24 +22631,6 @@ "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/eslint-plugin-jsdoc/node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -23847,6 +23577,25 @@ "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, + "node_modules/expo-modules-autolinking/node_modules/@expo/require-utils": { + "version": "55.0.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.4.tgz", + "integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/expo-modules-autolinking/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -23926,6 +23675,21 @@ "node": ">=8" } }, + "node_modules/expo-modules-autolinking/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/expo-modules-core": { "version": "55.0.4", "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-55.0.4.tgz", @@ -24094,6 +23858,25 @@ "xml2js": "0.6.0" } }, + "node_modules/expo/node_modules/@expo/require-utils": { + "version": "55.0.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.4.tgz", + "integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/expo/node_modules/@expo/router-server": { "version": "55.0.9", "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-55.0.9.tgz", @@ -24637,6 +24420,21 @@ "node": ">=8" } }, + "node_modules/expo/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/exponential-backoff": { "version": "3.1.1", "license": "Apache-2.0" @@ -24738,6 +24536,8 @@ }, "node_modules/extend": { "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true, "license": "MIT" }, @@ -24893,6 +24693,30 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fetch-nodeshim": { "version": "0.4.9", "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.9.tgz", @@ -25515,6 +25339,19 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "dev": true, @@ -25653,37 +25490,38 @@ } }, "node_modules/gaxios": { - "version": "6.7.1", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", "dev": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" + "node-fetch": "^3.3.2" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/gaxios/node_modules/agent-base": { - "version": "7.1.1", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } }, "node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "7.0.5", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -25691,46 +25529,37 @@ } }, "node_modules/gaxios/node_modules/node-fetch": { - "version": "2.7.0", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-url": "^5.0.0" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/gaxios/node_modules/uuid": { - "version": "9.0.1", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, "node_modules/gcp-metadata": { - "version": "6.1.0", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "gaxios": "^6.0.0", + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/gensequence": { @@ -26019,59 +25848,62 @@ } }, "node_modules/google-auth-library": { - "version": "9.14.2", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", "dev": true, "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", "jws": "^4.0.0" }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=14" } }, "node_modules/googleapis": { - "version": "144.0.0", + "version": "152.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-152.0.0.tgz", + "integrity": "sha512-i/eo7ytwdWv9vONIY/MdUkSYOnGTtTMLP1y4eaTazGVArlmkH47Th07HLz8EYgIwsixqTR48bxAcdsGd9yoZqg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "google-auth-library": "^9.0.0", - "googleapis-common": "^7.0.0" + "google-auth-library": "^10.1.0", + "googleapis-common": "^8.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, "node_modules/googleapis-common": { - "version": "7.2.0", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", "dev": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", - "gaxios": "^6.0.3", - "google-auth-library": "^9.7.0", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", "qs": "^6.7.0", - "url-template": "^2.0.8", - "uuid": "^9.0.0" + "url-template": "^2.0.8" }, "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/googleapis-common/node_modules/uuid": { - "version": "9.0.1", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "node": ">=18.0.0" } }, "node_modules/gopd": { @@ -26088,11 +25920,6 @@ "version": "4.2.11", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, "node_modules/grid-index": { "version": "1.1.0", "license": "ISC" @@ -26101,18 +25928,6 @@ "resolved": "modules/group-ib-fp", "link": true }, - "node_modules/gtoken": { - "version": "7.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "gaxios": "^6.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/gzip-size": { "version": "6.0.0", "dev": true, @@ -26133,9 +25948,9 @@ "license": "MIT" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -26173,7 +25988,7 @@ }, "node_modules/has-flag": { "version": "3.0.0", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -26252,9 +26067,9 @@ "license": "LGPL-3.0" }, "node_modules/hermes-compiler": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-0.14.0.tgz", - "integrity": "sha512-clxa193o+GYYwykWVFfpHduCATz8fR5jvU7ngXpfKHj+E9hr9vjLNtdLSEe8MUbObvVexV3wcyxQ00xTPIrB1Q==", + "version": "250829098.0.4", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.4.tgz", + "integrity": "sha512-8PEHLTuGOXUAnv2zByUG7Vud0TbU21fPuVqlMuek8PZgdYwb7KFWjw3+6In9J93Tx/6c/nRf5I30vzbaHBlgAQ==", "license": "MIT" }, "node_modules/hermes-estree": { @@ -26272,13 +26087,6 @@ "hermes-estree": "0.32.0" } }, - "node_modules/highlight.js": { - "version": "10.7.3", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "license": "BSD-3-Clause", @@ -26695,7 +26503,7 @@ }, "node_modules/iconv-lite": { "version": "0.6.3", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -30035,6 +29843,8 @@ }, "node_modules/json-bigint": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "dev": true, "license": "MIT", "dependencies": { @@ -30123,21 +29933,25 @@ } }, "node_modules/jwa": { - "version": "2.0.0", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "dev": true, "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { - "version": "4.0.0", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "dev": true, "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -30579,6 +30393,8 @@ }, "node_modules/lodash.bindall": { "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.bindall/-/lodash.bindall-4.4.0.tgz", + "integrity": "sha512-NQ+QvFohS2gPbWpyLfyuiF0ZQA3TTaJ+n0XDID5jwtMZBKE32gN5vSyy7xBVsqvJkvT/UY9dvHXIk9tZmBVF3g==", "license": "MIT" }, "node_modules/lodash.camelcase": { @@ -30587,6 +30403,9 @@ }, "node_modules/lodash.clone": { "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz", + "integrity": "sha512-GhrVeweiTD6uTmmn5hV/lzgCQhccwReIVRLHp7LT4SopOjqEZ5BbX8b5WWEtAKasjmy8hR7ZPwsYlxRCku5odg==", + "deprecated": "This package is deprecated. Use structuredClone instead.", "license": "MIT" }, "node_modules/lodash.clonedeep": { @@ -30623,6 +30442,9 @@ }, "node_modules/lodash.pick": { "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==", + "deprecated": "This package is deprecated. Use destructuring assignment syntax instead.", "license": "MIT" }, "node_modules/lodash.throttle": { @@ -30631,6 +30453,8 @@ }, "node_modules/lodash.transform": { "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz", + "integrity": "sha512-LO37ZnhmBVx0GvOU/caQuipEh4GN82TcWv3yHlebGDgOxbxiwwzW5Pcx2AcvpIv2WmvmSMoC492yQFNhy/l/UQ==", "license": "MIT" }, "node_modules/log-symbols": { @@ -32206,15 +32030,6 @@ "version": "1.0.0", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nano-spawn": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-0.2.1.tgz", @@ -32271,17 +32086,16 @@ "license": "MIT" }, "node_modules/nitrogen": { - "version": "0.29.4", - "resolved": "https://registry.npmjs.org/nitrogen/-/nitrogen-0.29.4.tgz", - "integrity": "sha512-XtQyaWw12S8LpKD3muf+BXNXP2UAPONd4wC7oWK2+JoYVMSlYnMTnRxd6RRT7qH2j5KwFQeuRpwRPWSsxtCAXQ==", + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/nitrogen/-/nitrogen-0.35.0.tgz", + "integrity": "sha512-K8/4h9bCQahi3qEheWZx5joLFsAW3QjK0dVSC3gNLlQhlSJN42UFmffAouOZXYjg9rBDpVlrVo+Hsja45swsJQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { "chalk": "^5.3.0", - "react-native-nitro-modules": "^0.29.4", - "ts-morph": "^25.0.0", - "yargs": "^17.7.2", + "react-native-nitro-modules": "^0.35.0", + "ts-morph": "^27.0.0", + "yargs": "^18.0.0", "zod": "^4.0.5" }, "bin": { @@ -32348,6 +32162,27 @@ "node": ">= 0.10.5" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "license": "MIT", @@ -33019,21 +32854,6 @@ "dev": true, "license": "MIT" }, - "node_modules/parse5": { - "version": "5.1.1", - "license": "MIT" - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "parse5": "^6.0.1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "6.0.1", - "license": "MIT" - }, "node_modules/parseurl": { "version": "1.3.3", "license": "MIT", @@ -33187,6 +33007,8 @@ }, "node_modules/path-browserify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "dev": true, "license": "MIT" }, @@ -34014,91 +33836,6 @@ "react": ">=16.3.0" } }, - "node_modules/react-compiler-healthcheck": { - "version": "19.0.0-beta-8a03594-20241020", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "chalk": "4", - "fast-glob": "^3.3.2", - "ora": "5.4.1", - "yargs": "^17.7.2", - "zod": "^3.22.4", - "zod-validation-error": "^3.0.3" - }, - "bin": { - "react-compiler-healthcheck": "dist/index.js" - }, - "engines": { - "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/react-compiler-healthcheck/node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/react-compiler-healthcheck/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/react-content-loader": { "version": "7.0.0", "license": "MIT", @@ -34555,10 +34292,9 @@ } }, "node_modules/react-native-nitro-modules": { - "version": "0.29.4", - "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.29.4.tgz", - "integrity": "sha512-AfUMcwFtj9FuEDwDLN5eIVo0lBYTQqDaV7meiFzuoZyRmc8ywykFTKfyZwRN2t8Z/WtTlfCj9Y9yaET33IImsg==", - "hasInstallScript": true, + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.35.0.tgz", + "integrity": "sha512-Eho1yEcLbsteGpBFn2XZOp5FIptnEciWzuYBW49S0jo41Un2LeyesIO/MqYLY/c5o7D9Fw9th4pxGtV7OAb0+g==", "license": "MIT", "peerDependencies": { "react": "*", @@ -34566,34 +34302,23 @@ } }, "node_modules/react-native-nitro-sqlite": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-9.2.0.tgz", - "integrity": "sha512-ROWf5ZDiRoU0kF3uaQTY7UNA6gpPNaiNGiOZAMOdY5enAIxX2WpsGeHzO9IuPHdTXqr6QuAmFe7FpoC0+qSRyA==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/react-native-nitro-sqlite/-/react-native-nitro-sqlite-9.6.0.tgz", + "integrity": "sha512-a/N1yGhM8RvCCnaYhEHhh35YS+HDOAcGKeKFsp2ExCzIjP8vPXuzQtHylgLQLeAh7rUaism5q0QQFfogXm1SXA==", "license": "MIT", "dependencies": { - "typeorm": "0.3.20" + "typeorm": "0.3.27" }, "peerDependencies": { "react": ">=17.0.0", "react-native": ">=0.75.0", - "react-native-nitro-modules": ">=0.27.2" - } - }, - "node_modules/react-native-nitro-sqlite/node_modules/ansi-styles": { - "version": "4.3.0", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "react-native-nitro-modules": ">=0.35.0" } }, "node_modules/react-native-nitro-sqlite/node_modules/buffer": { "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -34614,83 +34339,52 @@ "ieee754": "^1.2.1" } }, - "node_modules/react-native-nitro-sqlite/node_modules/chalk": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/react-native-nitro-sqlite/node_modules/color-convert": { - "version": "2.0.1", + "node_modules/react-native-nitro-sqlite/node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/react-native-nitro-sqlite/node_modules/color-name": { - "version": "1.1.4", - "license": "MIT" - }, - "node_modules/react-native-nitro-sqlite/node_modules/has-flag": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": ">=8" + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } } }, - "node_modules/react-native-nitro-sqlite/node_modules/mkdirp": { - "version": "2.1.6", - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, + "node_modules/react-native-nitro-sqlite/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/react-native-nitro-sqlite/node_modules/supports-color": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "url": "https://dotenvx.com" } }, "node_modules/react-native-nitro-sqlite/node_modules/typeorm": { - "version": "0.3.20", + "version": "0.3.27", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.27.tgz", + "integrity": "sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==", "license": "MIT", "dependencies": { "@sqltools/formatter": "^1.2.5", + "ansis": "^3.17.0", "app-root-path": "^3.1.0", "buffer": "^6.0.3", - "chalk": "^4.1.2", - "cli-highlight": "^2.1.11", - "dayjs": "^1.11.9", - "debug": "^4.3.4", - "dotenv": "^16.0.3", - "glob": "^10.3.10", - "mkdirp": "^2.1.3", - "reflect-metadata": "^0.2.1", - "sha.js": "^2.4.11", - "tslib": "^2.5.0", - "uuid": "^9.0.0", - "yargs": "^17.6.2" + "dayjs": "^1.11.13", + "debug": "^4.4.0", + "dedent": "^1.6.0", + "dotenv": "^16.4.7", + "glob": "^10.4.5", + "sha.js": "^2.4.12", + "sql-highlight": "^6.0.0", + "tslib": "^2.8.1", + "uuid": "^11.1.0", + "yargs": "^17.7.2" }, "bin": { "typeorm": "cli.js", @@ -34704,23 +34398,23 @@ "url": "https://opencollective.com/typeorm" }, "peerDependencies": { - "@google-cloud/spanner": "^5.18.0", - "@sap/hana-client": "^2.12.25", - "better-sqlite3": "^7.1.2 || ^8.0.0 || ^9.0.0", - "hdb-pool": "^0.1.6", + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", "ioredis": "^5.0.4", - "mongodb": "^5.8.0", - "mssql": "^9.1.1 || ^10.0.1", + "mongodb": "^5.8.0 || ^6.0.0", + "mssql": "^9.1.1 || ^10.0.1 || ^11.0.1", "mysql2": "^2.2.5 || ^3.0.1", "oracledb": "^6.3.0", "pg": "^8.5.1", "pg-native": "^3.0.0", "pg-query-stream": "^4.0.0", - "redis": "^3.1.1 || ^4.0.0", + "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", + "reflect-metadata": "^0.1.14 || ^0.2.0", "sql.js": "^1.4.0", "sqlite3": "^5.0.3", "ts-node": "^10.7.0", - "typeorm-aurora-data-api-driver": "^2.0.0" + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" }, "peerDependenciesMeta": { "@google-cloud/spanner": { @@ -34732,9 +34426,6 @@ "better-sqlite3": { "optional": true }, - "hdb-pool": { - "optional": true - }, "ioredis": { "optional": true }, @@ -34777,20 +34468,22 @@ } }, "node_modules/react-native-nitro-sqlite/node_modules/uuid": { - "version": "9.0.1", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/react-native-onyx": { - "version": "3.0.58", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.58.tgz", - "integrity": "sha512-S304EAUL76orw18DCvXU/ZzYGXzVGCuB4EfKD1QDnewVC5KrLGCxAJDmKy0B3FaLpqX7cuAPi+R4IdOaKbxyjQ==", + "version": "3.0.63", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.63.tgz", + "integrity": "sha512-6UUpjyUh9viXll9M2YomXM9PMg+791foSPA7E0fazgWfrUAmofNwo4xkXT4k3NDdToZFOYxaXL0uw5z00g/MOA==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -34810,8 +34503,8 @@ "react": ">=18.1.0", "react-native": ">=0.75.0", "react-native-device-info": "^10.3.0", - "react-native-nitro-modules": ">=0.27.2", - "react-native-nitro-sqlite": "^9.2.0" + "react-native-nitro-modules": ">=0.35.0", + "react-native-nitro-sqlite": "^9.6.0" }, "peerDependenciesMeta": { "idb-keyval": { @@ -34830,6 +34523,8 @@ }, "node_modules/react-native-onyx/node_modules/fast-equals": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", "license": "MIT" }, "node_modules/react-native-pager-view": { @@ -35476,7 +35171,10 @@ }, "node_modules/reflect-metadata": { "version": "0.2.2", - "license": "Apache-2.0" + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0", + "peer": true }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -36342,16 +36040,45 @@ } }, "node_modules/sha.js": { - "version": "2.4.11", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "license": "(MIT AND BSD-3-Clause)", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sha.js/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/shallow-clone": { "version": "3.0.1", "dev": true, @@ -36816,6 +36543,22 @@ "version": "1.0.3", "license": "BSD-3-Clause" }, + "node_modules/sql-highlight": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", @@ -37351,7 +37094,7 @@ }, "node_modules/supports-color": { "version": "5.5.0", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^3.0.0" @@ -37698,23 +37441,6 @@ "utrie": "^1.0.2" } }, - "node_modules/thenify": { - "version": "3.3.1", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/thingies": { "version": "1.12.0", "dev": true, @@ -37909,6 +37635,40 @@ "version": "1.0.5", "license": "BSD-3-Clause" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "license": "MIT", @@ -37952,6 +37712,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-dedent": { "version": "2.2.0", "dev": true, @@ -37961,19 +37734,19 @@ } }, "node_modules/ts-jest": { - "version": "29.4.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", - "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.2", + "semver": "^7.7.4", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -37990,7 +37763,7 @@ "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { @@ -38014,9 +37787,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -38050,11 +37823,13 @@ } }, "node_modules/ts-morph": { - "version": "25.0.1", + "version": "27.0.2", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", + "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", "dev": true, "license": "MIT", "dependencies": { - "@ts-morph/common": "~0.26.0", + "@ts-morph/common": "~0.28.1", "code-block-writer": "^13.0.3" } }, @@ -38295,7 +38070,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -38307,16 +38084,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.45.0.tgz", - "integrity": "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.45.0", - "@typescript-eslint/parser": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0", - "@typescript-eslint/utils": "8.45.0" + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -38326,8 +38103,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/typewise": { @@ -38578,6 +38355,8 @@ }, "node_modules/url-template": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "dev": true, "license": "BSD" }, @@ -38811,6 +38590,16 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/web-vitals": { "version": "4.2.4", "license": "Apache-2.0" diff --git a/package.json b/package.json index 66a7f38055a5..0446b625a74b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.3.58-7", + "version": "9.3.62-3", "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.", @@ -44,8 +44,8 @@ "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand", "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc", - "typecheck-tsgo": "tsgo --project tsconfig.tsgo.json", - "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=314 --cache --cache-location=node_modules/.cache/eslint --cache-strategy content --concurrency=auto", + "typecheck-tsgo": "tsgo --noEmit --incremental --tsBuildInfoFile tsconfig.tsgo.tsbuildinfo", + "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=288 --cache --cache-location=node_modules/.cache/eslint --cache-strategy content --concurrency=auto", "lint-changed": "NODE_OPTIONS=--max_old_space_size=8192 ./scripts/lintChanged.sh", "lint-watch": "npx eslint-watch --watch --changed", "shellcheck": "./scripts/shellCheck.sh", @@ -102,6 +102,7 @@ "@react-native-firebase/app": "^22.2.1", "@react-native-google-signin/google-signin": "^10.0.1", "@react-native-picker/picker": "2.11.3", + "@react-navigation/bottom-tabs": "7.15.5", "@react-navigation/core": "7.16.1", "@react-navigation/material-top-tabs": "7.4.19", "@react-navigation/native": "7.1.33", @@ -153,7 +154,7 @@ "react-content-loader": "^7.0.0", "react-dom": "19.2.0", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "^1.0.32", + "react-fast-pdf": "^1.0.33", "react-is": "^18.3.1", "react-map-gl": "^7.1.3", "react-native": "0.83.1", @@ -175,9 +176,9 @@ "react-native-keyboard-controller": "1.21.0-beta.1", "react-native-launch-arguments": "^4.1.0", "react-native-localize": "^3.5.4", - "react-native-nitro-modules": "0.29.4", - "react-native-nitro-sqlite": "9.2.0", - "react-native-onyx": "3.0.58", + "react-native-nitro-modules": "0.35.0", + "react-native-nitro-sqlite": "9.6.0", + "react-native-onyx": "3.0.63", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -243,7 +244,7 @@ "@rock-js/plugin-metro": "0.12.10", "@rock-js/provider-s3": "0.12.10", "@sentry/cli": "3.3.0", - "@sentry/webpack-plugin": "4.6.0", + "@sentry/webpack-plugin": "5.2.0", "@storybook/addon-a11y": "10.1.10", "@storybook/addon-docs": "10.1.10", "@storybook/addon-webpack5-compiler-babel": "4.0.0", @@ -310,7 +311,7 @@ "eslint-plugin-testing-library": "^7.11.0", "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", "glob": "^10.4.5", - "googleapis": "^144.0.0", + "googleapis": "^152.0.0", "html-webpack-plugin": "^5.5.0", "http-server": "^14.1.1", "jest": "29.7.0", @@ -323,14 +324,13 @@ "link": "^2.1.1", "memfs": "^4.6.0", "mini-css-extract-plugin": "^2.9.4", - "nitrogen": "0.29.4", + "nitrogen": "0.35.0", "onchange": "^7.1.0", "openai": "^6.16.0", "patch-package": "^8.1.0-canary.1", "peggy": "^4.0.3", "portfinder": "^1.0.34", "prettier": "3.7.4", - "react-compiler-healthcheck": "^19.0.0-beta-8a03594-20241020", "react-native-clean-project": "^4.0.0-alpha4.0", "react-refresh": "^0.14.2", "react-test-renderer": "19.2.0", @@ -344,13 +344,13 @@ "style-loader": "^3.0.0", "svgo": "^4.0.0", "time-analytics-webpack-plugin": "^0.1.17", - "ts-jest": "^29.2.5", + "ts-jest": "^29.4.9", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "tsx": "4.21.0", "type-fest": "4.35.0", - "typescript": "^5.9.2", - "typescript-eslint": "^8.45.0", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1", "wait-port": "^0.2.9", "webpack": "^5.104.1", "webpack-bundle-analyzer": "^4.5.0", @@ -371,6 +371,7 @@ "express": "4.20.0", "elliptic": "6.5.7", "fast-json-patch": "3.1.1", + "hermes-compiler": "250829098.0.4", "webpack": "^5.104.1", "@blakeembrey/template": "1.2.0", "body-parser": "1.20.3", diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch index 007d0682e1cc..e9d9e3dfd981 100644 --- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js -index fb40ded..ea4eba2 100644 +index fb40ded..12375d9 100644 --- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js +++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js @@ -92,6 +92,17 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager { diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch new file mode 100644 index 000000000000..edb436a356b4 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch @@ -0,0 +1,191 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index dd2d3bc..d7a3d84 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -2,8 +2,8 @@ + * RecyclerView is a high-performance list component that efficiently renders and recycles list items. + * It's designed to handle large lists with optimal memory usage and smooth scrolling. + */ +-import React, { useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; +-import { Animated, I18nManager, } from "react-native"; ++import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react"; ++import { Animated, I18nManager, Platform, } from "react-native"; + import { ErrorMessages } from "../errors/ErrorMessages"; + import { WarningMessages } from "../errors/WarningMessages"; + import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout"; +@@ -66,6 +66,66 @@ const RecyclerViewComponent = (props, ref) => { + // Hook to detect when scrolling reaches list bounds + const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef); + const isHorizontalRTL = I18nManager.isRTL && horizontal; ++ // Web-only: Fix inverted scroll direction. ++ useEffect(() => { ++ if (!inverted || Platform.OS !== "web") { ++ return; ++ } ++ const scrollRef = scrollViewRef.current; ++ if (!scrollRef || typeof scrollRef.getScrollableNode !== "function") { ++ return; ++ } ++ const node = scrollRef.getScrollableNode(); ++ if (!node) { ++ return; ++ } ++ const wheelHandler = (ev) => { ++ const target = ev.target; ++ const deltaX = ev.deltaX || ev.wheelDeltaX || 0; ++ const deltaY = ev.deltaY || ev.wheelDeltaY || 0; ++ // Compute scroll limits from the DOM node for overscroll recoil prevention. ++ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; ++ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; ++ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; ++ const isOnScrollLimit = nodeScrollOffset <= 0 || Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; ++ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; ++ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; ++ const clientLength = horizontal ? target.clientWidth : target.clientHeight; ++ const isEventTargetScrollable = scrollLength > clientLength; ++ const delta = horizontal ? deltaX : deltaY; ++ let leftoverDelta = delta; ++ if (isEventTargetScrollable) { ++ leftoverDelta = delta < 0 ++ ? Math.min(delta + scrollOffset, 0) ++ : Math.max(delta - (scrollLength - clientLength - scrollOffset), 0); ++ } ++ const targetDelta = delta - leftoverDelta; ++ if (horizontal) { ++ if (Math.abs(deltaX) > Math.abs(deltaY)) { ++ target.scrollLeft += targetDelta; ++ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } ++ } ++ else { ++ // Prevent overscroll recoil/rubber band at scroll boundaries. ++ if (isOnScrollLimit && Math.abs(deltaY) > 0) { ++ ev.preventDefault(); ++ } ++ if (Math.abs(deltaY) > Math.abs(deltaX)) { ++ target.scrollTop += targetDelta; ++ node.scrollTop = node.scrollTop - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } ++ } ++ }; ++ node.addEventListener("wheel", wheelHandler, { passive: false }); ++ return () => { ++ node.removeEventListener("wheel", wheelHandler); ++ }; ++ }, [inverted, horizontal]); + /** + * Initialize the RecyclerView by measuring and setting up the window size + * This effect runs when the component mounts or when layout changes +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 34722d4..ea801d2 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -5,6 +5,7 @@ + import React, { + RefObject, + useCallback, ++ useEffect, + useLayoutEffect, + useMemo, + useRef, +@@ -17,6 +18,7 @@ import { + I18nManager, + NativeScrollEvent, + NativeSyntheticEvent, ++ Platform, + } from "react-native"; + + import { FlashListRef } from "../FlashListRef"; +@@ -158,6 +160,88 @@ const RecyclerViewComponent = ( + + const isHorizontalRTL = I18nManager.isRTL && horizontal; + ++ /** ++ * Web-only: Fix inverted scroll direction. ++ * When a list is visually inverted via scaleY/scaleX: -1, the browser's native ++ * wheel scroll goes in the wrong visual direction. This effect attaches a wheel ++ * event listener that negates the delta to correct the scroll direction. ++ * Mirrors the fix in react-native-web's VirtualizedList. ++ */ ++ useEffect(() => { ++ if (!inverted || Platform.OS !== "web") { ++ return; ++ } ++ const scrollRef = scrollViewRef.current; ++ if (!scrollRef || typeof (scrollRef as any).getScrollableNode !== "function") { ++ return; ++ } ++ const node = (scrollRef as any).getScrollableNode() as HTMLElement; ++ if (!node) { ++ return; ++ } ++ ++ const wheelHandler = (ev: WheelEvent) => { ++ const target = ev.target as HTMLElement; ++ const deltaX = ev.deltaX || (ev as any).wheelDeltaX || 0; ++ const deltaY = ev.deltaY || (ev as any).wheelDeltaY || 0; ++ ++ // Compute scroll limits from the DOM node for overscroll recoil prevention. ++ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop; ++ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight; ++ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight; ++ const isOnScrollLimit = ++ nodeScrollOffset <= 0 || ++ Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength; ++ ++ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop; ++ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight; ++ const clientLength = horizontal ? target.clientWidth : target.clientHeight; ++ const isEventTargetScrollable = scrollLength > clientLength; ++ const delta = horizontal ? deltaX : deltaY; ++ ++ // Calculate how much delta the event target can consume vs leftover for parent ++ let leftoverDelta = delta; ++ if (isEventTargetScrollable) { ++ leftoverDelta = ++ delta < 0 ++ ? Math.min(delta + scrollOffset, 0) ++ : Math.max( ++ delta - (scrollLength - clientLength - scrollOffset), ++ 0 ++ ); ++ } ++ const targetDelta = delta - leftoverDelta; ++ ++ // Only adjust scroll and consume the event when the dominant axis ++ // matches the list orientation. stopPropagation prevents parent ++ // inverted lists from also handling this event. ++ if (horizontal) { ++ if (Math.abs(deltaX) > Math.abs(deltaY)) { ++ target.scrollLeft += targetDelta; ++ node.scrollLeft = node.scrollLeft - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } ++ } else { ++ // Prevent overscroll recoil/rubber band at scroll boundaries. ++ if (isOnScrollLimit && Math.abs(deltaY) > 0) { ++ ev.preventDefault(); ++ } ++ if (Math.abs(deltaY) > Math.abs(deltaX)) { ++ target.scrollTop += targetDelta; ++ node.scrollTop = node.scrollTop - leftoverDelta; ++ ev.preventDefault(); ++ ev.stopPropagation(); ++ } ++ } ++ }; ++ ++ node.addEventListener("wheel", wheelHandler, { passive: false }); ++ return () => { ++ node.removeEventListener("wheel", wheelHandler); ++ }; ++ }, [inverted, horizontal]); ++ + /** + * Initialize the RecyclerView by measuring and setting up the window size + * This effect runs when the component mounts or when layout changes diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch new file mode 100644 index 000000000000..98bac124be64 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index d7a3d84..ffcdad8 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -142,9 +142,11 @@ const RecyclerViewComponent = (props, ref) => { + containerViewSizeRef.current = outerViewSize; + // firstChildViewLayout is already relative to the outer container, + // so its x/y directly gives the first item offset. +- const firstItemOffset = horizontal +- ? firstChildViewLayout.x +- : firstChildViewLayout.y; ++ const firstItemOffset = inverted ++ ? 0 ++ : horizontal ++ ? firstChildViewLayout.x ++ : firstChildViewLayout.y; + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams({ + width: horizontal ? outerViewSize.width : firstChildViewLayout.width, +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index ea801d2..8a7deff 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -259,9 +259,11 @@ const RecyclerViewComponent = ( + + // firstChildViewLayout is already relative to the outer container, + // so its x/y directly gives the first item offset. +- const firstItemOffset = horizontal +- ? firstChildViewLayout.x +- : firstChildViewLayout.y; ++ const firstItemOffset = inverted ++ ? 0 ++ : horizontal ++ ? firstChildViewLayout.x ++ : firstChildViewLayout.y; + + // Update the RecyclerView manager with window dimensions + recyclerViewManager.updateLayoutParams( diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch new file mode 100644 index 000000000000..6b96845c5875 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch @@ -0,0 +1,68 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +index ffcdad8..ee42f63 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js +@@ -166,9 +166,6 @@ const RecyclerViewComponent = (props, ref) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + var _a, _b; +- if (pendingChildIds.size > 0) { +- return; +- } + if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 && + ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) { + return; +@@ -196,8 +193,17 @@ const RecyclerViewComponent = (props, ref) => { + } + if (recyclerViewManager.modifyChildrenLayout(layoutInfo, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) && + !hasExceededMaxRendersWithoutCommit) { +- // Trigger re-render if layout modifications were made +- setRenderId((prev) => prev + 1); ++ if (pendingChildIds.size > 0) { ++ // When child FlashLists are still loading, avoid triggering a full ++ // RecyclerView re-render (setRenderId) to prevent cascading setState ++ // calls that could cause "Maximum update depth exceeded" errors. ++ // Instead, just commit the layout to update item positions in ++ // ViewHolderCollection without re-measuring. ++ (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); ++ } else { ++ // Trigger re-render if layout modifications were made ++ setRenderId((prev) => prev + 1); ++ } + } + else { + (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout(); +diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +index 8a7deff..b2bd67a 100644 +--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx ++++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx +@@ -287,9 +287,6 @@ const RecyclerViewComponent = ( + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { +- if (pendingChildIds.size > 0) { +- return; +- } + const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => { + const layout = measureItemLayout( + viewHolderRef.current!, +@@ -323,8 +320,17 @@ const RecyclerViewComponent = ( + recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) && + !hasExceededMaxRendersWithoutCommit + ) { +- // Trigger re-render if layout modifications were made +- setRenderId((prev) => prev + 1); ++ if (pendingChildIds.size > 0) { ++ // When child FlashLists are still loading, avoid triggering a full ++ // RecyclerView re-render (setRenderId) to prevent cascading setState ++ // calls that could cause "Maximum update depth exceeded" errors. ++ // Instead, just commit the layout to update item positions in ++ // ViewHolderCollection without re-measuring. ++ viewHolderCollectionRef.current?.commitLayout(); ++ } else { ++ // Trigger re-render if layout modifications were made ++ setRenderId((prev) => prev + 1); ++ } + } else { + viewHolderCollectionRef.current?.commitLayout(); + applyOffsetCorrection(); diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch new file mode 100644 index 000000000000..814d54311362 --- /dev/null +++ b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch @@ -0,0 +1,114 @@ +diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +index 70f856a..52546f7 100644 +--- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js ++++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js +@@ -1,5 +1,5 @@ + import { useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react"; +-import { I18nManager } from "react-native"; ++import { I18nManager, Platform } from "react-native"; + import { adjustOffsetForRTL } from "../utils/adjustOffsetForRTL"; + import { PlatformConfig } from "../../native/config/PlatformHelper"; + import { WarningMessages } from "../../errors/WarningMessages"; +@@ -25,6 +25,8 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + const isUnmounted = useUnmountFlag(); + const [_, setRenderId] = useState(0); + const pauseOffsetCorrection = useRef(false); ++ const pendingAndroidInvertedRafId = useRef(null); ++ const skipNextAndroidInvertedCorrection = useRef(false); + const lastDataLengthRef = useRef(recyclerViewManager.getDataLength()); + const { setTimeout } = useUnmountAwareTimeout(); + // Track the first visible item for maintaining scroll position +@@ -79,7 +81,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + */ + const applyOffsetCorrection = useCallback(() => { + var _a, _b, _c; +- const { horizontal, data } = recyclerViewManager.props; ++ const { horizontal, data, inverted } = recyclerViewManager.props; + // Execute all pending callbacks from previous scroll offset updates + // This ensures any scroll operations that were waiting for render are completed + const callbacks = pendingScrollCallbacks.current; +@@ -91,6 +93,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + currentDataLength > 0 && + recyclerViewManager.shouldMaintainVisibleContentPosition()) { + const hasDataChanged = currentDataLength !== lastDataLengthRef.current; ++ // Read and reset the skip flag so it never persists across multiple correction cycles ++ const shouldSkipAndroidInvertedCorrection = hasDataChanged && inverted && Platform.OS === 'android' && skipNextAndroidInvertedCorrection.current; ++ if (shouldSkipAndroidInvertedCorrection) { ++ skipNextAndroidInvertedCorrection.current = false; ++ } + // If we have a tracked first visible item, maintain its position + if (firstVisibleItemKey.current) { + const currentIndexOfFirstVisibleItem = (_a = recyclerViewManager +@@ -115,10 +122,31 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + !pauseOffsetCorrection.current && + !recyclerViewManager.animationOptimizationsEnabled) { + // console.log("diff", diff, firstVisibleItemKey.current); +- if (PlatformConfig.supportsOffsetCorrection) { +- // console.log("scrollBy", diff); ++ const useAndroidInvertedFallback = hasDataChanged && inverted && Platform.OS === 'android'; ++ if (PlatformConfig.supportsOffsetCorrection && !useAndroidInvertedFallback) { + (_b = scrollAnchorRef.current) === null || _b === void 0 ? void 0 : _b.scrollBy(diff); + } ++ else if (useAndroidInvertedFallback) { ++ if (!shouldSkipAndroidInvertedCorrection) { ++ const scrollToParams = horizontal ++ ? { ++ x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ animated: false, ++ } ++ : { ++ y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, ++ animated: false, ++ }; ++ if (pendingAndroidInvertedRafId.current !== null) { ++ cancelAnimationFrame(pendingAndroidInvertedRafId.current); ++ } ++ // rAF scrollTo to correct after native layout commits ++ pendingAndroidInvertedRafId.current = requestAnimationFrame(() => { ++ pendingAndroidInvertedRafId.current = null; ++ (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); ++ }); ++ } ++ } + else { + const scrollToParams = horizontal + ? { +@@ -162,6 +190,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + * Handles RTL layouts and first item offset adjustments. + */ + scrollToOffset: ({ offset, animated, skipFirstItemOffset = true, }) => { ++ if (pendingAndroidInvertedRafId.current !== null) { ++ cancelAnimationFrame(pendingAndroidInvertedRafId.current); ++ pendingAndroidInvertedRafId.current = null; ++ } ++ if (recyclerViewManager.props.inverted && Platform.OS === 'android') { ++ skipNextAndroidInvertedCorrection.current = true; ++ } + const { horizontal } = recyclerViewManager.props; + if (scrollViewRef.current) { + // Adjust offset for RTL layouts in horizontal mode +@@ -205,6 +240,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + * Scrolls to the end of the list. + */ + scrollToEnd: async ({ animated } = {}) => { ++ if (pendingAndroidInvertedRafId.current !== null) { ++ cancelAnimationFrame(pendingAndroidInvertedRafId.current); ++ pendingAndroidInvertedRafId.current = null; ++ } ++ if (recyclerViewManager.props.inverted && Platform.OS === 'android') { ++ skipNextAndroidInvertedCorrection.current = true; ++ } + const { data } = recyclerViewManager.props; + if (data && data.length > 0) { + const lastIndex = data.length - 1; +@@ -235,6 +277,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe + * Returns a Promise that resolves when the scroll is complete. + */ + scrollToIndex: ({ index, animated, viewPosition, viewOffset, }) => { ++ if (pendingAndroidInvertedRafId.current !== null) { ++ cancelAnimationFrame(pendingAndroidInvertedRafId.current); ++ pendingAndroidInvertedRafId.current = null; ++ } + return new Promise((resolve) => { + const { horizontal } = recyclerViewManager.props; + if (scrollViewRef.current && diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md index 6012a1c00a87..45570d16761f 100644 --- a/patches/@shopify/flash-list/details.md +++ b/patches/@shopify/flash-list/details.md @@ -20,3 +20,31 @@ - Upstream PR/issue: TBD - E/App issue: https://github.com/Expensify/App/issues/83976 - PR introducing patch: https://github.com/Expensify/App/pull/84887 + +### [@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch) + +- Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly โ€” scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`. +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/33725 +- PR introducing patch: https://github.com/Expensify/App/pull/85114 + +### [@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch) + +- Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system โ€” causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion. +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/33725 +- PR introducing patch: https://github.com/Expensify/App/pull/85114 + +### [@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch) + +- Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` โ€” while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent. +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/33725 +- PR introducing patch: https://github.com/Expensify/App/pull/85114 + +### [@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch) + +- Reason: Fixes `maintainVisibleContentPosition` not working on Android for inverted lists when items are prepended (e.g. new messages arriving, or `useFlashListScrollKey` switching from sliced to full data). FlashList's offset correction uses a `ScrollAnchor` component โ€” an invisible absolutely-positioned element whose `top` changes to trigger the native `maintainVisibleContentPosition` on the ScrollView. On Android, where inversion uses `rotate: 180deg` (vs `scaleY: -1` on iOS), this mechanism silently fails: the anchor position changes but the native ScrollView does not adjust its scroll offset. The fix detects the specific case (`inverted && Platform.OS === 'android' && hasDataChanged`) and bypasses `ScrollAnchor` in favor of a deferred `scrollTo` via `requestAnimationFrame`, which fires after the native layout has committed the new content size. Non-inverted lists, iOS, web, and layout-only corrections (no data change) are unaffected and continue using the original code paths. +- Upstream PR/issue: TBD +- E/App issue: https://github.com/Expensify/App/issues/33725 +- PR introducing patch: https://github.com/Expensify/App/pull/85114 diff --git a/patches/react-compiler-healthcheck/details.md b/patches/react-compiler-healthcheck/details.md deleted file mode 100644 index aea119f68fe7..000000000000 --- a/patches/react-compiler-healthcheck/details.md +++ /dev/null @@ -1,38 +0,0 @@ -# `react-compiler-healthcheck` patches - -### [react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch](react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch) - -- Reason: - - ``` - This patch adds verbose error logging option. - ``` - -- Upstream PR/issue: https://github.com/facebook/react/pull/29080 and https://github.com/facebook/react/pull/29851 -- E/App issue: https://github.com/Expensify/App/issues/44384 -- PR introducing patch: https://github.com/Expensify/App/pull/44460 - -### [react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch](react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch) - -- Reason: - - ``` - This patch allows mutating refs in certain components. - ``` - -- Upstream PR/issue: https://github.com/facebook/react/pull/29916 -- E/App issue: Same as the PR. -- PR introducing patch: https://github.com/Expensify/App/pull/45464 - - -### [react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch](react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch) - -- Reason: - - ``` - This patch adds --json option to healthcheck CLI. - ``` - -- Upstream PR/issue: ๐Ÿ›‘, commented in the App PR https://github.com/Expensify/App/pull/45915#issuecomment-3346345841 -- E/App issue: https://github.com/Expensify/App/pull/45464 -- PR introducing patch: https://github.com/Expensify/App/pull/45915 \ No newline at end of file diff --git a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch b/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch deleted file mode 100644 index 03b386587338..000000000000 --- a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+001+add-verbose-error-logging-option.patch +++ /dev/null @@ -1,90 +0,0 @@ -diff --git a/node_modules/react-compiler-healthcheck/dist/index.js b/node_modules/react-compiler-healthcheck/dist/index.js -index 5a4060d..460339b 100755 ---- a/node_modules/react-compiler-healthcheck/dist/index.js -+++ b/node_modules/react-compiler-healthcheck/dist/index.js -@@ -56969,7 +56969,7 @@ var reactCompilerCheck = { - compile(source, path); - } - }, -- report() { -+ report(verbose) { - const totalComponents = - SucessfulCompilation.length + - countUniqueLocInEvents(OtherFailures) + -@@ -56979,6 +56979,50 @@ var reactCompilerCheck = { - `Successfully compiled ${SucessfulCompilation.length} out of ${totalComponents} components.` - ) - ); -+ -+ if (verbose) { -+ for (const compilation of [...SucessfulCompilation, ...ActionableFailures, ...OtherFailures]) { -+ const filename = compilation.fnLoc?.filename; -+ -+ if (compilation.kind === "CompileSuccess") { -+ const name = compilation.fnName; -+ const isHook = name?.startsWith('use'); -+ -+ if (name) { -+ console.log( -+ chalk.green( -+ `Successfully compiled ${isHook ? "hook" : "component" } [${name}](${filename})` -+ ) -+ ); -+ } else { -+ console.log(chalk.green(`Successfully compiled ${compilation.fnLoc?.filename}`)); -+ } -+ } -+ -+ if (compilation.kind === "CompileError") { -+ const { reason, severity, loc } = compilation.detail; -+ -+ const lnNo = loc.start?.line; -+ const colNo = loc.start?.column; -+ -+ const isTodo = severity === ErrorSeverity.Todo; -+ -+ console.log( -+ chalk[isTodo ? 'yellow' : 'red']( -+ `Failed to compile ${ -+ filename -+ }${ -+ lnNo !== undefined ? `:${lnNo}${ -+ colNo !== undefined ? `:${colNo}` : "" -+ }.` : "" -+ }` -+ ), -+ chalk[isTodo ? 'yellow' : 'red'](reason? `Reason: ${reason}` : "") -+ ); -+ console.log("\n"); -+ } -+ } -+ } - }, - }; - const JsFileExtensionRE = /(js|ts|jsx|tsx)$/; -@@ -57015,9 +57059,16 @@ function main() { - type: 'string', - default: '**/+(*.{js,mjs,jsx,ts,tsx}|package.json)', - }) -+ .option('verbose', { -+ description: 'run with verbose logging', -+ type: 'boolean', -+ default: false, -+ alias: 'v', -+ }) - .parseSync(); - const spinner = ora('Checking').start(); - let src = argv.src; -+ let verbose = argv.verbose; - const globOptions = { - onlyFiles: true, - ignore: [ -@@ -57037,7 +57088,7 @@ function main() { - libraryCompatCheck.run(source, path); - } - spinner.stop(); -- reactCompilerCheck.report(); -+ reactCompilerCheck.report(verbose); - strictModeCheck.report(); - libraryCompatCheck.report(); - }); diff --git a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch b/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch deleted file mode 100644 index 8ae46e379619..000000000000 --- a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+002+enable-ref-identifiers.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/node_modules/react-compiler-healthcheck/dist/index.js b/node_modules/react-compiler-healthcheck/dist/index.js -index 460339b..17b0f96 100755 ---- a/node_modules/react-compiler-healthcheck/dist/index.js -+++ b/node_modules/react-compiler-healthcheck/dist/index.js -@@ -56902,6 +56902,9 @@ const COMPILER_OPTIONS = { - noEmit: true, - compilationMode: 'infer', - panicThreshold: 'critical_errors', -+ environment: { -+ enableTreatRefLikeIdentifiersAsRefs: true, -+ }, - logger: logger, - }; - function isActionableDiagnostic(detail) { -diff --git a/node_modules/react-compiler-healthcheck/src/checks/reactCompiler.ts b/node_modules/react-compiler-healthcheck/src/checks/reactCompiler.ts -index 3094548..fd05b76 100644 ---- a/node_modules/react-compiler-healthcheck/src/checks/reactCompiler.ts -+++ b/node_modules/react-compiler-healthcheck/src/checks/reactCompiler.ts -@@ -50,6 +50,9 @@ const COMPILER_OPTIONS: Partial = { - noEmit: true, - compilationMode: 'infer', - panicThreshold: 'critical_errors', -+ environment: { -+ enableTreatRefLikeIdentifiersAsRefs: true, -+ }, - logger, - }; - diff --git a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch b/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch deleted file mode 100644 index 246351351195..000000000000 --- a/patches/react-compiler-healthcheck/react-compiler-healthcheck+19.0.0-beta-8a03594-20241020+003+json.patch +++ /dev/null @@ -1,72 +0,0 @@ -diff --git a/node_modules/react-compiler-healthcheck/dist/index.js b/node_modules/react-compiler-healthcheck/dist/index.js -index 17b0f96..e386e34 100755 ---- a/node_modules/react-compiler-healthcheck/dist/index.js -+++ b/node_modules/react-compiler-healthcheck/dist/index.js -@@ -56972,16 +56972,28 @@ var reactCompilerCheck = { - compile(source, path); - } - }, -- report(verbose) { -+ report(verbose, json) { - const totalComponents = - SucessfulCompilation.length + - countUniqueLocInEvents(OtherFailures) + - countUniqueLocInEvents(ActionableFailures); -- console.log( -- chalk.green( -- `Successfully compiled ${SucessfulCompilation.length} out of ${totalComponents} components.` -- ) -- ); -+ if (!json) { -+ console.log( -+ chalk.green( -+ `Successfully compiled ${SucessfulCompilation.length} out of ${totalComponents} components.` -+ ) -+ ); -+ } -+ -+ if (json) { -+ const extractFileName = (output) => output.fnLoc.filename; -+ const successfulFiles = SucessfulCompilation.map(extractFileName); -+ const unsuccessfulFiles = [...new Set([...OtherFailures, ...ActionableFailures].map(extractFileName))]; -+ console.log(JSON.stringify({ -+ success: successfulFiles, -+ failure: unsuccessfulFiles, -+ })); -+ } - - if (verbose) { - for (const compilation of [...SucessfulCompilation, ...ActionableFailures, ...OtherFailures]) { -@@ -57068,10 +57080,17 @@ function main() { - default: false, - alias: 'v', - }) -+ .option('json', { -+ description: 'print a list of compiled/not-compiled files as JSON', -+ type: 'boolean', -+ default: false, -+ alias: 'j', -+ }) - .parseSync(); - const spinner = ora('Checking').start(); - let src = argv.src; - let verbose = argv.verbose; -+ let json = argv.json; - const globOptions = { - onlyFiles: true, - ignore: [ -@@ -57091,9 +57110,11 @@ function main() { - libraryCompatCheck.run(source, path); - } - spinner.stop(); -- reactCompilerCheck.report(verbose); -- strictModeCheck.report(); -- libraryCompatCheck.report(); -+ reactCompilerCheck.report(verbose, json); -+ if (!json) { -+ strictModeCheck.report(); -+ libraryCompatCheck.report(); -+ } - }); - } - main(); diff --git a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch index 3925c00cc187..9706c9d87989 100644 --- a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch +++ b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch @@ -34,24 +34,29 @@ index 2f59c7a..a324da2 100644 const onListContentSizeChange = (w: number, h: number) => { scrollViewSize.value = props.horizontal ? w : h; props.onContentSizeChange?.(w, h); -@@ -357,6 +366,16 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { +@@ -357,6 +366,21 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { props.onViewableItemsChanged?.(info); }); + // Wrap the provided ListFooterComponent to add an onLayout + const wrappedListFooterComponent = ListFooterComponent ? -+ + {ListFooterComponent} + + : undefined ++ ++ // Reset footer size when footer is removed so stale values don't affect drag constraints ++ if (!ListFooterComponent) { ++ footerSize.value = 0; ++ } + return ( (props: DraggableFlatListProps) { +@@ -388,6 +411,7 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { scrollEventThrottle={16} simultaneousHandlers={props.simultaneousHandlers} removeClippedSubviews={false} diff --git a/patches/react-native-keyboard-controller/details.md b/patches/react-native-keyboard-controller/details.md new file mode 100644 index 000000000000..c55b5f8d3f75 --- /dev/null +++ b/patches/react-native-keyboard-controller/details.md @@ -0,0 +1,13 @@ +# `react-native-keyboard-controller` patches + +### [react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch](react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch) + +- Reason: + + ``` + In EdgeToEdgeReactViewGroup.setupWindowInsets(), the library unconditionally applies navBarInsets.left and navBarInsets.right as margins on the content view, even though the bottom margin is already conditional on navigationBarTranslucent. In landscape mode on Android, the navigation bar moves to the side of the screen, and these unconditional margins shrink the content area by the nav bar width. This causes useSafeAreaFrame() to report a reduced windowWidth and useSafeAreaInsets() to return 0 for left/right insets, since the SafeAreaProvider view no longer overlaps with the nav bar area. The patch makes left/right margins respect the same shouldApplyZeroPaddingBottom condition used for the bottom margin. + ``` + +- Upstream PR/issue: - +- E/App issue: https://github.com/Expensify/App/issues/87307 +- PR introducing patch: https://github.com/Expensify/App/pull/87376 \ No newline at end of file diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch new file mode 100644 index 000000000000..b63ed87575eb --- /dev/null +++ b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.21.0-beta.1+001+fix-insets-in-landscape.patch @@ -0,0 +1,28 @@ +diff --git a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +index 003890b..90d3375 100644 +--- a/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt ++++ b/node_modules/react-native-keyboard-controller/android/src/main/java/com/reactnativekeyboardcontroller/views/EdgeToEdgeReactViewGroup.kt +@@ -116,13 +116,21 @@ class EdgeToEdgeReactViewGroup( + val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + + params.setMargins( +- navBarInsets.left, ++ if (shouldApplyZeroPaddingBottom) { ++ 0 ++ } else { ++ navBarInsets.left ++ }, + if (shouldApplyZeroPaddingTop) { + 0 + } else { + systemBarInsets.top + }, +- navBarInsets.right, ++ if (shouldApplyZeroPaddingBottom) { ++ 0 ++ } else { ++ navBarInsets.right ++ }, + if (shouldApplyZeroPaddingBottom) { + 0 + } else { diff --git a/patches/react-native-onyx/details.md b/patches/react-native-onyx/details.md new file mode 100644 index 000000000000..67ad7c054e85 --- /dev/null +++ b/patches/react-native-onyx/details.md @@ -0,0 +1,7 @@ +# `react-native-onyx` patches + +### [react-native-onyx+3.0.63.patch](react-native-onyx+3.0.63.patch) + +- Reason: Onyx v3.0.59 ([PR #756](https://github.com/Expensify/react-native-onyx/pull/756)) added a state reset inside the `subscribe` callback of `useOnyx` to fix stale data when keys change dynamically. However, this reset runs unconditionally โ€” including on initial mount โ€” which causes `useSyncExternalStore` to see a new snapshot reference after subscription, triggering one extra render per `useOnyx` hook. This patch guards the reset with a `hasMountedRef` flag so it only runs on key-change re-subscriptions, not on initial mount. +- E/App issue: https://github.com/Expensify/App/issues/85416 +- Upstream PR/issue: N/A diff --git a/patches/react-native-onyx/react-native-onyx+3.0.63.patch b/patches/react-native-onyx/react-native-onyx+3.0.63.patch new file mode 100644 index 000000000000..7b321b0f51de --- /dev/null +++ b/patches/react-native-onyx/react-native-onyx+3.0.63.patch @@ -0,0 +1,35 @@ +diff --git a/node_modules/react-native-onyx/dist/useOnyx.js b/node_modules/react-native-onyx/dist/useOnyx.js +index b361d0d..4df16fa 100644 +--- a/node_modules/react-native-onyx/dist/useOnyx.js ++++ b/node_modules/react-native-onyx/dist/useOnyx.js +@@ -97,6 +97,9 @@ function useOnyx(key, options, dependencies = []) { + // after cleanup), so the hook automatically enters first-connection mode for the new key without any + // explicit reset logic โ€” eliminating the race condition where cleanup could clobber a boolean flag. + const connectedKeyRef = (0, react_1.useRef)(null); ++ // Tracks whether the hook has completed its initial mount subscription. ++ // Unlike connectedKeyRef (which gets nulled by cleanup), this persists across re-subscriptions. ++ const hasMountedRef = (0, react_1.useRef)(false); + // Indicates if the hook is connecting to an Onyx key. + const isConnectingRef = (0, react_1.useRef)(false); + // Stores the `onStoreChange()` function, which can be used to trigger a `getSnapshot()` update when desired. +@@ -220,11 +223,15 @@ function useOnyx(key, options, dependencies = []) { + const subscribe = (0, react_1.useCallback)((onStoreChange) => { + // Reset internal state so the hook properly transitions through loading + // for the new key instead of preserving stale state from the previous one. +- previousValueRef.current = null; +- newValueRef.current = null; +- shouldGetCachedValueRef.current = true; +- sourceValueRef.current = undefined; +- resultRef.current = [undefined, { status: (options === null || options === void 0 ? void 0 : options.initWithStoredValues) === false ? 'loaded' : 'loading' }]; ++ // Only reset when the key has actually changed (not on initial mount). ++ if (hasMountedRef.current) { ++ previousValueRef.current = null; ++ newValueRef.current = null; ++ shouldGetCachedValueRef.current = true; ++ sourceValueRef.current = undefined; ++ resultRef.current = [undefined, { status: (options === null || options === void 0 ? void 0 : options.initWithStoredValues) === false ? 'loaded' : 'loading' }]; ++ } ++ hasMountedRef.current = true; + isConnectingRef.current = true; + onStoreChangeFnRef.current = onStoreChange; + connectionRef.current = OnyxConnectionManager_1.default.connect({ diff --git a/patches/react-native-pdf/details.md b/patches/react-native-pdf/details.md index d5404acc4048..794d4c5a22f7 100644 --- a/patches/react-native-pdf/details.md +++ b/patches/react-native-pdf/details.md @@ -3,7 +3,7 @@ ### [react-native-pdf+7.0.2+001+fix-onPressLink-android.patch](react-native-pdf+7.0.2+001+fix-onPressLink-android.patch) - Reason: - + ``` This patch fixes the onPressLink callback not working on Android by upgrading the pdfiumandroid library from version 1.0.32 to 1.0.35. @@ -19,7 +19,7 @@ ### [react-native-pdf+7.0.2+002+fix-pdf-crash-already-closed-android.patch](react-native-pdf+7.0.2+002+fix-pdf-crash-already-closed-android.patch) - Reason: - + ``` This patch fixes a fatal Android crash (java.lang.IllegalStateException: Already closed) that occurs when users navigate away from a PDF while it's still rendering. @@ -29,3 +29,18 @@ - Upstream PR/issue: https://github.com/wonday/react-native-pdf/issues/976, https://github.com/wonday/react-native-pdf/issues/882, https://github.com/wonday/react-native-pdf/issues/830, https://github.com/wonday/react-native-pdf/pull/999 - E/App issue: https://github.com/Expensify/App/issues/82570 - PR introducing patch: https://github.com/Expensify/App/pull/82628 + + +### [react-native-pdf+7.0.2+003+disable-topchange-coalescing-android.patch](react-native-pdf+7.0.2+003+disable-topchange-coalescing-android.patch) + +- Reason: + + ``` + This patch prevents Android Fabric from coalescing react-native-pdf's shared topChange events. + + react-native-pdf sends loadComplete and pageChanged through the same topChange event name. Under Fabric, TopChangeEvent inherited React Native's default coalescing behavior, so pageChanged could replace loadComplete for the same PDF view before JS received it. That broke the onLoadComplete contract for hidden PDF validation renders. Marking the event as non-coalescible preserves both native callbacks in JS without adding an app-level success fallback. + ``` + +- Upstream PR/issue: https://github.com/wonday/react-native-pdf/issues/1009, https://github.com/wonday/react-native-pdf/pull/1011 +- E/App issue: https://github.com/Expensify/App/issues/81225 +- PR introducing patch: https://github.com/Expensify/App/pull/87416 diff --git a/patches/react-native-pdf/react-native-pdf+7.0.2+003+disable-topchange-coalescing-android.patch b/patches/react-native-pdf/react-native-pdf+7.0.2+003+disable-topchange-coalescing-android.patch new file mode 100644 index 000000000000..612949a37ee2 --- /dev/null +++ b/patches/react-native-pdf/react-native-pdf+7.0.2+003+disable-topchange-coalescing-android.patch @@ -0,0 +1,16 @@ +diff --git a/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/events/TopChangeEvent.java b/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/events/TopChangeEvent.java +index 6feb97d..aab7738 100644 +--- a/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/events/TopChangeEvent.java ++++ b/node_modules/react-native-pdf/android/src/main/java/org/wonday/pdf/events/TopChangeEvent.java +@@ -17,6 +17,11 @@ public class TopChangeEvent extends Event { + return "topChange"; + } + ++ @Override ++ public boolean canCoalesce() { ++ return false; ++ } ++ + @Nullable + @Override + protected WritableMap getEventData() { diff --git a/patches/react-native-screens/details.md b/patches/react-native-screens/details.md new file mode 100644 index 000000000000..e9986d919fec --- /dev/null +++ b/patches/react-native-screens/details.md @@ -0,0 +1,8 @@ +# `react-native-screens` patches + +### [react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch](react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch) + +- Reason: In HybridApp, React Native is hosted inside a `ReactNativeFragment`, which causes `ScreenFragment.dispatchViewAnimationEvent()` to silently dismiss lifecycle events for root screen fragments. This prevents `transitionStart`/`transitionEnd` from being emitted, which breaks `TransitionTracker` (`src/libs/Navigation/TransitionTracker.ts`). The fix allows event dispatch when the parent fragment is not a `ScreenFragment`. +- Upstream PR/issue: https://github.com/software-mansion/react-native-screens/pull/3854 โ€” once merged and released, bump the version and remove this patch. +- E/App issue: ๐Ÿ›‘ +- PR Introducing Patch: https://github.com/Expensify/App/pull/85759 diff --git a/patches/react-native-screens/react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch b/patches/react-native-screens/react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch new file mode 100644 index 000000000000..7060a0baaa35 --- /dev/null +++ b/patches/react-native-screens/react-native-screens+4.15.4+001+fix-lifecycle-events-in-fragment-host.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt +index 65c6e30..3b9f2e2 100644 +--- a/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt ++++ b/node_modules/react-native-screens/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt +@@ -293,7 +293,7 @@ open class ScreenFragment : + // check for `isTransitioning` should be enough since the child's animation should take only + // 20ms due to always being `StackAnimation.NONE` when nested stack being pushed + val parent = parentFragment +- if (parent == null || (parent is ScreenFragment && !parent.isTransitioning)) { ++ if (parent == null || parent !is ScreenFragment || (parent is ScreenFragment && !parent.isTransitioning)) { + // onViewAnimationStart/End is triggered from View#onAnimationStart/End method of the fragment's root + // view. We override an appropriate method of the StackFragment's + // root view in order to achieve this. diff --git a/patches/react-navigation/@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch new file mode 100644 index 000000000000..d49cb29e0812 --- /dev/null +++ b/patches/react-navigation/@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch @@ -0,0 +1,7 @@ +diff --git a/node_modules/@react-navigation/bottom-tabs/lib/module/package.json b/node_modules/@react-navigation/bottom-tabs/lib/module/package.json +index 089153b..0967ef4 100644 +--- a/node_modules/@react-navigation/bottom-tabs/lib/module/package.json ++++ b/node_modules/@react-navigation/bottom-tabs/lib/module/package.json +@@ -1 +1 @@ +-{"type":"module"} ++{} diff --git a/patches/react-navigation/@react-navigation+core+7.16.1+003+fix-clearing-preloaded-routes-after-logout.patch b/patches/react-navigation/@react-navigation+core+7.16.1+003+fix-clearing-preloaded-routes-after-logout.patch deleted file mode 100644 index 66e300ceab6d..000000000000 --- a/patches/react-navigation/@react-navigation+core+7.16.1+003+fix-clearing-preloaded-routes-after-logout.patch +++ /dev/null @@ -1,70 +0,0 @@ -diff --git a/node_modules/@react-navigation/core/lib/module/useNavigationBuilder.js b/node_modules/@react-navigation/core/lib/module/useNavigationBuilder.js -index 0000000..c93a4f5 100644 ---- a/node_modules/@react-navigation/core/lib/module/useNavigationBuilder.js -+++ b/node_modules/@react-navigation/core/lib/module/useNavigationBuilder.js -@@ -575,15 +575,16 @@ export function useNavigationBuilder(createRouter, options, convertCustomScreenO - router, - // @ts-expect-error: this should have both core and custom events, but too much work right now - emitter - }, convertCustomScreenOptions); -+ const validState = state?.preloadedRoutes?.some(preloadedRoute => !state.routeNames.includes(preloadedRoute.name)) ? {...state, preloadedRoutes: []} : state; - useCurrentRender({ -- state, -+ state: validState, - navigation, - descriptors - }); - const NavigationContent = useComponent(children => { - const element = layout != null ? layout({ -- state, -+ state: validState, - descriptors, - navigation, - children -@@ -605,7 +606,7 @@ export function useNavigationBuilder(createRouter, options, convertCustomScreenO - }); - }); - return { -- state, -+ state: validState, - navigation, - describe, - descriptors, -diff --git a/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx b/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx -index 0000000..3bab87a 100644 ---- a/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx -+++ b/node_modules/@react-navigation/core/src/useNavigationBuilder.tsx -@@ -985,10 +985,14 @@ export function useNavigationBuilder< - router, - // @ts-expect-error: this should have both core and custom events, but too much work right now - emitter, - }, convertCustomScreenOptions); -+ -+ const validState = state?.preloadedRoutes?.some((preloadedRoute) => !state.routeNames.includes(preloadedRoute.name)) -+ ? {...state, preloadedRoutes: []} -+ : state; - - useCurrentRender({ -- state, -+ state: validState, - navigation, - descriptors, - }); -@@ -997,7 +1001,7 @@ export function useNavigationBuilder< - const element = - layout != null - ? layout({ -- state, -+ state: validState, - descriptors, - navigation, - children, -@@ -1020,7 +1024,7 @@ export function useNavigationBuilder< - }); - - return { -- state, -+ state: validState, - navigation, - describe, - descriptors, diff --git a/patches/react-navigation/@react-navigation+elements+2.9.10+001+fix-failing-jest-by-disabling-esmodule.patch b/patches/react-navigation/@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch similarity index 100% rename from patches/react-navigation/@react-navigation+elements+2.9.10+001+fix-failing-jest-by-disabling-esmodule.patch rename to patches/react-navigation/@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 7f686a6f73e5..2c7e824771a4 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -1,8 +1,9 @@ # `@react-navigation` patches ### @react-navigation+package-name+7+fix-failing-jest-by-disabling-esmodule.patch +#### [@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+bottom-tabs+7.15.5+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+core+7.16.1+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+core+7.16.1+001+fix-failing-jest-by-disabling-esmodule.patch) -#### [@react-navigation+elements+2.9.10+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.10+001+fix-failing-jest-by-disabling-esmodule.patch) +#### [@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+elements+2.9.14+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+material-top-tabs+7.4.19+001+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native-stack+7.14.5+002+fix-failing-jest-by-disabling-esmodule.patch) #### [@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch](@react-navigation+native+7.1.33+002+fix-failing-jest-by-disabling-esmodule.patch) @@ -69,10 +70,3 @@ - E/App issue: [#48150](https://github.com/Expensify/App/issues/48150) - PR Introducing Patch: [#48151](https://github.com/Expensify/App/pull/48151) - PR Updating Patch: [#64155](https://github.com/Expensify/App/pull/64155) - -### [@react-navigation+core+7.16.1+003+fix-clearing-preloaded-routes-after-logout.patch](@react-navigation+core+7.16.1+003+fix-clearing-preloaded-routes-after-logout.patch) -- Reason: Clear preloaded routes from state that are not available after logging out. -- Upstream PR/issue: N/A -- E/App issue: [#65709](https://github.com/Expensify/App/issues/65211) -- PR Introducing Patch: [#65836](https://github.com/Expensify/App/pull/66890) -- PR Updating Patch: N/A \ No newline at end of file diff --git a/rock.config.mjs b/rock.config.mjs index 3bfe70bca218..3f441f1ac5a3 100644 --- a/rock.config.mjs +++ b/rock.config.mjs @@ -22,6 +22,7 @@ export default { android: platformAndroid({sourceDir: isHybrid ? './Mobile-Expensify/Android' : './android'}), }, fingerprint: { + extraSources: ['android/gradle.properties', 'ios/Podfile', 'scripts/compute-patches-hash.sh'], env: ['USE_WEB_PROXY', 'PUSHER_DEV_SUFFIX', 'SECURE_NGROK_URL', 'NGROK_URL', 'USE_NGROK', 'FORCE_NATIVE_BUILD'], ignorePaths: ['Mobile-Expensify/Android/assets/app/shared/bundle.js'], }, diff --git a/scripts/compute-patches-hash.sh b/scripts/compute-patches-hash.sh index b87e178a728b..fc8c23549bd5 100755 --- a/scripts/compute-patches-hash.sh +++ b/scripts/compute-patches-hash.sh @@ -13,4 +13,14 @@ PATCH_DIRS=("$@") readonly PATCH_DIRS # Find all patches, compute their hash, put filename before hash, sort, compute hash of hashes -find "${PATCH_DIRS[@]}" -type f \( -name "react-native+*.patch" -o -name "@react-native+*.patch" \) -exec sha256sum {} \; | awk '{split($2, pathParts, "/"); print pathParts[length(pathParts)], $1 }' | sort | sha256sum | awk '{print $1}' +PATCHES_HASH=$(find "${PATCH_DIRS[@]}" -type f \( -name "react-native+*.patch" -o -name "@react-native+*.patch" \) -exec sha256sum {} \; | awk '{split($2, pathParts, "/"); print pathParts[length(pathParts)], $1 }' | sort | sha256sum | awk '{print $1}') + +# Include hermesV1Enabled flag in the hash so that enabling Hermes V1 invalidates +# prebuilt artifacts and triggers a build from source. +ANDROID_GRADLE_PROPS="$SCRIPT_DIR/../android/gradle.properties" + +if [ -f "$ANDROID_GRADLE_PROPS" ] && grep -q '^[[:space:]]*hermesV1Enabled[[:space:]]*=[[:space:]]*true' "$ANDROID_GRADLE_PROPS"; then + PATCHES_HASH=$(printf "%s" "${PATCHES_HASH}-hermesV1" | sha256sum | awk '{print $1}') +fi + +echo "$PATCHES_HASH" diff --git a/scripts/createOrUpdateDeployChecklist.ts b/scripts/createOrUpdateDeployChecklist.ts index 710f1b4a6d71..dc91a84345a0 100644 --- a/scripts/createOrUpdateDeployChecklist.ts +++ b/scripts/createOrUpdateDeployChecklist.ts @@ -314,7 +314,7 @@ async function run(): Promise { const {data: newChecklist} = await GithubUtils.octokit.issues.create({ ...defaultPayload, title: `Deploy Checklist: New Expensify ${format(new Date(), CONST.DATE_FORMAT_STRING)}`, - labels: [CONST.LABELS.STAGING_DEPLOY, CONST.LABELS.LOCK_DEPLOY], + labels: [CONST.LABELS.STAGING_DEPLOY, CONST.LABELS.LOCK_DEPLOY, CONST.LABELS.DAILY], assignees: [CONST.APPLAUSE_BOT as string].concat(checklistAssignees), }); console.log(`Successfully created new deploy checklist! ๐ŸŽ‰ ${newChecklist.html_url}`); diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 716b466404e2..8d05ee6ea827 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -1,859 +1,326 @@ #!/usr/bin/env ts-node -/* eslint-disable max-classes-per-file */ /** - * React Compiler Compliance Checker + * React Compiler Compliance Check * - * This script tracks which components can be compiled by React Compiler and which cannot. - * It provides both CI and local development tools to enforce Rules of React compliance. + * Checks whether React components and hooks compile with React Compiler. + * Two modes: + * - `check ` -- check specific files, report per-file status + * - `check-changed` -- check files changed in a PR, enforce two rules: + * 1. New files with components/hooks must compile + * 2. Modified files must not regress (compiled on main -> must compile on PR) */ -import {execSync} from 'child_process'; -import fs, {readFileSync} from 'fs'; +import {transformSync} from '@babel/core'; +import fs from 'fs'; import path from 'path'; -import type {TupleToUnion} from 'type-fest'; import CLI from './utils/CLI'; -import EslintUtils from './utils/EslintUtils'; import FileUtils from './utils/FileUtils'; import Git from './utils/Git'; -import type {DiffResult} from './utils/Git'; -import {log, bold as logBold, error as logError, info as logInfo, note as logNote, success as logSuccess, warn as logWarn} from './utils/Logger'; - -type CompilerResults = { - success: Set; - errors: Map; - errorsForAddedFiles: Map; - errorsForModifiedFiles: Map; - manualMemoErrors: Map; - suppressedErrors: Map; +import {error as logError, errorDetail as logErrorDetail, info as logInfo, success as logSuccess, warn as logWarn} from './utils/Logger'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment +const ReactCompilerConfig = require('../config/babel/reactCompilerConfig'); + +type SourceLocation = { + start: {line: number; column: number}; + end: {line: number; column: number}; }; type CompilerError = { - file: string; - line: number; - column: number; - reason?: string; + reason: string; + severity: string; + loc?: SourceLocation; + fnLoc?: SourceLocation; }; -type ManualMemoizationError = { - keyword: string; - line: number; - column: number; +type CompilationResult = { + status: 'compiled' | 'failed' | 'no-components'; + errors: CompilerError[]; }; -type CheckMode = 'static' | 'incremental'; - -type BaseCheckParameters = { - remote?: string; - verbose?: boolean; +type CompilerLogEvent = { + kind: string; + fnLoc?: SourceLocation; + fnName?: string; + detail?: { + severity?: string; + reason?: string; + loc?: SourceLocation; + }; }; -type CheckParameters = BaseCheckParameters & { - mode?: CheckMode; - files?: string[]; -}; +const FILE_EXTENSIONS = ['.ts', '.tsx']; -type CheckOptions = { - mode: CheckMode; - verbose: boolean; -}; +const IS_CI = process.env.CI === 'true'; /** - * Handles running react-compiler-healthcheck and parsing its output. + * Check if a source string compiles with React Compiler. + * Returns the compilation status and any errors with their details. */ -class ReactCompilerHealthcheck { - private static readonly SUPPRESSED_ERRORS = [ - // This error is caused by an internal limitation of React Compiler - // https://github.com/facebook/react/issues/29583 - '(BuildHIR::lowerExpression) Expected Identifier, got MemberExpression key in ObjectExpression', - ] as const; - - private static readonly OUTPUT_REGEXES = { - SUCCESS: /Successfully compiled (?:hook|component) \[([^\]]+)\]\(([^)]+)\)/, - ERROR_WITH_REASON: /Failed to compile ([^:]+):(\d+):(\d+)\. Reason: (.+)/, - ERROR_WITHOUT_REASON: /Failed to compile ([^:]+):(\d+):(\d+)\./, - REASON: /Reason: (.+)/, - } as const; - - /** - * Run the react-compiler-healthcheck CLI tool and parse its output. - */ - static run(src?: string): CompilerResults { - const srcArg = src ? `--src "${src}"` : ''; - const output = execSync(`npx react-compiler-healthcheck ${srcArg} --verbose`, { - encoding: 'utf8', - cwd: process.cwd(), - }); - - return this.parseOutput(output); - } - - /** - * Parse the output of the react-compiler-healthcheck command. - */ - private static parseOutput(output: string): CompilerResults { - const lines = output.split('\n'); - - const results: CompilerResults = { - success: new Set(), - errors: new Map(), - errorsForAddedFiles: new Map(), - errorsForModifiedFiles: new Map(), - manualMemoErrors: new Map(), - suppressedErrors: new Map(), - }; - - let currentErrorWithoutReason: CompilerError | null = null; - - for (const line of lines) { - const successMatch = line.match(this.OUTPUT_REGEXES.SUCCESS); - if (successMatch) { - const filePath = successMatch[2]; - results.success.add(filePath); - continue; - } - - const errorWithReasonMatch = line.match(this.OUTPUT_REGEXES.ERROR_WITH_REASON); - if (errorWithReasonMatch) { - const newError: CompilerError = { - file: errorWithReasonMatch[1], - line: parseInt(errorWithReasonMatch[2], 10), - column: parseInt(errorWithReasonMatch[3], 10), - reason: errorWithReasonMatch[4], - }; - - currentErrorWithoutReason = null; - - if (this.shouldSuppressError(newError.reason)) { - this.addOrUpdateError(results.suppressedErrors, newError); - continue; - } +function checkReactCompilerCompliance(source: string, filename: string): CompilationResult { + let hasError = false; + let hasSuccess = false; + const errors: CompilerError[] = []; - this.addOrUpdateError(results.errors, newError); - } - - const errorWithoutReasonMatch = line.match(this.OUTPUT_REGEXES.ERROR_WITHOUT_REASON); - if (errorWithoutReasonMatch) { - const newError: CompilerError = { - file: errorWithoutReasonMatch[1], - line: parseInt(errorWithoutReasonMatch[2], 10), - column: parseInt(errorWithoutReasonMatch[3], 10), - }; - - currentErrorWithoutReason = newError; - this.addOrUpdateError(results.errors, newError); - continue; - } - - const reasonMatch = line.match(this.OUTPUT_REGEXES.REASON); - if (reasonMatch && currentErrorWithoutReason) { - const reason = reasonMatch[1]; - - const currentError: CompilerError = { - file: currentErrorWithoutReason.file, - line: currentErrorWithoutReason.line, - column: currentErrorWithoutReason.column, - reason, - }; - - currentErrorWithoutReason = null; - - if (this.shouldSuppressError(reason)) { - this.addOrUpdateError(results.suppressedErrors, currentError); - continue; - } - - this.addOrUpdateError(results.errors, currentError); - } - } - - results.success = new Set(Array.from(results.success).sort((a, b) => a.localeCompare(b))); - results.errors = this.sortErrors(results.errors); - results.suppressedErrors = this.sortErrors(results.suppressedErrors); - - return results; - } - - private static addOrUpdateError(errorMap: Map, newError: CompilerError): boolean { - const key = this.getErrorKey(newError); - const existingError = errorMap.get(key); - - if (existingError) { - const isReasonSet = !!existingError.reason; - const isNewReasonSet = !!newError.reason; - if (!isReasonSet && isNewReasonSet) { - errorMap.set(key, newError); - return true; - } - - return false; - } - - errorMap.set(key, newError); - return true; - } - - private static sortErrors(errors: Map) { - const arr = Array.from(errors.entries()); - arr.sort(([, a], [, b]) => { - const keyA = this.getErrorKey(a); - const keyB = this.getErrorKey(b); - return keyA.localeCompare(keyB); + try { + transformSync(source, { + filename, + ast: false, + code: false, + configFile: false, + babelrc: false, + parserOpts: { + plugins: ['typescript', 'jsx'], + }, + plugins: [ + [ + 'babel-plugin-react-compiler', + { + ...ReactCompilerConfig, + panicThreshold: 'none', + noEmit: true, + logger: { + logEvent(_filename: string, event: CompilerLogEvent) { + if (event.kind === 'CompileError') { + hasError = true; + if (event.detail?.reason) { + errors.push({ + reason: event.detail.reason ?? 'Unknown compiler error', + severity: event.detail.severity ?? 'Error', + loc: event.detail.loc, + fnLoc: event.fnLoc, + }); + } + } + if (event.kind === 'CompileSuccess') { + hasSuccess = true; + } + }, + }, + }, + ], + ], + }); + } catch (e) { + hasError = true; + errors.push({ + reason: e instanceof Error ? e.message : String(e), + severity: 'Error', }); - return new Map(arr); } - private static shouldSuppressError(reason: string | undefined): boolean { - if (!reason) { - return false; - } - - return this.SUPPRESSED_ERRORS.some((suppressedError) => reason.includes(suppressedError)); + if (hasError) { + return {status: 'failed', errors}; } - - static getErrorKey({file, line, column}: CompilerError): string { - const isLineSet = line !== undefined; - const isLineAndColumnSet = isLineSet && column !== undefined; - - return file + (isLineSet ? `:${line}` : '') + (isLineAndColumnSet ? `:${column}` : ''); + if (hasSuccess) { + return {status: 'compiled', errors: []}; } + return {status: 'no-components', errors: []}; +} - static getErrorsByFile(errors: Map) { - const errorsByFile = new Map>(); - for (const [key, error] of errors.entries()) { - if (!errorsByFile.has(error.file)) { - errorsByFile.set(error.file, new Map()); - } - errorsByFile.get(error.file)?.set(key, error); - } - - const filesWithErrors = new Set(errorsByFile.keys()); - - return { - errorsByFile, - filesWithErrors, - }; +function formatErrorLocation(filename: string, error: CompilerError): string { + const loc = error.loc ?? error.fnLoc; + if (loc) { + return `${loc.start.line}:${loc.start.column}`; } + return filename; } -/** - * Analyzes git diffs to filter compiler results to only changed lines. - */ -class DiffAnalyzer { - private static readonly ESLINT_LINT_RULES = ['react-hooks'] as const; - - /** - * Filter compiler results to only include errors for lines that were changed in the git diff. - */ - static async filterResultsByDiff(results: CompilerResults, mainBaseCommitHash: string, diffResult: DiffResult, {verbose}: CheckOptions): Promise { - logInfo(`Filtering results by diff between ${mainBaseCommitHash} and the working tree...`); - - if (!diffResult.hasChanges) { - return { - success: new Set(), - errors: new Map(), - errorsForAddedFiles: new Map(), - errorsForModifiedFiles: new Map(), - manualMemoErrors: new Map(), - suppressedErrors: new Map(), - }; - } - - const changedLinesMap = this.buildChangedLinesMap(diffResult); - const {filesWithEslintDisable, linesWithEslintDisableNextLine} = this.detectEslintDisables(diffResult); - - const filterErrorsByChangedLines = (errors: Map) => { - const filteredErrors = new Map(); - - for (const [key, error] of errors) { - const changedLines = changedLinesMap.get(error.file); - - if (!changedLines) { - continue; - } - - if (filesWithEslintDisable.has(error.file)) { - filteredErrors.set(key, error); - continue; - } - - if (error.line !== undefined) { - const isLineChanged = changedLines.has(error.line); - const isLineEslintDisabled = linesWithEslintDisableNextLine.get(error.file)?.has(error.line); - - if (isLineChanged || isLineEslintDisabled) { - filteredErrors.set(key, error); - } - continue; - } - - filteredErrors.set(key, error); - } - - return filteredErrors; - }; - - const filteredErrors = filterErrorsByChangedLines(results.errors); - const filteredSuppressedErrors = filterErrorsByChangedLines(results.suppressedErrors); - - const changedFiles = new Set(diffResult.files.map((file) => file.filePath)); - const filteredSuccesses = new Set(); - for (const file of results.success) { - if (!changedFiles.has(file)) { - continue; - } - filteredSuccesses.add(file); - } - - if (filteredErrors.size === 0) { - logInfo('No errors remain after filtering by diff.'); - } else { - logInfo(`${filteredErrors.size} out of ${results.errors.size} errors remain after filtering by diff.`); - } - - if (verbose) { - if (filteredSuppressedErrors.size === 0) { - logInfo('No suppressed errors remain after filtering by diff.'); - } else { - logInfo(`${filteredSuppressedErrors.size} out of ${results.suppressedErrors.size} successes remain after filtering by diff.`); - } - - if (filteredSuccesses.size === 0) { - logInfo('No successes remain after filtering by diff.'); - } else { - logInfo(`${filteredSuccesses.size} out of ${results.success.size} successes remain after filtering by diff.`); - } - } - - return { - success: filteredSuccesses, - errors: filteredErrors, - errorsForAddedFiles: new Map(), - errorsForModifiedFiles: new Map(), - manualMemoErrors: new Map(), - suppressedErrors: filteredSuppressedErrors, - }; +function printErrors(filename: string, errors: CompilerError[]): void { + if (IS_CI) { + console.log(`::group::${filename} (${errors.length} error${errors.length === 1 ? '' : 's'})`); } - - private static buildChangedLinesMap(diffResult: DiffResult): Map> { - const changedLinesMap = new Map>(); - - for (const file of diffResult.files) { - const changedLines = new Set([...file.addedLines, ...file.modifiedLines]); - changedLinesMap.set(file.filePath, changedLines); - } - - return changedLinesMap; + for (const error of errors) { + const location = formatErrorLocation(filename, error); + logErrorDetail(`${location}: ${error.reason}`); } - - private static detectEslintDisables(diffResult: DiffResult): { - filesWithEslintDisable: Set; - linesWithEslintDisableNextLine: Map>; - } { - const filesWithEslintDisable = new Set(); - const linesWithEslintDisableNextLine = new Map>(); - - for (const file of diffResult.files) { - for (const hunk of file.hunks) { - for (const line of hunk.lines) { - if (EslintUtils.hasEslintDisableComment(line.content, true, [...this.ESLINT_LINT_RULES])) { - filesWithEslintDisable.add(file.filePath); - } - - if (EslintUtils.hasEslintDisableComment(line.content, false, [...this.ESLINT_LINT_RULES])) { - if (!linesWithEslintDisableNextLine.has(file.filePath)) { - linesWithEslintDisableNextLine.set(file.filePath, new Set()); - } - - const disabledLines = linesWithEslintDisableNextLine.get(file.filePath); - if (!disabledLines) { - continue; - } - - const reactCompilerErrorLineNumber = line.type === 'removed' ? line.number + hunk.newCount : line.number + hunk.newCount + 1; - disabledLines.add(reactCompilerErrorLineNumber); - } - } - } - } - - return {filesWithEslintDisable, linesWithEslintDisableNextLine}; + if (IS_CI) { + console.log('::endgroup::'); } } /** - * Checks for manual memoization in files that should use automatic memoization. + * Check specific files and report per-file status. */ -class ManualMemoizationChecker { - static readonly PATTERNS = { - memo: /\b(?:React\.)?memo\s*\(/g, - useMemo: /\b(?:React\.)?useMemo\s*\(/g, - useCallback: /\b(?:React\.)?useCallback\s*\(/g, - } as const; - - private static readonly FILE_EXTENSIONS = ['.tsx', '.jsx'] as const; +function checkFiles(inputs: string[], verbose: boolean): boolean { + const files = FileUtils.resolveFilePaths(inputs, FILE_EXTENSIONS); - private static readonly NO_MEMO_DIRECTIVE_PATTERN = /["']use no memo["']\s*;?/; - - static getErrorMessage(keyword: keyof typeof this.PATTERNS): string { - return `Found a manual memoization usage of \`${keyword}\`. Newly added React component files must not contain any manual memoization and instead be auto-memoized by React Compiler. Remove \`${keyword}\` or disable automatic memoization by adding the \`"use no memo";\` directive at the beginning of the component and give a reason why automatic memoization is not applicable.`; + if (files.length === 0) { + logWarn(`No ${FILE_EXTENSIONS.join('/')} files found matching the provided paths.`); + return true; } - /** - * Split errors by file diff type and check for manual memoization violations. - */ - static splitErrorsBasedOnFileDiffType({success, errors: reactCompilerErrors}: CompilerResults, diffResult: DiffResult) { - const {filesWithErrors, errorsByFile} = ReactCompilerHealthcheck.getErrorsByFile(reactCompilerErrors); - - const {addedFiles, enforcedAutoMemoFiles} = this.categorizeFiles(diffResult, success, filesWithErrors); - - const reactCompilerErrorsForModifiedFiles = reactCompilerErrors; - const reactCompilerErrorsForAddedFiles = new Map(); - - for (const file of filesWithErrors) { - if (enforcedAutoMemoFiles.has(file)) { - continue; - } - - const errors = errorsByFile.get(file); - - if (addedFiles.has(file)) { - for (const [errorKey, error] of errors?.entries() ?? []) { - reactCompilerErrorsForAddedFiles.set(errorKey, error); - reactCompilerErrors.delete(errorKey); + let hasFailure = false; + + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + const result = checkReactCompilerCompliance(source, file); + + switch (result.status) { + case 'compiled': + logSuccess(`COMPILED ${file}`); + break; + case 'failed': + logError(`FAILED ${file}`); + printErrors(file, result.errors); + hasFailure = true; + break; + case 'no-components': + default: + if (verbose) { + logInfo(`SKIPPED ${file} (no components or hooks)`); } - } + break; } - - const manualMemoErrors = this.findViolations(enforcedAutoMemoFiles); - - return { - manualMemoErrors, - reactCompilerErrorsForModifiedFiles, - reactCompilerErrorsForAddedFiles, - }; } - private static categorizeFiles( - diffResult: DiffResult, - successFiles: Set, - filesWithErrors: Set, - ): { - addedFiles: Set; - enforcedAutoMemoFiles: Set; - } { - const enforcedAutoMemoFiles = new Set(); - const addedFiles = new Set(); - - for (const file of diffResult.files) { - const filePath = file.filePath; - - filesWithErrors.add(filePath); - - const isAddedFile = file.diffType === 'added'; - if (isAddedFile) { - addedFiles.add(filePath); - } - - const isReactComponentSourceFile = this.FILE_EXTENSIONS.some((extension) => filePath.endsWith(extension)); - const isSuccessfullyCompiled = successFiles.has(filePath); - - if (isReactComponentSourceFile && isSuccessfullyCompiled && isAddedFile) { - enforcedAutoMemoFiles.add(filePath); - } - } - - return {addedFiles, enforcedAutoMemoFiles}; - } - - private static findViolations(files: Set): Map { - const manualMemoErrors = new Map(); - - for (const file of files) { - let source: string | null = null; - try { - const absolutePath = path.join(process.cwd(), file); - source = readFileSync(absolutePath, 'utf8'); - } catch (error) { - logWarn(`Unable to read ${file} while enforcing new component rules.`, error); - } - - if (!source || this.NO_MEMO_DIRECTIVE_PATTERN.test(source)) { - continue; - } - - const manualMemoizationMatches = this.findMatches(source); - - if (manualMemoizationMatches.length === 0) { - continue; - } - - manualMemoErrors.set(file, manualMemoizationMatches); - } - - return manualMemoErrors; - } - - private static findMatches(source: string): ManualMemoizationError[] { - const matches: ManualMemoizationError[] = []; - - for (const keyword of Object.keys(this.PATTERNS) as Array) { - const regex = this.PATTERNS[keyword]; - const regexMatches = source.matchAll(regex); - - for (const regexMatch of regexMatches) { - const matchIndex = regexMatch.index; - if (matchIndex === undefined) { - continue; - } - const {line, column} = FileUtils.getLineAndColumnFromIndex(source, matchIndex); - matches.push({keyword, line, column}); - } - } - - matches.sort((a, b) => { - if (a.line !== b.line) { - return a.line - b.line; - } - return a.column - b.column; - }); - - return matches; - } + return !hasFailure; } /** - * Handles printing compiler results to the console. + * Check files changed in a PR for React Compiler compliance. + * Rule 1: New files with components/hooks must compile. + * Rule 2: Modified files must not regress (compiled on main -> must compile on PR). */ -class ResultsPrinter { - private static readonly TAB = ' '; - - /** - * Print all results and determine pass/fail status. - */ - static printResults({success, errorsForAddedFiles, errorsForModifiedFiles, suppressedErrors, manualMemoErrors}: CompilerResults, {verbose}: CheckOptions): boolean { - this.printSuccesses(success, verbose); - this.printSuppressedErrors(suppressedErrors, verbose); - - const {hasModifiedFilesErrors, hasAddedFilesErrors} = this.printCompilerErrors(errorsForModifiedFiles, errorsForAddedFiles); - const hasManualMemoErrors = this.printManualMemoErrors(manualMemoErrors); - - if ((hasModifiedFilesErrors || hasAddedFilesErrors) && !hasManualMemoErrors) { - log(); - } +async function checkChangedFiles(remote: string, verbose: boolean): Promise { + const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); + const changedFiles = await Git.getChangedFilesWithStatus(mainBaseCommitHash); - const didCheckForAddedFilesPass = errorsForAddedFiles.size === 0; - const isPassed = didCheckForAddedFilesPass && !hasManualMemoErrors; + const reactFiles = changedFiles.filter((f) => FILE_EXTENSIONS.some((ext) => f.filename.endsWith(ext)) && f.status !== 'removed'); - if (isPassed) { - if (hasModifiedFilesErrors) { - logWarn(`React Compiler compliance check passed with warnings! The warnings must NOT be fixed and can get ignored.`); - } - - logSuccess('React Compiler compliance check passed!'); - return true; - } - - log(); - logError( - `The files above failed the React Compiler compliance check. Do not remove any manual memoization patterns, unless a file is already able to compile with React Compiler. You can use the "React Compiler Marker" VS Code extension to check whether a file is being compiled with React Compiler.`, - ); - - return false; - } - - private static printSuccesses(success: Set, verbose: boolean): void { - if (!verbose || success.size === 0) { - return; - } - - log(); - logSuccess(`Successfully compiled ${success.size} files with React Compiler:`); - log(); - - for (const successFile of success) { - logSuccess(`${successFile}`); - } - - log(); + if (reactFiles.length === 0) { + logSuccess('No React files changed, skipping check.'); + return true; } - private static printSuppressedErrors(suppressedErrors: Map, verbose: boolean): void { - if (!verbose || suppressedErrors.size === 0) { - return; - } - - const suppressedErrorMap = new Map(); - for (const [, error] of suppressedErrors) { - if (!error.reason) { - continue; - } - - if (!suppressedErrorMap.has(error.reason)) { - suppressedErrorMap.set(error.reason, []); - } - - suppressedErrorMap.get(error.reason)?.push(error); - } + logInfo(`Checking ${reactFiles.length} changed React files...`); - log(); - logWarn(`Suppressed the following errors in these files:`); - log(); + const failures: Array<{file: string; reason: string; errors: CompilerError[]}> = []; - for (const [error, suppressedErrorFiles] of suppressedErrorMap) { - logBold(error); - const filesLine = suppressedErrorFiles.map((suppressedError) => ReactCompilerHealthcheck.getErrorKey(suppressedError)).join(', '); - logNote(`${this.TAB} - ${filesLine}`); + for (const {filename, status, previousFilename} of reactFiles) { + const absolutePath = path.resolve(filename); + if (!fs.existsSync(absolutePath)) { + continue; } - log(); - } - - private static printCompilerErrors( - errorsForModifiedFiles: Map, - errorsForAddedFiles: Map, - ): {hasModifiedFilesErrors: boolean; hasAddedFilesErrors: boolean} { - const hasModifiedFilesErrors = errorsForModifiedFiles.size > 0; - const hasAddedFilesErrors = errorsForAddedFiles.size > 0; - - if (hasModifiedFilesErrors) { - const {filesWithErrors} = ReactCompilerHealthcheck.getErrorsByFile(errorsForModifiedFiles); - - if (filesWithErrors.size > 0) { - log(); - logWarn(`Failed to compile ${filesWithErrors.size} modified files with React Compiler:`); - log(); + const source = fs.readFileSync(absolutePath, 'utf8'); + const result = checkReactCompilerCompliance(source, absolutePath); - this.printErrors(errorsForModifiedFiles); + if (status === 'added') { + if (result.status === 'failed') { + failures.push({file: filename, reason: 'New file contains components/hooks that fail to compile with React Compiler', errors: result.errors}); + logError(`FAILED ${filename} (new file must compile)`); + printErrors(filename, result.errors); + } else if (verbose) { + const label = result.status === 'compiled' ? 'COMPILED' : 'SKIPPED '; + logSuccess(`${label} ${filename}`); } + continue; } - if (hasAddedFilesErrors) { - const {filesWithErrors} = ReactCompilerHealthcheck.getErrorsByFile(errorsForAddedFiles); - - if (filesWithErrors.size > 0) { - log(); - logError(`Failed to compile ${filesWithErrors.size} added files with React Compiler:`); - log(); - - this.printErrors(errorsForAddedFiles); + // Modified or renamed files: check for regression + if (result.status === 'failed') { + let mainStatus: CompilationResult['status'] = 'no-components'; + const mainPath = previousFilename ?? filename; + try { + const mainSource = Git.show('origin/main', mainPath); + mainStatus = checkReactCompilerCompliance(mainSource, mainPath).status; + } catch { + mainStatus = 'no-components'; } - } - - return {hasModifiedFilesErrors, hasAddedFilesErrors}; - } - - private static printManualMemoErrors(manualMemoErrors: Map): boolean { - const hasManualMemoErrors = manualMemoErrors.size > 0; - - if (!hasManualMemoErrors) { - return false; - } - - log(); - logError(`The following newly added components should be auto memoized by the React Compiler (manual memoization is not allowed):`); - for (const [filePath, manualMemoizationMatches] of manualMemoErrors) { - log(); - - for (const manualMemoizationMatch of manualMemoizationMatches) { - const location = manualMemoizationMatch.line && manualMemoizationMatch.column ? `:${manualMemoizationMatch.line}:${manualMemoizationMatch.column}` : ''; - logBold(`${filePath}${location}`); - logNote(`${this.TAB}${ManualMemoizationChecker.getErrorMessage(manualMemoizationMatch.keyword as keyof typeof ManualMemoizationChecker.PATTERNS)}`); + if (mainStatus === 'compiled') { + failures.push({file: filename, reason: 'File compiled on main but fails to compile on this branch (regression)', errors: result.errors}); + logError(`FAILED ${filename} (regression: compiled on main)`); + printErrors(filename, result.errors); + } else if (verbose) { + logWarn(`WARNING ${filename} (fails to compile, but also failed on main)`); } + } else if (verbose) { + const label = result.status === 'compiled' ? 'COMPILED' : 'SKIPPED '; + logSuccess(`${label} ${filename}`); } - - return true; - } - - private static printErrors(errorsToPrint: Map, level = 0) { - for (const error of errorsToPrint.values()) { - const location = error.line && error.column ? `:${error.line}:${error.column}` : ''; - logBold(`${this.TAB.repeat(level)}${error.file}${location}`); - logNote(`${this.TAB.repeat(level + 1)}${error.reason ?? 'No reason provided'}`); - } - } -} - -/** - * Generates JSON reports of compiler results. - */ -class ReportGenerator { - /** - * Generate a report and save it to /tmp. - */ - static generate({success, errors, suppressedErrors, manualMemoErrors, errorsForAddedFiles, errorsForModifiedFiles}: CompilerResults): void { - const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-'); - const reportFileName = `react-compiler-compliance-check-report-${timestamp}.json`; - const reportFile = path.join('/tmp', reportFileName); - - const resultsObject = { - success: Array.from(success), - errors: Object.fromEntries(errors.entries()), - errorsForAddedFiles: Object.fromEntries(errorsForAddedFiles.entries()), - errorsForModifiedFiles: Object.fromEntries(errorsForModifiedFiles.entries()), - manualMemoErrors: Object.fromEntries(manualMemoErrors.entries()), - suppressedErrors: Object.fromEntries(suppressedErrors.entries()), - } satisfies Record | string[]>; - - fs.writeFileSync( - reportFile, - JSON.stringify( - { - timestamp: new Date().toISOString(), - results: resultsObject, - }, - null, - 2, - ), - ); - - log(); - logInfo(`Report saved to: ${reportFile}`); - } -} - -/** - * Main checker orchestrates the compliance check workflow. - */ -class Checker { - /** - * Check changed files for React Compiler compliance. - */ - static async checkChangedFiles({remote, ...restOptions}: BaseCheckParameters): Promise { - logInfo('Checking changed files for React Compiler compliance...'); - - const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); - const changedFiles = await Git.getChangedFileNames(mainBaseCommitHash, undefined, true); - - if (changedFiles.length === 0) { - logSuccess('No React files changed, skipping check.'); - return true; - } - - return this.check({mode: 'incremental', files: changedFiles, ...restOptions}); } - /** - * Check specific files or all files for React Compiler compliance. - */ - static async check({mode = 'static', files, remote, verbose = false}: CheckParameters): Promise { - const options: CheckOptions = {mode, verbose}; - - if (files) { - logInfo(`Running React Compiler check for ${files.length} files or glob patterns...`); - } else { - logInfo('Running React Compiler check for all files...'); - } - - let results = ReactCompilerHealthcheck.run(this.createFilesGlob(files)); - - const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); - const diffResult = Git.diff(mainBaseCommitHash, undefined, undefined, true); - - if (mode === 'incremental') { - results = await DiffAnalyzer.filterResultsByDiff(results, mainBaseCommitHash, diffResult, options); - } - - const {reactCompilerErrorsForModifiedFiles, reactCompilerErrorsForAddedFiles, manualMemoErrors} = ManualMemoizationChecker.splitErrorsBasedOnFileDiffType(results, diffResult); - - results.manualMemoErrors = manualMemoErrors; - results.errorsForAddedFiles = reactCompilerErrorsForAddedFiles; - results.errorsForModifiedFiles = reactCompilerErrorsForModifiedFiles; - - const isPassed = ResultsPrinter.printResults(results, options); - - ReportGenerator.generate(results); - - return isPassed; + console.log(); + if (failures.length > 0) { + logError(`React Compiler compliance check failed with ${failures.length} error(s).`); + console.log(); + logInfo('See contributingGuides/REACT_COMPILER.md for help fixing these errors.'); + return false; } - /** - * Create a glob pattern from an array of file paths. - */ - private static createFilesGlob(files?: string[]): string | undefined { - if (!files || files.length === 0) { - return undefined; - } - - if (files.length === 1) { - return files.at(0); - } - - return `**/+(${files.join('|')})`; - } + logSuccess('React Compiler compliance check passed!'); + return true; } const CLI_COMMANDS = ['check', 'check-changed'] as const; -type CliCommand = TupleToUnion; async function main() { const cli = new CLI({ positionalArgs: [ { name: 'command', - description: 'Command to run', - required: false, + description: 'Command to run (check or check-changed)', default: 'check', parse: (val) => { - if (!CLI_COMMANDS.includes(val as CliCommand)) { + if (!(CLI_COMMANDS as readonly string[]).includes(val)) { throw new Error(`Invalid command. Must be one of: ${CLI_COMMANDS.join(', ')}`); } return val; }, }, { - name: 'file', - description: 'File path or glob pattern to check', - required: false, - default: '', + name: 'files', + description: 'File paths, directories, or glob patterns to check (only for "check" command)', + variadic: true, + default: [], }, ], namedArgs: { remote: { - description: 'Git remote name to use for main branch (default: no remote locally and origin in CI)', + description: 'Git remote name (default: origin in CI, none locally)', required: false, - supersedes: ['check-changed'], }, }, flags: { verbose: { - description: 'Print logs of successes and suppressed errors', - required: false, - default: false, + description: 'Show detailed output including skipped files', }, }, }); - const {command, file} = cli.positionalArgs; + const {command} = cli.positionalArgs; + const files = cli.positionalArgs.files as string[]; const {remote} = cli.namedArgs; const {verbose} = cli.flags; - const commonOptions: BaseCheckParameters = { - verbose, - }; + let passed = false; - async function runCommand() { - switch (command) { - case 'check': - return Checker.check({files: file ? [file] : undefined, ...commonOptions}); - case 'check-changed': - return Checker.checkChangedFiles({remote, ...commonOptions}); - default: - logError(`Unknown command: ${String(command)}`); - return Promise.resolve(false); - } + switch (command) { + case 'check': + if (files.length === 0) { + logError('No paths specified. Usage: npm run react-compiler-compliance-check check '); + process.exit(1); + } + passed = checkFiles(files, verbose); + break; + case 'check-changed': + passed = await checkChangedFiles(remote ?? 'origin', verbose); + break; + default: + logError(`Unknown command: ${String(command)}`); + process.exit(1); } - try { - const isPassed = await runCommand(); - process.exit(isPassed ? 0 : 1); - } catch (error) { - logError('Error running react-compiler-compliance-check:', error); - process.exit(1); - } + process.exit(passed ? 0 : 1); } if (require.main === module) { - main(); + main().catch((error: unknown) => { + logError('Unexpected error:', error); + process.exit(1); + }); } -export default Checker; +export {checkReactCompilerCompliance}; +export type {CompilationResult, CompilerError}; diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 716f7449c21e..30adc245c654 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -1,14 +1,11 @@ { "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", + "module": "node16", + "moduleResolution": "node16", "target": "ES2023", + "rootDir": "..", "types": ["node"], "strict": true, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "downlevelIteration": true, - "baseUrl": ".", "paths": { "@assets/*": ["../assets/*"], "@components/*": ["../src/components/*"], diff --git a/scripts/utils/CLI.ts b/scripts/utils/CLI.ts index 49b844637ab5..de8bfc0dfd22 100644 --- a/scripts/utils/CLI.ts +++ b/scripts/utils/CLI.ts @@ -34,14 +34,16 @@ type StringArg = CLIArg & { /** * A positional argument is just a string arg, but also must be assigned a name which we will eventually expose the CLI consumer. + * If `variadic` is true, this must be the last positional arg and it collects all remaining positional args into a string[]. */ type PositionalArg = StringArg & { name: string; + variadic?: true; }; /** * This type represents the config for a CLI. - * Note: this utility does not yet support variadic args of any kind. + * The last positional arg can be marked `variadic: true` to collect all remaining positional args into a string[]. */ type CLIConfig = NonEmptyObject<{ /** @@ -87,9 +89,10 @@ type ParsedNamedArgs = { /** * Record of positional args after parsing. + * Variadic args are parsed as string[]; all others use InferStringArgParsedValue. */ type ParsedPositionalArgs = { - [K in NonNullable[number] as K['name']]: InferStringArgParsedValue; + [K in NonNullable[number] as K['name']]: K extends {variadic: true} ? string[] : InferStringArgParsedValue; }; /** @@ -217,6 +220,19 @@ class CLI { if (spec === undefined) { throw new Error(`Unexpected arg: ${rawArg}`); } + if (spec.variadic) { + // Variadic: collect this and all remaining non-flag args into an array + const collected: string[] = []; + for (let j = i; j < rawArgs.length; j++) { + const remaining = rawArgs.at(j); + if (remaining === undefined || remaining.startsWith('--')) { + break; + } + collected.push(remaining); + } + parsedPositionalArgs[spec.name as keyof typeof parsedPositionalArgs] = collected as ValueOf; + break; + } parsedPositionalArgs[spec.name as keyof typeof parsedPositionalArgs] = CLI.parseStringArg(rawArg, spec.name, spec) as ValueOf; positionalIndex++; } @@ -265,6 +281,8 @@ class CLI { if (!(spec.name in parsedPositionalArgs)) { if (spec.default !== undefined) { parsedPositionalArgs[spec.name as keyof typeof parsedPositionalArgs] = spec.default as ValueOf; + } else if (spec.variadic) { + parsedPositionalArgs[spec.name as keyof typeof parsedPositionalArgs] = [] as ValueOf; } else { throw new Error(`Missing required positional argument --${spec.name}`); } @@ -291,7 +309,12 @@ class CLI { private printHelp(): void { const {flags = {}, namedArgs = {}, positionalArgs = []} = this.config; const scriptName = process.argv.at(1) ?? 'script.ts'; - const positionalUsage = positionalArgs.map((arg) => (arg.default === undefined ? `<${arg.name}>` : `[${arg.name}]`)).join(' '); + const positionalUsage = positionalArgs + .map((arg) => { + const label = arg.variadic ? `${arg.name}...` : arg.name; + return arg.default === undefined ? `<${label}>` : `[${label}]`; + }) + .join(' '); const namedArgUsage = Object.keys(namedArgs) .map((key) => `[--${key} ]`) .join(' '); diff --git a/scripts/utils/FileUtils.ts b/scripts/utils/FileUtils.ts index 83e227c041da..a559a8aea817 100644 --- a/scripts/utils/FileUtils.ts +++ b/scripts/utils/FileUtils.ts @@ -1,3 +1,9 @@ +import fs from 'fs'; +import {globSync} from 'glob'; +import path from 'path'; + +const DEFAULT_EXTENSIONS = ['.ts', '.tsx']; + const ERROR_MESSAGES = { SOURCE_CANNOT_BE_EMPTY: 'Source cannot be empty', INDEX_CANNOT_BE_NEGATIVE: 'Index cannot be negative', @@ -31,6 +37,44 @@ const FileUtils = { const column = lastLineBreakIndex === -1 ? index + 1 : index - lastLineBreakIndex; return {line, column}; }, + + /** + * Resolve a list of inputs (file paths, directories, or glob patterns) to concrete file paths. + * Directories are expanded recursively. Results are deduplicated. + * + * @param inputs - File paths, directories, or glob patterns + * @param extensions - File extensions to include (default: .ts, .tsx) + */ + resolveFilePaths: (inputs: string[], extensions: string[] = DEFAULT_EXTENSIONS): string[] => { + const resolved = new Set(); + + for (const input of inputs) { + const absoluteInput = path.resolve(input); + const exists = fs.existsSync(absoluteInput); + const stat = exists ? fs.statSync(absoluteInput) : null; + + if (exists && stat?.isDirectory()) { + const pattern = path.join(absoluteInput, '**', `*{${extensions.join(',')}}`); + for (const file of globSync(pattern)) { + resolved.add(file); + } + continue; + } + + if (exists && stat?.isFile()) { + resolved.add(absoluteInput); + continue; + } + + for (const file of globSync(input, {absolute: true})) { + if (extensions.some((ext) => file.endsWith(ext))) { + resolved.add(file); + } + } + } + + return Array.from(resolved); + }, }; export default FileUtils; diff --git a/scripts/utils/Git.ts b/scripts/utils/Git.ts index 523ef31cf8c0..be34e6e1438a 100644 --- a/scripts/utils/Git.ts +++ b/scripts/utils/Git.ts @@ -6,7 +6,7 @@ import path from 'path'; import {promisify} from 'util'; import CONST from '@github/libs/CONST'; import GitHubUtils from '@github/libs/GithubUtils'; -import {log, error as logError, warn as logWarn} from './Logger'; +import {error as logError, warn as logWarn} from './Logger'; type ExecOptions = Omit & {cwd?: ExecWithCallbackOptions['cwd']}; function exec(command: string, options?: ExecOptions) { @@ -64,7 +64,8 @@ type DiffHunk = { */ type FileDiff = { filePath: string; - diffType: 'added' | 'removed' | 'modified'; + diffType: 'added' | 'removed' | 'modified' | 'renamed'; + previousFilePath?: string; hunks: DiffHunk[]; addedLines: Set; removedLines: Set; @@ -79,6 +80,12 @@ type DiffResult = { hasChanges: boolean; }; +type ChangedFile = { + filename: string; + status: 'added' | 'modified' | 'removed' | 'renamed'; + previousFilename?: string; +}; + /** * Utility class for git operations. */ @@ -110,8 +117,8 @@ class Git { * @throws Error when git command fails (invalid refs, not a git repo, file not found, etc.) */ static diff(fromRef: string, toRef?: string, filePaths?: string | string[], shouldIncludeUntrackedFiles = false): DiffResult { - // Build git diff command (with 0 context lines for easier parsing) - let command = `git diff -U0 ${fromRef}`; + // Build git diff command (with 0 context lines for easier parsing, -M for rename detection) + let command = `git diff -U0 -M ${fromRef}`; if (toRef) { command += ` ${toRef}`; } @@ -160,13 +167,13 @@ class Git { const files: FileDiff[] = []; let currentFile: FileDiff | null = null; let currentHunk: DiffHunk | null = null; - let oldFilePath: string | null = null; // Track old file path to determine fileDiffType + let oldFilePath: string | null = null; + let renameFromPath: string | null = null; for (const line of lines) { // File header: diff --git a/file b/file if (line.startsWith('diff --git')) { if (currentFile) { - // Push the current hunk to the current file before processing the new file if (currentHunk) { currentFile.hunks.push(currentHunk); } @@ -174,38 +181,48 @@ class Git { } currentFile = null; currentHunk = null; - oldFilePath = null; // Reset for next file + oldFilePath = null; + renameFromPath = null; + continue; + } + + // Rename detection: "rename from " appears before --- / +++ + if (line.startsWith('rename from ')) { + renameFromPath = line.slice('rename from '.length); + continue; + } + + if (line.startsWith('rename to ') || line.startsWith('similarity index ')) { continue; } // Old file path: --- a/file or --- /dev/null (for new files) - // This comes before +++ in git diff output if (line.startsWith('--- ')) { - oldFilePath = line.slice(4); // Store the old file path (remove '--- ') + oldFilePath = line.slice(4); continue; } // New file path: +++ b/file or +++ /dev/null (for removed files) if (line.startsWith('+++ ')) { - const newFilePath = line.slice(4); // Remove '+++ ' + const newFilePath = line.slice(4); - // Determine fileDiffType based on old and new file paths - // Note: oldFilePath should always be set by the time we see +++, but handle null for type safety - let fileDiffType: 'added' | 'removed' | 'modified' = 'modified'; + let fileDiffType: FileDiff['diffType'] = 'modified'; let diffFilePath: string; + let previousFilePath: string | undefined; const oldPath = oldFilePath ?? ''; if (oldPath === '/dev/null') { - // New file: use the new file path fileDiffType = 'added'; diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; } else if (newFilePath === '/dev/null') { - // Removed file: use the old file path fileDiffType = 'removed'; diffFilePath = oldPath.startsWith('a/') ? oldPath.slice(2) : oldPath; + } else if (renameFromPath) { + fileDiffType = 'renamed'; + diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; + previousFilePath = renameFromPath; } else { - // Modified file: use the new file path fileDiffType = 'modified'; diffFilePath = newFilePath.startsWith('b/') ? newFilePath.slice(2) : newFilePath; } @@ -213,6 +230,7 @@ class Git { currentFile = { filePath: diffFilePath, diffType: fileDiffType, + previousFilePath, hunks: [], addedLines: new Set(), removedLines: new Set(), @@ -374,7 +392,7 @@ class Git { } try { - log(`๐Ÿ”„ Fetching missing ref: ${ref}`); + console.log(`๐Ÿ”„ Fetching missing ref: ${ref}`); await exec(`git fetch ${remote} ${ref} --no-tags --depth=1 --quiet`); // Verify the ref is now available @@ -458,22 +476,40 @@ class Git { } } - static async getChangedFileNames(fromRef: string, toRef?: string, shouldIncludeUntrackedFiles = false): Promise { + /** + * Get changed files with their status (added, modified, removed, renamed). + * In CI, uses the GitHub API with pagination for accuracy. + * Locally, uses git diff against the provided ref. + */ + static async getChangedFilesWithStatus(fromRef: string, toRef?: string, shouldIncludeUntrackedFiles = false): Promise { if (IS_CI) { - const {data: changedFiles} = await GitHubUtils.octokit.pulls.listFiles({ + const files = await GitHubUtils.paginate(GitHubUtils.octokit.pulls.listFiles, { owner: CONST.GITHUB_OWNER, repo: CONST.APP_REPO, // eslint-disable-next-line @typescript-eslint/naming-convention pull_number: context.payload.pull_request?.number ?? 0, + // eslint-disable-next-line @typescript-eslint/naming-convention + per_page: 100, }); - return changedFiles.map((file) => file.filename); + return files.map((file) => ({ + filename: file.filename, + status: file.status as 'added' | 'modified' | 'removed' | 'renamed', + previousFilename: file.previous_filename, + })); } - // Get the diff output and check status const diffResult = this.diff(fromRef, toRef, undefined, shouldIncludeUntrackedFiles); - const files = diffResult.files.map((file) => file.filePath); - return files; + return diffResult.files.map((file) => ({ + filename: file.filePath, + status: file.diffType, + previousFilename: file.previousFilePath, + })); + } + + static async getChangedFileNames(fromRef: string, toRef?: string, shouldIncludeUntrackedFiles = false): Promise { + const files = await this.getChangedFilesWithStatus(fromRef, toRef, shouldIncludeUntrackedFiles); + return files.map((file) => file.filename); } /** @@ -583,4 +619,4 @@ class Git { } export default Git; -export type {DiffResult, FileDiff, DiffHunk, DiffLine}; +export type {DiffResult, FileDiff, DiffHunk, DiffLine, ChangedFile}; diff --git a/scripts/utils/Logger.ts b/scripts/utils/Logger.ts index dc5a366ca565..aac4a279b8c4 100644 --- a/scripts/utils/Logger.ts +++ b/scripts/utils/Logger.ts @@ -15,40 +15,34 @@ const EMOJIS = { ERROR: '๐Ÿ”ด', }; -const log = (...args: unknown[]) => { - console.debug(...args); -}; - const info = (...args: unknown[]) => { - const lines = [EMOJIS.INFO, ...args]; - log(...lines); + console.log(EMOJIS.INFO, ...args); }; const bold = (...args: unknown[]) => { - const lines = [COLOR_BOLD, ...args, COLOR_RESET]; - log(...lines); + console.log(COLOR_BOLD, ...args, COLOR_RESET); }; const success = (...args: unknown[]) => { - const lines = [`${EMOJIS.SUCCESS}${COLOR_GREEN}`, ...args, COLOR_RESET]; - log(...lines); + console.log(`${EMOJIS.SUCCESS}${COLOR_GREEN}`, ...args, COLOR_RESET); }; const warn = (...args: unknown[]) => { - const lines = [`${EMOJIS.WARN}${COLOR_YELLOW}`, ...args, COLOR_RESET]; - log(...lines); + console.warn(`${EMOJIS.WARN}${COLOR_YELLOW}`, ...args, COLOR_RESET); }; const note = (...args: unknown[]) => { - const lines = [COLOR_DIM, ...args, COLOR_RESET]; - log(...lines); + console.log(COLOR_DIM, ...args, COLOR_RESET); }; const error = (...args: unknown[]) => { - const lines = [`${EMOJIS.ERROR}${COLOR_RED}`, ...args, COLOR_RESET]; - log(...lines); + console.error(`${EMOJIS.ERROR}${COLOR_RED}`, ...args, COLOR_RESET); +}; + +const errorDetail = (...args: unknown[]) => { + console.error(` ${COLOR_RED}โ†ณ`, ...args, COLOR_RESET); }; const formatLink = (name: string | number, url: string) => `\x1b]8;;${url}\x1b\\${name}\x1b]8;;\x1b\\`; -export {log, info, warn, note, error, success, formatLink, bold}; +export {info, warn, note, error, errorDetail, success, formatLink, bold}; diff --git a/src/App.tsx b/src/App.tsx index 8ef6d1a55f19..e4772e4d3e42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import {PortalProvider} from '@gorhom/portal'; import * as Sentry from '@sentry/react-native'; +import {maybeCompleteAuthSession} from 'expo-web-browser'; import React from 'react'; import {LogBox, View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; @@ -20,28 +21,19 @@ import FullScreenLoaderContextProvider from './components/FullScreenLoaderContex import HTMLEngineProvider from './components/HTMLEngineProvider'; import InitialURLContextProvider from './components/InitialURLContextProvider'; import {InputBlurContextProvider} from './components/InputBlurContext'; -import {KeyboardDismissibleFlatListContextProvider} from './components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext'; import KeyboardProvider from './components/KeyboardProvider'; -import KYCWallContextProvider from './components/KYCWall/KYCWallContext'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import {ModalProvider} from './components/Modal/Global/ModalContext'; import NavigationBar from './components/NavigationBar'; import OnyxListItemProvider from './components/OnyxListItemProvider'; import PopoverContextProvider from './components/PopoverProvider'; -import {ProductTrainingContextProvider} from './components/ProductTrainingContext'; import SafeArea from './components/SafeArea'; import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider'; -import {SearchRouterContextProvider} from './components/Search/SearchRouter/SearchRouterContext'; import SidePanelContextProvider from './components/SidePanel/SidePanelContextProvider'; import SVGDefinitionsProvider from './components/SVGDefinitionsProvider'; import ThemeIllustrationsProvider from './components/ThemeIllustrationsProvider'; import ThemeProvider from './components/ThemeProvider'; import ThemeStylesProvider from './components/ThemeStylesContextProvider'; -import FullScreenContextProvider from './components/VideoPlayerContexts/FullScreenContextProvider'; -import {PlaybackContextProvider} from './components/VideoPlayerContexts/PlaybackContext'; -import {VideoPopoverMenuContextProvider} from './components/VideoPlayerContexts/VideoPopoverMenuContext'; -import {VolumeContextProvider} from './components/VideoPlayerContexts/VolumeContext'; -import WideRHPContextProvider from './components/WideRHPContextProvider'; import {KeyboardStateProvider} from './components/withKeyboardState'; import CONFIG from './CONFIG'; import CONST from './CONST'; @@ -51,15 +43,15 @@ import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop'; import HybridAppHandler from './HybridAppHandler'; import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; import './libs/HybridApp'; -import {AttachmentModalContextProvider} from './pages/media/AttachmentModalScreen/AttachmentModalContext'; -import ExpensifyCardContextProvider from './pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardContextProvider'; -import TravelCVVContextProvider from './pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider'; import './setup/backgroundLocationTrackingTask'; import './setup/backgroundTask'; import './setup/fraudProtection'; import './setup/hybridApp'; import {SplashScreenStateContextProvider} from './SplashScreenStateContext'; +// This is needed to close pop-up window during logout for users logged in via SSO +maybeCompleteAuthSession(); + LogBox.ignoreLogs([ // Basically it means that if the app goes in the background and back to foreground on Android, // the timer is lost. Currently Expensify is using a 30 minutes interval to refresh personal details. @@ -111,30 +103,18 @@ function App() { PopoverContextProvider, CurrentReportIDContextProvider, ScrollOffsetContextProvider, - AttachmentModalContextProvider, PickerStateProvider, EnvironmentProvider, CustomStatusBarAndBackgroundContextProvider, ActiveElementRoleProvider, ActionSheetAwareScrollViewProvider, - PlaybackContextProvider, - FullScreenContextProvider, - VolumeContextProvider, - VideoPopoverMenuContextProvider, KeyboardProvider, KeyboardStateProvider, - KeyboardDismissibleFlatListContextProvider, - SearchRouterContextProvider, - ProductTrainingContextProvider, InputBlurContextProvider, FullScreenBlockingViewContextProvider, FullScreenLoaderContextProvider, ModalProvider, SidePanelContextProvider, - ExpensifyCardContextProvider, - TravelCVVContextProvider, - KYCWallContextProvider, - WideRHPContextProvider, ]} > diff --git a/src/CONST/LOCALES.ts b/src/CONST/LOCALES.ts index 004e51725e47..0ebe70e3a4bb 100644 --- a/src/CONST/LOCALES.ts +++ b/src/CONST/LOCALES.ts @@ -83,7 +83,6 @@ function isTranslationTargetLocale(locale: string): locale is TranslationTargetL } export { - BETA_LOCALES, EXTENDED_LOCALES, FULLY_SUPPORTED_LOCALES, LOCALES, diff --git a/src/CONST/index.ts b/src/CONST/index.ts index fd1f437b73f8..e72e95694c5d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -148,7 +148,12 @@ const onboardingInviteTypes = { } as const; const onboardingCompanySize = { + MICRO_SMALL: '1-4', + MICRO_MEDIUM: '5-10', + + // This range is deprecated in favor of the smaller ranges above, but the constant is kept to compare against saved data for backwards compatibility. MICRO: '1-10', + SMALL: '11-50', MEDIUM_SMALL: '51-100', MEDIUM: '101-1000', @@ -234,6 +239,8 @@ const CONST = { }, ANIMATION_IN_TIMING: 100, COMPOSER_FOCUS_DELAY: 150, + MAX_TRANSITION_DURATION_MS: 1000, + MAX_TRANSITION_START_WAIT_MS: 1000, ANIMATION_DIRECTION: { IN: 'in', OUT: 'out', @@ -290,6 +297,12 @@ const CONST = { START: 'start', STOP: 'stop', }, + OOO_DURATION_UNITS: { + HOUR: 'hours', + DAY: 'days', + WEEK: 'weeks', + MONTH: 'months', + }, }, RECEIPT_CAMERA: { @@ -867,15 +880,14 @@ const CONST = { TRAVEL_INVOICING: 'travelInvoicing', EXPENSIFY_CARD_EU_UK: 'expensifyCardEuUk', EUR_BILLING: 'eurBilling', - NO_OPTIMISTIC_TRANSACTION_THREADS: 'noOptimisticTransactionThreads', UBER_FOR_BUSINESS: 'uberForBusiness', PAY_INVOICE_VIA_EXPENSIFY: 'payInvoiceViaExpensify', - PERSONAL_CARD_IMPORT: 'personalCardImport', SUGGESTED_FOLLOWUPS: 'suggestedFollowups', - FREEZE_CARD: 'freezeCard', GUSTO: 'gustoNewDot', + BULK_DUPLICATE_REPORT: 'bulkDuplicateReport', BULK_EDIT: 'bulkEdit', NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', + BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', }, BUTTON_STATES: { DEFAULT: 'default', @@ -1303,6 +1315,7 @@ const CONST = { MAX_COUNT_BEFORE_FOCUS_UPDATE: 30, MIN_INITIAL_REPORT_ACTION_COUNT: 15, UNREPORTED_REPORT_ID: '0', + TRASH_REPORT_ID: '-1', SPLIT_REPORT_ID: '-2', SECONDARY_ACTIONS: { SUBMIT: 'submit', @@ -1396,6 +1409,10 @@ const CONST = { PAY: 'pay', FIX: 'fix', }, + ACTION_TYPES_FOR_ASSIGNEE_TO_COMPLETE: { + EXPENSE: 'expense', + TASK: 'task', + }, ACTIONS: { LIMIT: 50, // OldDot Actions render getMessage from Web-Expensify/lib/Report/Action PHP files via getMessageOfOldDotReportAction in ReportActionsUtils.ts @@ -1888,6 +1905,7 @@ const CONST = { }, DEFERRED_LAYOUT_WRITE_KEYS: { SEARCH: 'search', + DISMISS_MODAL: 'dismiss_modal', }, TELEMETRY: { CONTEXT_FULLSTORY: 'Fullstory', @@ -1964,6 +1982,9 @@ const CONST = { SPAN_SEND_MESSAGE: 'ManualSendMessage', SPAN_NOT_FOUND_PAGE: 'ManualNotFoundPage', SPAN_SKELETON: 'ManualSkeleton', + SPAN_ODOMETER_TO_CONFIRMATION: 'ManualOdometerToConfirmation', + SPAN_ODOMETER_IMAGE_STITCH: 'ManualOdometerImageStitch', + SPAN_ODOMETER_IMAGE_CAPTURE: 'ManualOdometerImageCapture', SPAN_NAVIGATION_ROOT_READY: 'NavigationRootReady', SPAN_BOOTSPLASH: { ROOT: 'BootsplashVisible', @@ -2010,18 +2031,33 @@ const CONST = { ATTRIBUTE_IS_FROM_GLOBAL_CREATE: 'is_from_global_create', /** Sentry span attribute: follow-up action taken after submit (e.g. dismiss_modal_and_open_report, navigate_to_search). */ ATTRIBUTE_SUBMIT_FOLLOW_UP_ACTION: 'submit_follow_up_action', + ATTRIBUTE_FAST_PATH_HANDLER: 'fast_path_handler', ATTRIBUTE_COMMAND: 'command', ATTRIBUTE_JSON_CODE: 'json_code', ATTRIBUTE_COLD_START: 'cold_start', ATTRIBUTE_TRIGGER: 'trigger', ATTRIBUTE_PLATFORM: 'platform', ATTRIBUTE_IS_MULTI_SCAN: 'is_multi_scan', + ATTRIBUTE_ODOMETER_IMAGE_TYPE: 'odometer_image_type', /** Follow-up action after expense submit (action-based; used as submit_follow_up_action in span). */ SUBMIT_FOLLOW_UP_ACTION: { DISMISS_MODAL_AND_OPEN_REPORT: 'dismiss_modal_and_open_report', NAVIGATE_TO_SEARCH: 'navigate_to_search', DISMISS_MODAL_ONLY: 'dismiss_modal_only', }, + FAST_PATH_HANDLER: { + SEARCH_PRE_INSERT: 'search_pre_insert', + REPORT_PRE_INSERT: 'report_pre_insert', + DISMISS_MODAL: 'dismiss_modal', + REPORT_IN_RHP_DISMISS: 'report_in_rhp_dismiss', + SEARCH_DISMISS: 'search_dismiss', + DEFAULT: 'default', + }, + SUBMIT_OPTIMIZATION: { + PRE_INSERT: 'pre_insert', + DISMISS_FIRST: 'dismiss_first', + DEFERRED_WRITE: 'deferred_write', + }, /** Trigger for useSubmitToDestinationVisible: end span on focus vs on layout. */ SUBMIT_TO_DESTINATION_VISIBLE_TRIGGER: { FOCUS: 'focus', @@ -2205,14 +2241,11 @@ const CONST = { MAX_RETRY_WAIT_TIME_MS: 10 * 1000, PROCESS_REQUEST_DELAY_MS: 1000, MAX_PENDING_TIME_MS: 10 * 1000, - RECHECK_INTERVAL_MS: 60 * 1000, MAX_REQUEST_RETRIES: 10, MAX_OPEN_APP_REQUEST_RETRIES: 2, - NETWORK_STATUS: { - ONLINE: 'online', - OFFLINE: 'offline', - UNKNOWN: 'unknown', - }, + SUSTAINED_FAILURE_THRESHOLD_COUNT: 3, + SUSTAINED_FAILURE_WINDOW_MS: 10 * 1000, + RECONNECT_STAMPEDE_JITTER_MS: 5000, }, // The number of milliseconds for an idle session to expire SESSION_EXPIRATION_TIME_MS: 2 * 3600 * 1000, // 2 hours @@ -2220,7 +2253,6 @@ const CONST = { DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, - DEFAULT_NETWORK_DATA: {isOffline: false}, FORMS: { LOGIN_FORM: 'LoginForm', VALIDATE_CODE_FORM: 'ValidateCodeForm', @@ -2534,6 +2566,7 @@ const CONST = { SUGGESTER_INNER_PADDING: 8, SUGGESTION_ROW_HEIGHT: 40, SMALL_CONTAINER_HEIGHT_FACTOR: 2.5, + SMALL_CONTAINER_HEIGHT_FACTOR_LANDSCAPE_MODE: 1.5, MAX_AMOUNT_OF_SUGGESTIONS: 20, MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER: 5, HERE_TEXT: '@here', @@ -2730,6 +2763,14 @@ const CONST = { }, }, + GUSTO: { + APPROVAL_MODE: { + BASIC: 'basic', + MANAGER: 'manager', + CUSTOM: 'custom', + }, + }, + QUICKBOOKS_REIMBURSABLE_ACCOUNT_TYPE: { VENDOR_BILL: 'bill', CHECK: 'check', @@ -2777,6 +2818,7 @@ const CONST = { REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'reimbursableExpensesExportDestination', NON_REIMBURSABLE_EXPENSES_EXPORT_DESTINATION: 'nonreimbursableExpensesExportDestination', DEFAULT_VENDOR: 'defaultVendor', + TRAVEL_INVOICING_PAYABLE_ACCOUNT: 'travelInvoicingPayableAccountID', REIMBURSABLE_PAYABLE_ACCOUNT: 'reimbursablePayableAccount', PAYABLE_ACCT: 'payableAcct', JOURNAL_POSTING_PREFERENCE: 'journalPostingPreference', @@ -3236,6 +3278,9 @@ const CONST = { isLoading: false, errors: {}, }, + // Redirect URI used by the native Plaid SDK on iOS. It is not a registered app route โ€” + // the SDK handles the OAuth callback itself, so deep-link handlers must ignore this path. + OAUTH_REDIRECT_PATH_IOS: 'partners/plaid/oauth_ios', }, ONFIDO: { @@ -3568,6 +3613,7 @@ const CONST = { IS_TRAVEL_ENABLED: 'isTravelEnabled', REQUIRE_COMPANY_CARDS_ENABLED: 'requireCompanyCardsEnabled', IS_TIME_TRACKING_ENABLED: 'isTimeTrackingEnabled', + IS_HR_ENABLED: 'isHREnabled', }, DEFAULT_CATEGORIES: { ADVERTISING: 'Advertising', @@ -3677,8 +3723,14 @@ const CONST = { microsoftDynamics: 'Microsoft Dynamics', other: 'Other', }, + get ACCOUNTING_CONNECTION_NAMES() { + return [this.NAME.QBO, this.NAME.QBD, this.NAME.XERO, this.NAME.NETSUITE, this.NAME.SAGE_INTACCT, this.NAME.CERTINIA] as const; + }, + get HR_CONNECTION_NAMES() { + return [this.NAME.GUSTO] as const; + }, get EXPORTED_TO_INTEGRATION_DISPLAY_NAMES(): string[] { - return Object.values(this.NAME).map((name) => this.NAME_USER_FRIENDLY[name as keyof typeof this.NAME_USER_FRIENDLY]); + return this.ACCOUNTING_CONNECTION_NAMES.map((name) => this.NAME_USER_FRIENDLY[name as keyof typeof this.NAME_USER_FRIENDLY]); }, CORPORATE: ['quickbooksDesktop', 'netsuite', 'intacct', 'oracle', 'sap', 'microsoftDynamics', 'other'], AUTH_HELP_LINKS: { @@ -3691,6 +3743,7 @@ const CONST = { STARTING_IMPORT_QBO: 'startingImportQBO', STARTING_IMPORT_XERO: 'startingImportXero', STARTING_IMPORT_QBD: 'startingImportQBD', + STARTING_IMPORT_GUSTO: 'startingImportGusto', QBO_IMPORT_MAIN: 'quickbooksOnlineImportMain', QBO_IMPORT_CUSTOMERS: 'quickbooksOnlineImportCustomers', QBO_IMPORT_EMPLOYEES: 'quickbooksOnlineImportEmployees', @@ -3756,6 +3809,10 @@ const CONST = { SAGE_INTACCT_SYNC_IMPORT_EMPLOYEES: 'intacctImportEmployees', SAGE_INTACCT_SYNC_IMPORT_DIMENSIONS: 'intacctImportDimensions', SAGE_INTACCT_SYNC_IMPORT_SYNC_REIMBURSED_REPORTS: 'intacctImportSyncBillPayments', + GUSTO_SYNC_LOAD_COMPANY: 'gustoSyncLoadCompany', + GUSTO_SYNC_IMPORT_EMPLOYEES: 'gustoSyncImportEmployees', + GUSTO_SYNC_BUILD_APPROVAL_CHAINS: 'gustoSyncBuildApprovalChains', + GUSTO_SYNC_FINALIZE: 'gustoSyncFinalize', }, SYNC_STAGE_TIMEOUT_MINUTES: 20, }, @@ -3769,6 +3826,15 @@ const CONST = { DEFAULT_MAX_EXPENSE_AMOUNT: 200000, DEFAULT_MAX_AMOUNT_NO_RECEIPT: 2500, DEFAULT_MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 7500, + DEFAULT_PROHIBITED_EXPENSES: { + alcohol: false, + hotelIncidentals: false, + gambling: true, + tobacco: false, + adultEntertainment: true, + }, + DEFAULT_BILLABLE: false, + DEFAULT_REIMBURSABLE: true, DEFAULT_TAG_LIST: { Tag: { name: 'Tag', @@ -3879,6 +3945,7 @@ const CONST = { AMEX_DIRECT: 'oauth.americanexpressfdx.com', AMEX_FILE_DOWNLOAD: 'americanexpressfd.us', CSV: 'ccupload', + CSV_CLASSIC: 'csv', MOCK_BANK: 'oauth.mockbank.com', UPLOAD: 'upload', }, @@ -6607,6 +6674,7 @@ const CONST = { RHP_CONCIERGE_DM: 'rhpConciergeDm', RHP_ADMINS_ROOM: 'rhpAdminsRoom', RHP_HOME_PAGE: 'rhpHomePage', + TRACK_EXPENSES_WITH_CONCIERGE: 'trackExpensesWithConcierge', CONTROL: 'control', }, ONBOARDING_JOINABLE_WORKSPACES_LIMIT: 5, @@ -7461,6 +7529,7 @@ const CONST = { DONE: 'done', EXPORT_TO_ACCOUNTING: 'exportToAccounting', PAID: 'paid', + UNDELETE: 'undelete', }, HAS_VALUES: { RECEIPT: 'receipt', @@ -7484,7 +7553,10 @@ const CONST = { CHANGE_REPORT: 'changeReport', SPLIT: 'split', DUPLICATE: 'duplicate', + DUPLICATE_REPORT: 'duplicateReport', + UNDELETE: 'undelete', }, + BULK_DUPLICATE_LIMIT: 50, TRANSACTION_TYPE: { CASH: 'cash', CARD: 'card', @@ -7664,8 +7736,6 @@ const CONST = { this.TABLE_COLUMNS.MERCHANT, this.TABLE_COLUMNS.FROM, this.TABLE_COLUMNS.CATEGORY, - this.TABLE_COLUMNS.ATTENDEES, - this.TABLE_COLUMNS.TOTAL_PER_ATTENDEE, this.TABLE_COLUMNS.TAG, this.TABLE_COLUMNS.TOTAL_AMOUNT, ], @@ -7722,6 +7792,7 @@ const CONST = { APPROVED: 'approved', DONE: 'done', PAID: 'paid', + DELETED: 'deleted', }, EXPENSE_REPORT: { ALL: '', @@ -8056,7 +8127,6 @@ const CONST = { SEARCH_KEYS: { EXPENSES: 'expenses', REPORTS: 'reports', - CHATS: 'chats', SUBMIT: 'submit', APPROVE: 'approve', PAY: 'pay', @@ -8064,7 +8134,6 @@ const CONST = { STATEMENTS: 'statements', UNAPPROVED_CASH: 'unapprovedCash', UNAPPROVED_CARD: 'unapprovedCard', - EXPENSIFY_CARD: 'expensifyCard', RECONCILIATION: 'reconciliation', TOP_SPENDERS: 'topSpenders', TOP_CATEGORIES: 'topCategories', @@ -8313,6 +8382,14 @@ const CONST = { description: 'workspace.upgrade.perDiem.description' as const, icon: 'PerDiem', }, + hr: { + id: 'hr' as const, + alias: 'hr', + name: 'HR', + title: 'workspace.upgrade.hr.title' as const, + description: 'workspace.upgrade.hr.description' as const, + icon: 'Members', + }, travel: { id: 'travel' as const, alias: 'travel', @@ -8689,7 +8766,6 @@ const CONST = { }, MODAL_EVENTS: { - CLOSED: 'modalClosed', DISABLE_RHP_ANIMATION: 'disableRHPAnimation', RESTORE_RHP_ANIMATION: 'restoreRHPAnimation', }, @@ -8709,6 +8785,9 @@ const CONST = { }, SENTRY_LABEL: { + BILLING_BANNER: { + RIGHT_ICON: 'BillingBanner-RightIcon', + }, NAVIGATION_TAB_BAR: { EXPENSIFY_LOGO: 'NavigationTabBar-ExpensifyLogo', INBOX: 'NavigationTabBar-Inbox', @@ -8804,22 +8883,11 @@ const CONST = { SELECT_ALL_BUTTON: 'Search-SelectAllButton', TYPE_MENU_BUTTON: 'Search-TypeMenuButton', FILTER_DISPLAY: 'Search-FilterDisplay', - FILTER_TYPE: 'Search-FilterType', - FILTER_STATUS: 'Search-FilterStatus', - FILTER_DATE: 'Search-FilterDate', - FILTER_FROM: 'Search-FilterFrom', - FILTER_WORKSPACE: 'Search-FilterWorkspace', FILTER_GROUP_BY: 'Search-FilterGroupBy', FILTER_SORT_BY: 'Search-FilterSortBy', FILTER_GROUP_CURRENCY: 'Search-FilterGroupCurrency', FILTER_VIEW: 'Search-FilterView', FILTER_LIMIT: 'Search-FilterLimit', - FILTER_FEED: 'Search-FilterFeed', - FILTER_POSTED: 'Search-FilterPosted', - FILTER_WITHDRAWN: 'Search-FilterWithdrawn', - FILTER_WITHDRAWAL_TYPE: 'Search-FilterWithdrawalType', - FILTER_HAS: 'Search-FilterHas', - FILTER_IS: 'Search-FilterIs', ADVANCED_FILTERS_BUTTON: 'Search-AdvancedFiltersButton', COLUMNS_BUTTON: 'Search-ColumnsButton', SELECT_ALL_MATCHING_BUTTON: 'Search-SelectAllMatchingButton', @@ -9214,6 +9282,7 @@ const CONST = { MEMBERS: 'WorkspaceInitial-Members', REPORTS: 'WorkspaceInitial-Reports', ACCOUNTING: 'WorkspaceInitial-Accounting', + HR: 'WorkspaceInitial-HR', RECEIPT_PARTNERS: 'WorkspaceInitial-ReceiptPartners', CATEGORIES: 'WorkspaceInitial-Categories', TAGS: 'WorkspaceInitial-Tags', @@ -9488,11 +9557,14 @@ const CONST = { AUTHENTICATE_PAYMENT: 'SettingsSubscription-AuthenticatePayment', VIEW_PAYMENT_HISTORY: 'SettingsSubscription-ViewPaymentHistory', REQUEST_REFUND: 'SettingsSubscription-RequestRefund', - REQUEST_EARLY_CANCELLATION: 'SettingsSubscription-RequestEarlyCancellation', + CANCEL_SUBSCRIPTION: 'SettingsSubscription-CancelSubscription', }, SETTINGS_HELP: { CONCIERGE_CHAT: 'SettingsHelp-ConciergeChat', HELP_DOCS: 'SettingsHelp-HelpDocs', + ACCOUNT_MANAGER: 'SettingsHelp-AccountManager', + PARTNER_MANAGER: 'SettingsHelp-PartnerManager', + GUIDE: 'SettingsHelp-Guide', }, SETTINGS_ABOUT: { APP_DOWNLOAD_LINKS: 'SettingsAbout-AppDownloadLinks', @@ -9534,6 +9606,7 @@ const CONST = { }, BULK_ACTION_TYPES: { CLOSE_ACCOUNT: 'closeAccount', + MOVE_TO_GROUP: 'moveToGroup', }, }, }, @@ -9546,12 +9619,6 @@ const CONST = { HOME: { ANNOUNCEMENTS: [ - { - title: 'Smarter spend controls & Concierge anywhere', - subtitle: 'Product update', - url: 'https://use.expensify.com/blog/expensify-february-2026-product-update', - publishedDate: '2026-02-19', - }, { title: 'More power, right where you need it', subtitle: 'Product update', @@ -9564,6 +9631,12 @@ const CONST = { url: 'https://use.expensify.com/blog/expensify-new-integrations-march-2026', publishedDate: '2026-03-25', }, + { + title: 'Smarter cards, mileage, and approvals', + subtitle: 'Product update', + url: 'https://use.expensify.com/blog/expensify-april-2026-product-update', + publishedDate: '2026-04-15', + }, ], }, @@ -9576,7 +9649,15 @@ const CONST = { AUTH_IMAGES: 'auth-images', }, + MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO: 0.9, MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE: 0.75, + + MAP_VIEW_LAYERS: { + USER_LOCATION_SOURCE: 'user-location-source', + USER_LOCATION: 'user-location', + ROUTE_SOURCE: 'route-source', + ROUTE_FILL: 'route-fill', + }, } as const; const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [ diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 9c5f021b57bb..70f8a81666f6 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -24,7 +24,8 @@ import Log from './libs/Log'; import migrateOnyx from './libs/migrateOnyx'; import Navigation from './libs/Navigation/Navigation'; import NavigationRoot from './libs/Navigation/NavigationRoot'; -import NetworkConnection from './libs/NetworkConnection'; +// This lib needs to be imported for its module-level NetInfo and Onyx subscriptions +import './libs/NetworkState'; import PushNotification from './libs/Notification/PushNotification'; import {endSpan, getSpan, startSpan} from './libs/telemetry/activeSpans'; import type {BootsplashGateStatus} from './libs/telemetry/bootsplashTelemetry'; @@ -34,7 +35,6 @@ import Visibility from './libs/Visibility'; import ONYXKEYS from './ONYXKEYS'; import PriorityModeHandler from './PriorityModeHandler'; import type {Route} from './ROUTES'; -import {accountIDSelector} from './selectors/Session'; import {useSplashScreenActions, useSplashScreenState} from './SplashScreenStateContext'; Onyx.registerLogger(({level, message, parameters}) => { @@ -56,7 +56,6 @@ function Expensify() { const {setSplashScreenState} = useSplashScreenActions(); const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false); const {preferredLocale} = useLocalize(); - const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE); const [isCheckingPublicRoom = true] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, {initWithStoredValues: false}); const [updateRequired] = useOnyx(ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED); @@ -210,12 +209,7 @@ function Expensify() { useLayoutEffect(() => { // Initialize this client as being an active client ActiveClientManager.init(); - - // Used for the offline indicator appearing when someone is offline - const unsubscribeNetInfo = NetworkConnection.subscribeToNetInfo(accountID); - - return unsubscribeNetInfo; - }, [accountID]); + }, []); // Log the platform and config to debug .env issues useEffect(() => { diff --git a/src/NAVIGATORS.ts b/src/NAVIGATORS.ts index b96cc6959559..23f0755290cc 100644 --- a/src/NAVIGATORS.ts +++ b/src/NAVIGATORS.ts @@ -19,4 +19,5 @@ export default { SEARCH_FULLSCREEN_NAVIGATOR: 'SearchFullscreenNavigator', SHARE_MODAL_NAVIGATOR: 'ShareModalNavigator', TEST_TOOLS_MODAL_NAVIGATOR: 'TestToolsModalNavigator', + TAB_NAVIGATOR: 'TabNavigator', } as const; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0fa0a003371b..3d21a3c0529a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -31,7 +31,7 @@ const ONYXKEYS = { DEVICE_ID: 'deviceID', /** Boolean flag set whenever the sidebar has loaded */ - IS_SIDEBAR_LOADED: 'isSidebarLoaded', + RAM_ONLY_IS_SIDEBAR_LOADED: 'isSidebarLoaded', /** Boolean flag set whenever we are searching for reports in the server */ RAM_ONLY_IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports', @@ -210,7 +210,7 @@ const ONYXKEYS = { NVP_PREFERRED_LOCALE: 'nvp_preferredLocale', /** Whether the app is currently loading a translation */ - ARE_TRANSLATIONS_LOADING: 'areTranslationsLoading', + RAM_ONLY_ARE_TRANSLATIONS_LOADING: 'areTranslationsLoading', /** Whether the user has tried focus mode yet */ NVP_TRY_FOCUS_MODE: 'nvp_tryFocusMode', @@ -272,6 +272,9 @@ const ONYXKEYS = { /** ID associated with the payment card added by the user. */ NVP_BILLING_FUND_ID: 'nvp_expensify_billingFundID', + /** The user's freebie credits balance (in cents). */ + NVP_PRIVATE_FREEBIE_CREDITS: 'nvp_private_freebieCredits', + /** The amount owed by the workspaceโ€™s owner. */ NVP_PRIVATE_AMOUNT_OWED: 'nvp_private_amountOwed', @@ -426,6 +429,9 @@ const ONYXKEYS = { /** A map of the user's security group IDs they belong to in specific domains */ MY_DOMAIN_SECURITY_GROUPS: 'myDomainSecurityGroups', + /** Selected domain member account IDs for the move-to-group operation */ + DOMAIN_MEMBERS_SELECTED_FOR_MOVE: 'domainMembersSelectedForMove', + // The theme setting set by the user in preferences. // This can be either "light", "dark", "system", "light-contrast", "dark-contrast" or "system-contrast" PREFERRED_THEME: 'nvp_preferredTheme', @@ -547,7 +553,7 @@ const ONYXKEYS = { ASSIGN_CARD: 'assignCard', /** Stores the information if mobile selection mode is active */ - MOBILE_SELECTION_MODE: 'mobileSelectionMode', + RAM_ONLY_MOBILE_SELECTION_MODE: 'mobileSelectionMode', NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails', @@ -804,9 +810,6 @@ const ONYXKEYS = { /** Stores the information about the state of issuing a new card */ ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard_', - /** Used for identifying user as admin of a domain */ - SHARED_NVP_PRIVATE_ADMIN_ACCESS: 'sharedNVP_private_admin_access_', - /** Stored the user information with whom bank account is being shared */ BANK_ACCOUNT_SHARE_DETAILS: 'expensify_bankAccountShare_', @@ -890,6 +893,8 @@ const ONYXKEYS = { ONBOARDING_WORKSPACE_DETAILS_FORM_DRAFT: 'onboardingWorkspaceDetailsFormDraft', ROOM_NAME_FORM: 'roomNameForm', ROOM_NAME_FORM_DRAFT: 'roomNameFormDraft', + CHRONOS_SCHEDULE_OOO_FORM: 'chronosScheduleOOOForm', + CHRONOS_SCHEDULE_OOO_FORM_DRAFT: 'chronosScheduleOOOFormDraft', REPORT_DESCRIPTION_FORM: 'reportDescriptionForm', REPORT_DESCRIPTION_FORM_DRAFT: 'reportDescriptionFormDraft', LEGAL_NAME_FORM: 'legalNameForm', @@ -964,8 +969,8 @@ const ONYXKEYS = { PERSONAL_BANK_ACCOUNT_FORM_DRAFT: 'personalBankAccountDraft', DISABLE_AUTO_RENEW_SURVEY_FORM: 'disableAutoRenewSurveyForm', DISABLE_AUTO_RENEW_SURVEY_FORM_DRAFT: 'disableAutoRenewSurveyFormDraft', - REQUEST_EARLY_CANCELLATION_FORM: 'requestEarlyCancellationForm', - REQUEST_EARLY_CANCELLATION_FORM_DRAFT: 'requestEarlyCancellationFormDraft', + CANCEL_SUBSCRIPTION_FORM: 'cancelSubscriptionForm', + CANCEL_SUBSCRIPTION_FORM_DRAFT: 'cancelSubscriptionFormDraft', EXIT_SURVEY_RESPONSE_FORM: 'exitSurveyResponseForm', EXIT_SURVEY_RESPONSE_FORM_DRAFT: 'exitSurveyResponseFormDraft', WALLET_ADDITIONAL_DETAILS: 'walletAdditionalDetails', @@ -1004,6 +1009,8 @@ const ONYXKEYS = { EDIT_EXPENSIFY_CARD_NAME_DRAFT_FORM: 'editExpensifyCardNameDraft', EDIT_EXPENSIFY_CARD_LIMIT_FORM: 'editExpensifyCardLimit', EDIT_EXPENSIFY_CARD_LIMIT_DRAFT_FORM: 'editExpensifyCardLimitDraft', + EDIT_TRAVEL_INVOICING_MONTHLY_LIMIT_FORM: 'editTravelInvoicingMonthlyLimit', + EDIT_TRAVEL_INVOICING_MONTHLY_LIMIT_DRAFT_FORM: 'editTravelInvoicingMonthlyLimitDraft', EDIT_EXPENSIFY_CARD_LIMIT_TYPE_FORM: 'editExpensifyCardLimitType', EDIT_EXPENSIFY_CARD_LIMIT_TYPE_DRAFT_FORM: 'editExpensifyCardLimitTypeDraft', SAGE_INTACCT_CREDENTIALS_FORM: 'sageIntacctCredentialsForm', @@ -1078,6 +1085,8 @@ const ONYXKEYS = { ADD_DOMAIN_MEMBER_FORM_DRAFT: 'addDomainMemberFormDraft', ADD_WORK_EMAIL_FORM: 'addWorkEmailForm', ADD_WORK_EMAIL_FORM_DRAFT: 'addWorkEmailFormDraft', + EDIT_DOMAIN_GROUP_NAME_FORM: 'editDomainGroupNameForm', + EDIT_DOMAIN_GROUP_NAME_FORM_DRAFT: 'editDomainGroupNameFormDraft', }, DERIVED: { REPORT_ATTRIBUTES: 'reportAttributes', @@ -1119,6 +1128,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; + [ONYXKEYS.FORMS.CHRONOS_SCHEDULE_OOO_FORM]: FormTypes.ChronosScheduleOOOForm; [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm; [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm; [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.WorkspaceInviteMessageForm; @@ -1130,7 +1140,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.NewTaskForm; [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.EditTaskForm; [ONYXKEYS.FORMS.DISABLE_AUTO_RENEW_SURVEY_FORM]: FormTypes.FeedbackSurveyForm; - [ONYXKEYS.FORMS.REQUEST_EARLY_CANCELLATION_FORM]: FormTypes.FeedbackSurveyForm; + [ONYXKEYS.FORMS.CANCEL_SUBSCRIPTION_FORM]: FormTypes.FeedbackSurveyForm; [ONYXKEYS.FORMS.EXIT_SURVEY_RESPONSE_FORM]: FormTypes.ExitSurveyResponseForm; [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.MoneyRequestDescriptionForm; [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.MoneyRequestMerchantForm; @@ -1183,6 +1193,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ASSIGN_CARD_FORM]: FormTypes.AssignCardForm; [ONYXKEYS.FORMS.EDIT_EXPENSIFY_CARD_NAME_FORM]: FormTypes.EditExpensifyCardNameForm; [ONYXKEYS.FORMS.EDIT_EXPENSIFY_CARD_LIMIT_FORM]: FormTypes.EditExpensifyCardLimitForm; + [ONYXKEYS.FORMS.EDIT_TRAVEL_INVOICING_MONTHLY_LIMIT_FORM]: FormTypes.EditTravelInvoicingMonthlyLimitForm; [ONYXKEYS.FORMS.EDIT_EXPENSIFY_CARD_LIMIT_TYPE_FORM]: FormTypes.EditExpensifyCardLimitTypeForm; [ONYXKEYS.FORMS.SAGE_INTACCT_CREDENTIALS_FORM]: FormTypes.SageIntactCredentialsForm; [ONYXKEYS.FORMS.NETSUITE_CUSTOM_FIELD_FORM]: FormTypes.NetSuiteCustomFieldForm; @@ -1220,6 +1231,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.SPEND_RULE_FORM]: FormTypes.SpendRuleForm; [ONYXKEYS.FORMS.ADD_DOMAIN_MEMBER_FORM]: FormTypes.AddDomainMemberForm; [ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM]: FormTypes.AddWorkEmailForm; + [ONYXKEYS.FORMS.EDIT_DOMAIN_GROUP_NAME_FORM]: FormTypes.DomainGroupEditNameForm; }; type OnyxFormDraftValuesMapping = { @@ -1286,7 +1298,6 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED]: OnyxTypes.FundID; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST]: OnyxTypes.CardOnWaitlist; [ONYXKEYS.COLLECTION.ISSUE_NEW_EXPENSIFY_CARD]: OnyxTypes.IssueNewCard; - [ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_ADMIN_ACCESS]: boolean; [ONYXKEYS.COLLECTION.SAML_METADATA]: OnyxTypes.SamlMetadata; [ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS]: OnyxTypes.DomainPendingActions; [ONYXKEYS.COLLECTION.DOMAIN_ERRORS]: OnyxTypes.DomainErrors; @@ -1309,7 +1320,7 @@ type OnyxValuesMapping = { [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; [ONYXKEYS.DEVICE_ID]: string; - [ONYXKEYS.IS_SIDEBAR_LOADED]: boolean; + [ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED]: boolean; [ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.AnyRequest[]; [ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: OnyxTypes.AnyRequest; [ONYXKEYS.CURRENT_DATE]: string; @@ -1376,7 +1387,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONFIDO_TOKEN]: string; [ONYXKEYS.ONFIDO_APPLICANT_ID]: string; [ONYXKEYS.NVP_PREFERRED_LOCALE]: OnyxTypes.Locale; - [ONYXKEYS.ARE_TRANSLATIONS_LOADING]: boolean; + [ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING]: boolean; [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: string; [ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners; [ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING]: boolean; @@ -1410,11 +1421,11 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_SHARE_BANK_ACCOUNTS]: boolean; [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; + [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; [ONYXKEYS.IS_LOADING_SUBSCRIPTION_DATA]: boolean; [ONYXKEYS.IS_PENDING_UPDATE_PERSONAL_KARMA]: boolean; [ONYXKEYS.IS_SEARCH_PAGE_DATA_LOADED]: boolean; - [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_TEST_TOOLS_MODAL_OPEN]: boolean; [ONYXKEYS.IS_LOADING_APP]: boolean; [ONYXKEYS.HAS_LOADED_APP]: boolean; @@ -1424,6 +1435,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_BETA]: boolean; [ONYXKEYS.IS_CHECKING_PUBLIC_ROOM]: boolean; [ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record; + [ONYXKEYS.DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[]; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; [ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken; @@ -1464,11 +1476,12 @@ type OnyxValuesMapping = { [ONYXKEYS.ADD_NEW_COMPANY_CARD]: OnyxTypes.AddNewCompanyCardFeed; [ONYXKEYS.ADD_NEW_PERSONAL_CARD]: OnyxTypes.AddNewPersonalCard; [ONYXKEYS.ASSIGN_CARD]: OnyxTypes.AssignCard; - [ONYXKEYS.MOBILE_SELECTION_MODE]: boolean; + [ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE]: boolean; [ONYXKEYS.DUPLICATE_WORKSPACE]: OnyxTypes.DuplicateWorkspace; [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string; [ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL]: string; [ONYXKEYS.NVP_BILLING_FUND_ID]: number; + [ONYXKEYS.NVP_PRIVATE_FREEBIE_CREDITS]: number; [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; [ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL]: string | undefined; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 93afc48e0a97..5452f3973804 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -111,29 +111,77 @@ const DYNAMIC_ROUTES = { path: 'visibility', entryScreens: [SCREENS.REPORT_SETTINGS.ROOT], }, + CHANGE_POLICY_EDUCATIONAL: { + path: 'change-workspace-educational', + entryScreens: [ + SCREENS.REPORT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.REPORT_DETAILS.ROOT, + SCREENS.REPORT_CHANGE_WORKSPACE.ROOT, + ], + }, + NETSUITE_AUTO_SYNC: { + path: 'netsuite-autosync', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_ADVANCED, SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION], + }, + NETSUITE_ACCOUNTING_METHOD: { + path: 'netsuite-accounting-method', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_AUTO_SYNC], + }, + SAGE_INTACCT_PREREQUISITES: { + path: 'sage-intacct/prerequisites', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS], + }, POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT: { path: 'vendor/select', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT_EXPENSES], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES], }, POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT: { path: 'payable-account/select', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT_EXPENSES], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES], }, POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT: { path: 'journal-posting-preference/select', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT_EXPENSES], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES], }, POLICY_ACCOUNTING_NETSUITE_RECEIVABLE_ACCOUNT_SELECT: { path: 'receivable-account/select', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT], }, + POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT: { + path: 'invoice-item-preference/select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT], + }, + POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_SELECT: { + path: 'invoice-item/select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT], + }, + POLICY_ACCOUNTING_NETSUITE_PREFERRED_EXPORTER_SELECT: { + path: 'preferred-exporter/select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT], + }, + POLICY_ACCOUNTING_NETSUITE_DATE_SELECT: { + path: 'date/select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT], + }, + POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES: { + path: 'expenses/:expenseType', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.NETSUITE_EXPORT], + getRoute: (expenseType: ValueOf) => `expenses/${expenseType as string}` as const, + }, + POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_DESTINATION_SELECT: { + path: 'destination/select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_NETSUITE_EXPORT_EXPENSES], + }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_PREFERRED_EXPORTER: { path: 'preferred-exporter', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT], }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES: { path: 'out-of-pocket-expense', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT], }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: { path: 'account-select', @@ -145,7 +193,27 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT: { path: 'date-select', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT: { + path: 'quickbooks-online/export', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.COMPANY_CARD_EXPORT], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: { + path: 'company-card-expense-account', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: { + path: 'company-card-expense-account-select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT, SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_CARD_SELECT: { + path: 'card-select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT], + }, + POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECT: { + path: 'invoice-account-select', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_EXPORT], }, POLICY_ACCOUNTING_SAGE_INTACCT_NON_REIMBURSABLE_DESTINATION: { path: 'destination', @@ -161,6 +229,14 @@ const DYNAMIC_ROUTES = { path: 'credit-card-account', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES], }, + POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC: { + path: 'sage-intacct-autosync', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_ADVANCED], + }, + POLICY_ACCOUNTING_SAGE_INTACCT_ACCOUNTING_METHOD: { + path: 'sage-intacct-accounting-method', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_SAGE_INTACCT_AUTO_SYNC, SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION_SAGE_INTACCT_AUTO_SYNC], + }, POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT: { path: 'bank-account-select', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT], @@ -170,13 +246,21 @@ const DYNAMIC_ROUTES = { entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.XERO_EXPORT], }, POLICY_ACCOUNTING_XERO_AUTO_SYNC: { - path: 'autosync', + path: 'xero-autosync', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.XERO_ADVANCED, SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION], }, POLICY_ACCOUNTING_XERO_ACCOUNTING_METHOD: { path: 'accounting-method', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION, SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_XERO_AUTO_SYNC], }, + WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_AUTO_SYNC: { + path: 'quickbooks-online-autosync', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ADVANCED, SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION], + }, + WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD: { + path: 'quickbooks-online-accounting-method', + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.DYNAMIC_QUICKBOOKS_ONLINE_AUTO_SYNC, SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION], + }, WORKSPACE_ACCOUNTING_RECONCILIATION_ACCOUNT_SETTINGS: { path: 'account-reconciliation-settings', entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.CARD_RECONCILIATION, SCREENS.WORKSPACE.EXPENSIFY_CARD_SETTINGS_ACCOUNT], @@ -194,6 +278,10 @@ const DYNAMIC_ROUTES = { getRoute: (country = '') => `country?country=${country}`, queryParams: ['country'], }, + SETTINGS_CATEGORY_EDIT: { + path: 'category-edit', + entryScreens: [SCREENS.SETTINGS_CATEGORIES.SETTINGS_CATEGORY_SETTINGS], + }, NOTIFICATION_PREFERENCES: { path: 'notification-preferences', entryScreens: [SCREENS.REPORT_SETTINGS.ROOT, SCREENS.PROFILE_ROOT], @@ -208,6 +296,14 @@ const DYNAMIC_ROUTES = { path: 'workspace-address', entryScreens: [SCREENS.WORKSPACE.PROFILE], }, + WORKSPACE_CATEGORIES_IMPORT: { + path: 'import', + entryScreens: [SCREENS.WORKSPACE.CATEGORIES], + }, + WORKSPACE_CATEGORIES_IMPORTED: { + path: 'imported', + entryScreens: [SCREENS.WORKSPACE.CATEGORIES], + }, WORKSPACE_INVITE: { path: 'invite', entryScreens: [SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE.MEMBERS], @@ -247,6 +343,18 @@ const DYNAMIC_ROUTES = { }), queryParams: ['fieldName', 'fieldValue', 'policyID'], }, + EXIT_SURVEY_REASON: { + path: 'exit-survey/reason', + entryScreens: [SCREENS.SETTINGS.TROUBLESHOOT], + }, + EXIT_SURVEY_CONFIRM: { + path: 'exit-survey/confirm', + entryScreens: [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_REASON, SCREENS.SETTINGS.TROUBLESHOOT], + }, + REPORT_CHANGE_APPROVER: { + path: 'change-approver', + entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], + }, FLAG_COMMENT: { path: 'flag/:reportID/:reportActionID', entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], @@ -501,7 +609,7 @@ const ROUTES = { SETTINGS_SUBSCRIPTION_CHANGE_BILLING_CURRENCY: 'settings/subscription/change-billing-currency', SETTINGS_SUBSCRIPTION_CHANGE_PAYMENT_CURRENCY: 'settings/subscription/add-payment-card/change-payment-currency', SETTINGS_SUBSCRIPTION_DISABLE_AUTO_RENEW_SURVEY: 'settings/subscription/disable-auto-renew-survey', - SETTINGS_SUBSCRIPTION_REQUEST_EARLY_CANCELLATION: 'settings/subscription/request-early-cancellation-survey', + SETTINGS_SUBSCRIPTION_CANCEL_SUBSCRIPTION: 'settings/subscription/cancel-subscription-survey', SETTINGS_SUBSCRIPTION_DOWNGRADE_BLOCKED: { route: 'settings/subscription/downgrade-blocked', @@ -562,6 +670,10 @@ const ROUTES = { route: 'settings/wallet/card/:cardID?', getRoute: (cardID: string) => `settings/wallet/card/${cardID}` as const, }, + SETTINGS_WALLET_EXPENSIFY_CARD_SPEND_RULES: { + route: 'settings/wallet/expensify-card/spend-rules/:policyID/:ruleID', + getRoute: (policyID: string, ruleID?: string) => `settings/wallet/expensify-card/spend-rules/${policyID}/${ruleID ?? 'new'}` as const, + }, SETTINGS_WALLET_PERSONAL_CARD_DETAILS: { route: 'settings/wallet/personal-card/:cardID', getRoute: (cardID: string | undefined) => { @@ -804,14 +916,6 @@ const ROUTES = { SETTINGS_VACATION_DELEGATE: 'settings/profile/status/vacation-delegate', SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', SETTINGS_HELP: 'settings/help', - SETTINGS_EXIT_SURVEY_REASON: 'settings/exit-survey/reason', - - SETTINGS_EXIT_SURVEY_CONFIRM: { - route: 'settings/exit-survey/confirm', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (backTo?: string) => getUrlWithBackToParam('settings/exit-survey/confirm', backTo), - }, SETTINGS_SAVE_THE_WORLD: 'settings/teachersunite', SETTINGS_SAVE_THE_WORLD_ADD_PAYMENT_CARD: 'settings/teachersunite/add-payment-card', @@ -972,18 +1076,6 @@ const ROUTES = { // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/settings` as const, backTo), }, - REPORT_SETTINGS_NAME: { - route: 'r/:reportID/settings/name', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/settings/name` as const, backTo), - }, - REPORT_CHANGE_APPROVER: { - route: 'r/:reportID/change-approver', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (reportID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/change-approver` as const, backTo), - }, REPORT_CHANGE_APPROVER_ADD_APPROVER: { route: 'r/:reportID/change-approver/add', getRoute: (reportID: string) => `r/${reportID}/change-approver/add` as const, @@ -996,6 +1088,10 @@ const ROUTES = { route: 'r/:reportID/settings/columns', getRoute: (reportID: string) => `r/${reportID}/settings/columns` as const, }, + CHRONOS_SCHEDULE_OOO: { + route: 'r/:reportID/chronos/schedule-ooo', + getRoute: (reportID: string) => `r/${reportID}/chronos/schedule-ooo` as const, + }, SPLIT_BILL_DETAILS: { route: 'r/:reportID/split/:reportActionID', getRoute: (reportID: string | undefined, reportActionID: string, backTo?: string) => { @@ -1141,6 +1237,11 @@ const ROUTES = { return `${action as string}/${iouType as string}/start/${transactionID}/${reportID}` as const; }, }, + MONEY_REQUEST_CREATE_VERIFY_ACCOUNT: { + route: `:action/:iouType/start/:transactionID/:reportID/${VERIFY_ACCOUNT}`, + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => + `${action as string}/${iouType as string}/start/${transactionID}/${reportID}/${VERIFY_ACCOUNT}` as const, + }, MONEY_REQUEST_STEP_SEND_FROM: { route: 'create/:iouType/from/:transactionID/:reportID', @@ -1432,12 +1533,6 @@ const ROUTES = { // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/categories/new`, backTo), }, - SETTINGS_CATEGORY_EDIT: { - route: 'settings/:policyID/category/:categoryName/edit', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, categoryName: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/category/${encodeURIComponent(categoryName)}/edit`, backTo), - }, SETTINGS_CATEGORIES_IMPORT: { route: 'settings/:policyID/categories/import', @@ -1765,23 +1860,6 @@ const ROUTES = { return getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export` as const, backTo, false); }, }, - POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: { - route: 'workspaces/:policyID/accounting/quickbooks-online/export/company-card-expense-account', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export/company-card-expense-account` as const, backTo), - }, - POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: { - route: 'workspaces/:policyID/accounting/quickbooks-online/export/company-card-expense-account/account-select', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export/company-card-expense-account/account-select` as const, backTo); - }, - }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT: { route: 'workspaces/:policyID/accounting/quickbooks-online/export/company-card-expense-account/default-vendor-select', getRoute: (policyID: string | undefined) => { @@ -1802,12 +1880,6 @@ const ROUTES = { return getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export/company-card-expense-account/card-select` as const, backTo); }, }, - POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECT: { - route: 'workspaces/:policyID/accounting/quickbooks-online/export/invoice-account-select', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/quickbooks-online/export/invoice-account-select` as const, backTo), - }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_TRAVEL_INVOICING_CONFIGURATION: { route: 'workspaces/:policyID/accounting/quickbooks-online/export/travel-invoicing', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/quickbooks-online/export/travel-invoicing` as const, @@ -1820,6 +1892,14 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/quickbooks-online/export/travel-invoicing/payable-account', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/quickbooks-online/export/travel-invoicing/payable-account` as const, }, + POLICY_ACCOUNTING_NETSUITE_TRAVEL_INVOICING_CONFIGURATION: { + route: 'workspaces/:policyID/accounting/netsuite/export/travel-invoicing', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/netsuite/export/travel-invoicing` as const, + }, + POLICY_ACCOUNTING_NETSUITE_TRAVEL_INVOICING_PAYABLE_ACCOUNT_SELECT: { + route: 'workspaces/:policyID/accounting/netsuite/export/travel-invoicing/payable-account', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/netsuite/export/travel-invoicing/payable-account` as const, + }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: { route: 'workspaces/:policyID/accounting/quickbooks-desktop/export/company-card-expense-account/account-select', getRoute: (policyID?: string, backTo?: string) => { @@ -2206,18 +2286,6 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/quickbooks-online/invoice-account-selector', getRoute: (policyID: string | undefined) => `workspaces/${policyID}/accounting/quickbooks-online/invoice-account-selector` as const, }, - WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_AUTO_SYNC: { - route: 'workspaces/:policyID/connections/quickbooks-online/advanced/autosync', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string | undefined, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/connections/quickbooks-online/advanced/autosync` as const, backTo), - }, - WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD: { - route: 'workspaces/:policyID/connections/quickbooks-online/advanced/autosync/accounting-method', - getRoute: (policyID: string | undefined, backTo?: string) => - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getUrlWithBackToParam(`workspaces/${policyID}/connections/quickbooks-online/advanced/autosync/accounting-method` as const, backTo), - }, WORKSPACE_ACCOUNTING_CARD_RECONCILIATION: { route: 'workspaces/:policyID/accounting/:connection/card-reconciliation', getRoute: (policyID?: string, connection?: ValueOf) => { @@ -2263,14 +2331,6 @@ const ROUTES = { route: 'workspaces/:policyID/categories/settings', getRoute: (policyID: string) => `workspaces/${policyID}/categories/settings` as const, }, - WORKSPACE_CATEGORIES_IMPORT: { - route: 'workspaces/:policyID/categories/import', - getRoute: (policyID: string) => `workspaces/${policyID}/categories/import` as const, - }, - WORKSPACE_CATEGORIES_IMPORTED: { - route: 'workspaces/:policyID/categories/imported', - getRoute: (policyID: string) => `workspaces/${policyID}/categories/imported` as const, - }, WORKSPACE_CATEGORY_CREATE: { route: 'workspaces/:policyID/categories/new', getRoute: (policyID: string) => `workspaces/${policyID}/categories/new` as const, @@ -2324,6 +2384,15 @@ const ROUTES = { return `workspaces/${policyID}/more-features` as const; }, }, + WORKSPACE_HR: { + route: 'workspaces/:policyID/hr', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_HR route'); + } + return `workspaces/${policyID}/hr` as const; + }, + }, WORKSPACE_TAGS: { route: 'workspaces/:policyID/tags', getRoute: (policyID: string | undefined) => { @@ -2739,10 +2808,17 @@ const ROUTES = { }, WORKSPACE_EXPENSIFY_CARD_SELECT_FEED: { route: 'workspaces/:policyID/expensify-card/select-feed', - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/expensify-card/select-feed`, backTo), }, + WORKSPACE_EXPENSIFY_CARD_ADD_WORK_EMAIL: { + route: 'workspaces/:policyID/expensify-card/:fundID/work-email', + getRoute: (policyID: string, fundID: number) => `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/work-email` as const, + }, + WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL: { + route: 'workspaces/:policyID/expensify-card/:fundID/verify-work-email', + getRoute: (policyID: string, fundID: number) => `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/verify-work-email` as const, + }, WORKSPACE_EXPENSIFY_CARD_SETTINGS_FREQUENCY: { route: 'workspaces/:policyID/expensify-card/settings/frequency', getRoute: (policyID: string) => `workspaces/${policyID}/expensify-card/settings/frequency` as const, @@ -2803,6 +2879,10 @@ const ROUTES = { route: 'workspaces/:policyID/travel/settings/frequency', getRoute: (policyID: string) => `workspaces/${policyID}/travel/settings/frequency` as const, }, + WORKSPACE_TRAVEL_SETTINGS_MONTHLY_LIMIT: { + route: 'workspaces/:policyID/travel/settings/monthly-limit', + getRoute: (policyID: string) => `workspaces/${policyID}/travel/settings/monthly-limit` as const, + }, WORKSPACE_TRAVEL_MISSING_PERSONAL_DETAILS: { route: 'workspaces/:policyID/travel/missing-personal-details', getRoute: (policyID: string) => `workspaces/${policyID}/travel/missing-personal-details` as const, @@ -3091,12 +3171,6 @@ const ROUTES = { route: 'share/submit-details/:reportOrAccountID', getRoute: (reportOrAccountID: string) => `share/submit-details/${reportOrAccountID}` as const, }, - CHANGE_POLICY_EDUCATIONAL: { - route: 'change-workspace-educational', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (backTo?: string) => getUrlWithBackToParam('change-workspace-educational', backTo), - }, TRAVEL_MY_TRIPS: { route: 'travel', getRoute: (policyID?: string) => `travel?${policyID ? `policyID=${policyID}` : ''}` as const, @@ -3655,55 +3729,6 @@ const ROUTES = { return getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/` as const, backTo, false); }, }, - POLICY_ACCOUNTING_NETSUITE_PREFERRED_EXPORTER_SELECT: { - route: 'workspaces/:policyID/connections/netsuite/export/preferred-exporter/select', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/preferred-exporter/select` as const, backTo), - }, - POLICY_ACCOUNTING_NETSUITE_DATE_SELECT: { - route: 'workspaces/:policyID/connections/netsuite/export/date/select', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/date/select` as const, backTo), - }, - POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES: { - route: 'workspaces/:policyID/connections/netsuite/export/expenses/:expenseType', - getRoute: (policyID: string | undefined, expenseType: ValueOf, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/expenses/${expenseType as string}` as const, backTo); - }, - }, - POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_DESTINATION_SELECT: { - route: 'workspaces/:policyID/connections/netsuite/export/expenses/:expenseType/destination/select', - getRoute: (policyID: string, expenseType: ValueOf, backTo?: string) => - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/expenses/${expenseType as string}/destination/select` as const, backTo), - }, - POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT: { - route: 'workspaces/:policyID/connections/netsuite/export/invoice-item-preference/select', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/export/invoice-item-preference/select` as const, backTo); - }, - }, - POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_SELECT: { - route: 'workspaces/:policyID/connections/netsuite/export/invoice-item-preference/invoice-item/select', - getRoute: (policyID: string | undefined) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_NETSUITE_INVOICE_ITEM_SELECT route'); - } - return `workspaces/${policyID}/connections/netsuite/export/invoice-item-preference/invoice-item/select` as const; - }, - }, POLICY_ACCOUNTING_NETSUITE_TAX_POSTING_ACCOUNT_SELECT: { route: 'workspaces/:policyID/connections/netsuite/export/tax-posting-account/select', getRoute: (policyID: string) => `workspaces/${policyID}/connections/netsuite/export/tax-posting-account/select` as const, @@ -3750,34 +3775,6 @@ const ROUTES = { getRoute: (policyID: string, expenseType: ValueOf) => `workspaces/${policyID}/connections/netsuite/advanced/custom-form-id/${expenseType as string}` as const, }, - POLICY_ACCOUNTING_NETSUITE_AUTO_SYNC: { - route: 'workspaces/:policyID/connections/netsuite/advanced/autosync', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_NETSUITE_AUTO_SYNC route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/advanced/autosync` as const, backTo); - }, - }, - POLICY_ACCOUNTING_NETSUITE_ACCOUNTING_METHOD: { - route: 'workspaces/:policyID/connections/netsuite/advanced/autosync/accounting-method', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_NETSUITE_ACCOUNTING_METHOD route'); - } - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - return getUrlWithBackToParam(`workspaces/${policyID}/connections/netsuite/advanced/autosync/accounting-method` as const, backTo); - }, - }, - POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES: { - route: 'workspaces/:policyID/accounting/sage-intacct/prerequisites', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/accounting/sage-intacct/prerequisites` as const, backTo), - }, POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS: { route: 'workspaces/:policyID/accounting/sage-intacct/enter-credentials', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/sage-intacct/enter-credentials` as const, @@ -3881,25 +3878,6 @@ const ROUTES = { return `workspaces/${policyID}/accounting/sage-intacct/advanced/payment-account` as const; }, }, - POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC: { - route: 'workspaces/:policyID/accounting/sage-intacct/advanced/autosync', - getRoute: (policyID: string | undefined, backTo?: string) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC route'); - } - // eslint-disable-next-line no-restricted-syntax - return getUrlWithBackToParam(`workspaces/${policyID}/accounting/sage-intacct/advanced/autosync` as const, backTo); - }, - }, - POLICY_ACCOUNTING_SAGE_INTACCT_ACCOUNTING_METHOD: { - route: 'workspaces/:policyID/accounting/sage-intacct/advanced/autosync/accounting-method', - getRoute: (policyID: string | undefined) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING_SAGE_INTACCT_ACCOUNTING_METHOD route'); - } - return `workspaces/${policyID}/accounting/sage-intacct/advanced/autosync/accounting-method` as const; - }, - }, ADD_UNREPORTED_EXPENSE: { route: 'search/r/:reportID/add-unreported-expense/:backToReport?', getRoute: (reportID: string | undefined, backToReport?: string) => `search/r/${reportID}/add-unreported-expense/${backToReport ?? ''}` as const, @@ -4086,6 +4064,10 @@ const ROUTES = { route: 'domain/:domainAccountID/members/:accountID/reset-two-factor-auth', getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/reset-two-factor-auth` as const, }, + DOMAIN_MEMBERS_MOVE_TO_GROUP: { + route: 'domain/:domainAccountID/members/move', + getRoute: (domainAccountID: number) => `domain/${domainAccountID}/members/move` as const, + }, MULTIFACTOR_AUTHENTICATION_MAGIC_CODE: `multifactor-authentication/magic-code`, MULTIFACTOR_AUTHENTICATION_BIOMETRICS_TEST: 'multifactor-authentication/scenario/biometrics-test', @@ -4118,6 +4100,16 @@ const ROUTES = { route: 'domain/:domainAccountID/members/:accountID/lock-account', getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/lock-account` as const, }, + + DOMAIN_GROUP_DETAILS: { + route: 'domain/:domainAccountID/groups/:groupID', + getRoute: (domainAccountID: number, groupID: string) => `domain/${domainAccountID}/groups/${groupID}` as const, + }, + + DOMAIN_GROUP_EDIT_NAME: { + route: 'domain/:domainAccountID/groups/:groupID/name', + getRoute: (domainAccountID: number, groupID: string) => `domain/${domainAccountID}/groups/${groupID}/name` as const, + }, } as const; /** diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 5fdccc4d6629..f2feb6e9110a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -53,8 +53,6 @@ const SCREENS = { REPORT_VERIFY_ACCOUNT: 'Search_Report_Verify_Account', ADVANCED_FILTERS_RHP: 'Search_Advanced_Filters_RHP', ADVANCED_FILTERS_TYPE_RHP: 'Search_Advanced_Filters_Type_RHP', - ADVANCED_FILTERS_GROUP_BY_RHP: 'Search_Advanced_Filters_GroupBy_RHP', - ADVANCED_FILTERS_VIEW_RHP: 'Search_Advanced_Filters_View_RHP', ADVANCED_FILTERS_STATUS_RHP: 'Search_Advanced_Filters_Status_RHP', ADVANCED_FILTERS_DATE_RHP: 'Search_Advanced_Filters_Date_RHP', ADVANCED_FILTERS_SUBMITTED_RHP: 'Search_Advanced_Filters_Submitted_RHP', @@ -65,7 +63,6 @@ const SCREENS = { ADVANCED_FILTERS_POSTED_RHP: 'Search_Advanced_Filters_Posted_RHP', ADVANCED_FILTERS_WITHDRAWN_RHP: 'Search_Advanced_Filters_Withdrawn_RHP', ADVANCED_FILTERS_CURRENCY_RHP: 'Search_Advanced_Filters_Currency_RHP', - ADVANCED_FILTERS_GROUP_CURRENCY_RHP: 'Search_Advanced_Filters_Group_Currency_RHP', ADVANCED_FILTERS_DESCRIPTION_RHP: 'Search_Advanced_Filters_Description_RHP', ADVANCED_FILTERS_MERCHANT_RHP: 'Search_Advanced_Filters_Merchant_RHP', ADVANCED_FILTERS_REPORT_ID_RHP: 'Search_Advanced_Filters_ReportID_RHP', @@ -81,7 +78,6 @@ const SCREENS = { ADVANCED_FILTERS_WITHDRAWAL_ID_RHP: 'Search_Advanced_Filters_Withdrawal_ID_RHP', ADVANCED_FILTERS_TAG_RHP: 'Search_Advanced_Filters_Tag_RHP', ADVANCED_FILTERS_HAS_RHP: 'Search_Advanced_Filters_Has_RHP', - ADVANCED_FILTERS_LIMIT_RHP: 'Search_Advanced_Filters_Limit_RHP', ADVANCED_FILTERS_FROM_RHP: 'Search_Advanced_Filters_From_RHP', ADVANCED_FILTERS_TO_RHP: 'Search_Advanced_Filters_To_RHP', ADVANCED_FILTERS_TITLE_RHP: 'Search_Advanced_Filters_Title_RHP', @@ -135,6 +131,8 @@ const SCREENS = { HELP: 'Settings_Help', DYNAMIC_VERIFY_ACCOUNT: 'Dynamic_Verify_Account', DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT: 'Dynamic_Add_Bank_Account_Verify_Account', + DYNAMIC_EXIT_SURVEY_CONFIRM: 'Dynamic_ExitSurvey_Confirm', + DYNAMIC_EXIT_SURVEY_REASON: 'Dynamic_ExitSurvey_Reason', DYNAMIC_KEYBOARD_SHORTCUTS: 'Dynamic_Keyboard_Shortcuts', PROFILE: { @@ -227,12 +225,11 @@ const SCREENS = { PERSONAL_CARD_ADD_NEW: 'Settings_Wallet_PersonalCard_New', PERSONAL_CARD_WARNING: 'Settings_Wallet_PersonalCard_Warning', PERSONAL_CARD_UPGRADE: 'Settings_Wallet_PersonalCard_Upgrade', + EXPENSIFY_CARD_SPEND_RULES: 'Settings_Wallet_ExpensifyCard_SpendRules', }, EXIT_SURVEY: { - REASON: 'Settings_ExitSurvey_Reason', RESPONSE: 'Settings_ExitSurvey_Response', - CONFIRM: 'Settings_ExitSurvey_Confirm', }, DELEGATE: { @@ -263,7 +260,7 @@ const SCREENS = { DISABLE_AUTO_RENEW_SURVEY: 'Settings_Subscription_DisableAutoRenewSurvey', CHANGE_BILLING_CURRENCY: 'Settings_Subscription_Change_Billing_Currency', CHANGE_PAYMENT_CURRENCY: 'Settings_Subscription_Change_Payment_Currency', - REQUEST_EARLY_CANCELLATION: 'Settings_Subscription_RequestEarlyCancellation', + CANCEL_SUBSCRIPTION: 'Settings_Subscription_CancelSubscription', SUBSCRIPTION_DOWNGRADE_BLOCKED: 'Settings_Subscription_DowngradeBlocked', }, }, @@ -339,6 +336,7 @@ const SCREENS = { DOMAIN: 'Domain', EXPENSE_REPORT: 'ExpenseReport', MULTIFACTOR_AUTHENTICATION: 'MultifactorAuthentication', + CHRONOS_SCHEDULE_OOO: 'Chronos_Schedule_OOO', }, REPORT_CARD_ACTIVATE: 'Report_Card_Activate_Root', SAML_SIGN_IN: 'SAMLSignIn', @@ -346,6 +344,7 @@ const SCREENS = { MONEY_REQUEST: { CREATE: 'Money_Request_Create', + CREATE_VERIFY_ACCOUNT: 'Money_Request_Create_Verify_Account', HOLD: 'Money_Request_Hold_Reason', REJECT: 'Money_Request_Reject_Reason', REPORT_REJECT: 'Money_Request_Report_Reject', @@ -430,7 +429,7 @@ const SCREENS = { SETTINGS_CATEGORY_SETTINGS: 'Settings_Category_Settings', SETTINGS_CATEGORIES_SETTINGS: 'Settings_Categories_Settings', SETTINGS_CATEGORY_CREATE: 'Settings_Category_Create', - SETTINGS_CATEGORY_EDIT: 'Settings_Category_Edit', + DYNAMIC_SETTINGS_CATEGORY_EDIT: 'Dynamic_Settings_Category_Edit', SETTINGS_CATEGORIES_ROOT: 'Settings_Categories', SETTINGS_CATEGORIES_IMPORT: 'Settings_Categories_Import', SETTINGS_CATEGORIES_IMPORTED: 'Settings_Categories_Imported', @@ -529,17 +528,19 @@ const SCREENS = { QUICKBOOKS_ONLINE_CUSTOMERS: 'Policy_Accounting_Quickbooks_Online_Import_Customers', QUICKBOOKS_ONLINE_LOCATIONS: 'Policy_Accounting_Quickbooks_Online_Import_Locations', QUICKBOOKS_ONLINE_TAXES: 'Policy_Accounting_Quickbooks_Online_Import_Taxes', - QUICKBOOKS_ONLINE_EXPORT: 'Workspace_Accounting_Quickbooks_Online_Export', + DYNAMIC_QUICKBOOKS_ONLINE_EXPORT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export', DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_DATE_SELECT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Date_Select', - QUICKBOOKS_ONLINE_EXPORT_INVOICE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Invoice_Account_Select', - QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense', - QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense_Account_Select', + DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_INVOICE_ACCOUNT_SELECT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Invoice_Account_Select', + DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense', + DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense_Account_Select', QUICKBOOKS_ONLINE_NON_REIMBURSABLE_DEFAULT_VENDOR_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Non_Reimbursable_Default_Vendor_Select', - QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT: 'Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense_Select', + DYNAMIC_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Company_Card_Expense_Select', DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Preferred_Exporter', QUICKBOOKS_ONLINE_TRAVEL_INVOICING_CONFIGURATION: 'Workspace_Accounting_Quickbooks_Online_Travel_Invoicing_Configuration', QUICKBOOKS_ONLINE_TRAVEL_INVOICING_VENDOR_SELECT: 'Workspace_Accounting_Quickbooks_Online_Travel_Invoicing_Vendor_Select', QUICKBOOKS_ONLINE_TRAVEL_INVOICING_PAYABLE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Online_Travel_Invoicing_Payable_Account_Select', + NETSUITE_TRAVEL_INVOICING_CONFIGURATION: 'Workspace_Accounting_NetSuite_Travel_Invoicing_Configuration', + NETSUITE_TRAVEL_INVOICING_PAYABLE_ACCOUNT_SELECT: 'Workspace_Accounting_NetSuite_Travel_Invoicing_Payable_Account_Select', DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Out_Of_Pocket_Expenses', DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_SELECT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Out_Of_Pocket_Expenses_Select', DYNAMIC_QUICKBOOKS_ONLINE_EXPORT_OUT_OF_POCKET_EXPENSES_ACCOUNT_SELECT: 'Dynamic_Workspace_Accounting_Quickbooks_Online_Export_Out_Of_Pocket_Expenses_Account_Select', @@ -549,8 +550,8 @@ const SCREENS = { QUICKBOOKS_ONLINE_CLASSES_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Online_Import_Classes_Displayed_As', QUICKBOOKS_ONLINE_CUSTOMERS_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Online_Import_Customers_Displayed_As', QUICKBOOKS_ONLINE_LOCATIONS_DISPLAYED_AS: 'Policy_Accounting_Quickbooks_Online_Import_Locations_Displayed_As', - QUICKBOOKS_ONLINE_AUTO_SYNC: 'Policy_Accounting_Quickbooks_Online_Auto_Sync', - QUICKBOOKS_ONLINE_ACCOUNTING_METHOD: 'Policy_Accounting_Quickbooks_Online_Accounting_Method', + DYNAMIC_QUICKBOOKS_ONLINE_AUTO_SYNC: 'Dynamic_Policy_Accounting_Quickbooks_Online_Auto_Sync', + DYNAMIC_QUICKBOOKS_ONLINE_ACCOUNTING_METHOD: 'Dynamic_Policy_Accounting_Quickbooks_Online_Accounting_Method', QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Company_Card_Expense_Account_Select', QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT_COMPANY_CARD_SELECT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Company_Card_Expense_Select', QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT: 'Workspace_Accounting_Quickbooks_Desktop_Export_Company_Card_Expense', @@ -606,16 +607,16 @@ const SCREENS = { NETSUITE_SUBSIDIARY_SELECTOR: 'Policy_Accounting_NetSuite_Subsidiary_Selector', NETSUITE_IMPORT: 'Policy_Accounting_NetSuite_Import', NETSUITE_EXPORT: 'Policy_Accounting_NetSuite_Export', - NETSUITE_PREFERRED_EXPORTER_SELECT: 'Policy_Accounting_NetSuite_Preferred_Exporter_Select', - NETSUITE_DATE_SELECT: 'Policy_Accounting_NetSuite_Date_Select', - NETSUITE_EXPORT_EXPENSES: 'Policy_Accounting_NetSuite_Export_Expenses', - NETSUITE_EXPORT_EXPENSES_DESTINATION_SELECT: 'Policy_Accounting_NetSuite_Export_Expenses_Destination_Select', + DYNAMIC_NETSUITE_PREFERRED_EXPORTER_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Preferred_Exporter_Select', + DYNAMIC_NETSUITE_DATE_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Date_Select', + DYNAMIC_NETSUITE_EXPORT_EXPENSES: 'Dynamic_Policy_Accounting_NetSuite_Export_Expenses', + DYNAMIC_NETSUITE_EXPORT_EXPENSES_DESTINATION_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Export_Expenses_Destination_Select', DYNAMIC_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Export_Expenses_Vendor_Select', DYNAMIC_NETSUITE_EXPORT_EXPENSES_PAYABLE_ACCOUNT_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Export_Expenses_Payable_Account_Select', DYNAMIC_NETSUITE_EXPORT_EXPENSES_JOURNAL_POSTING_PREFERENCE_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Export_Expenses_Journal_Posting_Preference_Select', DYNAMIC_NETSUITE_RECEIVABLE_ACCOUNT_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Receivable_Account_Select', - NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT: 'Policy_Accounting_NetSuite_Invoice_Item_Preference_Select', - NETSUITE_INVOICE_ITEM_SELECT: 'Policy_Accounting_NetSuite_Invoice_Item_Select', + DYNAMIC_NETSUITE_INVOICE_ITEM_PREFERENCE_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Invoice_Item_Preference_Select', + DYNAMIC_NETSUITE_INVOICE_ITEM_SELECT: 'Dynamic_Policy_Accounting_NetSuite_Invoice_Item_Select', NETSUITE_TAX_POSTING_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Tax_Posting_Account_Select', NETSUITE_PROVINCIAL_TAX_POSTING_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Provincial_Tax_Posting_Account_Select', NETSUITE_ADVANCED: 'Policy_Accounting_NetSuite_Advanced', @@ -626,9 +627,9 @@ const SCREENS = { NETSUITE_JOURNAL_ENTRY_APPROVAL_LEVEL_SELECT: 'Policy_Accounting_NetSuite_Journal_Entry_Approval_Level_Select', NETSUITE_APPROVAL_ACCOUNT_SELECT: 'Policy_Accounting_NetSuite_Approval_Account_Select', NETSUITE_CUSTOM_FORM_ID: 'Policy_Accounting_NetSuite_Custom_Form_ID', - NETSUITE_AUTO_SYNC: 'Policy_Accounting_NetSuite_Auto_Sync', - NETSUITE_ACCOUNTING_METHOD: 'Policy_Accounting_NetSuite_Accounting_Method', - SAGE_INTACCT_PREREQUISITES: 'Policy_Accounting_Sage_Intacct_Prerequisites', + DYNAMIC_NETSUITE_AUTO_SYNC: 'Dynamic_Policy_Accounting_NetSuite_Auto_Sync', + DYNAMIC_NETSUITE_ACCOUNTING_METHOD: 'Dynamic_Policy_Accounting_NetSuite_Accounting_Method', + DYNAMIC_SAGE_INTACCT_PREREQUISITES: 'Dynamic_Policy_Accounting_Sage_Intacct_Prerequisites', ENTER_SAGE_INTACCT_CREDENTIALS: 'Policy_Enter_Sage_Intacct_Credentials', EXISTING_SAGE_INTACCT_CONNECTIONS: 'Policy_Existing_Sage_Intacct_Connections', SAGE_INTACCT_ENTITY: 'Policy_Sage_Intacct_Entity', @@ -650,8 +651,8 @@ const SCREENS = { DYNAMIC_SAGE_INTACCT_DEFAULT_VENDOR: 'Dynamic_Policy_Accounting_Sage_Intacct_Default_Vendor', DYNAMIC_SAGE_INTACCT_NON_REIMBURSABLE_CREDIT_CARD_ACCOUNT: 'Dynamic_Policy_Accounting_Sage_Intacct_Non_Reimbursable_Credit_Card_Account', SAGE_INTACCT_ADVANCED: 'Policy_Accounting_Sage_Intacct_Advanced', - SAGE_INTACCT_AUTO_SYNC: 'Policy_Accounting_Sage_Intacct_Auto_Sync', - SAGE_INTACCT_ACCOUNTING_METHOD: 'Policy_Accounting_Sage_Intacct_Accounting_Method', + DYNAMIC_SAGE_INTACCT_AUTO_SYNC: 'Dynamic_Policy_Accounting_Sage_Intacct_Auto_Sync', + DYNAMIC_SAGE_INTACCT_ACCOUNTING_METHOD: 'Dynamic_Policy_Accounting_Sage_Intacct_Accounting_Method', SAGE_INTACCT_PAYMENT_ACCOUNT: 'Policy_Accounting_Sage_Intacct_Payment_Account', CARD_RECONCILIATION: 'Policy_Accounting_Card_Reconciliation', CARD_RECONCILIATION_QUICKBOOKS_DESKTOP_AUTO_SYNC: 'Policy_Accounting_Card_Reconciliation_Quickbooks_Desktop_Auto_Sync', @@ -660,6 +661,7 @@ const SCREENS = { }, INITIAL: 'Workspace_Initial', PROFILE: 'Workspace_Overview', + HR: 'Workspace_HR', COMPANY_CARDS: 'Workspace_CompanyCards', COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION: 'Workspace_CompanyCards_BrokenCardFeedConnection', COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION: 'Workspace_CompanyCards_RefreshCardFeedConnection', @@ -698,6 +700,8 @@ const SCREENS = { EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_MAGIC_CODE: 'Workspace_ExpensifyCard_New_Confirm_Magic_Code', EXPENSIFY_CARD_NAME: 'Workspace_ExpensifyCard_Name', EXPENSIFY_CARD_SELECT_FEED: 'Workspace_ExpensifyCard_Select_Feed', + EXPENSIFY_CARD_ADD_WORK_EMAIL: 'Workspace_ExpensifyCard_Add_Work_Email', + EXPENSIFY_CARD_VERIFY_WORK_EMAIL: 'Workspace_ExpensifyCard_Verify_Work_Email', EXPENSIFY_CARD_LIMIT_TYPE: 'Workspace_ExpensifyCard_LimitType', EXPENSIFY_CARD_BANK_ACCOUNT: 'Workspace_ExpensifyCard_BankAccount', EXPENSIFY_CARD_SETTINGS: 'Workspace_ExpensifyCard_Settings', @@ -779,8 +783,8 @@ const SCREENS = { CATEGORY_REQUIRE_ITEMIZED_RECEIPTS_OVER: 'Category_Require_Itemized_Receipts_Over', CATEGORY_REQUIRED_FIELDS: 'Category_Required_Fields', CATEGORIES_SETTINGS: 'Categories_Settings', - CATEGORIES_IMPORT: 'Categories_Import', - CATEGORIES_IMPORTED: 'Categories_Imported', + DYNAMIC_CATEGORIES_IMPORT: 'Dynamic_Categories_Import', + DYNAMIC_CATEGORIES_IMPORTED: 'Dynamic_Categories_Imported', MORE_FEATURES: 'Workspace_More_Features', MEMBER_DETAILS: 'Workspace_Member_Details', MEMBER_DETAILS_ROLE: 'Workspace_Member_Details_Role', @@ -793,6 +797,7 @@ const SCREENS = { TRAVEL: 'Travel', TRAVEL_SETTINGS_ACCOUNT: 'Workspace_Travel_Settings_Account', TRAVEL_SETTINGS_FREQUENCY: 'Workspace_Travel_Settings_Frequency', + TRAVEL_SETTINGS_MONTHLY_LIMIT: 'Workspace_Travel_Settings_Monthly_Limit', TRAVEL_EXPORT: 'Workspace_Travel_Invoicing_Export', TRAVEL_MISSING_PERSONAL_DETAILS: 'Travel_Missing_Personal_Details', CREATE_DISTANCE_RATE: 'Create_Distance_Rate', @@ -922,8 +927,9 @@ const SCREENS = { DETAILS_ROOT: 'Details_Root', PROFILE_ROOT: 'Profile_Root', AUTO_SUBMIT_ROOT: 'AutoSubmit_Modal_Root', - CHANGE_POLICY_EDUCATIONAL_ROOT: 'ChangePolicyEducational_Root', + DYNAMIC_CHANGE_POLICY_EDUCATIONAL_ROOT: 'DynamicChangePolicyEducational_Root', REPORT_DESCRIPTION_ROOT: 'Report_Description_Root', + CHRONOS_SCHEDULE_OOO_ROOT: 'Chronos_Schedule_OOO_Root', REPORT_PARTICIPANTS: { ROOT: 'ReportParticipants_Root', INVITE: 'ReportParticipants_Invite', @@ -969,7 +975,7 @@ const SCREENS = { CONFIRMATION: 'ScheduleCall_Confirmation', }, REPORT_CHANGE_APPROVER: { - ROOT: 'Report_Change_Approver_Root', + DYNAMIC_ROOT: 'Dynamic_Report_Change_Approver_Root', ADD_APPROVER: 'Report_Change_Approver_Add_Approver', }, TEST_TOOLS_MODAL: { @@ -1002,6 +1008,9 @@ const SCREENS = { MEMBER_RESET_TWO_FACTOR_AUTH: 'Member_Reset_Two_Factor_Auth', MEMBER_FORCE_TWO_FACTOR_AUTH: 'Member_Force_Two_Factor_Auth', MEMBER_LOCK_ACCOUNT: 'Member_Lock_Account', + MEMBERS_MOVE_TO_GROUP: 'Members_Move_To_Group', + GROUP_DETAILS: 'Domain_Group_Details', + GROUP_EDIT_NAME: 'Domain_Group_Edit_Name', }, MULTIFACTOR_AUTHENTICATION: { MAGIC_CODE: 'Multifactor_Authentication_Magic_Code', diff --git a/src/SplashScreenStateContext.tsx b/src/SplashScreenStateContext.tsx index 272510e2b6fb..3bb873960f58 100644 --- a/src/SplashScreenStateContext.tsx +++ b/src/SplashScreenStateContext.tsx @@ -72,5 +72,4 @@ function useSplashScreenActions() { return useContext(SplashScreenActionsContext); } -export default SplashScreenStateContext; export {SplashScreenStateContextProvider, useSplashScreenState, useSplashScreenActions}; diff --git a/src/components/ActionSheetAwareScrollView/__mocks__/index.android.tsx b/src/components/ActionSheetAwareScrollView/__mocks__/index.android.tsx index 7c2a20795cf6..dd2e929205ae 100644 --- a/src/components/ActionSheetAwareScrollView/__mocks__/index.android.tsx +++ b/src/components/ActionSheetAwareScrollView/__mocks__/index.android.tsx @@ -1,11 +1,5 @@ import {Actions, ActionSheetAwareScrollViewProvider, useActionSheetAwareScrollViewActions, useActionSheetAwareScrollViewState} from './ActionSheetAwareScrollViewContext'; -function ActionSheetAwareScrollView() { - return null; -} - -export default ActionSheetAwareScrollView; - const renderScrollComponent = undefined; export {renderScrollComponent, ActionSheetAwareScrollViewProvider, Actions, useActionSheetAwareScrollViewState, useActionSheetAwareScrollViewActions}; diff --git a/src/components/ActionSheetAwareScrollView/__mocks__/index.ios.tsx b/src/components/ActionSheetAwareScrollView/__mocks__/index.ios.tsx index 7c2a20795cf6..dd2e929205ae 100644 --- a/src/components/ActionSheetAwareScrollView/__mocks__/index.ios.tsx +++ b/src/components/ActionSheetAwareScrollView/__mocks__/index.ios.tsx @@ -1,11 +1,5 @@ import {Actions, ActionSheetAwareScrollViewProvider, useActionSheetAwareScrollViewActions, useActionSheetAwareScrollViewState} from './ActionSheetAwareScrollViewContext'; -function ActionSheetAwareScrollView() { - return null; -} - -export default ActionSheetAwareScrollView; - const renderScrollComponent = undefined; export {renderScrollComponent, ActionSheetAwareScrollViewProvider, Actions, useActionSheetAwareScrollViewState, useActionSheetAwareScrollViewActions}; diff --git a/src/components/ActionSheetAwareScrollView/__mocks__/index.tsx b/src/components/ActionSheetAwareScrollView/__mocks__/index.tsx index 7c2a20795cf6..dd2e929205ae 100644 --- a/src/components/ActionSheetAwareScrollView/__mocks__/index.tsx +++ b/src/components/ActionSheetAwareScrollView/__mocks__/index.tsx @@ -1,11 +1,5 @@ import {Actions, ActionSheetAwareScrollViewProvider, useActionSheetAwareScrollViewActions, useActionSheetAwareScrollViewState} from './ActionSheetAwareScrollViewContext'; -function ActionSheetAwareScrollView() { - return null; -} - -export default ActionSheetAwareScrollView; - const renderScrollComponent = undefined; export {renderScrollComponent, ActionSheetAwareScrollViewProvider, Actions, useActionSheetAwareScrollViewState, useActionSheetAwareScrollViewActions}; diff --git a/src/components/ActionSheetAwareScrollView/index.android.tsx b/src/components/ActionSheetAwareScrollView/index.android.tsx index 7d2fd839f651..b4c594bd6378 100644 --- a/src/components/ActionSheetAwareScrollView/index.android.tsx +++ b/src/components/ActionSheetAwareScrollView/index.android.tsx @@ -22,8 +22,6 @@ function ActionSheetAwareScrollView({style, children, ref, ...restProps}: Action ); } -export default ActionSheetAwareScrollView; - /** * This function should be used as renderScrollComponent prop for FlatList * @param props - props that will be passed to the ScrollView from FlatList diff --git a/src/components/ActionSheetAwareScrollView/index.ios.tsx b/src/components/ActionSheetAwareScrollView/index.ios.tsx index b9f39e8f6914..52ddbac4788d 100644 --- a/src/components/ActionSheetAwareScrollView/index.ios.tsx +++ b/src/components/ActionSheetAwareScrollView/index.ios.tsx @@ -28,8 +28,6 @@ function ActionSheetAwareScrollView({style, children, ref, ...restProps}: Action ); } -export default ActionSheetAwareScrollView; - /** * This function should be used as renderScrollComponent prop for FlatList * @param props - props that will be passed to the ScrollView from FlatList diff --git a/src/components/ActionSheetAwareScrollView/index.tsx b/src/components/ActionSheetAwareScrollView/index.tsx index 408149163968..a351d4621544 100644 --- a/src/components/ActionSheetAwareScrollView/index.tsx +++ b/src/components/ActionSheetAwareScrollView/index.tsx @@ -20,8 +20,6 @@ function ActionSheetAwareScrollView({children, ref, ...restProps}: ActionSheetAw ); } -export default ActionSheetAwareScrollView; - /** * The bottom spacing config for this action sheet is only used on Android and iOS. On other platforms, * this component will be a default Reanimated.ScrollView, because the onScroll handler used is from Reanimated. diff --git a/src/components/AddUnreportedExpenseFooter.tsx b/src/components/AddUnreportedExpenseFooter.tsx index 80ff3519cc02..618ee2744dc7 100644 --- a/src/components/AddUnreportedExpenseFooter.tsx +++ b/src/components/AddUnreportedExpenseFooter.tsx @@ -58,6 +58,7 @@ function AddUnreportedExpenseFooter({selectedIds, report, reportToConfirm, repor const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const [betas] = useOnyx(ONYXKEYS.BETAS); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); const handleConfirm = () => { if (selectedIds.size === 0) { @@ -93,6 +94,7 @@ function AddUnreportedExpenseFooter({selectedIds, report, reportToConfirm, repor reportNextStep, policyCategories, allTransactions: selectedTransactions, + policyTagList, }); } }); diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx index d8acc426172f..357d29eba9ae 100644 --- a/src/components/AmountForm.tsx +++ b/src/components/AmountForm.tsx @@ -46,6 +46,15 @@ type AmountFormProps = { /** Whether to hide the currency symbol */ hideCurrencySymbol?: boolean; + /** When true, shows the trailing dropdown (same as currency picker in IOU amount flows) */ + shouldShowCurrencyButton?: boolean; + + /** Text on the trailing dropdown button. Use with `shouldShowCurrencyButton` when the suffix is not a currency code (e.g. duration unit). */ + currencyButtonLabel?: string; + + /** Accessibility label for the trailing dropdown */ + currencyButtonAccessibilityLabel?: string; + /** Whether the input should be disabled */ disabled?: boolean; @@ -77,6 +86,9 @@ function AmountForm({ label, decimals: decimalsProp, hideCurrencySymbol = false, + shouldShowCurrencyButton = false, + currencyButtonLabel, + currencyButtonAccessibilityLabel, disabled = false, autoFocus, autoGrowExtraSpace, @@ -94,7 +106,7 @@ function AmountForm({ return ( ()] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [reportClosedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {canEvict: false, selector: getLastClosedReportAction}); + const [reportClosedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: getLastClosedReportAction}); const originalMessage = isClosedAction(reportClosedAction) ? getOriginalMessage(reportClosedAction) : null; const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; const actorPersonalDetails = personalDetails?.[reportClosedAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID]; diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 9124bc9fe50c..61f9f0d5270a 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -269,7 +269,7 @@ function AttachmentPicker({ return pickedFiles.map((file) => { const localCopy = localCopies.find((copy) => copy.sourceUri === file.uri); - if (!localCopy || localCopy.status !== 'success') { + if (localCopy?.status !== 'success') { throw new Error("Couldn't create local file copy"); } diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 8fb7d3e300b2..c31799f6005c 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -27,8 +27,8 @@ function AttachmentCarousel({ attachmentLink, onAttachmentError, }: AttachmentCarouselProps) { - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false}); - const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false}); + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); const canUseTouchScreen = canUseTouchScreenUtil(); const styles = useThemeStyles(); const isReportArchived = useReportIsArchived(report.reportID); diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.tsx index e8f6568f98c0..5196a3a99c66 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.tsx @@ -2,7 +2,7 @@ import React, {memo} from 'react'; import PDFView from '@components/PDFView'; import type AttachmentViewPdfProps from './types'; -function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, onPress, onToggleKeyboard, onLoadComplete, style, isUsedAsChatAttachment, onLoadError}: AttachmentViewPdfProps) { +function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, onPress, onToggleKeyboard, onLoadComplete, style, isUsedAsChatAttachment, onLoadError, rotation}: AttachmentViewPdfProps) { return ( ); } diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/types.ts b/src/components/Attachments/AttachmentView/AttachmentViewPdf/types.ts index dd5bae6e9e7b..d10e9561735f 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/types.ts +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/types.ts @@ -1,3 +1,4 @@ +import type {RotationDegrees} from 'react-fast-pdf'; import type {StyleProp, ViewStyle} from 'react-native'; import type {AttachmentViewProps} from '..'; @@ -16,6 +17,9 @@ type AttachmentViewPdfProps = Pick; + + /** Controlled rotation angle for the PDF */ + rotation?: RotationDegrees; }; function checkIsFileImage(source: string | number | ImageURISource | ImageURISource[], fileName: string | undefined) { @@ -131,6 +135,7 @@ function AttachmentView({ isUploading = false, reportID, transaction: transactionProp, + rotation, }: AttachmentViewProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowCircleClockwise', 'Gallery']); const [transactionFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); @@ -226,7 +231,7 @@ function AttachmentView({ const encryptedSourceUrl = isAuthTokenRequired ? addEncryptedAuthTokenToURL(source as string, encryptedAuthToken) : (source as string); const onPDFLoadComplete = (path: string) => { - const id = (transaction && transaction.transactionID) ?? reportActionID; + const id = transaction?.transactionID ?? reportActionID; if (path && id) { addCachedPDFPaths(id, path); } @@ -255,6 +260,7 @@ function AttachmentView({ style={isUsedInAttachmentModal ? styles.imageModalPDF : styles.flex1} isUsedAsChatAttachment={isUsedAsChatAttachment} onLoadError={onPDFLoadError} + rotation={rotation} /> ); diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.android.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.android.ts index d0e74e259c39..074a89d859b5 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.android.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.android.ts @@ -1,9 +1,13 @@ import {Dimensions} from 'react-native'; -function getBottomSuggestionPadding(bottom: number): number { +function getBottomSuggestionPadding(bottom: number, isInLandscapeMode: boolean): number { const {height} = Dimensions.get('window'); const basePadding = 30; + if (isInLandscapeMode) { + return basePadding; + } + // Calculate what percentage of the screen height the bottom position represents const bottomPercentageToHeight = (bottom / height) * 100; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts index 4e57eb889f33..b82e2bc0a66b 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ios.ts @@ -1,6 +1,6 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBottomSuggestionPadding(bottom?: number): number { - return 16; +function getBottomSuggestionPadding(bottom?: number, isInLandscapeMode?: boolean): number { + return 30; } export default getBottomSuggestionPadding; diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts index 577b278250df..a159938366a1 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/getBottomSuggestionPadding/index.ts @@ -1,5 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -function getBottomSuggestionPadding(bottom?: number): number { +function getBottomSuggestionPadding(bottom?: number, isInLandscapeMode?: boolean): number { return 6; } diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx index 0a51c6b92bbc..134bff3f4f38 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.native.tsx @@ -7,9 +7,16 @@ import getBottomSuggestionPadding from './getBottomSuggestionPadding'; import TransparentOverlay from './TransparentOverlay/TransparentOverlay'; import type {AutoCompleteSuggestionsPortalProps} from './types'; -function AutoCompleteSuggestionsPortal({left = 0, width = 0, bottom = 0, resetSuggestions = () => {}, ...props}: AutoCompleteSuggestionsPortalProps) { +function AutoCompleteSuggestionsPortal({ + left = 0, + width = 0, + bottom = 0, + resetSuggestions = () => {}, + isInLandscapeMode = false, + ...props +}: AutoCompleteSuggestionsPortalProps) { const StyleUtils = useStyleUtils(); - const bottomPadding = getBottomSuggestionPadding(bottom); + const bottomPadding = getBottomSuggestionPadding(bottom, isInLandscapeMode); const styles = useMemo(() => StyleUtils.getBaseAutoCompleteSuggestionContainerStyle({left, width, bottom: bottom + bottomPadding}), [StyleUtils, left, width, bottom, bottomPadding]); if (!width) { diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx index 2d5641aa760e..ef913628d226 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/index.tsx @@ -20,6 +20,9 @@ function AutoCompleteSuggestionsPortal({ width = 0, bottom = 0, resetSuggestions = () => {}, + // isInLandscapeMode is only used on native platforms to adjust the bottom padding + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isInLandscapeMode = false, ...props }: AutoCompleteSuggestionsPortalProps): ReactElement | null | false { const StyleUtils = useStyleUtils(); diff --git a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts index 61fa3e8dcd48..62fcc5afd77a 100644 --- a/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts +++ b/src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/types.ts @@ -7,6 +7,7 @@ type AutoCompleteSuggestionsPortalProps = ExternalProps { +const measureHeightOfSuggestionRows = (numRows: number, canBeBig: boolean, isInLandscapeMode: boolean): number => { + if (isInLandscapeMode) { + if (numRows > 1) { + // In landscape mode, we display a scrollable window with a height of 1.5 items, indicating that there are more items available beyond what is currently visible + return CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR_LANDSCAPE_MODE * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + + return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } if (canBeBig) { if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) { // On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available @@ -42,6 +50,13 @@ const initialContainerState = { cursorCoordinates: {x: 0, y: 0}, }; +function getLeftOffset(x: number, leftInset: number, bigScreenLeftOffset: number, shouldUseNarrowLayout: boolean, isInLandscapeMode: boolean): number { + if (shouldUseNarrowLayout) { + return isInLandscapeMode ? x - leftInset : x; + } + return bigScreenLeftOffset; +} + /** * On the mobile-web platform, when long-pressing on auto-complete suggestions, * we need to prevent focus shifting to avoid blurring the main input (which makes the suggestions picker close and fires the onSelect callback). @@ -55,13 +70,15 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu const leftValue = React.useRef(0); const prevLeftValue = React.useRef(0); const {height: windowHeight, width: windowWidth} = useWindowDimensionsForAutoCompleteSuggestions(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isInLandscapeMode} = useResponsiveLayout(); + const prevIsInLandscapeModeValue = React.useRef(isInLandscapeMode); const [suggestionHeight, setSuggestionHeight] = React.useState(0); const [containerState, setContainerState] = React.useState(initialContainerState); const StyleUtils = useStyleUtils(); const insets = useSafeAreaInsets(); const {keyboardHeight, isKeyboardAnimatingRef} = useKeyboardState(); const {paddingBottom: bottomInset, paddingTop: topInset} = StyleUtils.getPlatformSafeAreaPadding(insets ?? undefined); + const {left: leftInset} = insets; useEffect(() => { const container = containerRef.current; @@ -95,8 +112,8 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu xCoordinatesOfCursor + CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH > windowWidth ? windowWidth - CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH : xCoordinatesOfCursor; - const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true); - const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false); + const contentMaxHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); + const contentMinHeight = measureHeightOfSuggestionRows(suggestionsLength, false, isInLandscapeMode); let bottomValue = windowHeight - (cursorCoordinates.y - scrollValue + y) - keyboardHeight; const widthValue = shouldUseNarrowLayout ? width : CONST.AUTO_COMPLETE_SUGGESTER.BIG_SCREEN_SUGGESTION_WIDTH; @@ -107,34 +124,39 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu contentHeight: contentMaxHeight, topInset, }); - const isEnoughSpaceToRenderMenuAboveForSmall = isEnoughSpaceToRenderMenuAboveCursor({ - y, - cursorCoordinates, - scrollValue, - contentHeight: contentMinHeight, - topInset, - }); - const newLeftOffset = shouldUseNarrowLayout ? x : bigScreenLeftOffset; + // In landscape mode, we always render the menu above the cursor + const isEnoughSpaceToRenderMenuAboveForSmall = + isInLandscapeMode || + isEnoughSpaceToRenderMenuAboveCursor({ + y, + cursorCoordinates, + scrollValue, + contentHeight: contentMinHeight, + topInset, + }); + + const newLeftOffset = getLeftOffset(x, leftInset, bigScreenLeftOffset, shouldUseNarrowLayout, isInLandscapeMode); // If the suggested word is longer than 150 (approximately half the width of the suggestion popup), then adjust a new position of popup const isAdjustmentNeeded = Math.abs(prevLeftValue.current - bigScreenLeftOffset) > 150; - if (isInitialRender.current || isAdjustmentNeeded) { + if (isInitialRender.current || isAdjustmentNeeded || prevIsInLandscapeModeValue.current !== isInLandscapeMode) { isSuggestionMenuAboveRef.current = isSuggestionMenuRenderedAbove(isEnoughSpaceToRenderMenuAboveForBig, isEnoughSpaceToRenderMenuAboveForSmall); leftValue.current = newLeftOffset; isInitialRender.current = false; prevLeftValue.current = newLeftOffset; + prevIsInLandscapeModeValue.current = isInLandscapeMode; } let measuredHeight = 0; if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForBig) { // calculation for big suggestion box above the cursor - measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true); + measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); } else if (isSuggestionMenuAboveRef.current && isEnoughSpaceToRenderMenuAboveForSmall) { // calculation for small suggestion box above the cursor - measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, false); + measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, false, isInLandscapeMode); } else { // calculation for big suggestion box below the cursor - measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true); + measuredHeight = measureHeightOfSuggestionRows(suggestionsLength, true, isInLandscapeMode); bottomValue = windowHeight - y - cursorCoordinates.y + scrollValue - measuredHeight - CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT - keyboardHeight; } @@ -146,7 +168,19 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu cursorCoordinates, }); }); - }, [measureParentContainerAndReportCursor, windowHeight, windowWidth, keyboardHeight, shouldUseNarrowLayout, suggestionsLength, bottomInset, topInset, isKeyboardAnimatingRef]); + }, [ + measureParentContainerAndReportCursor, + windowHeight, + windowWidth, + keyboardHeight, + shouldUseNarrowLayout, + suggestionsLength, + bottomInset, + topInset, + isKeyboardAnimatingRef, + isInLandscapeMode, + leftInset, + ]); // Prevent rendering if container dimensions are not set or if we have no suggestions if ((containerState.width === 0 && containerState.left === 0 && containerState.bottom === 0) || !suggestionsLength) { @@ -161,6 +195,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu width={containerState.width} bottom={containerState.bottom} measuredHeightOfSuggestionRows={suggestionHeight} + isInLandscapeMode={isInLandscapeMode} /> ); } diff --git a/src/components/AvatarCropModal/AvatarCropModal.tsx b/src/components/AvatarCropModal/AvatarCropModal.tsx index f224255c76ae..5e0da4a96f42 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.tsx +++ b/src/components/AvatarCropModal/AvatarCropModal.tsx @@ -15,6 +15,7 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -54,12 +55,15 @@ type AvatarCropModalProps = { buttonLabel?: string; }; +const LANDSCAPE_MODE_SLIDER_CONTAINER_MIN_WIDTH = 300; + // This component can't be written using class since reanimated API uses hooks. function AvatarCropModal({imageUri = '', imageName = '', imageType = '', onClose, onSave, isVisible, maskImage, buttonLabel}: AvatarCropModalProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Rotate', 'Zoom']); + const isInLandscapeMode = useIsInLandscapeMode(); const originalImageWidth = useSharedValue(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); const originalImageHeight = useSharedValue(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); @@ -407,7 +411,15 @@ function AvatarCropModal({imageUri = '', imageName = '', imageType = '', onClose rotation={rotation} maskImage={maskImage} /> - + (null); useEffect(() => { if (!report?.parentReportActionID) { return; } - const parentReportAction = parentReportActions?.[report?.parentReportActionID]; - actorAccountID.current = parentReportAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID; - }, [parentReportActions, report?.parentReportActionID]); + actorAccountID.current = parentReportActionActorAccountID ?? CONST.DEFAULT_NUMBER_ID; + }, [parentReportActionActorAccountID, report?.parentReportActionID]); const goToDetailsPage = () => { navigateToDetailsPage(report, Navigation.getActiveRoute()); @@ -336,6 +343,8 @@ function AvatarWithDisplayName({ statusTextContainerStyles={parentNavigationStatusContainerStyles} statusTextColor={reportStatusColorStyle?.textColor} statusTextBackgroundColor={reportStatusColorStyle?.backgroundColor} + humanAgentAccountID={humanAgentAccountID} + humanAgentName={humanAgentName} /> )} {!!subtitle && ( diff --git a/src/components/Balance.tsx b/src/components/Balance.tsx index e92d25a83e13..990cdb0b9e2f 100644 --- a/src/components/Balance.tsx +++ b/src/components/Balance.tsx @@ -1,7 +1,8 @@ import React from 'react'; import type {StyleProp, TextStyle} from 'react-native'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; +import CONST from '@src/CONST'; import Text from './Text'; type BalanceProps = { @@ -10,8 +11,9 @@ type BalanceProps = { }; function Balance({textStyles, balance}: BalanceProps) { + const {convertToDisplayString} = useCurrencyListActions(); const styles = useThemeStyles(); - const formattedBalance = CurrencyUtils.convertToDisplayString(balance); + const formattedBalance = convertToDisplayString(balance, CONST.CURRENCY.USD); return {formattedBalance}; } diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index 262f07f44f6b..efce3c12ae2a 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -34,7 +34,7 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i const styles = useThemeStyles(); const [timer, setTimer] = useState(null); - const {isExtraSmallScreenHeight} = useResponsiveLayout(); + const {isExtraSmallScreenHeight, isInLandscapeMode} = useResponsiveLayout(); const numberPressedRef = useRef(numberPressed); useEffect(() => { @@ -64,10 +64,10 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i style={[styles.flexColumn, styles.w100]} id={id} > - {padNumbers.map((row) => ( + {padNumbers.map((row, index) => ( {row.map((column, columnIndex) => { // Adding margin between buttons except first column to @@ -77,8 +77,9 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i return ( + + {buttonText} + + + + )} {/* Dropdown overlay */} 0 ? policyIDs : undefined); - const integrationConnectionNames = Object.values(CONST.POLICY.CONNECTIONS.NAME); + const integrationConnectionNames = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; const tableIconForExportOption = ( diff --git a/src/components/Search/SearchPageHeader/FeedFilterPopup.tsx b/src/components/Search/FilterDropdowns/FeedSelectPopup.tsx similarity index 54% rename from src/components/Search/SearchPageHeader/FeedFilterPopup.tsx rename to src/components/Search/FilterDropdowns/FeedSelectPopup.tsx index 2b3723467ba3..3c754bf9e490 100644 --- a/src/components/Search/SearchPageHeader/FeedFilterPopup.tsx +++ b/src/components/Search/FilterDropdowns/FeedSelectPopup.tsx @@ -1,22 +1,22 @@ import React, {useEffect} from 'react'; -import type {MultiSelectItem} from '@components/Search/FilterDropdowns/MultiSelectPopup'; +import useFilterFeedData from '@components/Search/hooks/useFilterFeedData'; +import MultiSelectFilterPopup from '@components/Search/SearchPageHeader/MultiSelectFilterPopup'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import {openSearchCardFiltersPage} from '@libs/actions/Search'; import ONYXKEYS from '@src/ONYXKEYS'; -import MultiSelectFilterPopup from './MultiSelectFilterPopup'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; -type FeedFilterPopupProps = { +type FeedSelectPopupProps = { isExpanded: boolean; - items: Array>; - value: Array>; closeOverlay: () => void; - onChangeCallback: (items: Array>) => void; + updateFilterForm: (values: Partial) => void; }; -function FeedFilterPopup({closeOverlay, items, value, isExpanded, onChangeCallback}: FeedFilterPopupProps) { +function FeedSelectPopup({isExpanded, updateFilterForm, closeOverlay}: FeedSelectPopupProps) { const {isOffline} = useNetwork(); const [areCardsLoaded] = useOnyx(ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED); + const {feedOptions, feedValue} = useFilterFeedData(); useEffect(() => { if (isOffline || !isExpanded) { @@ -29,14 +29,14 @@ function FeedFilterPopup({closeOverlay, items, value, isExpanded, onChangeCallba return ( updateFilterForm({feed: items.map((item) => item.value)})} /> ); } -export default FeedFilterPopup; +export default FeedSelectPopup; diff --git a/src/components/Search/FilterDropdowns/GroupByPopup.tsx b/src/components/Search/FilterDropdowns/GroupByPopup.tsx index 6b26071d2b55..7257ef459b4f 100644 --- a/src/components/Search/FilterDropdowns/GroupByPopup.tsx +++ b/src/components/Search/FilterDropdowns/GroupByPopup.tsx @@ -5,6 +5,8 @@ import type {SearchGroupBy} from '@components/Search/types'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import type {ListItem} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import type {GroupBySection} from '@libs/SearchUIUtils'; @@ -17,9 +19,6 @@ type GroupByPopupItem = { }; type GroupByPopupProps = { - /** The label to show when in an overlay on mobile */ - label?: string; - /** The grouped options to show in the list */ sections: GroupBySection[]; @@ -27,6 +26,7 @@ type GroupByPopupProps = { value: GroupByPopupItem | null; style?: StyleProp; + onBackButtonPress: () => void; /** Function to call to close the overlay when changes are applied */ closeOverlay: () => void; @@ -35,8 +35,10 @@ type GroupByPopupProps = { onChange: (item: GroupByPopupItem | null) => void; }; -function GroupByPopup({label, value, sections, style, closeOverlay, onChange}: GroupByPopupProps) { +function GroupByPopup({value, sections, style, onBackButtonPress, closeOverlay, onChange}: GroupByPopupProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); + const {isInLandscapeMode} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const [selectedItem, setSelectedItem] = useState(value); @@ -56,7 +58,7 @@ function GroupByPopup({label, value, sections, style, closeOverlay, onChange}: G [sections, selectedItem?.value, styles.dividerLine], ); - const optionsCount = Math.max( + const itemCount = Math.max( 1, listSections.reduce((count, section) => count + section.data.length + (section.data.length > 0 && !!section.customHeader ? 1 : 0), 0), ); @@ -81,14 +83,14 @@ function GroupByPopup({label, value, sections, style, closeOverlay, onChange}: G return ( - + void; closeOverlay: () => void; onChange: (item: SingleSelectItem | null) => void; }; @@ -19,8 +19,7 @@ function filterGroupCurrencySelector(searchAdvancedFiltersForm: OnyxEntry { const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${id}`]; @@ -80,7 +87,7 @@ function InSelectPopup({closeOverlay, updateFilterForm}: InSelectPopupProps) { const isReportArchived = !!privateIsArchived; const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportData?.policyID}`]; const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`]; - const alternateText = getAlternateText(report, {}, {isReportArchived, policy, reportAttributesDerived, policyTags: reportPolicyTags}); + const alternateText = getAlternateText(report, {}, {isReportArchived, policy, reportAttributesDerived, policyTags: reportPolicyTags, conciergeReportID}); return {...report, alternateText}; }); @@ -98,6 +105,8 @@ function InSelectPopup({closeOverlay, updateFilterForm}: InSelectPopupProps) { currentUserEmail, personalDetails, policyCollection: allPolicies, + sortedActions, + conciergeReportID, }); const chatOptions = filterAndOrderOptions(defaultOptions, cleanSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, { @@ -176,6 +185,7 @@ function InSelectPopup({closeOverlay, updateFilterForm}: InSelectPopupProps) { headerMessage, }; + const itemCount = sections.flatMap((section) => section.data).length || 1; return ( section.data).length || 1, windowHeight, shouldUseNarrowLayout, true)]} > - + + + ); } diff --git a/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx b/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx index 61433c3261eb..5abba2ef3b17 100644 --- a/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/MultiSelectPopup.tsx @@ -7,6 +7,7 @@ import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectL import type {ListItem} from '@components/SelectionList/ListItem/types'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -52,6 +53,9 @@ function MultiSelectPopup({label, loading, value, items, close const theme = useTheme(); const {translate} = useLocalize(); const styles = useThemeStyles(); + + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const [selectedItems, setSelectedItems] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -107,6 +111,8 @@ function MultiSelectPopup({label, loading, value, items, close const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'MultiSelectPopupDataLoading'}; + const hasTitle = isSmallScreenWidth && !!label; + return ( ({label, loading, value, items, close resetSentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_RESET_MULTI_SELECT} applySentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_APPLY_MULTI_SELECT} > - + {!!loading && ( ({label, loading, value, items, close ListItem={MultiSelectListItem} onSelectRow={updateSelectedItems} textInputOptions={textInputOptions} + style={{contentContainerStyle: [styles.pb0]}} /> )} diff --git a/src/components/Search/FilterDropdowns/ReportFieldPopup.tsx b/src/components/Search/FilterDropdowns/ReportFieldPopup.tsx index cee095c86bcf..fcb7a9097748 100644 --- a/src/components/Search/FilterDropdowns/ReportFieldPopup.tsx +++ b/src/components/Search/FilterDropdowns/ReportFieldPopup.tsx @@ -7,11 +7,11 @@ import MenuItem from '@components/MenuItem'; import ScrollView from '@components/ScrollView'; import type {SearchDateValues} from '@components/Search/FilterComponents/DatePresetFilterBase'; import type {ReportFieldDateKey, ReportFieldTextKey} from '@components/Search/types'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {getDateRangeDisplayValueFromFormValue, isSearchDatePreset} from '@libs/SearchQueryUtils'; import {getDatePresets} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -49,10 +49,6 @@ function getFieldNameAsKey(fieldName: string) { } function ReportFieldListPopup({value, field, onBackButtonPress, onChange}: ReportFieldSubPopupProps) { - const styles = useThemeStyles(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - const items = field.values.map((fieldValue) => ({ value: fieldValue, text: fieldValue, @@ -60,32 +56,25 @@ function ReportFieldListPopup({value, field, onBackButtonPress, onChange}: Repor const selectedValue = {text: value, value}; return ( - - - onChange(item?.value ?? '')} - /> - + onChange(item?.value ?? '')} + /> ); } function ReportFieldDatePopup({value, field, onBackButtonPress, onChange}: ReportFieldDatePopupProps) { const styles = useThemeStyles(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); + const {windowHeight} = useWindowDimensions(); + const isInLandscapeMode = useIsInLandscapeMode(); const filterKey = `${CONST.SEARCH.REPORT_FIELD.DEFAULT_PREFIX}${getFieldNameAsKey(field.name)}` as const; return ( - + - - - + ); } function ReportFieldPopup({closeOverlay, updateFilterForm}: ReportFieldPopupProps) { const {translate, localeCompare} = useLocalize(); - const StyleUtils = useStyleUtils(); + const {windowHeight} = useWindowDimensions(); + const isInLandscapeMode = useIsInLandscapeMode(); + const styles = useThemeStyles(); const policyReportFieldsSelector = (policies: OnyxCollection) => createAllPolicyReportFieldsSelector(policies, localeCompare); const [fieldList] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: policyReportFieldsSelector, @@ -275,8 +254,9 @@ function ReportFieldPopup({closeOverlay, updateFilterForm}: ReportFieldPopupProp onApply={applyChanges} resetSentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_RESET_REPORT_FIELD} applySentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_APPLY_REPORT_FIELD} + style={[styles.getPopoverMaxHeight(windowHeight, isInLandscapeMode)]} > - + {listItems.map((item) => ( = { /** The currently selected item */ value: SingleSelectItem | null; + onBackButtonPress?: () => void; + /** Function to call to close the overlay when changes are applied */ closeOverlay: () => void; @@ -45,12 +48,16 @@ type SingleSelectPopupProps = { /** Custom styles for the SelectionList */ selectionListStyle?: SelectionListStyle; + + /** Whether SelectionList of popup should stay mounted when popup is not visible. */ + shouldShowList?: boolean; }; function SingleSelectPopup({ label, value, items, + onBackButtonPress, closeOverlay, onChange, isSearchable, @@ -58,9 +65,12 @@ function SingleSelectPopup({ defaultValue, style, selectionListStyle, + shouldShowList = true, }: SingleSelectPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const [selectedItem, setSelectedItem] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -124,27 +134,43 @@ function SingleSelectPopup({ [searchTerm, isSearchable, searchPlaceholder, translate, setSearchTerm, noResultsFound], ); + const hasTitle = isSmallScreenWidth && !!label && !onBackButtonPress; + return ( - - + + + + ); diff --git a/src/components/Search/FilterDropdowns/SortByPopup.tsx b/src/components/Search/FilterDropdowns/SortByPopup.tsx index 9b82d6426a6a..4344d997551c 100644 --- a/src/components/Search/FilterDropdowns/SortByPopup.tsx +++ b/src/components/Search/FilterDropdowns/SortByPopup.tsx @@ -1,53 +1,71 @@ -import React from 'react'; +import React, {useState} from 'react'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import type {SearchColumnType, SearchGroupBy, SearchQueryJSON} from '@components/Search/types'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {close} from '@libs/actions/Modal'; import Navigation from '@libs/Navigation/Navigation'; import {buildSearchQueryString} from '@libs/SearchQueryUtils'; -import {getColumnsToShow, getSearchColumnTranslationKey, getSortByOptions} from '@libs/SearchUIUtils'; +import {getColumnsToShow, getSortByOptions} from '@libs/SearchUIUtils'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import type {SearchResults} from '@src/types/onyx'; -import SingleSelectPopup from './SingleSelectPopup'; +import BasePopup from './BasePopup'; import type {SingleSelectItem} from './SingleSelectPopup'; +const DIVIDER_HEIGHT = 25; + type SortByPopupProps = { searchResults: OnyxEntry; queryJSON: SearchQueryJSON; groupBy: SingleSelectItem | null; onSort: () => void; + onSortOrderPress: () => void; + onBackButtonPress: () => void; closeOverlay: () => void; }; -function SortByPopup({searchResults, queryJSON, groupBy, onSort, closeOverlay}: SortByPopupProps) { +function SortByPopup({searchResults, queryJSON, groupBy, onSort, onSortOrderPress, onBackButtonPress, closeOverlay}: SortByPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {windowHeight} = useWindowDimensions(); + const isInLandscapeMode = useIsInLandscapeMode(); const {accountID} = useCurrentUserPersonalDetails(); const {shouldUseLiveData} = useSearchStateContext(); const {clearSelectedTransactions} = useSearchActionsContext(); + const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); + const searchDataType = shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type; const currentColumns = !searchResults?.data ? [] : getColumnsToShow({currentAccountID: accountID, data: searchResults.data, visibleColumns, type: searchDataType, groupBy: groupBy?.value}); const sortableColumns = getSortByOptions(currentColumns, translate); - const sortBy = {text: translate(getSearchColumnTranslationKey(queryJSON.sortBy)), value: queryJSON.sortBy}; + const sortOrder = queryJSON.sortOrder; + + const [selectedItem, setSelectedItem] = useState(queryJSON.sortBy); + + const options = sortableColumns.map((item) => ({ + text: item.text, + keyForList: item.value, + isSelected: item.value === selectedItem, + })); const onSortChange = (column: SearchColumnType) => { clearSelectedTransactions(); - const newQuery = buildSearchQueryString({ - ...queryJSON, - sortBy: column, - sortOrder: CONST.SEARCH.SORT_ORDER.DESC, - }); + const newQuery = buildSearchQueryString({...queryJSON, sortBy: column}); onSort(); // We want to explicitly clear stale rawQuery since it's only used for manually typed-in queries. close(() => { @@ -55,21 +73,60 @@ function SortByPopup({searchResults, queryJSON, groupBy, onSort, closeOverlay}: }); }; + const updateSelectedItem = (item: ListItem) => { + setSelectedItem(item.keyForList as SearchColumnType); + }; + + const applyChanges = () => { + onSortChange(selectedItem); + closeOverlay(); + }; + + const resetChanges = () => { + const defaultSortBy = sortableColumns.at(0)?.value; + if (!defaultSortBy) { + return; + } + onSortChange(defaultSortBy); + closeOverlay(); + }; + return ( - { - if (!item) { - return; - } - onSortChange(item.value); - }} - /> + + + + + + + ); } diff --git a/src/components/Search/FilterDropdowns/SortOrderPopup.tsx b/src/components/Search/FilterDropdowns/SortOrderPopup.tsx new file mode 100644 index 000000000000..d6187af4d22e --- /dev/null +++ b/src/components/Search/FilterDropdowns/SortOrderPopup.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import {useSearchActionsContext} from '@components/Search/SearchContext'; +import type {SearchQueryJSON, SortOrder} from '@components/Search/types'; +import useLocalize from '@hooks/useLocalize'; +import {close} from '@libs/actions/Modal'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildSearchQueryString} from '@libs/SearchQueryUtils'; +import {getSortOrderOptions} from '@libs/SearchUIUtils'; +import SingleSelectPopup from './SingleSelectPopup'; + +type SortOrderPopupProps = { + queryJSON: SearchQueryJSON; + onSort: () => void; + onBackButtonPress: () => void; + closeOverlay: () => void; +}; + +function SortOrderPopup({queryJSON, onSort, onBackButtonPress, closeOverlay}: SortOrderPopupProps) { + const {translate} = useLocalize(); + const {clearSelectedTransactions} = useSearchActionsContext(); + + const onSortChange = (sortOrder: SortOrder) => { + clearSelectedTransactions(); + const newQuery = buildSearchQueryString({...queryJSON, sortOrder}); + onSort(); + // We want to explicitly clear stale rawQuery since it's only used for manually typed-in queries. + close(() => { + Navigation.setParams({q: newQuery, rawQuery: undefined}); + }); + }; + + const sortOrderOptions = getSortOrderOptions(translate); + const sortOrder = {text: translate(`search.filters.sortOrder.${queryJSON.sortOrder}`), value: queryJSON.sortOrder}; + + return ( + { + if (!item) { + return; + } + onSortChange(item.value); + }} + /> + ); +} + +export default SortOrderPopup; diff --git a/src/components/Search/FilterDropdowns/TextInputPopup.tsx b/src/components/Search/FilterDropdowns/TextInputPopup.tsx index 588ed6ebb4b7..f46a27bbf52f 100644 --- a/src/components/Search/FilterDropdowns/TextInputPopup.tsx +++ b/src/components/Search/FilterDropdowns/TextInputPopup.tsx @@ -10,11 +10,12 @@ type TextInputPopupProps = { defaultValue: string; label?: string; placeholder?: string; + onBackButtonPress?: () => void; closeOverlay: () => void; onChange: (value: string) => void; }; -function TextInputPopup({style, defaultValue, label, placeholder, closeOverlay, onChange}: TextInputPopupProps) { +function TextInputPopup({style, defaultValue, label, placeholder, onBackButtonPress, closeOverlay, onChange}: TextInputPopupProps) { const styles = useThemeStyles(); const [value, setValue] = useState(defaultValue); @@ -33,6 +34,7 @@ function TextInputPopup({style, defaultValue, label, placeholder, closeOverlay, label={label} onReset={resetChanges} onApply={applyChanges} + onBackButtonPress={onBackButtonPress} resetSentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_RESET_TEXT_INPUT} applySentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_APPLY_TEXT_INPUT} style={style} diff --git a/src/components/Search/FilterDropdowns/UserSelectPopup.tsx b/src/components/Search/FilterDropdowns/UserSelectPopup.tsx index 409e8e2963f1..9c5e727b58d0 100644 --- a/src/components/Search/FilterDropdowns/UserSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/UserSelectPopup.tsx @@ -1,5 +1,6 @@ import isEmpty from 'lodash/isEmpty'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import SelectionList from '@components/SelectionList'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; @@ -23,7 +24,7 @@ type UserSelectPopupProps = { value: string[]; /** The popup label */ - label?: string; + label: string; /** Function to call to close the overlay when changes are applied */ closeOverlay: () => void; @@ -45,7 +46,8 @@ function UserSelectPopup({value, label, closeOverlay, onChange, isSearchable}: U const {translate} = useLocalize(); const personalDetails = usePersonalDetails(); const {windowHeight} = useWindowDimensions(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); @@ -176,19 +178,31 @@ function UserSelectPopup({value, label, closeOverlay, onChange, isSearchable}: U onApply={applyChanges} resetSentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_RESET_USER} applySentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_POPUP_APPLY_USER} - style={[styles.getUserSelectionListPopoverHeight(listData.length || 1, windowHeight, shouldUseNarrowLayout, shouldShowSearchInput)]} > - + + + ); } diff --git a/src/components/Search/FilterDropdowns/WorkspaceSelectPopup.tsx b/src/components/Search/FilterDropdowns/WorkspaceSelectPopup.tsx index 4f8be0b07f51..e2afd79b33a6 100644 --- a/src/components/Search/FilterDropdowns/WorkspaceSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/WorkspaceSelectPopup.tsx @@ -6,13 +6,13 @@ import useOnyx from '@hooks/useOnyx'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import type {Icon} from '@src/types/onyx/OnyxCommon'; -import type {PopoverComponentProps} from './DropdownButton'; import type {MultiSelectItem} from './MultiSelectPopup'; import MultiSelectPopup from './MultiSelectPopup'; -type WorkspaceSelectPopupProps = PopoverComponentProps & { - updateFilterForm: (values: Partial) => void; +type WorkspaceSelectPopupProps = { policyIDQuery: string[] | undefined; + updateFilterForm: (values: Partial) => void; + closeOverlay: () => void; }; function filterPolicyIDSelector(searchAdvancedFiltersForm: OnyxEntry) { diff --git a/src/components/Search/SearchAutocompleteInput.tsx b/src/components/Search/SearchAutocompleteInput.tsx index 9b097fb86a0f..df1785d31675 100644 --- a/src/components/Search/SearchAutocompleteInput.tsx +++ b/src/components/Search/SearchAutocompleteInput.tsx @@ -2,7 +2,7 @@ import passthroughPolicyTagListSelector from '@selectors/PolicyTagList'; import type {ForwardedRef} from 'react'; import React, {useEffect, useRef} from 'react'; -import type {StyleProp, TextInputProps, ViewStyle} from 'react-native'; +import type {StyleProp, TextInputProps, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Animated, {interpolateColor, useAnimatedStyle, useSharedValue} from 'react-native-reanimated'; import FormHelpMessage from '@components/FormHelpMessage'; @@ -60,6 +60,12 @@ type SearchAutocompleteInputProps = { /** Any additional styles to apply to text input along with FormHelperMessage */ outerWrapperStyle?: StyleProp; + inputStyle?: StyleProp; + + inputContainerStyle?: StyleProp; + + touchableInputWrapperStyle?: StyleProp; + /** Whether the search reports API call is running */ isSearchingForReports?: boolean; @@ -88,6 +94,9 @@ function SearchAutocompleteInput({ wrapperStyle, wrapperFocusedStyle = {}, outerWrapperStyle, + inputStyle, + inputContainerStyle, + touchableInputWrapperStyle, isSearchingForReports, selection, substitutionMap, @@ -193,63 +202,62 @@ function SearchAutocompleteInput({ return ( - - - { - onFocus?.(); - focusedSharedValue.set(true); - }} - onBlur={() => { - focusedSharedValue.set(false); - onBlur?.(); - }} - onKeyPress={onKeyPress} - isLoading={isSearchingForReports} - ref={(element) => { - if (!ref) { - return; - } - - inputRef.current = element as AnimatedTextInputRef; - - if (typeof ref === 'function') { - ref(element); - return; - } - - // eslint-disable-next-line no-param-reassign - ref.current = element; - }} - type="markdown" - multiline={false} - parser={parser} - selection={selection} - shouldShowClearButton={!!value && !isSearchingForReports} - shouldHideClearButton={false} - onClearInput={clearInput} - /> - + + { + onFocus?.(); + focusedSharedValue.set(true); + }} + onBlur={() => { + focusedSharedValue.set(false); + onBlur?.(); + }} + onKeyPress={onKeyPress} + isLoading={isSearchingForReports} + ref={(element) => { + if (!ref) { + return; + } + + inputRef.current = element as AnimatedTextInputRef; + + if (typeof ref === 'function') { + ref(element); + return; + } + + // eslint-disable-next-line no-param-reassign + ref.current = element; + }} + type="markdown" + multiline={false} + parser={parser} + selection={selection} + shouldShowClearButton={!!value && !isSearchingForReports} + shouldHideClearButton={false} + onClearInput={clearInput} + /> ; - /** Personal details */ - personalDetails: OnyxEntry; - - /** Reports */ - reports: OnyxCollection; - - /** All feeds */ - allFeeds: Record | undefined; - - /** All cards */ - allCards: CardList | undefined; - + /** Map of display values to actual IDs for filters (e.g. workspace name -> policy ID). Used to exclude by ID when multiple options share the same name. */ + autocompleteSubstitutions?: SubstitutionMap; /** Reference to the outer element */ ref?: ForwardedRef; }; @@ -144,10 +137,7 @@ function SearchAutocompleteList({ shouldSubscribeToArrowKeyEvents = true, onHighlightFirstItem, textInputRef, - personalDetails, - reports, - allFeeds, - allCards = CONST.EMPTY_OBJECT, + autocompleteSubstitutions, ref, }: SearchAutocompleteListProps) { const styles = useThemeStyles(); @@ -164,11 +154,18 @@ function SearchAutocompleteList({ const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [policies = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + const sortedActions = useSortedActions(); + const personalDetails = usePersonalDetails(); + const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [personalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST); + const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); + const allCards = personalAndWorkspaceCards ?? CONST.EMPTY_OBJECT; + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; const expensifyIcons = useMemoizedLazyExpensifyIcons(['History', 'MagnifyingGlass']); - const taxRates = getAllTaxRates(policies); + const taxRates = useMemo(() => getAllTaxRates(policies), [policies]); const {options: listOptions, isLoading: isLoadingOptions} = useFilteredOptions({enabled: true, isSearching: !!autocompleteQueryValue.trim(), betas: betas ?? []}); @@ -192,7 +189,7 @@ function SearchAutocompleteList({ } }, [isLoadingOptions]); - const searchOptions = (() => { + const searchOptions = useMemo(() => { if (listOptions === null) { return defaultListOptions; } @@ -217,8 +214,25 @@ function SearchAutocompleteList({ currentUserEmail, policyCollection: policies, personalDetails, + sortedActions, + conciergeReportID, }); - })(); + }, [ + listOptions, + draftComments, + nvpDismissedProductTraining, + betas, + autocompleteQueryValue, + countryCode, + loginList, + visibleReportActionsData, + currentUserAccountID, + currentUserEmail, + policies, + personalDetails, + sortedActions, + conciergeReportID, + ]); const [isInitialRender, setIsInitialRender] = useState(true); const prevQueryRef = useRef(autocompleteQueryValue); @@ -294,41 +308,58 @@ function SearchAutocompleteList({ personalDetails, feedKeysWithCards, translate, + autocompleteSubstitutions, }); const autocompleteQueryWithoutFilters = getQueryWithoutFilters(autocompleteQueryValue); - const sortedRecentSearches = Object.entries(recentSearches ?? {}).sort(([, firstRecentSearch], [, secondRecentSearch]) => - localeCompare(secondRecentSearch.timestamp, firstRecentSearch.timestamp), - ); + const recentSearchesData = useMemo(() => { + const sortedRecentSearches = Object.entries(recentSearches ?? {}).sort(([, firstRecentSearch], [, secondRecentSearch]) => + localeCompare(secondRecentSearch.timestamp, firstRecentSearch.timestamp), + ); - const recentSearchesData = sortedRecentSearches?.slice(0, 5).map(([recentSearchHash, {query}]) => { - const searchQueryJSON = buildSearchQueryJSON(query); - return { - text: searchQueryJSON - ? buildUserReadableQueryString({ - queryJSON: searchQueryJSON, - PersonalDetails: personalDetails, - reports, - taxRates, - cardList: allCards, - cardFeeds: allFeeds, - policies, - currentUserAccountID, - autoCompleteWithSpace: false, - translate, - feedKeysWithCards, - reportAttributes, - }) - : query, - singleIcon: expensifyIcons.History, - searchQuery: query, - keyForList: recentSearchHash, - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, - }; - }); + return sortedRecentSearches.slice(0, 5).map(([recentSearchHash, {query}]) => { + const searchQueryJSON = buildSearchQueryJSON(query); + return { + text: searchQueryJSON + ? buildUserReadableQueryString({ + queryJSON: searchQueryJSON, + PersonalDetails: personalDetails, + reports, + taxRates, + cardList: allCards, + cardFeeds: allFeeds, + policies, + currentUserAccountID, + autoCompleteWithSpace: false, + translate, + feedKeysWithCards, + reportAttributes, + }) + : query, + singleIcon: expensifyIcons.History, + searchQuery: query, + keyForList: recentSearchHash, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, + }; + }); + }, [ + recentSearches, + localeCompare, + personalDetails, + reports, + taxRates, + allCards, + allFeeds, + policies, + currentUserAccountID, + translate, + feedKeysWithCards, + reportAttributes, + expensifyIcons.History, + ]); - const recentReportsOptions = (() => { + const recentReportsOptions = useMemo(() => { if (autocompleteQueryValue.trim() === '') { return searchOptions.recentReports; } @@ -344,7 +375,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - })(); + }, [autocompleteQueryValue, searchOptions]); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index d0184c76a33c..afa88ed632e1 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -25,6 +25,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BulkDuplicateHandler from './BulkDuplicateHandler'; +import BulkDuplicateReportHandler from './BulkDuplicateReportHandler'; import {useSearchActionsContext, useSearchStateContext} from './SearchContext'; import type {BulkPaySelectionData, SearchQueryJSON} from './types'; @@ -38,7 +39,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { // We need isSmallScreenWidth (not just shouldUseNarrowLayout) because DecisionModal requires it for correct modal type // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); - const {selectedTransactions, areAllMatchingItemsSelected, shouldShowSelectAllMatchingItems} = useSearchStateContext(); + const {selectedTransactions, selectedReports, areAllMatchingItemsSelected, shouldShowSelectAllMatchingItems} = useSearchStateContext(); const {selectAllMatchingItems} = useSearchActionsContext(); const kycWallRef = useContext(KYCWallContext); const {isAccountLocked} = useLockedAccountState(); @@ -60,17 +61,17 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { confirmPayment, isOfflineModalVisible, isDownloadErrorModalVisible, - isNonReimbursablePaymentErrorModalVisible, isHoldEducationalModalVisible, rejectModalAction, emptyReportsCount, handleOfflineModalClose, handleDownloadErrorModalClose, - handleNonReimbursablePaymentErrorModalClose, dismissModalAndUpdateUseHold, dismissRejectModalBasedOnAction, isDuplicateOptionVisible, setDuplicateHandler, + isDuplicateReportOptionVisible, + setDuplicateReportHandler, allTransactions, allReports, searchData, @@ -116,6 +117,14 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { onHandlerReady={setDuplicateHandler} /> )} + {isDuplicateReportOptionVisible && ( + + )} ) : ( - + null} shouldAlwaysShowDropdownMenu - buttonSize={CONST.DROPDOWN_BUTTON_SIZE.SMALL} customText={selectionButtonText} options={headerButtonsOptions} shouldPopoverUseScrollView={popoverUseScrollView} @@ -243,15 +251,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { isVisible={isDownloadErrorModalVisible} onClose={handleDownloadErrorModalClose} /> - 1)} - isSmallScreenWidth={isSmallScreenWidth} - onSecondOptionSubmit={handleNonReimbursablePaymentErrorModalClose} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isNonReimbursablePaymentErrorModalVisible} - onClose={handleNonReimbursablePaymentErrorModalClose} - /> {!!rejectModalAction && ( {}, removeTransaction: () => {}, clearSelectedTransactions: () => {}, - setShouldShowActionsBarLoading: () => {}, + setShouldShowFiltersBarLoading: () => {}, setShouldShowSelectAllMatchingItems: () => {}, selectAllMatchingItems: () => {}, setShouldResetSearchQuery: () => {}, @@ -103,7 +103,7 @@ function SearchContextProvider({children}: SearchContextProps) { const areTransactionsEmpty = useRef(true); const [lastSearchType, setLastSearchType] = useState(); const [areAllMatchingItemsSelected, selectAllMatchingItems] = useState(false); - const [shouldShowActionsBarLoading, setShouldShowActionsBarLoading] = useState(false); + const [shouldShowFiltersBarLoading, setShouldShowFiltersBarLoading] = useState(false); const [shouldShowSelectAllMatchingItems, setShouldShowSelectAllMatchingItems] = useState(false); const [searchContextData, setSearchContextData] = useState({...defaultSearchContextData}); @@ -188,6 +188,7 @@ function SearchContextProvider({children}: SearchContextProps) { ownerAccountID, parentReportActionID, parentReportID, + type, }) => ({ reportID, action, @@ -200,6 +201,7 @@ function SearchContextProvider({children}: SearchContextProps) { ownerAccountID, parentReportActionID, parentReportID, + type, }), ); } else if (data.length && data.every(isTransactionListItemType)) { @@ -330,7 +332,7 @@ function SearchContextProvider({children}: SearchContextProps) { currentSimilarSearchHash, currentSearchResults, shouldUseLiveData, - shouldShowActionsBarLoading, + shouldShowFiltersBarLoading, lastSearchType, shouldShowSelectAllMatchingItems, areAllMatchingItemsSelected, @@ -344,7 +346,7 @@ function SearchContextProvider({children}: SearchContextProps) { currentSimilarSearchHash, currentSearchResults, shouldUseLiveData, - shouldShowActionsBarLoading, + shouldShowFiltersBarLoading, lastSearchType, shouldShowSelectAllMatchingItems, areAllMatchingItemsSelected, @@ -358,13 +360,13 @@ function SearchContextProvider({children}: SearchContextProps) { setSelectedTransactions, setCurrentSelectedTransactionReportID, clearSelectedTransactions, - setShouldShowActionsBarLoading, + setShouldShowFiltersBarLoading, setLastSearchType, setShouldShowSelectAllMatchingItems, selectAllMatchingItems, setShouldResetSearchQuery, }), - // setShouldShowActionsBarLoading, setLastSearchType, setShouldShowSelectAllMatchingItems, + // shouldShowFiltersBarLoading, setLastSearchType, setShouldShowSelectAllMatchingItems, // and selectAllMatchingItems are stable useState setters โ€” excluded from deps intentionally. // setCurrentSelectedTransactionReportID only uses setSearchContextData (stable setter). [removeTransaction, setSelectedTransactions, clearSelectedTransactions, setShouldResetSearchQuery], diff --git a/src/components/Search/SearchDatePresetFilterBasePage.tsx b/src/components/Search/SearchDatePresetFilterBasePage.tsx index 5852acaa81aa..200cf9260388 100644 --- a/src/components/Search/SearchDatePresetFilterBasePage.tsx +++ b/src/components/Search/SearchDatePresetFilterBasePage.tsx @@ -139,6 +139,14 @@ function SearchDatePresetFilterBasePage({dateKey, titleKey}: SearchDatePresetFil }); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); }} + onReset={(values) => { + updateAdvancedFilters({ + [dateOnKey]: values[CONST.SEARCH.DATE_MODIFIERS.ON] ?? null, + [dateBeforeKey]: values[CONST.SEARCH.DATE_MODIFIERS.BEFORE] ?? null, + [dateAfterKey]: values[CONST.SEARCH.DATE_MODIFIERS.AFTER] ?? null, + [dateRangeKey]: values[CONST.SEARCH.DATE_MODIFIERS.RANGE] ?? null, + }); + }} /> ); diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index 3f8fc264fbd9..f035cd5d0efc 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -1,16 +1,17 @@ import passthroughPolicyTagListSelector from '@selectors/PolicyTagList'; import React, {useEffect, useState} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import {useOptionsList} from '@components/OptionListContextProvider'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; +import useFilteredOptions from '@hooks/useFilteredOptions'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import useSortedActions from '@hooks/useSortedActions'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {createOptionFromReport, filterAndOrderOptions, formatSectionsFromSearchTerm, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; @@ -46,14 +47,17 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const {translate} = useLocalize(); const personalDetails = usePersonalDetails(); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); - const {options, areOptionsInitialized} = useOptionsList({ - shouldInitialize: didScreenTransitionEnd, + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const {options: listOptions, isLoading} = useFilteredOptions({ + enabled: didScreenTransitionEnd, + isSearching: !!debouncedSearchTerm.trim(), }); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -61,12 +65,12 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); const reportAttributesDerived = useReportAttributes(); const [selectedReportIDs, setSelectedReportIDs] = useState(initialReportIDs); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const cleanSearchTerm = searchTerm.trim().toLowerCase(); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const privateIsArchivedMap = usePrivateIsArchivedMap(); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: passthroughPolicyTagListSelector}); + const sortedActions = useSortedActions(); const selectedOptions: OptionData[] = selectedReportIDs.map((id) => { const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${id}`]; @@ -75,15 +79,15 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const report = getSelectedOptionData(createOptionFromReport({...reportData, reportID: id}, personalDetails, privateIsArchived, reportPolicy, reportAttributesDerived)); const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportData?.policyID}`]; const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`]; - const alternateText = getAlternateText(report, {}, {isReportArchived: privateIsArchived, policy, reportAttributesDerived, policyTags: reportPolicyTags}); + const alternateText = getAlternateText(report, {}, {isReportArchived: privateIsArchived, policy, reportAttributesDerived, policyTags: reportPolicyTags, conciergeReportID}); return {...report, alternateText}; }); const defaultOptions = - !areOptionsInitialized || !isScreenTransitionEnd + isLoading || !isScreenTransitionEnd || !listOptions ? defaultListOptions : getSearchOptions({ - options, + options: listOptions, draftComments, nvpDismissedProductTraining, betas: undefined, @@ -94,6 +98,8 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen currentUserEmail, personalDetails, policyCollection: allPolicies, + sortedActions, + conciergeReportID, }); const chatOptions = filterAndOrderOptions(defaultOptions, cleanSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, { @@ -103,7 +109,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const sections: SelectionListSections = []; - if (areOptionsInitialized) { + if (!isLoading) { const formattedResults = formatSectionsFromSearchTerm( cleanSearchTerm, selectedOptions, @@ -170,7 +176,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen ); const isLoadingNewOptions = !!isSearchingForReports; - const shouldShowLoadingPlaceholder = !didScreenTransitionEnd || !areOptionsInitialized || !initialReportIDs || !personalDetails; + const shouldShowLoadingPlaceholder = !didScreenTransitionEnd || isLoading || !initialReportIDs || !personalDetails; const textInputOptions = { value: searchTerm, diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx index 13c04e34ab1e..9ae212784330 100644 --- a/src/components/Search/SearchList/BaseSearchList/index.tsx +++ b/src/components/Search/SearchList/BaseSearchList/index.tsx @@ -3,13 +3,13 @@ import {FlashList} from '@shopify/flash-list'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {NativeSyntheticEvent} from 'react-native'; import Animated from 'react-native-reanimated'; +import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import {isMobileChrome} from '@libs/Browser'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; import CONST from '@src/CONST'; -import type {SearchListItem} from '../ListItem/types'; import type BaseSearchListProps from './types'; const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); @@ -32,7 +32,8 @@ function BaseSearchList({ flattenedItemsLength, newTransactions, selectedTransactions, - customCardNames, + policyForMovingExpenses, + nonPersonalAndWorkspaceCards, }: BaseSearchListProps) { const hasKeyBeenPressed = useRef(false); const isFocused = useIsFocused(); @@ -106,8 +107,8 @@ function BaseSearchList({ }, [setHasKeyBeenPressed]); const extraData = useMemo( - () => [focusedIndex, columns, newTransactions, selectedTransactions, customCardNames], - [focusedIndex, columns, newTransactions, selectedTransactions, customCardNames], + () => [focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards, policyForMovingExpenses], + [focusedIndex, columns, newTransactions, selectedTransactions, nonPersonalAndWorkspaceCards, policyForMovingExpenses], ); return ( diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts index afd925474cea..b0c05aeb9e58 100644 --- a/src/components/Search/SearchList/BaseSearchList/types.ts +++ b/src/components/Search/SearchList/BaseSearchList/types.ts @@ -4,7 +4,7 @@ import type {NativeSyntheticEvent} from 'react-native'; import type {SearchListItem} from '@components/Search/SearchList/ListItem/types'; import type {SearchColumnType, SelectedTransactions} from '@components/Search/types'; import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; -import type {Transaction} from '@src/types/onyx'; +import type {CardList, Policy, Transaction} from '@src/types/onyx'; type BaseSearchListProps = Pick< FlashListProps, @@ -45,8 +45,11 @@ type BaseSearchListProps = Pick< /** Selected transactions for triggering re-render via extraData */ selectedTransactions?: SelectedTransactions; - /** Custom card names for triggering re-render via extraData */ - customCardNames?: Record; + /** Policy for moving expenses for triggering re-render via extraData */ + policyForMovingExpenses?: Policy; + + /** Non-personal and workspace cards for triggering re-render via extraData */ + nonPersonalAndWorkspaceCards?: CardList; }; export default BaseSearchListProps; diff --git a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx index 6aa6c38790ea..8d0670ab460d 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx @@ -4,16 +4,15 @@ import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/ import {SearchScopeProvider} from '@components/Search/SearchScopeProvider'; import SettlementButton from '@components/SettlementButton'; import type {PaymentActionParams} from '@components/SettlementButton/types'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useNetwork from '@hooks/useNetwork'; -import useNonReimbursablePaymentModal from '@hooks/useNonReimbursablePaymentModal'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useReportWithTransactionsAndViolations from '@hooks/useReportWithTransactionsAndViolations'; import useThemeStyles from '@hooks/useThemeStyles'; -import {canIOUBePaid} from '@libs/actions/IOU'; +import {canIOUBePaid} from '@libs/actions/IOU/ReportWorkflow'; import {getPayMoneyOnSearchInvoiceParams, payMoneyRequestOnSearch} from '@libs/actions/Search'; -import {convertToDisplayString} from '@libs/CurrencyUtils'; -import {hasOnlyNonReimbursableTransactions, isInvoiceReport} from '@libs/ReportUtils'; +import {isInvoiceReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -30,21 +29,18 @@ type PayActionCellProps = { function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, shouldDisablePointerEvents}: PayActionCellProps) { const styles = useThemeStyles(); + const {convertToDisplayString} = useCurrencyListActions(); const {isOffline} = useNetwork(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const [iouReport, transactions] = useReportWithTransactionsAndViolations(reportID); - const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment} = useNonReimbursablePaymentModal(iouReport, transactions); const policy = usePolicy(policyID); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReport?.chatReportID}`); const invoiceReceiverPolicyID = chatReport?.invoiceReceiver && 'policyID' in chatReport.invoiceReceiver ? chatReport.invoiceReceiver.policyID : undefined; const invoiceReceiverPolicy = usePolicy(invoiceReceiverPolicyID); const canBePaid = canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, false, undefined, invoiceReceiverPolicy); - const shouldOnlyShowElsewhere = - !canBePaid && - canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, true, undefined, invoiceReceiverPolicy) && - !hasOnlyNonReimbursableTransactions(iouReport?.reportID, transactions); + const shouldOnlyShowElsewhere = !canBePaid && canIOUBePaid(iouReport, chatReport, policy, bankAccountList, transactions, true, undefined, invoiceReceiverPolicy); const {currency} = iouReport ?? {}; @@ -58,11 +54,6 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, extraSmall, return; } - if (shouldBlockDirectPayment(type)) { - showNonReimbursablePaymentErrorModal(); - return; - } - const invoiceParams = getPayMoneyOnSearchInvoiceParams(policyID, payAsBusiness, methodID, paymentMethod); payMoneyRequestOnSearch(hash, [ { diff --git a/src/components/Search/SearchList/ListItem/ActionCell/actionTranslationsMap.ts b/src/components/Search/SearchList/ListItem/ActionCell/actionTranslationsMap.ts index d1d96ce43458..c1da181721ef 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/actionTranslationsMap.ts +++ b/src/components/Search/SearchList/ListItem/ActionCell/actionTranslationsMap.ts @@ -11,6 +11,7 @@ const actionTranslationsMap: Record = [CONST.SEARCH.ACTION_TYPES.EXPORT_TO_ACCOUNTING]: 'common.export', [CONST.SEARCH.ACTION_TYPES.DONE]: 'common.done', [CONST.SEARCH.ACTION_TYPES.PAID]: 'iou.settledExpensify', + [CONST.SEARCH.ACTION_TYPES.UNDELETE]: 'search.bulkActions.undelete', }; export default actionTranslationsMap; diff --git a/src/components/Search/SearchList/ListItem/ActionCell/index.tsx b/src/components/Search/SearchList/ListItem/ActionCell/index.tsx index 1ab70eeac6ea..3aa1772fba18 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/index.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/index.tsx @@ -11,7 +11,7 @@ import PayActionCell from './PayActionCell'; type ActionCellProps = { action?: SearchTransactionAction; isSelected?: boolean; - goToItem: () => void; + onButtonPress: () => void; isChildListItem?: boolean; isLoading?: boolean; policyID?: string; @@ -25,7 +25,7 @@ type ActionCellProps = { function ActionCell({ action = CONST.SEARCH.ACTION_TYPES.VIEW, isSelected = false, - goToItem, + onButtonPress, isChildListItem = false, isLoading = false, policyID = '', @@ -41,7 +41,7 @@ function ActionCell({ const shouldUseViewAction = action === CONST.SEARCH.ACTION_TYPES.VIEW || action === CONST.SEARCH.ACTION_TYPES.PAID || action === CONST.SEARCH.ACTION_TYPES.DONE; - if (shouldUseViewAction || isChildListItem) { + if (shouldUseViewAction || (isChildListItem && action !== CONST.SEARCH.ACTION_TYPES.UNDELETE)) { const text = translate(actionTranslationsMap[CONST.SEARCH.ACTION_TYPES.VIEW]); const buttonInnerStyles = isSelected ? styles.buttonDefaultSelected : {}; @@ -49,7 +49,7 @@ function ActionCell({ - ); -} - -/** - * In the submit-and-navigate flow we only ever land on `type:expense` or `type:invoice` - * with default status and no extra filters, so the chips are mostly hardcoded. - * The only conditional chip is "Workspaces" (shown when the user has >1 workspace), - * resolved via a cheap boolean Onyx selector. - */ -function StaticFiltersBar({queryJSON}: {queryJSON: SearchQueryJSON}) { - const styles = useThemeStyles(); - const theme = useTheme(); - const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Filter'] as const); - const [policyInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: staticPolicyInfoSelector}); - const hasMultipleWorkspaces = policyInfo?.hasMultipleWorkspaces ?? false; - - const typeLabel = queryJSON.type === CONST.SEARCH.DATA_TYPES.INVOICE ? translate('common.invoice') : translate('common.expense'); - - const chips = useMemo( - () => [ - {key: 'type', label: `${translate('common.type')}: ${typeLabel}`}, - {key: 'status', label: translate('common.status')}, - {key: 'date', label: translate('common.date')}, - {key: 'from', label: translate('common.from')}, - ...(hasMultipleWorkspaces ? [{key: 'workspace', label: translate('workspace.common.workspace')}] : []), - ], - [translate, typeLabel, hasMultipleWorkspaces], - ); - - return ( - - item.key} - renderItem={({item}) => } - ListFooterComponent={ - - )} - {/** - These are the actionable buttons that appear at the bottom of a Concierge message - for example: Invite a user mentioned but not a member of the room - https://github.com/Expensify/App/issues/32741 - */} - {actionableItemButtons.length > 0 && ( - - )} - - ) : ( - - )} - - - - + ); } const numberOfThreadReplies = action.childVisibleActionCount ?? 0; @@ -1914,24 +1111,12 @@ function PureReportActionItem({ !isEmptyObject(item))} /> )} - {!isMessageDeleted(action) && ( + {!isOnSearch && !isMessageDeleted(action) && ( { - if (isAnonymousUser()) { - hideContextMenu(false); - - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - signOutAndRedirectToSignIn(); - }); - } else { - toggleReaction(emoji, preferredSkinTone, ignoreSkinToneOnCompare); - } - }} setIsEmojiPickerActive={setIsEmojiPickerActive} /> @@ -1948,7 +1133,6 @@ function PureReportActionItem({ accountIDs={oldestFourAccountIDs} onSecondaryInteraction={showPopover} isActive={isReportActionActive && !isContextMenuActive} - currentUserAccountID={currentUserAccountID} /> )} @@ -2248,27 +1432,22 @@ export default memo(PureReportActionItem, (prevProps, nextProps) => { deepEqual(prevParentReportAction, nextParentReportAction) && prevProps.draftMessage === nextProps.draftMessage && prevProps.iouReport?.reportID === nextProps.iouReport?.reportID && - deepEqual(prevProps.emojiReactions, nextProps.emojiReactions) && deepEqual(prevProps.linkedTransactionRouteError, nextProps.linkedTransactionRouteError) && prevProps.isUserValidated === nextProps.isUserValidated && prevProps.parentReport?.reportID === nextProps.parentReport?.reportID && deepEqual(prevProps.personalDetails, nextProps.personalDetails) && deepEqual(prevProps.introSelected, nextProps.introSelected) && deepEqual(prevProps.betas, nextProps.betas) && - deepEqual(prevProps.blockedFromConcierge, nextProps.blockedFromConcierge) && prevProps.originalReportID === nextProps.originalReportID && deepEqual(prevProps.originalReport?.participants, nextProps.originalReport?.participants) && prevProps.isArchivedRoom === nextProps.isArchivedRoom && prevProps.isChronosReport === nextProps.isChronosReport && prevProps.isClosedExpenseReportWithNoExpenses === nextProps.isClosedExpenseReportWithNoExpenses && deepEqual(prevProps.missingPaymentMethod, nextProps.missingPaymentMethod) && - prevProps.reimbursementDeQueuedOrCanceledActionMessage === nextProps.reimbursementDeQueuedOrCanceledActionMessage && - prevProps.modifiedExpenseMessage === nextProps.modifiedExpenseMessage && prevProps.userBillingFundID === nextProps.userBillingFundID && deepEqual(prevProps.taskReport, nextProps.taskReport) && prevProps.shouldHighlight === nextProps.shouldHighlight && deepEqual(prevProps.bankAccountList, nextProps.bankAccountList) && - deepEqual(prevProps.cardList, nextProps.cardList) && prevProps.reportNameValuePairsOrigin === nextProps.reportNameValuePairsOrigin && prevProps.reportNameValuePairsOriginalID === nextProps.reportNameValuePairsOriginalID && prevProps.reportMetadata?.pendingExpenseAction === nextProps.reportMetadata?.pendingExpenseAction diff --git a/src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx b/src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx new file mode 100644 index 000000000000..835cc1323b4f --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/AgentZeroAwareTypingIndicator.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; +import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; + +function AgentZeroAwareTypingIndicator({reportID}: {reportID: string}) { + const shouldSuppress = useShouldSuppressConciergeIndicators(reportID); + if (shouldSuppress) { + return null; + } + return ; +} + +export default AgentZeroAwareTypingIndicator; diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 347472be3fa5..a3da37031996 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -1,6 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; -import React, {Activity, useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import AttachmentPicker from '@components/AttachmentPicker'; @@ -532,39 +532,37 @@ function AttachmentPickerWithMenuItems({ )} - - { - setMenuVisibility(false); - onItemSelected(); - - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === menuItems.length - 1) { - if (isSafari()) { - triggerAttachmentPicker(); - return; - } - close(() => { - triggerAttachmentPicker(); - }); + { + setMenuVisibility(false); + onItemSelected(); + + // In order for the file picker to open dynamically, the click + // function must be called from within a event handler that was initiated + // by the user on Safari. + if (index === menuItems.length - 1) { + if (isSafari()) { + triggerAttachmentPicker(); + return; } - }} - anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - }} - menuItems={menuItems} - anchorRef={actionButtonRef} - /> - + close(() => { + triggerAttachmentPicker(); + }); + } + }} + anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + menuItems={menuItems} + anchorRef={actionButtonRef} + /> ); }} diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx new file mode 100644 index 000000000000..93b89eb5c433 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerActionMenu.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; +import useOnyx from '@hooks/useOnyx'; +import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; +import {chatIncludesConcierge} from '@libs/ReportUtils'; +import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {FileObject} from '@src/types/utils/Attachment'; +import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; +import {useComposerActions, useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; + +type ComposerActionMenuProps = { + reportID: string; + onAttachmentPicked: (files: FileObject | FileObject[]) => void; +}; + +function ComposerActionMenu({reportID, onAttachmentPicked}: ComposerActionMenuProps) { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {isMenuVisible, isFullComposerAvailable} = useComposerState(); + const {exceededMaxLength} = useComposerSendState(); + const {setMenuVisibility, focus, onAddActionPressed, onItemSelected, onTriggerAttachmentPicker} = useComposerActions(); + const {actionButtonRef} = useComposerMeta(); + + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); + + const {raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); + const isBlockedFromConcierge = chatIncludesConcierge({participants: report?.participants}) && isBlockedFromConciergeUserAction(blockedFromConcierge); + + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); + + const shouldFocusComposerOnScreenFocus = canFocusInputOnScreenFocus() || !!draftComment; + + return ( + { + if (!shouldFocusComposerOnScreenFocus) { + return; + } + focus(); + }} + actionButtonRef={actionButtonRef} + shouldDisableAttachmentItem={!!exceededMaxLength} + /> + ); +} + +export default ComposerActionMenu; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx new file mode 100644 index 000000000000..f4bc3fc4d2c6 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerBox.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import {View} from 'react-native'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {useComposerMeta, useComposerSendState, useComposerState} from './ComposerContext'; + +type ComposerBoxProps = { + reportID: string; + children: React.ReactNode; +}; + +function ComposerBox({reportID, children}: ComposerBoxProps) { + const styles = useThemeStyles(); + const {isFocused} = useComposerState(); + const {exceededMaxLength, isBlockedFromConcierge} = useComposerSendState(); + const {containerRef} = useComposerMeta(); + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + + const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); + const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; + + return ( + + + {children} + + + ); +} + +export default ComposerBox; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 3378fb68bbd0..9962ee8a99a1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -36,7 +36,7 @@ type ComposerSendState = { // Frozen โ€” stable references, never changes after mount type ComposerActions = { - setValue: (v: string) => void; + setText: (v: string) => void; setMenuVisibility: (v: boolean) => void; setIsFullComposerAvailable: (v: boolean) => void; setComposerRef: (ref: ComposerRef | null) => void; @@ -85,7 +85,7 @@ const defaultSendState: ComposerSendState = { const ComposerSendStateContext = createContext(defaultSendState); const defaultActions: ComposerActions = { - setValue: noop, + setText: noop, setMenuVisibility: noop, setIsFullComposerAvailable: noop, setComposerRef: noop, diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx new file mode 100644 index 000000000000..ecc2ef677e13 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerEmojiPicker.tsx @@ -0,0 +1,63 @@ +import React, {useEffect} from 'react'; +import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import DomUtils from '@libs/DomUtils'; +import {hideEmojiPicker, isActive as isActiveEmojiPickerAction} from '@userActions/EmojiPickerAction'; +import CONST from '@src/CONST'; +import {useComposerActions, useComposerMeta, useComposerSendState} from './ComposerContext'; + +type ComposerEmojiPickerProps = { + reportID: string; +}; + +function ComposerEmojiPicker({reportID}: ComposerEmojiPickerProps) { + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isMediumScreenWidth} = useResponsiveLayout(); + const {focus} = useComposerActions(); + const {composerRef} = useComposerMeta(); + const {isBlockedFromConcierge} = useComposerSendState(); + + const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; + const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; + const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; + const emojiShiftVertical = reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; + + // Hide emoji picker on unmount or when switching reports + useEffect( + () => () => { + if (!isActiveEmojiPickerAction(reportID)) { + return; + } + hideEmojiPicker(); + }, + [reportID], + ); + + if (canUseTouchScreen() && isMediumScreenWidth) { + return null; + } + + return ( + { + if (isNavigating) { + return; + } + const activeElementId = DomUtils.getActiveElement()?.id; + if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { + return; + } + focus(); + }} + onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} + emojiPickerID={reportID} + shiftVertical={emojiShiftVertical} + /> + ); +} + +export default ComposerEmojiPicker; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerExceededLength.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerExceededLength.tsx new file mode 100644 index 000000000000..c7e35ab69638 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerExceededLength.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import ExceededCommentLength from '@components/ExceededCommentLength'; +import {useComposerSendState} from './ComposerContext'; + +function ComposerExceededLength() { + const {exceededMaxLength, hasExceededMaxTaskTitleLength} = useComposerSendState(); + if (!exceededMaxLength) { + return null; + } + return ( + + ); +} + +export default ComposerExceededLength; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx new file mode 100644 index 000000000000..c34ca8e8e9de --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerFooter.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import {View} from 'react-native'; +import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type ComposerFooterProps = { + children: React.ReactNode; +}; + +function ComposerFooter({children}: ComposerFooterProps) { + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const {isOffline} = useNetwork(); + + return ( + + {children} + + ); +} + +export default ComposerFooter; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerImportedState.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerImportedState.tsx new file mode 100644 index 000000000000..7215cf9f334c --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerImportedState.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import {View} from 'react-native'; +import ImportedStateIndicator from '@components/ImportedStateIndicator'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +function ComposerImportedState() { + const styles = useThemeStyles(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + if (isSmallScreenWidth) { + return null; + } + + return ( + + + + ); +} + +export default ComposerImportedState; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx new file mode 100644 index 000000000000..456b6e397627 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerInput.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import type {MeasureInWindowOnSuccessCallback} from 'react-native'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import FS from '@libs/Fullstory'; +import { + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + chatIncludesChronos, + chatIncludesConcierge, + isMoneyRequestReport, + isReportTransactionThread, +} from '@libs/ReportUtils'; +import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; +import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {FileObject} from '@src/types/utils/Attachment'; +import {useComposerActions, useComposerMeta, useComposerSendActions, useComposerSendState, useComposerState} from './ComposerContext'; +import ComposerWithSuggestions from './ComposerWithSuggestions'; +import useComposerSubmit from './useComposerSubmit'; + +type ComposerInputProps = { + reportID: string; + onPasteFile: (files: FileObject | FileObject[]) => void; +}; + +const AI_PLACEHOLDER_KEYS = ['reportActionCompose.askConciergeToUpdate', 'reportActionCompose.askConciergeToCorrect', 'reportActionCompose.askConciergeForHelp'] as const; + +function getRandomPlaceholder(translate: LocalizedTranslate): string { + const randomIndex = Math.floor(Math.random() * AI_PLACEHOLDER_KEYS.length); + return translate(AI_PLACEHOLDER_KEYS[randomIndex]); +} + +function ComposerInput({reportID, onPasteFile}: ComposerInputProps) { + const {translate, preferredLocale} = useLocalize(); + const {isMenuVisible} = useComposerState(); + const {isBlockedFromConcierge} = useComposerSendState(); + const {setIsFullComposerAvailable, onBlur, onFocus, setComposerRef} = useComposerActions(); + const {handleSendMessage, onValueChange} = useComposerSendActions(); + const {containerRef, suggestionsRef, isNextModalWillOpenRef} = useComposerMeta(); + + const submitForm = useComposerSubmit(reportID); + + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); + const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); + const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); + + const measureContainer = (callback: MeasureInWindowOnSuccessCallback) => { + containerRef.current?.measureInWindow(callback); + }; + + const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const isReportArchived = useReportIsArchived(report?.reportID); + + const includesConcierge = chatIncludesConcierge({participants: report?.participants}); + const isGroupPolicyReport = !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE; + const isExpenseRelatedReport = isReportTransactionThread(report) || isMoneyRequestReport(report); + const isEnglishLocale = (preferredLocale ?? CONST.LOCALES.DEFAULT) === CONST.LOCALES.EN; + const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); + + let inputPlaceholder = translate('reportActionCompose.writeSomething'); + if (includesConcierge && userBlockedFromConcierge) { + inputPlaceholder = translate('reportActionCompose.blockedFromConcierge'); + } else if (isExpenseRelatedReport && canUserPerformWriteAction && isEnglishLocale) { + inputPlaceholder = getRandomPlaceholder(translate); + } + const fsClass = report ? FS.getChatFSClass(report) : undefined; + + return ( + + ); +} + +export default ComposerInput; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx new file mode 100644 index 000000000000..d692662712d3 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerLocalTime.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; +import {canShowReportRecipientLocalTime, getReportOfflinePendingActionAndErrors, getReportRecipientAccountIDs} from '@libs/ReportUtils'; +import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type ComposerLocalTimeProps = { + reportID: string; +}; + +function ComposerLocalTime({reportID}: ComposerLocalTimeProps) { + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); + + const shouldShow = canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize; + const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); + const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; + + if (!shouldShow || isEmptyObject(reportRecipient)) { + return null; + } + + return ( + + + + ); +} + +export default ComposerLocalTime; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 64cdd5731924..51ffb8502ead 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -40,11 +40,11 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); const [isMenuVisible, setMenuVisibility] = useState(false); - const [value, setValue] = useState(() => { + const [text, setText] = useState(() => { return draftComment ?? ''; }); - const isEmpty = !value || !!value.match(CONST.REGEX.EMPTY_COMMENT); + const isEmpty = !text || !!text.match(CONST.REGEX.EMPTY_COMMENT); const includesConcierge = chatIncludesConcierge({participants: report?.participants}); const userBlockedFromConcierge = isBlockedFromConciergeUserAction(blockedFromConcierge); @@ -127,14 +127,13 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }; const onValueChange = (v: string) => { + setText(v); if (v.length === 0 && isComposerFullSize) { setIsComposerFullSize(reportID, false); } debouncedValidate(v); }; - const text = value; - const composerState = { isFocused, isMenuVisible, @@ -149,7 +148,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { }; const composerActions = { - setValue, + setText, setMenuVisibility, setIsFullComposerAvailable, setComposerRef, diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx new file mode 100644 index 000000000000..111420516795 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ComposerSendButton.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import {useComposerSendActions, useComposerSendState} from './ComposerContext'; +import SendButton from './SendButton'; + +function ComposerSendButton() { + const {isSendDisabled} = useComposerSendState(); + const {handleSendMessage} = useComposerSendActions(); + + return ( + + ); +} + +export default ComposerSendButton; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index ff903d77e125..9dd24af5831d 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -53,6 +53,7 @@ import getScrollPosition from '@pages/inbox/report/ReportActionCompose/getScroll import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import SilentCommentUpdater from '@pages/inbox/report/ReportActionCompose/SilentCommentUpdater'; import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; +import useLastEditableAction from '@pages/inbox/report/ReportActionCompose/useLastEditableAction'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; import {inputFocusChange} from '@userActions/InputFocus'; @@ -61,7 +62,6 @@ import {broadcastUserIsTyping, saveReportActionDraft, saveReportDraftComment} fr import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; // eslint-disable-next-line no-restricted-imports @@ -112,9 +112,6 @@ type ComposerWithSuggestionsProps = Partial & /** Whether the input is disabled, defaults to false */ disabled?: boolean; - /** Function to set whether the comment is empty */ - setIsCommentEmpty: (isCommentEmpty: boolean) => void; - /** Function to handle sending a message */ onEnterKeyPress: () => void; @@ -136,9 +133,6 @@ type ComposerWithSuggestionsProps = Partial & /** The ref to the next modal will open */ isNextModalWillOpenRef: RefObject; - /** The last report action */ - lastReportAction?: OnyxEntry; - /** Whether to include chronos */ includeChronos?: boolean; @@ -148,9 +142,6 @@ type ComposerWithSuggestionsProps = Partial & /** policy ID of the report */ policyID?: string; - /** Whether the main composer was hidden */ - didHideComposerInput?: boolean; - /** Reference to the outer element */ ref?: Ref; }; @@ -214,7 +205,6 @@ function ComposerWithSuggestions({ // Props: Report reportID, includeChronos, - lastReportAction, isGroupPolicyReport, policyID, @@ -230,7 +220,6 @@ function ComposerWithSuggestions({ inputPlaceholder, onPasteFile, disabled, - setIsCommentEmpty, onEnterKeyPress, shouldShowComposeInput, measureParentContainer = () => {}, @@ -246,11 +235,11 @@ function ComposerWithSuggestions({ // For testing children, - didHideComposerInput, // Fullstory forwardedFSClass, }: ComposerWithSuggestionsProps) { + const lastReportAction = useLastEditableAction(reportID); const route = useRoute(); const {isKeyboardShown} = useKeyboardState(); const theme = useTheme(); @@ -293,7 +282,7 @@ function ComposerWithSuggestions({ const {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; - const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!draftComment) && shouldShowComposeInput && areAllModalsHidden() && isFocused && !didHideComposerInput; + const shouldAutoFocus = (shouldFocusInputOnScreenFocus || !!draftComment) && shouldShowComposeInput && areAllModalsHidden() && isFocused; const delayedAutoFocusRouteKeyRef = useRef(null); const valueRef = useRef(value); @@ -455,13 +444,6 @@ function ComposerWithSuggestions({ } } const newCommentConverted = convertToLTRForComposer(newComment); - const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); - const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); - - /** Only update isCommentEmpty state if it's different from previous one */ - if (isNewCommentEmpty !== isPrevCommentEmpty) { - setIsCommentEmpty(isNewCommentEmpty); - } emojisPresentBefore.current = emojis; setValue(newCommentConverted); @@ -497,7 +479,6 @@ function ComposerWithSuggestions({ preferredLocale, preferredSkinTone, reportID, - setIsCommentEmpty, suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 7f3600739379..39e74ca90b91 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,301 +1,107 @@ -import {useRoute} from '@react-navigation/native'; -import {Str} from 'expensify-common'; -import lodashDebounce from 'lodash/debounce'; -import noop from 'lodash/noop'; -import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; -import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; +import React, {useState} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useSharedValue} from 'react-native-reanimated'; import {scheduleOnUI} from 'react-native-worklets'; -import type {Emoji} from '@assets/emojis/types'; import DragAndDropConsumer from '@components/DragAndDrop/Consumer'; import DropZoneUI from '@components/DropZone/DropZoneUI'; import DualDropZone from '@components/DropZone/DualDropZone'; -import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; -import ExceededCommentLength from '@components/ExceededCommentLength'; -import ImportedStateIndicator from '@components/ImportedStateIndicator'; -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; -import type {Mention} from '@components/MentionSuggestions'; import OfflineIndicator from '@components/OfflineIndicator'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength'; -import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitleLength'; -import useIsInSidePanel from '@hooks/useIsInSidePanel'; -import useIsScrollLikelyLayoutTriggered from '@hooks/useIsScrollLikelyLayoutTriggered'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useParentReportAction from '@hooks/useParentReportAction'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useShortMentionsList from '@hooks/useShortMentionsList'; -import useShouldSuppressConciergeIndicators from '@hooks/useShouldSuppressConciergeIndicators'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {addComment} from '@libs/actions/Report'; -import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; -import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import ComposerFocusManager from '@libs/ComposerFocusManager'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import DomUtils from '@libs/DomUtils'; -import FS from '@libs/Fullstory'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {isEmailPublicDomain} from '@libs/LoginUtils'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; -import {rand64} from '@libs/NumberUtils'; -import {addDomainToShortMention} from '@libs/ParsingUtils'; -import { - getCombinedReportActions, - getFilteredReportActionsForReportView, - getLinkedTransactionID, - getOneTransactionThreadReportID, - getReportAction, - isMoneyRequestAction, - isSentMoneyReportAction, -} from '@libs/ReportActionsUtils'; +import {getFilteredReportActionsForReportView, getLinkedTransactionID, getReportAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import { canEditFieldOfMoneyRequest, - canEditReportAction, - canShowReportRecipientLocalTime, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - chatIncludesChronos, - chatIncludesConcierge, getParentReport, - getReportOfflinePendingActionAndErrors, - getReportRecipientAccountIDs, isChatRoom, isGroupChat, isInvoiceReport, - isMoneyRequestReport, isReportApproved, isReportTransactionThread, isSettled, temporary_getMoneyRequestOptions, } from '@libs/ReportUtils'; -import {startSpan} from '@libs/telemetry/activeSpans'; import {getTransactionID, hasReceipt as hasReceiptTransactionUtils} from '@libs/TransactionUtils'; -import {generateAccountID} from '@libs/UserUtils'; -import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; -import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; -import ParticipantLocalTime from '@pages/inbox/report/ParticipantLocalTime'; -import ReportTypingIndicator from '@pages/inbox/report/ReportTypingIndicator'; -import {ActionListContext} from '@pages/inbox/ReportScreenContext'; -import {hideEmojiPicker, isActive as isActiveEmojiPickerAction, isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; -import {addAttachmentWithComment, setIsComposerFullSize} from '@userActions/Report'; -import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; -import ComposerWithSuggestions from './ComposerWithSuggestions'; +import AgentZeroAwareTypingIndicator from './AgentZeroAwareTypingIndicator'; +import ComposerActionMenu from './ComposerActionMenu'; +import ComposerBox from './ComposerBox'; +import type {SuggestionsRef} from './ComposerContext'; +import {useComposerMeta, useComposerSendState} from './ComposerContext'; +import ComposerEmojiPicker from './ComposerEmojiPicker'; +import ComposerExceededLength from './ComposerExceededLength'; +import ComposerFooter from './ComposerFooter'; +import ComposerImportedState from './ComposerImportedState'; +import ComposerInput from './ComposerInput'; +import ComposerLocalTime from './ComposerLocalTime'; +import ComposerProvider from './ComposerProvider'; +import ComposerSendButton from './ComposerSendButton'; import type {ComposerRef} from './ComposerWithSuggestions/ComposerWithSuggestions'; -import SendButton from './SendButton'; import useAttachmentUploadValidation from './useAttachmentUploadValidation'; -type SuggestionsRef = { - resetSuggestions: () => void; - onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; - triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; - updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; - setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; - getSuggestions: () => Mention[] | Emoji[]; - getIsSuggestionsMenuVisible: () => boolean; -}; - type ReportActionComposeProps = { - /** The ID of the report this composer is for */ reportID: string; }; -function AgentZeroAwareTypingIndicator({reportID}: {reportID: string}) { - const shouldSuppress = useShouldSuppressConciergeIndicators(reportID); - if (shouldSuppress) { - return null; - } - return ; -} - -// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will -// prevent auto focus on existing chat for mobile device -const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); - -const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc(); - -/** - * List of AI-aware placeholder translation keys for expense threads - */ -const AI_PLACEHOLDER_KEYS = ['reportActionCompose.askConciergeToUpdate', 'reportActionCompose.askConciergeToCorrect', 'reportActionCompose.askConciergeForHelp'] as const; - -/** - * Returns a random AI-aware placeholder for expense threads - */ -function getRandomPlaceholder(translate: LocalizedTranslate): string { - const randomIndex = Math.floor(Math.random() * AI_PLACEHOLDER_KEYS.length); - return translate(AI_PLACEHOLDER_KEYS[randomIndex]); -} - -// eslint-disable-next-line import/no-mutable-exports -let onSubmitAction = noop; - -function ReportActionCompose({reportID}: ReportActionComposeProps) { +function ReportActionComposeInner({reportID}: ReportActionComposeProps) { const styles = useThemeStyles(); const theme = useTheme(); - const {translate, preferredLocale} = useLocalize(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth, isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isOffline} = useNetwork(); - const isInSidePanel = useIsInSidePanel(); - const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); - const actionButtonRef = useRef(null); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const personalDetails = usePersonalDetails(); - const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); - const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); - const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); - const {availableLoginsList} = useShortMentionsList(); - const currentUserEmail = currentUserPersonalDetails.email ?? ''; const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); + // Context: only 2 subscriptions needed for attachment/DropZone logic + const {exceededMaxLength} = useComposerSendState(); + const {composerRef, suggestionsRef, attachmentFileRef} = useComposerMeta(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); - const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); - const filteredReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); - - const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const allReportTransactions = useReportTransactionsCollection(reportID); - const reportTransactions = useMemo( - () => getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true), - [allReportTransactions, filteredReportActions, isOffline], - ); - const visibleTransactions = useMemo( - () => reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), - [reportTransactions, isOffline], - ); - const reportTransactionIDs = useMemo(() => visibleTransactions?.map((t) => t.transactionID), [visibleTransactions]); - const isSentMoneyReport = useMemo(() => filteredReportActions.some((action) => isSentMoneyReportAction(action)), [filteredReportActions]); - const transactionThreadReportID = useMemo( - () => getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs), - [report, chatReport, filteredReportActions, isOffline, reportTransactionIDs], - ); - const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; - - const parentReportAction = useParentReportAction(report); - const [transactionThreadReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${effectiveTransactionThreadReportID}`); - const transactionThreadReportActionsArray = useMemo(() => (transactionThreadReportActions ? Object.values(transactionThreadReportActions) : []), [transactionThreadReportActions]); - const combinedReportActions = useMemo( - () => getCombinedReportActions(filteredReportActions, effectiveTransactionThreadReportID ?? null, transactionThreadReportActionsArray), - [filteredReportActions, effectiveTransactionThreadReportID, transactionThreadReportActionsArray], - ); - - const route = useRoute(); - const isOnSearchMoneyRequestReport = route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || route.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT; - - // On the search money request report page (MoneyRequestReportView), lastReportAction uses only - // the parent report's actions โ€” not combined with transaction thread actions. The table view - // doesn't display transaction thread comments inline, so the last editable action should only - // come from what's visible in the parent report. ReportScreen (inbox) uses combinedReportActions - // because ReportActionsView merges thread comments into the visible list, and up-arrow-to-edit - // should be able to reach those comments. - const actionsForLastEditable = isOnSearchMoneyRequestReport ? filteredReportActions : combinedReportActions; - const lastReportAction = useMemo( - () => [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined)), - [actionsForLastEditable, parentReportAction], - ); - - const {reportPendingAction: pendingAction} = getReportOfflinePendingActionAndErrors(report); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [initialModalState] = useOnyx(ONYXKEYS.MODAL); const [newParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); - const [draftComment] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`); const [betas] = useOnyx(ONYXKEYS.BETAS); - const shouldFocusComposerOnScreenFocus = shouldFocusInputOnScreenFocus || !!draftComment; - - const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); - const reportAncestors = useAncestors(report); - const targetReportAncestors = useAncestors(targetReport); - const {scrollOffsetRef} = useContext(ActionListContext); - - /** - * Updates the Highlight state of the composer - */ - const [isFocused, setIsFocused] = useState(() => { - return shouldFocusComposerOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible; - }); - - const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize); - - const {isScrollLayoutTriggered, raiseIsScrollLayoutTriggered} = useIsScrollLikelyLayoutTriggered(); - - const [isCommentEmpty, setIsCommentEmpty] = useState(() => { - return !draftComment || !!draftComment.match(CONST.REGEX.EMPTY_COMMENT); - }); - - /** - * Updates the visibility state of the menu - */ - const [isMenuVisible, setMenuVisibility] = useState(false); const [isAttachmentPreviewActive, setIsAttachmentPreviewActive] = useState(false); - const [didHideComposerInput, setDidHideComposerInput] = useState(!shouldShowComposeInput); - - /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent - */ - const {hasExceededMaxCommentLength, validateCommentMaxLength, setHasExceededMaxCommentLength} = useHandleExceedMaxCommentLength(); - const {hasExceededMaxTaskTitleLength, validateTaskTitleMaxLength, setHasExceededMaxTitleLength} = useHandleExceedMaxTaskTitleLength(); - const [exceededMaxLength, setExceededMaxLength] = useState(null); const icons = useMemoizedLazyExpensifyIcons(['MessageInABottle']); - const suggestionsRef = useRef(null); - const composerRef = useRef(null); - const reportParticipantIDs = useMemo( - () => - Object.keys(report?.participants ?? {}) - .map(Number) - .filter((accountID) => accountID !== currentUserPersonalDetails.accountID), - [currentUserPersonalDetails.accountID, report?.participants], - ); - - const shouldShowReportRecipientLocalTime = useMemo( - () => canShowReportRecipientLocalTime(personalDetails, report, currentUserPersonalDetails.accountID) && !isComposerFullSize, - [personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize], - ); + const reportParticipantIDs = Object.keys(report?.participants ?? {}) + .map(Number) + .filter((accountID) => accountID !== currentUserPersonalDetails.accountID); - const includesConcierge = useMemo(() => chatIncludesConcierge({participants: report?.participants}), [report?.participants]); - const userBlockedFromConcierge = useMemo(() => isBlockedFromConciergeUserAction(blockedFromConcierge), [blockedFromConcierge]); - const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]); const isReportArchived = useReportIsArchived(report?.reportID); - const isTransactionThreadView = useMemo(() => isReportTransactionThread(report), [report]); - const isExpensesReport = useMemo(() => reportTransactions && reportTransactions.length > 1, [reportTransactions]); + const isTransactionThreadView = isReportTransactionThread(report); + const isExpensesReport = reportTransactions && reportTransactions.length > 1; - const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { - canEvict: false, - }); - - const iouAction = rawReportActions ? Object.values(rawReportActions).find((action) => isMoneyRequestAction(action)) : null; + const [rawReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`); + const iouAction = rawReportActions ? (Object.values(rawReportActions).find((action) => isMoneyRequestAction(action)) as OnyxTypes.ReportAction | undefined) : undefined; const linkedTransactionID = iouAction && !isExpensesReport ? getLinkedTransactionID(iouAction) : undefined; - - const transactionID = useMemo(() => getTransactionID(report) ?? linkedTransactionID, [report, linkedTransactionID]); - + const transactionID = getTransactionID(report) ?? linkedTransactionID; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionID)}`); - const isSingleTransactionView = useMemo(() => !!transaction && !!reportTransactions && reportTransactions.length === 1, [transaction, reportTransactions]); + const isSingleTransactionView = !!transaction && !!reportTransactions && reportTransactions.length === 1; const effectiveParentReportAction = isSingleTransactionView ? iouAction : getReportAction(report?.parentReportID, report?.parentReportActionID); const canUserPerformWriteAction = !!canUserPerformWriteActionReportUtils(report, isReportArchived); const canEditReceipt = @@ -303,358 +109,36 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { canEditFieldOfMoneyRequest({reportAction: effectiveParentReportAction, fieldToEdit: CONST.EDIT_REQUEST_FIELD.RECEIPT, transaction}) && !transaction?.receipt?.isTestDriveReceipt; const shouldAddOrReplaceReceipt = (isTransactionThreadView || isSingleTransactionView) && canEditReceipt; + const hasReceipt = hasReceiptTransactionUtils(transaction); - const hasReceipt = useMemo(() => hasReceiptTransactionUtils(transaction), [transaction]); - - const shouldDisplayDualDropZone = useMemo(() => { - const parentReport = getParentReport(report); - const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); - const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; - const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; - const isRoomOrGroupChat = isChatRoom(report) || isGroupChat(report); - return !isRoomOrGroupChat && (canModifyReceipt || hasMoneyRequestOptions) && !isInvoiceReport(report); - }, [shouldAddOrReplaceReceipt, report, reportParticipantIDs, policy, isReportArchived, isRestrictedToPreferredPolicy, betas]); - - // Check if this is an expense-related report (IOU, expense report, or transaction thread) - const isExpenseRelatedReport = useMemo(() => isTransactionThreadView || isMoneyRequestReport(report), [isTransactionThreadView, report]); - - const isEnglishLocale = (preferredLocale ?? CONST.LOCALES.DEFAULT) === CONST.LOCALES.EN; - - // Placeholder to display in the chat input. - const inputPlaceholder = useMemo(() => { - if (includesConcierge && userBlockedFromConcierge) { - return translate('reportActionCompose.blockedFromConcierge'); - } - - // Only English should get AI-specific ghost text. - if (isExpenseRelatedReport && canUserPerformWriteAction && isEnglishLocale) { - return getRandomPlaceholder(translate); - } - - return translate('reportActionCompose.writeSomething'); - }, [includesConcierge, translate, userBlockedFromConcierge, isExpenseRelatedReport, canUserPerformWriteAction, isEnglishLocale]); - - const focus = () => { - if (composerRef.current === null) { - return; - } - composerRef.current?.focus(true); - }; - - const isKeyboardVisibleWhenShowingModalRef = useRef(false); - const isNextModalWillOpenRef = useRef(false); - - const containerRef = useRef(null); - const measureContainer = useCallback( - (callback: MeasureInWindowOnSuccessCallback) => { - if (!containerRef.current) { - return; - } - containerRef.current.measureInWindow(callback); - }, - // We added isComposerFullSize in dependencies so that when this value changes, we recalculate the position of the popup - // eslint-disable-next-line react-hooks/exhaustive-deps - [isComposerFullSize], - ); - - const onAddActionPressed = useCallback(() => { - if (!willBlurTextInputOnTapOutside) { - isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused(); - } - composerRef.current?.blur(); - }, []); + const parentReport = getParentReport(report); + const isSettledOrApproved = isSettled(report) || isSettled(parentReport) || isReportApproved({report}) || isReportApproved({report: parentReport}); + const hasMoneyRequestOptions = !!temporary_getMoneyRequestOptions(report, policy, reportParticipantIDs, betas, isReportArchived, isRestrictedToPreferredPolicy).length; + const canModifyReceipt = shouldAddOrReplaceReceipt && !isSettledOrApproved; + const isRoomOrGroupChat = isChatRoom(report) || isGroupChat(report); + const shouldDisplayDualDropZone = !isRoomOrGroupChat && (canModifyReceipt || hasMoneyRequestOptions) && !isInvoiceReport(report); - const onItemSelected = useCallback(() => { - isKeyboardVisibleWhenShowingModalRef.current = false; - }, []); - - const updateShouldShowSuggestionMenuToFalse = useCallback(() => { + const updateShouldShowSuggestionMenuToFalse = () => { if (!suggestionsRef.current) { return; } suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); - }, []); - - const attachmentFileRef = useRef(null); + }; - const addAttachment = useCallback((file: FileObject | FileObject[]) => { + const addAttachment = (file: FileObject | FileObject[]) => { attachmentFileRef.current = file; - const clearWorklet = composerRef.current?.clearWorklet; - if (!clearWorklet) { throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); } - scheduleOnUI(clearWorklet); - }, []); + }; - /** - * Event handler to update the state after the attachment preview is closed. - */ - const onAttachmentPreviewClose = useCallback(() => { + const onAttachmentPreviewClose = () => { updateShouldShowSuggestionMenuToFalse(); setIsAttachmentPreviewActive(false); - // This enables Composer refocus when the attachments modal is closed by the browser navigation ComposerFocusManager.setReadyToFocus(); - }, [updateShouldShowSuggestionMenuToFalse]); - - /** - * Add a new comment to this chat - */ - const submitForm = useCallback( - (newComment: string) => { - const newCommentTrimmed = newComment.trim(); - - kickoffWaitingIndicator(); - - if (attachmentFileRef.current) { - addAttachmentWithComment({ - report: targetReport, - notifyReportID: reportID, - ancestors: targetReportAncestors, - attachments: attachmentFileRef.current, - currentUserAccountID: currentUserPersonalDetails.accountID, - text: newCommentTrimmed, - timezone: currentUserPersonalDetails.timezone, - shouldPlaySound: true, - isInSidePanel, - }); - attachmentFileRef.current = null; - } else { - const taskMatch = newCommentTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskMatch) { - let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; - if (taskTitle) { - const mention = taskMatch[1] ? taskMatch[1].trim() : ''; - const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); - const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; - const isValidMention = Str.isValidEmail(mentionWithDomain); - - let assignee: OnyxEntry; - let assigneeChatReport; - if (mentionWithDomain) { - if (isValidMention) { - assignee = Object.values(personalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; - if (!Object.keys(assignee ?? {}).length) { - const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { - accountID: generateAccountID(mentionWithDomain), - login: mentionWithDomain, - }); - assignee = optimisticDataForNewAssignee.assignee; - assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; - } - } else { - taskTitle = `@${mentionWithDomain} ${taskTitle}`; - } - } - createTaskAndNavigate({ - parentReport: report, - title: taskTitle, - description: '', - assigneeEmail: assignee?.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail, - assigneeAccountID: assignee?.accountID, - assigneeChatReport, - policyID: report?.policyID, - isCreatedUsingMarkdown: true, - quickAction, - ancestors: reportAncestors, - }); - return; - } - } - - // Pre-generate the reportActionID so we can correlate the Sentry send-message span with the exact message - const optimisticReportActionID = rand64(); - - // The list is inverted, so an offset near 0 means the user is at the bottom (newest messages visible). - const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; - if (isScrolledToBottom) { - startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { - name: 'send-message', - op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, - attributes: { - [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, - [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, - }, - }); - } - addComment({ - report: targetReport, - notifyReportID: reportID, - ancestors: targetReportAncestors, - text: newCommentTrimmed, - timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, - currentUserAccountID: currentUserPersonalDetails.accountID, - shouldPlaySound: true, - isInSidePanel, - reportActionID: optimisticReportActionID, - }); - } - }, - [ - kickoffWaitingIndicator, - targetReport, - report, - reportID, - targetReportAncestors, - reportAncestors, - currentUserPersonalDetails.accountID, - currentUserPersonalDetails.timezone, - isInSidePanel, - currentUserEmail, - availableLoginsList, - personalDetails, - quickAction, - scrollOffsetRef, - ], - ); - - const onTriggerAttachmentPicker = useCallback(() => { - isNextModalWillOpenRef.current = true; - isKeyboardVisibleWhenShowingModalRef.current = true; - }, []); - - const onBlur = useCallback((event: BlurEvent) => { - const webEvent = event as unknown as FocusEvent; - setIsFocused(false); - if (suggestionsRef.current) { - suggestionsRef.current.resetSuggestions(); - } - if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) { - isKeyboardVisibleWhenShowingModalRef.current = true; - } - }, []); - - const onFocus = useCallback(() => { - setIsFocused(true); - }, []); - - useEffect(() => { - if (hasExceededMaxTaskTitleLength) { - setExceededMaxLength(CONST.TITLE_CHARACTER_LIMIT); - } else if (hasExceededMaxCommentLength) { - setExceededMaxLength(CONST.MAX_COMMENT_LENGTH); - } else { - setExceededMaxLength(null); - } - }, [hasExceededMaxTaskTitleLength, hasExceededMaxCommentLength]); - - useEffect(() => { - if (didHideComposerInput || shouldShowComposeInput) { - return; - } - // This is an intentional one-way latch: once the composer input has been hidden, it stays hidden. - // eslint-disable-next-line react-hooks/set-state-in-effect - setDidHideComposerInput(true); - }, [shouldShowComposeInput, didHideComposerInput]); - - // We are returning a callback here as we want to invoke the method on unmount only - useEffect( - () => () => { - if (!isActiveEmojiPickerAction(report?.reportID)) { - return; - } - hideEmojiPicker(); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - - // When we invite someone to a room they don't have the policy object, but we still want them to be able to mention other reports they are members of, so we only check if the policyID in the report is from a workspace - const isGroupPolicyReport = useMemo(() => !!report?.policyID && report.policyID !== CONST.POLICY.ID_FAKE, [report?.policyID]); - const reportRecipientAccountIDs = getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID); - const reportRecipient = personalDetails?.[reportRecipientAccountIDs[0]]; - const shouldUseFocusedColor = !isBlockedFromConcierge && isFocused; - - const hasReportRecipient = !isEmptyObject(reportRecipient); - - const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!exceededMaxLength; - - const validateMaxLength = useCallback( - (value: string) => { - const taskCommentMatch = value?.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskCommentMatch) { - const title = taskCommentMatch?.[3] ? taskCommentMatch[3].trim().replaceAll('\n', ' ') : ''; - setHasExceededMaxCommentLength(false); - return validateTaskTitleMaxLength(title); - } - setHasExceededMaxTitleLength(false); - return validateCommentMaxLength(value, {reportID}); - }, - [setHasExceededMaxCommentLength, setHasExceededMaxTitleLength, validateTaskTitleMaxLength, validateCommentMaxLength, reportID], - ); - - const debouncedValidate = useMemo(() => lodashDebounce(validateMaxLength, CONST.TIMING.COMMENT_LENGTH_DEBOUNCE_TIME, {leading: true}), [validateMaxLength]); - - // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value - // useSharedValue on web doesn't support functions, so we need to wrap it in an object. - const composerRefShared = useSharedValue>({}); - - const handleSendMessage = useCallback(() => { - if (isSendDisabled || !debouncedValidate.flush()) { - return; - } - - composerRef.current?.resetHeight(); - if (isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - - scheduleOnUI(() => { - const {clearWorklet} = composerRefShared.get(); - - if (!clearWorklet) { - throw new Error('The composerRef.clearWorklet function is not set yet. This should never happen, and indicates a developer error.'); - } - - clearWorklet?.(); - }); - }, [isSendDisabled, debouncedValidate, isComposerFullSize, reportID, composerRefShared]); - - onSubmitAction = handleSendMessage; - - const emojiPositionValues = useMemo( - () => ({ - secondaryRowHeight: styles.chatItemComposeSecondaryRow.height, - secondaryRowMarginTop: styles.chatItemComposeSecondaryRow.marginTop, - secondaryRowMarginBottom: styles.chatItemComposeSecondaryRow.marginBottom, - composeBoxMinHeight: styles.chatItemComposeBox.minHeight, - emojiButtonHeight: styles.chatItemEmojiButton.height, - }), - [ - styles.chatItemComposeSecondaryRow.height, - styles.chatItemComposeSecondaryRow.marginTop, - styles.chatItemComposeSecondaryRow.marginBottom, - styles.chatItemComposeBox.minHeight, - styles.chatItemEmojiButton.height, - ], - ); - - const emojiShiftVertical = useMemo(() => { - const chatItemComposeSecondaryRowHeight = emojiPositionValues.secondaryRowHeight + emojiPositionValues.secondaryRowMarginTop + emojiPositionValues.secondaryRowMarginBottom; - const reportActionComposeHeight = emojiPositionValues.composeBoxMinHeight + chatItemComposeSecondaryRowHeight; - const emojiOffsetWithComposeBox = (emojiPositionValues.composeBoxMinHeight - emojiPositionValues.emojiButtonHeight) / 2; - return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; - }, [ - emojiPositionValues.secondaryRowHeight, - emojiPositionValues.secondaryRowMarginTop, - emojiPositionValues.secondaryRowMarginBottom, - emojiPositionValues.composeBoxMinHeight, - emojiPositionValues.emojiButtonHeight, - ]); - - const onValueChange = useCallback( - (value: string) => { - if (value.length === 0 && isComposerFullSize) { - setIsComposerFullSize(reportID, false); - } - debouncedValidate(value); - }, - [isComposerFullSize, reportID, debouncedValidate], - ); + }; const {validateAttachments, onReceiptDropped, PDFValidationComponent, ErrorModal} = useAttachmentUploadValidation({ policy, @@ -676,159 +160,61 @@ function ReportActionCompose({reportID}: ReportActionComposeProps) { return null; } - const fsClass = FS.getChatFSClass(report); - return ( - - - {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + + - - - {PDFValidationComponent} - validateAttachments({files})} - reportID={reportID} - report={report} - currentUserPersonalDetails={currentUserPersonalDetails} - reportParticipantIDs={reportParticipantIDs} - isFullComposerAvailable={isFullComposerAvailable} - isComposerFullSize={isComposerFullSize} - disabled={isBlockedFromConcierge} - setMenuVisibility={setMenuVisibility} - isMenuVisible={isMenuVisible} - onTriggerAttachmentPicker={onTriggerAttachmentPicker} - raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLayoutTriggered} - onAddActionPressed={onAddActionPressed} - onItemSelected={onItemSelected} - onCanceledAttachmentPicker={() => { - if (!shouldFocusComposerOnScreenFocus) { - return; - } - focus(); - }} - actionButtonRef={actionButtonRef} - shouldDisableAttachmentItem={!!exceededMaxLength} - /> - { - composerRef.current = ref; - composerRefShared.set({ - clearWorklet: ref?.clearWorklet, - }); - }} - suggestionsRef={suggestionsRef} - isNextModalWillOpenRef={isNextModalWillOpenRef} - isScrollLikelyLayoutTriggered={isScrollLayoutTriggered} - raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLayoutTriggered} - reportID={reportID} - policyID={report?.policyID} - includeChronos={chatIncludesChronos(report)} - isGroupPolicyReport={isGroupPolicyReport} - lastReportAction={lastReportAction} - isMenuVisible={isMenuVisible} - inputPlaceholder={inputPlaceholder} - isComposerFullSize={isComposerFullSize} - setIsFullComposerAvailable={setIsFullComposerAvailable} - onPasteFile={(files) => validateAttachments({files})} - onClear={submitForm} - disabled={isBlockedFromConcierge || isEmojiPickerVisible()} - setIsCommentEmpty={setIsCommentEmpty} - onEnterKeyPress={handleSendMessage} - shouldShowComposeInput={shouldShowComposeInput} - onFocus={onFocus} - onBlur={onBlur} - measureParentContainer={measureContainer} - onValueChange={onValueChange} - didHideComposerInput={didHideComposerInput} - forwardedFSClass={fsClass} - /> - {shouldDisplayDualDropZone && ( - validateAttachments({dragEvent})} - onReceiptDrop={onReceiptDropped} - shouldAcceptSingleReceipt={shouldAddOrReplaceReceipt} - /> - )} - {!shouldDisplayDualDropZone && ( - validateAttachments({dragEvent})}> - - - )} - {canUseTouchScreen() && isMediumScreenWidth ? null : ( - { - if (isNavigating) { - return; - } - const activeElementId = DomUtils.getActiveElement()?.id; - if (activeElementId === CONST.COMPOSER.NATIVE_ID || activeElementId === CONST.EMOJI_PICKER_BUTTON_NATIVE_ID) { - return; - } - focus(); - }} - onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)} - emojiPickerID={report?.reportID} - shiftVertical={emojiShiftVertical} - /> - )} - + {PDFValidationComponent} + validateAttachments({files})} + /> + validateAttachments({files})} + /> + {shouldDisplayDualDropZone && ( + validateAttachments({dragEvent})} + onReceiptDrop={onReceiptDropped} + shouldAcceptSingleReceipt={shouldAddOrReplaceReceipt} /> - - {ErrorModal} - - {!shouldUseNarrowLayout && } - - {!!exceededMaxLength && ( - validateAttachments({dragEvent})}> + - )} - - - {!isSmallScreenWidth && ( - - - - )} + + )} + + + + {ErrorModal} + + {!shouldUseNarrowLayout && } + + + + ); } -export default memo(ReportActionCompose); -export {onSubmitAction}; +function ReportActionCompose({reportID}: ReportActionComposeProps) { + return ( + + + + ); +} + +export default ReportActionCompose; export type {SuggestionsRef, ComposerRef, ReportActionComposeProps}; diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposePlaceholder.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionComposePlaceholder.tsx new file mode 100644 index 000000000000..7a90fad4a08c --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposePlaceholder.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import PulsingView from '@components/PulsingView'; +import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; + +/** + * Lightweight visual replica of ReportActionCompose used as a placeholder + * while deferring the real (heavy) composer during navigation transitions. + * Renders the same container, icons, and placeholder text without any + * functional hooks, Onyx connections, or event handlers. + * + * Source of truth: ReportActionCompose (same directory). + * Mirrors: chatFooter, chatItemComposeBox, composerSizeButton (width/marginHorizontal), + * textInputComposeSpacing, textInputComposeBorder, chatItemEmojiButton, chatItemSubmitButton, + * chatItemComposeSecondaryRow, and icons [Plus, Emoji, Send]. + * If the real composer changes its layout or icon set, update this placeholder to match. + */ +function ReportActionComposePlaceholder() { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Emoji', 'Send']); + + const wrapperStyle = [styles.chatFooter, {minHeight: CONST.CHAT_FOOTER_MIN_HEIGHT}]; + + const plusButtonStyle = [ + {flexBasis: (styles.composerSizeButton.width ?? 0) + (styles.composerSizeButton.marginHorizontal ?? 0) * 2}, + styles.flexGrow0, + styles.flexShrink0, + styles.justifyContentCenter, + styles.alignItemsCenter, + ]; + + const textInputStyle = [styles.textInputComposeSpacing, styles.textInputComposeBorder, {paddingVertical: 0}]; + + const placeholderTextStyle = [ + styles.textNormal, + { + color: theme.placeholderText, + lineHeight: styles.textInputCompose.lineHeight, + paddingHorizontal: variables.avatarChatSpacing, + alignSelf: 'center' as const, + }, + ]; + + return ( + + + + + + + {translate('reportActionCompose.writeSomething')} + + + + + + + + + + + ); +} + +export default ReportActionComposePlaceholder; diff --git a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx index 34f2030933c3..5b0e5a8e41a0 100644 --- a/src/pages/inbox/report/ReportActionCompose/SendButton.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SendButton.tsx @@ -1,4 +1,4 @@ -import React, {memo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Icon from '@components/Icon'; @@ -79,4 +79,4 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonP ); } -export default memo(SendButton); +export default SendButton; diff --git a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx index e643583941b2..89e30e44ba0d 100644 --- a/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/inbox/report/ReportActionCompose/SuggestionMention.tsx @@ -1,7 +1,7 @@ import {Str} from 'expensify-common'; import lodashMapValues from 'lodash/mapValues'; import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import type {Mention} from '@components/MentionSuggestions'; import MentionSuggestions from '@components/MentionSuggestions'; @@ -103,34 +103,6 @@ function SuggestionMention({ const {currentReportID} = useCurrentReportIDState(); const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportID}`); - // Smaller weight means higher order in suggestion list - const getPersonalDetailsWeight = useCallback( - (detail: PersonalDetails, policyEmployeeAccountIDs: number[]): number => { - if (isReportParticipant(detail.accountID, currentReport)) { - return 0; - } - if (policyEmployeeAccountIDs.includes(detail.accountID)) { - return 1; - } - return 2; - }, - [currentReport], - ); - const weightedPersonalDetails: PersonalDetailsList | SuggestionPersonalDetailsList | undefined = useMemo(() => { - const policyEmployeeAccountIDs = getPolicyEmployeeAccountIDs(policy, currentUserPersonalDetails.accountID); - if (!isGroupChat(currentReport) && !doesReportBelongToWorkspace(currentReport, policyEmployeeAccountIDs, policyID, conciergeReportID)) { - return personalDetails; - } - return lodashMapValues(personalDetails, (detail) => - detail - ? { - ...detail, - weight: getPersonalDetailsWeight(detail, policyEmployeeAccountIDs), - } - : null, - ); - }, [policyID, policy, currentReport, personalDetails, getPersonalDetailsWeight, currentUserPersonalDetails.accountID, conciergeReportID]); - const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({ isActive: isMentionSuggestionsMenuVisible, maxIndex: suggestionValues.suggestedMentions.length - 1, @@ -297,7 +269,28 @@ function SuggestionMention({ ); const getUserMentionOptions = useCallback( - (personalDetailsParam: PersonalDetailsList | SuggestionPersonalDetailsList | undefined, searchValue = ''): Mention[] => { + (searchValue = ''): Mention[] => { + const policyEmployeeAccountIDs = getPolicyEmployeeAccountIDs(policy, currentUserPersonalDetails.accountID); + const shouldWeightDetails = isGroupChat(currentReport) || doesReportBelongToWorkspace(currentReport, policyEmployeeAccountIDs, policyID, conciergeReportID); + + let personalDetailsParam: PersonalDetailsList | SuggestionPersonalDetailsList | undefined; + + if (!shouldWeightDetails) { + personalDetailsParam = personalDetails; + } else { + // Smaller weight means higher order in suggestion list + const getPersonalDetailsWeight = (detail: PersonalDetails): number => { + if (isReportParticipant(detail.accountID, currentReport)) { + return 0; + } + if (policyEmployeeAccountIDs.includes(detail.accountID)) { + return 1; + } + return 2; + }; + personalDetailsParam = lodashMapValues(personalDetails, (detail) => (detail ? {...detail, weight: getPersonalDetailsWeight(detail)} : null)); + } + const suggestions: Mention[] = []; if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) { @@ -368,7 +361,20 @@ function SuggestionMention({ return suggestions; }, - [localeCompare, translate, expensifyIcons.Megaphone, expensifyIcons.FallbackAvatar, formatPhoneNumber, formatLoginPrivateDomain], + [ + localeCompare, + translate, + expensifyIcons.Megaphone, + expensifyIcons.FallbackAvatar, + formatPhoneNumber, + formatLoginPrivateDomain, + personalDetails, + policy, + currentReport, + policyID, + conciergeReportID, + currentUserPersonalDetails.accountID, + ], ); const getRoomMentionOptions = useCallback( @@ -439,7 +445,7 @@ function SuggestionMention({ }; if (isMentionCode(suggestionWord) && prefixType === '@') { - const suggestions = getUserMentionOptions(weightedPersonalDetails, normalizedPrefix); + const suggestions = getUserMentionOptions(normalizedPrefix); nextState.suggestedMentions = suggestions; nextState.shouldShowSuggestionMenu = !!suggestions.length; } @@ -465,7 +471,7 @@ function SuggestionMention({ })); setHighlightedMentionIndex(0); }, - [isComposerFocused, isGroupPolicyReport, setHighlightedMentionIndex, resetSuggestions, getUserMentionOptions, weightedPersonalDetails, getRoomMentionOptions], + [isComposerFocused, isGroupPolicyReport, setHighlightedMentionIndex, resetSuggestions, getUserMentionOptions, getRoomMentionOptions], ); const debouncedCalculateMentionSuggestion = useDebounce( diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts new file mode 100644 index 000000000000..7ce244e5b114 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -0,0 +1,150 @@ +import {Str} from 'expensify-common'; +import {useContext} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import useAncestors from '@hooks/useAncestors'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsInSidePanel from '@hooks/useIsInSidePanel'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useShortMentionsList from '@hooks/useShortMentionsList'; +import {addAttachmentWithComment, addComment} from '@libs/actions/Report'; +import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; +import {isEmailPublicDomain} from '@libs/LoginUtils'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {rand64} from '@libs/NumberUtils'; +import {addDomainToShortMention} from '@libs/ParsingUtils'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; +import {startSpan} from '@libs/telemetry/activeSpans'; +import {generateAccountID} from '@libs/UserUtils'; +import {useAgentZeroStatusActions} from '@pages/inbox/AgentZeroStatusContext'; +import {ActionListContext} from '@pages/inbox/ReportScreenContext'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import {useComposerMeta} from './ComposerContext'; + +function useComposerSubmit(reportID: string): (comment: string) => void { + const {isOffline} = useNetwork(); + const {kickoffWaitingIndicator} = useAgentZeroStatusActions(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); + const {availableLoginsList} = useShortMentionsList(); + const isInSidePanel = useIsInSidePanel(); + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); + + const {attachmentFileRef} = useComposerMeta(); + const {scrollOffsetRef} = useContext(ActionListContext); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); + const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + const allReportTransactions = useReportTransactionsCollection(reportID); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); + const visibleTransactions = isOffline ? reportTransactions : reportTransactions?.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); + const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${effectiveTransactionThreadReportID ?? reportID}`); + + const reportAncestors = useAncestors(report); + const targetReportAncestors = useAncestors(targetReport); + + const currentUserEmail = currentUserPersonalDetails.email ?? ''; + + return (newComment: string) => { + const newCommentTrimmed = newComment.trim(); + kickoffWaitingIndicator(); + + if (attachmentFileRef.current) { + addAttachmentWithComment({ + report: targetReport, + notifyReportID: reportID, + ancestors: targetReportAncestors, + attachments: attachmentFileRef.current, + currentUserAccountID: currentUserPersonalDetails.accountID, + text: newCommentTrimmed, + timezone: currentUserPersonalDetails.timezone, + shouldPlaySound: true, + isInSidePanel, + }); + attachmentFileRef.current = null; + return; + } + + const taskMatch = newCommentTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + if (taskMatch) { + let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; + if (taskTitle) { + const mention = taskMatch[1] ? taskMatch[1].trim() : ''; + const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); + const mentionWithDomain = addDomainToShortMention(mention, availableLoginsList, currentUserPrivateDomain) ?? mention; + const isValidMention = Str.isValidEmail(mentionWithDomain); + + let assignee: OnyxEntry; + let assigneeChatReport; + if (mentionWithDomain) { + if (isValidMention) { + assignee = Object.values(personalDetails ?? {}).find((value) => value?.login === mentionWithDomain) ?? undefined; + if (!Object.keys(assignee ?? {}).length) { + const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { + accountID: generateAccountID(mentionWithDomain), + login: mentionWithDomain, + }); + assignee = optimisticDataForNewAssignee.assignee; + assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; + } + } else { + taskTitle = `@${mentionWithDomain} ${taskTitle}`; + } + } + createTaskAndNavigate({ + parentReport: report, + title: taskTitle, + description: '', + assigneeEmail: assignee?.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail, + assigneeAccountID: assignee?.accountID, + assigneeChatReport, + policyID: report?.policyID, + isCreatedUsingMarkdown: true, + quickAction, + ancestors: reportAncestors, + }); + return; + } + } + + const optimisticReportActionID = rand64(); + const isScrolledToBottom = scrollOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + if (isScrolledToBottom) { + startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { + name: 'send-message', + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID, + [CONST.TELEMETRY.ATTRIBUTE_MESSAGE_LENGTH]: newCommentTrimmed.length, + }, + }); + } + addComment({ + report: targetReport, + notifyReportID: reportID, + ancestors: targetReportAncestors, + text: newCommentTrimmed, + timezoneParam: currentUserPersonalDetails.timezone ?? CONST.DEFAULT_TIME_ZONE, + currentUserAccountID: currentUserPersonalDetails.accountID, + shouldPlaySound: true, + isInSidePanel, + reportActionID: optimisticReportActionID, + }); + }; +} + +export default useComposerSubmit; diff --git a/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts b/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts new file mode 100644 index 000000000000..09e125d898b6 --- /dev/null +++ b/src/pages/inbox/report/ReportActionCompose/useLastEditableAction.ts @@ -0,0 +1,44 @@ +import {useRoute} from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useParentReportAction from '@hooks/useParentReportAction'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getCombinedReportActions, getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isMoneyRequestAction, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; +import {canEditReportAction} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; + +function useLastEditableAction(reportID: string): OnyxEntry { + const {isOffline} = useNetwork(); + const route = useRoute(); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); + const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + const allReportTransactions = useReportTransactionsCollection(reportID); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, filteredReportActions, isOffline, true); + const visibleTransactions = isOffline ? reportTransactions : reportTransactions?.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); + const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + + const parentReportAction = useParentReportAction(report); + const [transactionThreadReportActionsOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${effectiveTransactionThreadReportID}`); + const transactionThreadReportActionsArray = transactionThreadReportActionsOnyx ? Object.values(transactionThreadReportActionsOnyx) : []; + const combinedReportActions = getCombinedReportActions(filteredReportActions, effectiveTransactionThreadReportID ?? null, transactionThreadReportActionsArray); + + const isOnSearchMoneyRequestReport = route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT || route.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT; + const actionsForLastEditable = isOnSearchMoneyRequestReport ? filteredReportActions : combinedReportActions; + + return [...actionsForLastEditable, parentReportAction].find((action) => !isMoneyRequestAction(action) && canEditReportAction(action, undefined)); +} + +export default useLastEditableAction; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 114e4491d4e7..1949915ecd31 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -1,22 +1,14 @@ -import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React, {useCallback} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {useBlockedFromConcierge} from '@components/OnyxListItemProvider'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; -import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactions from '@hooks/useReportTransactions'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getForReportAction, getMovedReportID} from '@libs/ModifiedExpenseMessage'; import {getIOUReportIDFromReportActionPreview, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import { chatIncludesChronosWithID, - createDraftTransactionAndNavigateToParticipantSelector, getIndicatedMissingPaymentMethod, - getReimbursementDeQueuedOrCanceledActionMessage, getTransactionsWithReceipts, isArchivedNonExpenseReport, isChatThread, @@ -24,41 +16,20 @@ import { isCurrentUserTheOnlyParticipant, } from '@libs/ReportUtils'; import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors'; -import { - deleteReportActionDraft, - dismissTrackExpenseActionableWhisper, - resolveActionableMentionWhisper, - resolveActionableReportMentionWhisper, - toggleEmojiReaction, -} from '@userActions/Report'; +import {deleteReportActionDraft, resolveActionableMentionWhisper, resolveActionableReportMentionWhisper} from '@userActions/Report'; import {clearError} from '@userActions/Transaction'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, ReportAction, ReportActionReactions, Transaction} from '@src/types/onyx'; +import type {PersonalDetailsList, Transaction} from '@src/types/onyx'; import type {PureReportActionItemProps} from './PureReportActionItem'; import PureReportActionItem from './PureReportActionItem'; -type ReportActionItemProps = Omit< - PureReportActionItemProps, - | 'taskReport' - | 'linkedReport' - | 'iouReportOfLinkedReport' - | 'currentUserAccountID' - | 'currentUserEmail' - | 'personalPolicyID' - | 'draftTransactionIDs' - | 'userBillingGracePeriodEnds' - | 'betas' -> & { +type ReportActionItemProps = Omit & { /** Whether to show the draft message or not */ shouldShowDraftMessage?: boolean; /** Draft message for the report action */ draftMessage?: string; - /** Emoji reactions for the report action */ - emojiReactions?: OnyxEntry; - /** User wallet tierName */ userWalletTierName: string | undefined; @@ -79,7 +50,6 @@ function ReportActionItem({ action, report, draftMessage, - emojiReactions, userWalletTierName, isUserValidated, personalDetails, @@ -88,28 +58,16 @@ function ReportActionItem({ isTryNewDotNVPDismissed, ...props }: ReportActionItemProps) { - const {translate} = useLocalize(); const reportID = report?.reportID; const originalMessage = getOriginalMessage(action); const originalReportID = useOriginalReportID(reportID, action); const isOriginalReportArchived = useReportIsArchived(originalReportID); - const {accountID: currentUserAccountID, email: currentUserEmail} = useCurrentUserPersonalDetails(); - const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); - // When an expense is moved from a self-DM to a workspace, the report's policyID is temporarily - // set to a fake placeholder (CONST.POLICY.OWNER_EMAIL_FAKE). Looking up POLICY_TAGS with that - // fake ID would return nothing, so we fall back to policyForMovingExpensesID (the actual - // destination workspace) to fetch the correct tag list for display. - const policyIDForTags = report?.policyID === CONST.POLICY.OWNER_EMAIL_FAKE && policyForMovingExpensesID ? policyForMovingExpensesID : report?.policyID; - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyIDForTags}`); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`); const [originalReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`); const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getIOUReportIDFromReportActionPreview(action)}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.FROM)}`); - const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.TO)}`); const taskReportID = originalMessage && 'taskReportID' in originalMessage ? originalMessage.taskReportID : undefined; const [taskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`); @@ -119,10 +77,8 @@ function ReportActionItem({ const [iouReportOfLinkedReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportOfLinkedReportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const transactionsOnIOUReport = useReportTransactions(iouReport?.reportID); const transactionID = isMoneyRequestAction(action) && getOriginalMessage(action)?.IOUTransactionID; @@ -135,7 +91,6 @@ function ReportActionItem({ const [linkedTransactionRouteError] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {selector: getLinkedTransactionRouteError}); - const blockedFromConcierge = useBlockedFromConcierge(); const targetReport = isChatThread(report) ? parentReport : report; const missingPaymentMethod = getIndicatedMissingPaymentMethod(userWalletTierName, targetReport?.reportID, action, bankAccountList); @@ -145,60 +100,36 @@ function ReportActionItem({ {...props} introSelected={introSelected} betas={betas} - draftTransactionIDs={draftTransactionIDs} personalPolicyID={personalPolicyID} action={action} report={report} policy={policy} - currentUserAccountID={currentUserAccountID} - currentUserEmail={currentUserEmail} draftMessage={draftMessage} iouReport={iouReport} taskReport={taskReport} - cardList={cardList} linkedReport={linkedReport} iouReportOfLinkedReport={iouReportOfLinkedReport} - emojiReactions={emojiReactions} linkedTransactionRouteError={linkedTransactionRouteError} isUserValidated={isUserValidated} parentReport={parentReport} personalDetails={personalDetails} - blockedFromConcierge={blockedFromConcierge} originalReportID={originalReportID} originalReport={originalReport} deleteReportActionDraft={deleteReportActionDraft} isArchivedRoom={isArchivedNonExpenseReport(originalReport, isOriginalReportArchived)} isChronosReport={chatIncludesChronosWithID(originalReportID)} - toggleEmojiReaction={toggleEmojiReaction} - createDraftTransactionAndNavigateToParticipantSelector={createDraftTransactionAndNavigateToParticipantSelector} resolveActionableReportMentionWhisper={resolveActionableReportMentionWhisper} resolveActionableMentionWhisper={resolveActionableMentionWhisper} isClosedExpenseReportWithNoExpenses={isClosedExpenseReportWithNoExpenses(iouReport, transactionsOnIOUReport)} isCurrentUserTheOnlyParticipant={isCurrentUserTheOnlyParticipant} missingPaymentMethod={missingPaymentMethod} - reimbursementDeQueuedOrCanceledActionMessage={getReimbursementDeQueuedOrCanceledActionMessage( - translate, - action as OnyxEntry>, - report, - )} - modifiedExpenseMessage={getForReportAction({ - translate, - reportAction: action, - policy, - movedFromReport, - movedToReport, - policyTags: policyTags ?? CONST.POLICY.DEFAULT_TAG_LIST, - currentUserLogin: currentUserEmail ?? '', - })} getTransactionsWithReceipts={getTransactionsWithReceipts} clearError={clearError} clearAllRelatedReportActionErrors={clearAllRelatedReportActionErrors} - dismissTrackExpenseActionableWhisper={dismissTrackExpenseActionableWhisper} userBillingFundID={userBillingFundID} isTryNewDotNVPDismissed={isTryNewDotNVPDismissed} bankAccountList={bankAccountList} reportMetadata={reportMetadata} - userBillingGracePeriodEnds={userBillingGracePeriodEnds} /> ); } diff --git a/src/pages/inbox/report/ReportActionItemCreated.tsx b/src/pages/inbox/report/ReportActionItemCreated.tsx index 03742ae1dad5..fd09bd3eb800 100644 --- a/src/pages/inbox/report/ReportActionItemCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemCreated.tsx @@ -1,3 +1,4 @@ +import {hasSeenTourSelector} from '@selectors/Onboarding'; import React, {memo} from 'react'; import {View} from 'react-native'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -34,6 +35,7 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); if (!isChatReport(report)) { @@ -47,7 +49,7 @@ function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedPr pendingAction={report?.pendingFields?.addWorkspaceRoom ?? report?.pendingFields?.createChat} errors={report?.errorFields?.addWorkspaceRoom ?? report?.errorFields?.createChat} errorRowStyles={[styles.ml10, styles.mr2]} - onClose={() => clearCreateChatError(report, conciergeReportID, introSelected, currentUserAccountID, betas)} + onClose={() => clearCreateChatError(report, conciergeReportID, introSelected, currentUserAccountID, betas, isSelfTourViewed)} > diff --git a/src/pages/inbox/report/ReportActionItemParentAction.tsx b/src/pages/inbox/report/ReportActionItemParentAction.tsx index 8d21b7af6445..fb57ec1c6b07 100644 --- a/src/pages/inbox/report/ReportActionItemParentAction.tsx +++ b/src/pages/inbox/report/ReportActionItemParentAction.tsx @@ -22,7 +22,7 @@ import { } from '@libs/ReportUtils'; import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Report, ReportAction, ReportActionReactions, ReportActions, ReportActionsDrafts, ReportNameValuePairs, Transaction} from '@src/types/onyx'; +import type {PersonalDetailsList, Report, ReportAction, ReportActions, ReportActionsDrafts, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; import RepliesDivider from './RepliesDivider'; import ReportActionItem from './ReportActionItem'; @@ -171,23 +171,6 @@ function ReportActionItemParentAction({ const [ancestorDraftMessages] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS, {selector: ancestorDraftSelector}, [ancestors, ancestorsReportActions]); - const ancestorReactionSelector = useCallback( - (allReactions: OnyxCollection) => { - if (!allReactions) { - return {}; - } - const result: OnyxCollection = {}; - for (const ancestor of ancestors) { - const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${ancestor.reportAction.reportActionID}`; - result[key] = allReactions[key]; - } - return result; - }, - [ancestors], - ); - - const [ancestorReactions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS, {selector: ancestorReactionSelector}, [ancestors]); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -217,7 +200,6 @@ function ReportActionItemParentAction({ const reportDraftMessages = originalReportID ? ancestorDraftMessages?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`] : undefined; const matchingDraftMessage = reportDraftMessages?.[ancestorReportAction.reportActionID]; const matchingDraftMessageString = matchingDraftMessage?.message; - const actionEmojiReactions = ancestorReactions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${ancestorReportAction.reportActionID}`]; return ( ) : null} {!!delegateAccountID && {translate('delegate.onBehalfOfMessage', accountOwnerDetails?.displayName ?? '')}} + {!!humanAgentAccountID && ( + {translate('reportAction.assistedBy', humanAgentName ?? translate('reportAction.humanSupportAgent'))} + )} {!!vacationer && !!submittedTo && ( {translate( diff --git a/src/pages/inbox/report/ReportActionItemThread.tsx b/src/pages/inbox/report/ReportActionItemThread.tsx index 5c8568cf4edd..def30f62b538 100644 --- a/src/pages/inbox/report/ReportActionItemThread.tsx +++ b/src/pages/inbox/report/ReportActionItemThread.tsx @@ -5,6 +5,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; import ReportActionAvatars from '@components/ReportActionAvatars'; import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -37,24 +38,11 @@ type ReportActionItemThreadProps = { /** The function that should be called when the thread is LongPressed or right-clicked */ onSecondaryInteraction: (event: GestureResponderEvent | MouseEvent) => void; - - /** The accountID of the current user, used for creating optimistic report if needed */ - currentUserAccountID: number; }; -function ReportActionItemThread({ - numberOfReplies, - accountIDs, - mostRecentReply, - report, - reportAction, - isHovered, - onSecondaryInteraction, - isActive, - currentUserAccountID, -}: ReportActionItemThreadProps) { +function ReportActionItemThread({numberOfReplies, accountIDs, mostRecentReply, report, reportAction, isHovered, onSecondaryInteraction, isActive}: ReportActionItemThreadProps) { const styles = useThemeStyles(); - + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {translate, datetimeToCalendarTime} = useLocalize(); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction.childReportID}`); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 32689dec4c96..9316fc4996df 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,14 +1,15 @@ -import type {ListRenderItemInfo} from '@react-native/virtualized-lists'; import {useIsFocused, useRoute} from '@react-navigation/native'; import {isUserValidatedSelector} from '@selectors/Account'; import {tierNameSelector} from '@selectors/UserWallet'; +import type {ListRenderItemInfo} from '@shopify/flash-list'; import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import Button from '@components/Button'; -import InvertedFlatList from '@components/FlatList/InvertedFlatList'; +import InvertedFlashList from '@components/FlashList/InvertedFlashList'; +import getShowScrollIndicator from '@components/FlashList/InvertedFlashList/getShowScrollIndicator'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -22,6 +23,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollToEndOnNewMessageReceived from '@hooks/useScrollToEndOnNewMessageReceived'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {isSafari} from '@libs/Browser'; @@ -116,9 +118,6 @@ type ReportActionsListProps = { /** ID of the list */ listID: number; - /** Should enable auto scroll to top threshold */ - shouldEnableAutoScrollToTopThreshold?: boolean; - /** Whether the optimistic CREATED report action was added */ hasCreatedActionAdded?: boolean; @@ -154,8 +153,6 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { return item.reportActionID; } -const onScrollToIndexFailed = () => {}; - function ReportActionsList({ report, transactionThreadReport, @@ -168,7 +165,6 @@ function ReportActionsList({ onLayout, isComposerFullSize, listID, - shouldEnableAutoScrollToTopThreshold, parentReportActionForTransactionThread, hasCreatedActionAdded, isConciergeSidePanel, @@ -180,6 +176,7 @@ function ReportActionsList({ const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const personalDetailsList = usePersonalDetails(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['UpArrow']); const {windowHeight} = useWindowDimensions(); @@ -214,14 +211,14 @@ function ReportActionsList({ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`); const backTo = route?.params?.backTo as string; - // Display the new message indicator when comment linking and not close to the newest message. - const reportActionID = route?.params?.reportActionID; + const linkedReportActionID = route?.params?.reportActionID; const isTransactionThreadReport = useMemo(() => isTransactionThread(parentReportAction) && !isSentMoneyReportAction(parentReportAction), [parentReportAction]); const isMoneyRequestOrInvoiceReport = useMemo(() => isMoneyRequestReport(report) || isInvoiceReport(report), [report]); const shouldFocusToTopOnMount = useMemo(() => isTransactionThreadReport || isMoneyRequestOrInvoiceReport, [isMoneyRequestOrInvoiceReport, isTransactionThreadReport]); const topReportAction = sortedVisibleReportActions.at(-1); - const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !reportActionID); + const [shouldScrollToEndAfterLayout, setShouldScrollToEndAfterLayout] = useState(shouldFocusToTopOnMount && !linkedReportActionID); + const scrollEndTimerRef = useRef | undefined>(undefined); const isAnonymousUser = useIsAnonymousUser(); useEffect(() => { @@ -234,9 +231,9 @@ function ReportActionsList({ const readActionSkipped = useRef(false); const hasHeaderRendered = useRef(false); - const linkedReportActionID = route?.params?.reportActionID; const lastAction = sortedVisibleReportActions.at(0); + const [shouldMaintainVisibleContentPosition, setShouldMaintainVisibleContentPosition] = useState(() => scrollOffsetRef.current > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( () => sortedVisibleReportActions.reduce((actions, action) => { @@ -264,6 +261,17 @@ function ReportActionsList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [report.reportID]); + // When lastReadTime transitions from empty to a real value (e.g., data hasn't + // loaded yet after sign-in), update the marker so it uses the fresh value + // instead of the empty string from initial mount. + useEffect(() => { + if (reportLastReadTime === '' || unreadMarkerTime !== '') { + return; + } + setUnreadMarkerTime(reportLastReadTime); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reportLastReadTime]); + const prevUnreadMarkerReportActionID = useRef(null); /** @@ -356,15 +364,24 @@ function ReportActionsList({ unreadMarkerReportActionIndex, isInverted: true, onTrackScrolling: (event: NativeSyntheticEvent) => { - scrollOffsetRef.current = event.nativeEvent.contentOffset.y; + const offset = event.nativeEvent.contentOffset.y; + scrollOffsetRef.current = offset; + setShouldMaintainVisibleContentPosition(offset > CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); onScroll?.(event); - if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { - setShouldScrollToEndAfterLayout(false); + // We use a timeout to wait for the scroll to finish before resetting the flag. + // onMomentumScrollEnd would be ideal but it doesn't work on web. + if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline) && !scrollEndTimerRef.current) { + scrollEndTimerRef.current = setTimeout(() => { + setShouldScrollToEndAfterLayout(false); + scrollEndTimerRef.current = undefined; + }, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME); } }, hasOnceLoadedReportActions: !!reportMetadata?.hasOnceLoadedReportActions, }); + useEffect(() => () => clearTimeout(scrollEndTimerRef.current), []); + useScrollToEndOnNewMessageReceived({ sizeChangeType: 'changed', scrollOffsetRef, @@ -538,7 +555,10 @@ function ReportActionsList({ } } else { setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); + // Wait for the next frame so the list has laid out new items before scrolling + requestAnimationFrame(() => { + reportScrollManager.scrollToBottom(); + }); } setIsScrollToBottomEnabled(true); @@ -678,28 +698,28 @@ function ReportActionsList({ return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); }, [parentReportAction, report, sortedVisibleReportActions]); + // Precompute a reportActionID โ†’ index map so renderItem can resolve the real index in O(1) + // instead of scanning sortedVisibleReportActions with indexOf on every render. + const actionIndexMap = useMemo(() => { + const map = new Map(); + for (const [i, action] of sortedVisibleReportActions.entries()) { + map.set(action.reportActionID, i); + } + return map; + }, [sortedVisibleReportActions]); + const renderItem = useCallback( ({item: reportAction, index}: ListRenderItemInfo) => { const originalReportID = getOriginalReportID(report.reportID, reportAction, reportActionsFromOnyx); const showPreviousMessagesButton = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && !!isConciergeSidePanel && !!showHiddenHistory && !!hasPreviousMessages; + // Use the action's actual index in sortedVisibleReportActions rather than the FlashList-provided index, + // because useFlashListScrollKey may slice the data for deep-link scroll positioning, making the + // FlashList index offset from the full array and causing wrong displayAsGroup computation. + const safeIndex = actionIndexMap.get(reportAction.reportActionID) ?? index; + return ( <> - {showPreviousMessagesButton && ( - - - - + )} + {mayHaveActionableButtons && ( + + )} + + ) : ( + + )} + + + + + ); +} + +ChatMessageContent.displayName = 'ChatMessageContent'; + +export default ChatMessageContent; diff --git a/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx b/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx new file mode 100644 index 000000000000..7a2c9d9c4e5b --- /dev/null +++ b/src/pages/inbox/report/actionContents/ConfirmWhisperContent.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import {resolveActionableMentionConfirmWhisper} from '@libs/actions/Report'; +import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage'; +import CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; + +type ConfirmWhisperContentProps = { + action: ReportAction; + reportID: string | undefined; + originalReportID: string | undefined; + report: OnyxEntry; + originalReport: OnyxEntry; +}; + +function ConfirmWhisperContent({action, reportID, originalReportID, report, originalReport}: ConfirmWhisperContentProps) { + const reportActionReport = originalReport ?? report; + const isOriginalReportArchived = useReportIsArchived(originalReportID); + const mentionReportContextValue = {currentReportID: report?.reportID, exactlyMatch: true}; + + const buttons: ActionableItem[] = [ + { + text: 'common.buttonConfirm', + key: `${action.reportActionID}-actionableReportMentionConfirmWhisper-${CONST.REPORT.ACTIONABLE_MENTION_INVITE_TO_SUBMIT_EXPENSE_CONFIRM_WHISPER.DONE}`, + onPress: () => + resolveActionableMentionConfirmWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_MENTION_INVITE_TO_SUBMIT_EXPENSE_CONFIRM_WHISPER.DONE, isOriginalReportArchived), + isPrimary: true, + }, + ]; + + return ( + + + + + + + ); +} + +ConfirmWhisperContent.displayName = 'ConfirmWhisperContent'; + +export default ConfirmWhisperContent; diff --git a/src/pages/inbox/report/actionContents/FraudAlertContent.tsx b/src/pages/inbox/report/actionContents/FraudAlertContent.tsx new file mode 100644 index 000000000000..cfd144b6db55 --- /dev/null +++ b/src/pages/inbox/report/actionContents/FraudAlertContent.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useLocalize from '@hooks/useLocalize'; +import {getActionableCardFraudAlertMessage, getOriginalMessage} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import {resolveFraudAlert} from '@userActions/Card'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; + +type FraudAlertContentProps = { + action: ReportAction; + reportID: string | undefined; +}; + +function FraudAlertContent({action, reportID}: FraudAlertContentProps) { + const {translate, getLocalDateFromDatetime} = useLocalize(); + + const reportActionID = action?.reportActionID; + const originalMessage = getOriginalMessage(action); + const cardID = originalMessage?.cardID; + + const buttons: ActionableItem[] = originalMessage?.resolution + ? [] + : [ + { + text: 'cardPage.cardFraudAlert.confirmButtonText', + key: `${action.reportActionID}-cardFraudAlert-confirm`, + onPress: () => { + resolveFraudAlert(cardID, false, reportID, reportActionID); + }, + isPrimary: true, + }, + { + text: 'cardPage.cardFraudAlert.reportFraudButtonText', + key: `${action.reportActionID}-cardFraudAlert-reportFraud`, + onPress: () => { + resolveFraudAlert(cardID, true, reportID, reportActionID); + }, + }, + ]; + const message = getActionableCardFraudAlertMessage(translate, action, getLocalDateFromDatetime); + + return ( + + + {buttons.length > 0 && ( + + )} + + ); +} + +FraudAlertContent.displayName = 'FraudAlertContent'; + +export default FraudAlertContent; diff --git a/src/pages/inbox/report/actionContents/JoinRequestContent.tsx b/src/pages/inbox/report/actionContents/JoinRequestContent.tsx new file mode 100644 index 000000000000..5e9baef9e8d7 --- /dev/null +++ b/src/pages/inbox/report/actionContents/JoinRequestContent.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useLocalize from '@hooks/useLocalize'; +import {getJoinRequestMessage, getOriginalMessage} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import {acceptJoinRequest, declineJoinRequest} from '@userActions/Policy/Member'; +import CONST from '@src/CONST'; +import type {Policy, ReportAction} from '@src/types/onyx'; +import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; + +type JoinRequestContentProps = { + action: ReportAction; + reportID: string | undefined; + originalReportID: string; + policy: OnyxEntry; +}; + +function JoinRequestContent({action, reportID, originalReportID, policy}: JoinRequestContentProps) { + const {translate} = useLocalize(); + + const reportActionReportID = originalReportID ?? reportID; + const buttons: ActionableItem[] = + getOriginalMessage(action)?.choice !== ('' as JoinWorkspaceResolution) + ? [] + : [ + { + text: 'actionableMentionJoinWorkspaceOptions.accept', + key: `${action.reportActionID}-actionableMentionJoinWorkspace-${CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT}`, + onPress: () => acceptJoinRequest(reportActionReportID, action), + isPrimary: true, + }, + { + text: 'actionableMentionJoinWorkspaceOptions.decline', + key: `${action.reportActionID}-actionableMentionJoinWorkspace-${CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.DECLINE}`, + onPress: () => declineJoinRequest(reportActionReportID, action), + }, + ]; + + return ( + + + {buttons.length > 0 && ( + + )} + + ); +} + +JoinRequestContent.displayName = 'JoinRequestContent'; + +export default JoinRequestContent; diff --git a/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx b/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx new file mode 100644 index 000000000000..da9f09fe7000 --- /dev/null +++ b/src/pages/inbox/report/actionContents/MentionWhisperContent.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import RenderHTML from '@components/RenderHTML'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import {isPolicyAdmin, isPolicyMember, isPolicyOwner} from '@libs/PolicyUtils'; +import {getActionableMentionWhisperMessage, getOriginalMessage, isSystemUserMentioned} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import CONST from '@src/CONST'; +import type {Policy, Report, ReportAction} from '@src/types/onyx'; + +type MentionWhisperContentProps = { + action: ReportAction; + report: OnyxEntry; + originalReport: OnyxEntry; + policy: OnyxEntry; + personalPolicyID: string | undefined; + originalReportID: string | undefined; + resolveActionableMentionWhisper: ( + report: OnyxEntry, + reportAction: OnyxEntry, + resolution: ValueOf, + isReportArchived: boolean, + ) => void; +}; + +function MentionWhisperContent({action, report, originalReport, policy, personalPolicyID, originalReportID, resolveActionableMentionWhisper}: MentionWhisperContentProps) { + const {translate} = useLocalize(); + const isOriginalReportArchived = useReportIsArchived(originalReportID); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + + const reportActionReport = originalReport ?? report; + const reportPolicyID = report?.policyID; + + const isReportInPolicy = !!reportPolicyID && reportPolicyID !== CONST.POLICY.ID_FAKE && personalPolicyID !== reportPolicyID; + const hasMentionedPolicyMembers = getOriginalMessage(action)?.inviteeEmails?.every((login) => isPolicyMember(policy, login)); + + const buttons: ActionableItem[] = []; + if ((isPolicyAdmin(policy) || isPolicyOwner(policy, currentUserAccountID)) && isReportInPolicy && !isSystemUserMentioned(action) && !hasMentionedPolicyMembers) { + buttons.push({ + text: 'actionableMentionWhisperOptions.inviteToSubmitExpense', + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE_TO_SUBMIT_EXPENSE}`, + onPress: () => resolveActionableMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE_TO_SUBMIT_EXPENSE, isOriginalReportArchived), + }); + } + buttons.push( + { + text: 'actionableMentionWhisperOptions.inviteToChat', + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE}`, + onPress: () => resolveActionableMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE, isOriginalReportArchived), + }, + { + text: 'actionableMentionWhisperOptions.nothing', + key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING}`, + onPress: () => resolveActionableMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING, isOriginalReportArchived), + }, + ); + + return ( + + + {buttons.length > 0 && ( + + )} + + ); +} + +MentionWhisperContent.displayName = 'MentionWhisperContent'; + +export default MentionWhisperContent; diff --git a/src/pages/inbox/report/actionContents/ModifiedExpenseContent.tsx b/src/pages/inbox/report/actionContents/ModifiedExpenseContent.tsx new file mode 100644 index 000000000000..1501c8ab14d9 --- /dev/null +++ b/src/pages/inbox/report/actionContents/ModifiedExpenseContent.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import {getForReportAction, getMovedReportID} from '@libs/ModifiedExpenseMessage'; +import ReportActionItemMessageWithExplain from '@pages/inbox/report/ReportActionItemMessageWithExplain'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction} from '@src/types/onyx'; + +type ModifiedExpenseContentProps = { + action: ReportAction; + report: OnyxEntry; + childReport: OnyxEntry; + originalReport: OnyxEntry; +}; + +function ModifiedExpenseContent({action, report, childReport, originalReport}: ModifiedExpenseContentProps) { + const {translate} = useLocalize(); + const {email: currentUserEmail} = useCurrentUserPersonalDetails(); + const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); + + // When expense is moved from self-DM to workspace, policyID is temporarily OWNER_EMAIL_FAKE. + // Fall back to policyForMovingExpensesID (actual destination workspace) for correct tag list. + const policyIDForTags = report?.policyID === CONST.POLICY.OWNER_EMAIL_FAKE && policyForMovingExpensesID ? policyForMovingExpensesID : report?.policyID; + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyIDForTags}`); + const [movedFromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.FROM)}`); + const [movedToReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getMovedReportID(action, CONST.REPORT.MOVE_TYPE.TO)}`); + + const modifiedExpenseMessage = getForReportAction({ + translate, + reportAction: action, + policy, + movedFromReport, + movedToReport, + policyTags: policyTags ?? CONST.POLICY.DEFAULT_TAG_LIST, + currentUserLogin: currentUserEmail ?? '', + }); + + return ( + + ); +} + +ModifiedExpenseContent.displayName = 'ModifiedExpenseContent'; + +export default ModifiedExpenseContent; diff --git a/src/pages/inbox/report/actionContents/PaymentContent.tsx b/src/pages/inbox/report/actionContents/PaymentContent.tsx new file mode 100644 index 000000000000..7c33fbd77366 --- /dev/null +++ b/src/pages/inbox/report/actionContents/PaymentContent.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import RenderHTML from '@components/RenderHTML'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useLocalize from '@hooks/useLocalize'; +import {getBankAccountLastFourDigits} from '@libs/PaymentUtils'; +import {getOriginalMessage} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +type PaymentContentProps = { + action: OnyxTypes.ReportAction; + bankAccountList: OnyxTypes.BankAccountList | undefined; + policy: OnyxEntry; +}; + +function PaymentContent({action, bankAccountList, policy}: PaymentContentProps) { + const {translate} = useLocalize(); + const {convertToDisplayString} = useCurrencyListActions(); + const originalMessage = getOriginalMessage(action); + + if (!originalMessage) { + return null; + } + + const paymentType = originalMessage.paymentType; + const wasAutoPaid = originalMessage.automaticAction ?? false; + + if (paymentType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { + return ; + } + + if (paymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { + const last4Digits = getBankAccountLastFourDigits(originalMessage.bankAccountID, bankAccountList, policy); + if (wasAutoPaid) { + const translation = translate('iou.automaticallyPaidWithBusinessBankAccount', '', last4Digits); + return ( + + ${translation}`} /> + + ); + } + return ; + } + + if (wasAutoPaid) { + return ( + + ${translate('iou.automaticallyPaidWithExpensify')}`} /> + + ); + } + + const amount = convertToDisplayString(Math.abs(originalMessage.amount ?? 0), originalMessage.currency); + + if (originalMessage.bankAccountID) { + const bankAccount = bankAccountList?.[originalMessage.bankAccountID]; + return ( + + ); + } + + return ; +} + +PaymentContent.displayName = 'PaymentContent'; + +export default PaymentContent; diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx new file mode 100644 index 000000000000..45efcc416348 --- /dev/null +++ b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import RenderHTML from '@components/RenderHTML'; +import useLocalize from '@hooks/useLocalize'; +import {getCleanedTagName} from '@libs/PolicyUtils'; +import { + getAddedApprovalRuleMessage, + getAddedBudgetMessage, + getAddedCardFeedMessage, + getAddedConnectionMessage, + getAssignedCompanyCardMessage, + getAutoPayApprovedReportsEnabledMessage, + getAutoReimbursementMessage, + getCompanyAddressUpdateMessage, + getCurrencyDefaultTaxUpdateMessage, + getCustomTaxNameUpdateMessage, + getDefaultApproverUpdateMessage, + getDeletedApprovalRuleMessage, + getDeletedBudgetMessage, + getForeignCurrencyDefaultTaxUpdateMessage, + getForwardsToUpdateMessage, + getInvoiceCompanyNameUpdateMessage, + getInvoiceCompanyWebsiteUpdateMessage, + getPolicyChangeLogAddEmployeeMessage, + getPolicyChangeLogDefaultBillableMessage, + getPolicyChangeLogDefaultReimbursableMessage, + getPolicyChangeLogDefaultTitleEnforcedMessage, + getPolicyChangeLogDeleteMemberMessage, + getPolicyChangeLogMaxExpenseAgeMessage, + getPolicyChangeLogMaxExpenseAmountMessage, + getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, + getPolicyChangeLogUpdateEmployee, + getReimburserUpdateMessage, + getRemovedCardFeedMessage, + getRemovedConnectionMessage, + getRenamedCardFeedMessage, + getSetAutoJoinMessage, + getSubmitsToUpdateMessage, + getTagListNameUpdatedMessage, + getTagListUpdatedMessage, + getTagListUpdatedRequiredMessage, + getUnassignedCompanyCardMessage, + getUpdateACHAccountMessage, + getUpdatedApprovalRuleMessage, + getUpdatedAuditRateMessage, + getUpdatedAutoHarvestingMessage, + getUpdatedBudgetMessage, + getUpdatedCardFeedLiabilityMessage, + getUpdatedCardFeedStatementPeriodMessage, + getUpdatedDefaultTitleMessage, + getUpdatedIndividualBudgetNotificationMessage, + getUpdatedManualApprovalThresholdMessage, + getUpdatedOwnershipMessage, + getUpdatedProhibitedExpensesMessage, + getUpdatedReimbursementChoiceMessage, + getUpdatedSharedBudgetNotificationMessage, + getUpdatedTimeEnabledMessage, + getUpdatedTimeRateMessage, + getWorkspaceAttendeeTrackingUpdateMessage, + getWorkspaceCategoriesUpdatedMessage, + getWorkspaceCategoryUpdateMessage, + getWorkspaceCurrencyUpdateMessage, + getWorkspaceCustomUnitRateAddedMessage, + getWorkspaceCustomUnitRateDeletedMessage, + getWorkspaceCustomUnitRateImportedMessage, + getWorkspaceCustomUnitRateUpdatedMessage, + getWorkspaceCustomUnitSubRateDeletedMessage, + getWorkspaceCustomUnitSubRateUpdatedMessage, + getWorkspaceCustomUnitUpdatedMessage, + getWorkspaceFeatureEnabledMessage, + getWorkspaceFrequencyUpdateMessage, + getWorkspaceReimbursementUpdateMessage, + getWorkspaceReportFieldAddMessage, + getWorkspaceReportFieldDeleteMessage, + getWorkspaceReportFieldUpdateMessage, + getWorkspaceTagUpdateMessage, + getWorkspaceTaxUpdateMessage, + getWorkspaceUpdateFieldMessage, +} from '@libs/ReportActionsUtils'; +import {getWorkspaceNameUpdatedMessage} from '@libs/ReportUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +type PolicyChangeLogMessageResult = string | {html: string}; + +type ResolverFn = (translate: LocaleContextProps['translate'], action: OnyxTypes.ReportAction, policy: OnyxEntry) => PolicyChangeLogMessageResult; + +// Reusable resolvers for action types that share the same handler +const categoryResolver: ResolverFn = (translate, action, policy) => getWorkspaceCategoryUpdateMessage(translate, action, policy); +const taxResolver: ResolverFn = (translate, action) => getWorkspaceTaxUpdateMessage(translate, action); +const tagModificationResolver: ResolverFn = (translate, action) => getCleanedTagName(getWorkspaceTagUpdateMessage(translate, action)); + +const POLICY_CHANGE_LOG_RESOLVERS: Record = { + // Simple string translations + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_UPGRADE]: (translate) => translate('workspaceActions.upgradedWorkspace'), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.TEAM_DOWNGRADE]: (translate) => translate('workspaceActions.downgradedWorkspace'), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_TAGS]: (translate) => translate('workspaceActions.importTags'), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_ALL_TAGS]: (translate) => translate('workspaceActions.deletedAllTags'), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.LEAVE_ROOM]: (translate) => translate('report.actions.type.leftTheChat'), + + // HTML results + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_FORCE_UPGRADE]: (translate) => ({html: `${translate('workspaceActions.forcedCorporateUpgrade')}`}), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.INDIVIDUAL_BUDGET_NOTIFICATION]: (translate, action) => ({ + html: `${getUpdatedIndividualBudgetNotificationMessage(translate, action)}`, + }), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SHARED_BUDGET_NOTIFICATION]: (translate, action) => ({ + html: `${getUpdatedSharedBudgetNotificationMessage(translate, action)}`, + }), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OWNERSHIP]: (translate, action, policy) => ({ + html: `${getUpdatedOwnershipMessage(translate, action, policy)}`, + }), + + // Standard message functions (translate, action) + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME]: (translate, action) => getWorkspaceNameUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY]: (translate, action) => getWorkspaceCurrencyUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REPORTING_FREQUENCY]: (translate, action) => getWorkspaceFrequencyUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORIES]: (translate, action) => getWorkspaceCategoriesUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_TAX_NAME]: (translate, action) => getCustomTaxNameUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CURRENCY_DEFAULT_TAX]: (translate, action) => getCurrencyDefaultTaxUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FOREIGN_CURRENCY_DEFAULT_TAX]: (translate, action) => getForeignCurrencyDefaultTaxUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT]: (translate, action) => getWorkspaceCustomUnitUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.IMPORT_CUSTOM_UNIT_RATES]: (translate, action) => getWorkspaceCustomUnitRateImportedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CUSTOM_UNIT_RATE]: (translate, action) => getWorkspaceCustomUnitRateAddedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_RATE]: (translate, action) => getWorkspaceCustomUnitRateUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_RATE]: (translate, action) => getWorkspaceCustomUnitRateDeletedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CUSTOM_UNIT_SUB_RATE]: (translate, action) => getWorkspaceCustomUnitSubRateUpdatedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_SUB_RATE]: (translate, action) => getWorkspaceCustomUnitSubRateDeletedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_REPORT_FIELD]: (translate, action) => getWorkspaceReportFieldAddMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REPORT_FIELD]: (translate, action) => getWorkspaceReportFieldUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_REPORT_FIELD]: (translate, action) => getWorkspaceReportFieldDeleteMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FIELD]: (translate, action) => getWorkspaceUpdateFieldMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FEATURE_ENABLED]: (translate, action) => getWorkspaceFeatureEnabledMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_IS_ATTENDEE_TRACKING_ENABLED]: (translate, action) => getWorkspaceAttendeeTrackingUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_APPROVER]: (translate, action) => getDefaultApproverUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_SUBMITS_TO]: (translate, action) => getSubmitsToUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_FORWARDS_TO]: (translate, action) => getForwardsToUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_PAY_APPROVED_REPORTS_ENABLED]: (translate, action) => getAutoPayApprovedReportsEnabledMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_REIMBURSEMENT]: (translate, action) => getAutoReimbursementMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_NAME]: (translate, action) => getInvoiceCompanyNameUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_INVOICE_COMPANY_WEBSITE]: (translate, action) => getInvoiceCompanyWebsiteUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSER]: (translate, action) => getReimburserUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_ENABLED]: (translate, action) => getWorkspaceReimbursementUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ACH_ACCOUNT]: (translate, action) => getUpdateACHAccountMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_ADDRESS]: (translate, action) => getCompanyAddressUpdateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT_NO_RECEIPT]: (translate, action) => getPolicyChangeLogMaxExpenseAmountNoReceiptMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT]: (translate, action) => getPolicyChangeLogMaxExpenseAmountMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AGE]: (translate, action) => getPolicyChangeLogMaxExpenseAgeMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_BILLABLE]: (translate, action) => getPolicyChangeLogDefaultBillableMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_REIMBURSABLE]: (translate, action) => getPolicyChangeLogDefaultReimbursableMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE_ENFORCED]: (translate, action) => getPolicyChangeLogDefaultTitleEnforcedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EMPLOYEE]: (translate, action) => getPolicyChangeLogAddEmployeeMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EMPLOYEE]: (translate, action) => getPolicyChangeLogUpdateEmployee(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE]: (translate, action) => getPolicyChangeLogDeleteMemberMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_APPROVER_RULE]: (translate, action) => getAddedApprovalRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_APPROVER_RULE]: (translate, action) => getDeletedApprovalRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_APPROVER_RULE]: (translate, action) => getUpdatedApprovalRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION]: (translate, action) => getAddedConnectionMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION]: (translate, action) => getRemovedConnectionMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CARD_FEED]: (translate, action) => getAddedCardFeedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CARD_FEED]: (translate, action) => getRemovedCardFeedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.RENAME_CARD_FEED]: (translate, action) => getRenamedCardFeedMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ASSIGN_COMPANY_CARD]: (translate, action) => getAssignedCompanyCardMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UNASSIGN_COMPANY_CARD]: (translate, action) => getUnassignedCompanyCardMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_LIABILITY]: (translate, action) => getUpdatedCardFeedLiabilityMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CARD_FEED_STATEMENT_PERIOD]: (translate, action) => getUpdatedCardFeedStatementPeriodMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUDIT_RATE]: (translate, action) => getUpdatedAuditRateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MANUAL_APPROVAL_THRESHOLD]: (translate, action) => getUpdatedManualApprovalThresholdMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_ENABLED]: (translate, action) => getUpdatedTimeEnabledMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TIME_RATE]: (translate, action) => getUpdatedTimeRateMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_PROHIBITED_EXPENSES]: (translate, action) => getUpdatedProhibitedExpensesMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_REIMBURSEMENT_CHOICE]: (translate, action) => getUpdatedReimbursementChoiceMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_AUTO_JOIN]: (translate, action) => getSetAutoJoinMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_DEFAULT_TITLE]: (translate, action) => getUpdatedDefaultTitleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AUTO_HARVESTING]: (translate, action) => getUpdatedAutoHarvestingMessage(translate, action), + + // Message functions that need policy (translate, action, policy) + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CATEGORY]: categoryResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CATEGORY]: categoryResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_CATEGORY]: categoryResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.SET_CATEGORY_NAME]: categoryResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_BUDGET]: (translate, action, policy) => getAddedBudgetMessage(translate, action, policy), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_BUDGET]: (translate, action, policy) => getUpdatedBudgetMessage(translate, action, policy), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_BUDGET]: (translate, action, policy) => getDeletedBudgetMessage(translate, action, policy), + + // Tax action types + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAX]: taxResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAX]: taxResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAX]: taxResolver, + + // Tag-cleaned results + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_NAME]: (translate, action) => getCleanedTagName(getTagListNameUpdatedMessage(translate, action)), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST]: (translate, action) => getCleanedTagName(getTagListUpdatedMessage(translate, action)), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_LIST_REQUIRED]: (translate, action) => getCleanedTagName(getTagListUpdatedRequiredMessage(translate, action)), + + // Tag modification types + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_TAG]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_ENABLED]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG_NAME]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_TAG]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_MULTIPLE_TAGS]: tagModificationResolver, + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_TAG]: tagModificationResolver, +}; + +/** + * The set of action type strings that PolicyChangeLogContent handles. + */ +const HANDLED_POLICY_CHANGE_LOG_ACTIONS = new Set(Object.keys(POLICY_CHANGE_LOG_RESOLVERS)); + +function isHandledPolicyChangeLogAction(action: OnyxTypes.ReportAction): boolean { + return HANDLED_POLICY_CHANGE_LOG_ACTIONS.has(action.actionName); +} + +type PolicyChangeLogContentProps = { + action: OnyxTypes.ReportAction; + policy: OnyxEntry; +}; + +function PolicyChangeLogContent({action, policy}: PolicyChangeLogContentProps) { + const {translate} = useLocalize(); + + const resolver = POLICY_CHANGE_LOG_RESOLVERS[action.actionName]; + if (!resolver) { + return null; + } + + const message = resolver(translate, action, policy); + + if (typeof message === 'object') { + return ( + + + + ); + } + + return ; +} + +PolicyChangeLogContent.displayName = 'PolicyChangeLogContent'; + +export {isHandledPolicyChangeLogAction, HANDLED_POLICY_CHANGE_LOG_ACTIONS}; +export default PolicyChangeLogContent; diff --git a/src/pages/inbox/report/actionContents/ReimbursedContent.tsx b/src/pages/inbox/report/actionContents/ReimbursedContent.tsx new file mode 100644 index 000000000000..2699c1d6b14d --- /dev/null +++ b/src/pages/inbox/report/actionContents/ReimbursedContent.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import {getReimbursedMessage} from '@libs/ReportActionsUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import type {Report, ReportAction} from '@src/types/onyx'; + +type ReimbursedContentProps = { + action: ReportAction; + report: OnyxEntry; +}; + +function ReimbursedContent({action, report}: ReimbursedContentProps) { + const {translate} = useLocalize(); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const message = getReimbursedMessage(translate, action, report, currentUserAccountID); + + return ; +} + +ReimbursedContent.displayName = 'ReimbursedContent'; + +export default ReimbursedContent; diff --git a/src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx b/src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx new file mode 100644 index 000000000000..39992f810f67 --- /dev/null +++ b/src/pages/inbox/report/actionContents/ReimbursementDeQueuedContent.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import {getReimbursementDeQueuedOrCanceledActionMessage} from '@libs/ReportUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import type CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; + +type ReimbursementDeQueuedContentProps = { + action: ReportAction; + report: OnyxEntry; +}; + +function ReimbursementDeQueuedContent({action, report}: ReimbursementDeQueuedContentProps) { + const {translate} = useLocalize(); + const message = getReimbursementDeQueuedOrCanceledActionMessage( + translate, + action as OnyxEntry>, + report, + ); + + return ; +} + +ReimbursementDeQueuedContent.displayName = 'ReimbursementDeQueuedContent'; + +export default ReimbursementDeQueuedContent; diff --git a/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx b/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx new file mode 100644 index 000000000000..56d28342d49a --- /dev/null +++ b/src/pages/inbox/report/actionContents/ReportMentionWhisperContent.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; +import {getOriginalMessage} from '@libs/ReportActionsUtils'; +import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage'; +import CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; + +type ReportMentionWhisperContentProps = { + action: ReportAction; + reportID: string | undefined; + report: OnyxEntry; + originalReport: OnyxEntry; + isReportArchived: boolean; + resolveActionableReportMentionWhisper: ( + report: OnyxEntry, + reportAction: OnyxEntry, + resolution: ValueOf, + isReportArchived?: boolean, + ) => void; +}; + +function ReportMentionWhisperContent({action, reportID, report, originalReport, isReportArchived, resolveActionableReportMentionWhisper}: ReportMentionWhisperContentProps) { + const reportActionReport = originalReport ?? report; + const resolution = getOriginalMessage(action)?.resolution; + const mentionReportContextValue = {currentReportID: report?.reportID, exactlyMatch: true}; + + const buttons: ActionableItem[] = resolution + ? [] + : [ + { + text: 'common.yes', + key: `${action.reportActionID}-actionableReportMentionWhisper-${CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.CREATE}`, + onPress: () => resolveActionableReportMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.CREATE, isReportArchived), + isPrimary: true, + }, + { + text: 'common.no', + key: `${action.reportActionID}-actionableReportMentionWhisper-${CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.NOTHING}`, + onPress: () => resolveActionableReportMentionWhisper(reportActionReport, action, CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.NOTHING, isReportArchived), + }, + ]; + + return ( + + + + {buttons.length > 0 && ( + + )} + + + ); +} + +ReportMentionWhisperContent.displayName = 'ReportMentionWhisperContent'; + +export default ReportMentionWhisperContent; diff --git a/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx new file mode 100644 index 000000000000..9bc583940376 --- /dev/null +++ b/src/pages/inbox/report/actionContents/SimpleMessageContent.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import useLocalize from '@hooks/useLocalize'; +import { + getActionableCard3DSTransactionApprovalMessage, + getDemotedFromWorkspaceMessage, + getDismissedViolationMessageText, + getMarkedReimbursedMessage, + getOriginalMessage, + getRemovedFromApprovalChainMessage, + getReportActionMessageText, + getReportActionText, + isActionOfType, + isRejectedAction, + isUnapprovedAction, +} from '@libs/ReportActionsUtils'; +import {getDeletedTransactionMessage, getPolicyChangeMessage} from '@libs/ReportUtils'; +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +type SimpleMessageContentProps = { + action: OnyxTypes.ReportAction; +}; + +const SIMPLE_MESSAGE_ACTION_TYPES = new Set([ + CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED, + CONST.REPORT.ACTIONS.TYPE.HOLD, + CONST.REPORT.ACTIONS.TYPE.HOLD_COMMENT, + CONST.REPORT.ACTIONS.TYPE.UNHOLD, + CONST.REPORT.ACTIONS.TYPE.REJECTEDTRANSACTION_THREAD, + CONST.REPORT.ACTIONS.TYPE.REJECTED_TRANSACTION_MARKASRESOLVED, + CONST.REPORT.ACTIONS.TYPE.RETRACTED, + CONST.REPORT.ACTIONS.TYPE.REOPENED, + CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY, + CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION, + CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION, + CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES, + CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED, + CONST.REPORT.ACTIONS.TYPE.DEMOTED_FROM_WORKSPACE, + CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL, + CONST.REPORT.ACTIONS.TYPE.REMOVED_FROM_APPROVAL_CHAIN, +]); + +function isSimpleMessageAction(action: OnyxTypes.ReportAction): boolean { + return SIMPLE_MESSAGE_ACTION_TYPES.has(action.actionName) || isUnapprovedAction(action) || isRejectedAction(action); +} + +function SimpleMessageContent({action}: SimpleMessageContentProps) { + const {translate} = useLocalize(); + + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.HOLD)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.HOLD_COMMENT)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.UNHOLD)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REJECTEDTRANSACTION_THREAD)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REJECTED_TRANSACTION_MARKASRESOLVED)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RETRACTED)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REOPENED)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.CHANGE_POLICY)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DELETED_TRANSACTION)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MERGED_WITH_CASH_TRANSACTION)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DISMISSED_VIOLATION)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RESOLVED_DUPLICATES)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.RECEIPT_SCAN_FAILED)) { + const htmlMessage = getReportActionMessageText(action) || translate('iou.receiptScanningFailed'); + return ; + } + if (isUnapprovedAction(action)) { + return ; + } + if (isRejectedAction(action)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.DEMOTED_FROM_WORKSPACE)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_3DS_TRANSACTION_APPROVAL)) { + return ; + } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.REMOVED_FROM_APPROVAL_CHAIN)) { + return ; + } + + return null; +} + +SimpleMessageContent.displayName = 'SimpleMessageContent'; + +export default SimpleMessageContent; +export {isSimpleMessageAction}; diff --git a/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx b/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx index 36bc7712ca5c..76a31198d2a1 100644 --- a/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx +++ b/src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx @@ -45,7 +45,7 @@ export default function | PlatformStackScreenProps | PlatformStackScreenProps - | PlatformStackScreenProps; + | PlatformStackScreenProps; type WithReportOrNotFoundProps = WithReportOrNotFoundOnyxProps & { route: ScreenProps['route']; diff --git a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx index f20aa890543e..1c26ec9a423a 100644 --- a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx +++ b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx @@ -1,8 +1,8 @@ import React from 'react'; import {View} from 'react-native'; import Onyx from 'react-native-onyx'; -import NavigationTabBar from '@components/Navigation/NavigationTabBar'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; +import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import TopBarWithLoadingBar from '@components/Navigation/TopBarWithLoadingBar'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -36,11 +36,8 @@ function BaseSidebarScreen() { const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const shouldDisplayLHB = !shouldUseNarrowLayout; - const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); const shouldShowSkeleton = isLoadingApp && !hasEverFinishedLoading; - // Must be called unconditionally so openApp() can proceed even when // the skeleton is shown and SidebarLinksData has not mounted yet. useConfirmReadyToOpenApp(); @@ -50,7 +47,8 @@ function BaseSidebarScreen() { shouldEnableKeyboardAvoidingView={false} style={[styles.sidebar, isMobile() ? styles.userSelectNone : {}]} testID="BaseSidebarScreen" - bottomContent={!shouldDisplayLHB && } + bottomContent={} + bottomContentStyle={styles.overflowVisible} > {({insets}) => ( <> @@ -69,7 +67,6 @@ function BaseSidebarScreen() { )} - {shouldDisplayLHB && } )} diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index b843c41f52bd..f3a491d58077 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -20,7 +20,6 @@ const FAB_ITEM_ORDER = [ CONST.FAB_MENU_ITEM_IDS.NEW_CHAT, CONST.FAB_MENU_ITEM_IDS.INVOICE, CONST.FAB_MENU_ITEM_IDS.TRAVEL, - CONST.FAB_MENU_ITEM_IDS.TEST_DRIVE, CONST.FAB_MENU_ITEM_IDS.NEW_WORKSPACE, CONST.FAB_MENU_ITEM_IDS.QUICK_ACTION, ] as const; diff --git a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx index 1353aeb54108..d69ea73da64d 100644 --- a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx @@ -14,7 +14,6 @@ import InvoiceMenuItem from './FABPopoverContent/menuItems/InvoiceMenuItem'; import NewChatMenuItem from './FABPopoverContent/menuItems/NewChatMenuItem'; import NewWorkspaceMenuItem from './FABPopoverContent/menuItems/NewWorkspaceMenuItem'; import QuickActionMenuItem from './FABPopoverContent/menuItems/QuickActionMenuItem'; -import TestDriveMenuItem from './FABPopoverContent/menuItems/TestDriveMenuItem'; import TrackDistanceMenuItem from './FABPopoverContent/menuItems/TrackDistanceMenuItem'; import TravelMenuItem from './FABPopoverContent/menuItems/TravelMenuItem'; @@ -75,7 +74,6 @@ function FloatingActionButtonAndPopover() { - diff --git a/src/pages/inbox/sidebar/SidebarLinks.tsx b/src/pages/inbox/sidebar/SidebarLinks.tsx index 6793096bc0fe..f0d8e60d33bc 100644 --- a/src/pages/inbox/sidebar/SidebarLinks.tsx +++ b/src/pages/inbox/sidebar/SidebarLinks.tsx @@ -56,20 +56,21 @@ function SidebarLinks({insets, optionListItems, priorityMode = CONST.PRIORITY_MO // or when continuously clicking different LHNs, only apply to small screen // since getTopmostReportId always returns on other devices const reportActionID = Navigation.getTopmostReportActionId(); + const actionTargetReportActionID = option.actionTargetReportActionID; // Prevent opening a new Report page if the user quickly taps on another conversation // before the first one is displayed. const shouldBlockReportNavigation = Navigation.getActiveRoute() !== `/${ROUTES.INBOX}` && shouldUseNarrowLayout; if ( - (option.reportID === Navigation.getTopmostReportId() && !reportActionID) || - (shouldUseNarrowLayout && isActiveReport(option.reportID) && !reportActionID) || + (option.reportID === Navigation.getTopmostReportId() && !reportActionID && !actionTargetReportActionID) || + (shouldUseNarrowLayout && isActiveReport(option.reportID) && !reportActionID && !actionTargetReportActionID) || shouldBlockReportNavigation ) { cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${option.reportID}`); return; } - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID, actionTargetReportActionID)); }, [shouldUseNarrowLayout, isActiveReport], ); diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index a0b835492f73..4a25f8967ef2 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -12,7 +12,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {convertToDisplayString, convertToFrontendAmountAsInteger, convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; +import {convertToFrontendAmountAsInteger, convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; @@ -103,7 +103,7 @@ function MoneyRequestAmountForm({ const styles = useThemeStyles(); const {isExtraSmallScreenHeight} = useResponsiveLayout(); const {translate} = useLocalize(); - const {getCurrencyDecimals} = useCurrencyListActions(); + const {convertToDisplayString, getCurrencyDecimals} = useCurrencyListActions(); const textInput = useRef(null); const moneyRequestAmountInputRef = useRef(null); diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/SplitExpenseEditPage.tsx index 8262405dbd7e..67dc3f3ff497 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/SplitExpenseEditPage.tsx @@ -22,7 +22,6 @@ import {initDraftSplitExpenseDataForEdit, removeSplitExpenseField, updateSplitEx import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; import {getDecodedCategoryName, isCategoryDescriptionRequired} from '@libs/CategoryUtils'; -import {convertToDisplayString} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; @@ -48,7 +47,7 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {translate, toLocaleDigit} = useLocalize(); - const {getCurrencySymbol} = useCurrencyListActions(); + const {convertToDisplayString, getCurrencySymbol} = useCurrencyListActions(); const {currentSearchResults} = useSearchStateContext(); const {reportID, transactionID, splitExpenseTransactionID = '', backTo} = route.params; diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 65ddd08580fa..0dc8cbaf4bbc 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -20,6 +20,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; @@ -36,7 +37,7 @@ import { updateSplitExpenseAmountField, updateSplitTransactionsFromSplitExpensesFlow, } from '@libs/actions/IOU/Split'; -import {convertToBackendAmount, convertToDisplayString} from '@libs/CurrencyUtils'; +import {convertToBackendAmount} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -51,6 +52,7 @@ import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; import {getReportOrDraftReport, getTransactionDetails, isReportApproved, isSettled as isSettledReportUtils} from '@libs/ReportUtils'; import type {TransactionDetails} from '@libs/ReportUtils'; import {getActiveGroupSearchHashes} from '@libs/SearchUIUtils'; +import {computeSplitSaveErrorMessage, computeSplitWarningMessage} from '@libs/SplitExpenseUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {TranslationPathOrText} from '@libs/TransactionPreviewUtils'; import {getChildTransactions, getExpenseTypeTranslationKey, getTransactionType, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest} from '@libs/TransactionUtils'; @@ -73,12 +75,13 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const {showConfirmModal} = useConfirmModal(); + const {isOffline} = useNetwork(); const [errorMessage, setErrorMessage] = React.useState(''); const {currentSearchResults, currentSearchHash, currentSearchQueryJSON} = useSearchStateContext(); const {clearSelectedTransactions} = useSearchActionsContext(); - const {getCurrencySymbol} = useCurrencyListActions(); + const {convertToDisplayString, getCurrencySymbol} = useCurrencyListActions(); const [selectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.SPLIT_EXPENSE_TAB_TYPE}`); const [draftTransaction, draftTransactionMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); @@ -133,14 +136,8 @@ function SplitExpensePage({route}: SplitExpensePageProps) { } return transactionDetails.amount; }, [transactionDetails.amount]); - const sumOfSplitExpenses = (draftTransaction?.comment?.splitExpenses ?? []).reduce((acc, item) => acc + (item.amount ?? 0), 0); const splitExpenses = draftTransaction?.comment?.splitExpenses ?? []; - const invalidSplit = splitExpenses.find((split) => { - // A split is only invalid if it has the same sign as the total and its magnitude exceeds the total - const sameSign = (split.amount >= 0 && transactionDetailsAmount >= 0) || (split.amount < 0 && transactionDetailsAmount < 0); - return sameSign && Math.abs(split.amount) > Math.abs(transactionDetailsAmount); - }); - const difference = sumOfSplitExpenses - transactionDetailsAmount; + const sumOfSplitExpenses = splitExpenses.reduce((acc, item) => acc + (item.amount ?? 0), 0); const currencySymbol = getCurrencySymbol(transactionDetails.currency ?? '') ?? transactionDetails.currency ?? CONST.CURRENCY.USD; useEffect(() => { @@ -242,10 +239,6 @@ function SplitExpensePage({route}: SplitExpensePageProps) { return; } - if (splitExpenses.length > CONST.IOU.SPLITS_LIMIT) { - setErrorMessage(translate('iou.error.manySplitsProvided')); - return; - } if (splitExpenses.length <= 1 && !childTransactions.length) { const splitFieldDataFromOriginalTransactionWithoutID = {...splitFieldDataFromOriginalTransaction, transactionID: ''}; const splitExpenseWithoutID = {...splitExpenses.at(0), transactionID: ''}; @@ -267,28 +260,17 @@ function SplitExpensePage({route}: SplitExpensePageProps) { clearSplitTransactionDraftErrors(transactionID); } - if (invalidSplit && sumOfSplitExpenses !== transactionDetailsAmount && !isDistance) { - if (difference > 0) { - setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency))); - } else { - setErrorMessage(translate('iou.totalAmountLessThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency))); - } - return; - } - - if (sumOfSplitExpenses > transactionDetailsAmount && !isDistance) { - const greaterThanDifference = sumOfSplitExpenses - transactionDetailsAmount; - setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', convertToDisplayString(greaterThanDifference, transactionDetails?.currency))); - return; - } - if (sumOfSplitExpenses < transactionDetailsAmount && (isPerDiem || isCard) && !isDistance) { - const lessThanDifference = transactionDetailsAmount - sumOfSplitExpenses; - setErrorMessage(translate('iou.totalAmountLessThanOriginal', convertToDisplayString(lessThanDifference, transactionDetails?.currency))); - return; - } - - if (splitExpenses.find((item) => item.amount === 0)) { - setErrorMessage(translate('iou.splitExpenseZeroAmount')); + const saveError = computeSplitSaveErrorMessage({ + splitExpenses, + transactionDetailsAmount, + currency: transactionDetails?.currency ?? CONST.CURRENCY.USD, + isDistance, + isPerDiem, + isCard, + translate, + }); + if (saveError) { + setErrorMessage(saveError); return; } @@ -326,6 +308,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { personalDetails, transactionReport: draftTransactionReport, expenseReport, + isOffline, }); }; @@ -354,7 +337,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const currentItemReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${currentTransaction?.reportID}`]; const isApproved = isReportApproved({report: currentItemReport}); const isSettled = isSettledReportUtils(currentItemReport?.reportID); - const isCancelled = currentItemReport && currentItemReport?.isCancelledIOU; + const isCancelled = currentItemReport?.isCancelledIOU; const percentage = adjustedPercentages.at(index) ?? 0; const date = DateUtils.formatWithUTCTimeZone( @@ -414,19 +397,12 @@ function SplitExpensePage({route}: SplitExpensePageProps) { ); - let warningMessage = ''; - - if (invalidSplit && sumOfSplitExpenses !== transactionDetailsAmount) { - if (difference > 0) { - warningMessage = translate('iou.totalAmountGreaterThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency)); - } else { - warningMessage = translate('iou.totalAmountLessThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency)); - } - } else if (difference < 0) { - warningMessage = translate('iou.totalAmountLessThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails.currency)); - } else if (difference > 0) { - warningMessage = translate('iou.totalAmountGreaterThanOriginal', convertToDisplayString(Math.abs(difference), transactionDetails?.currency)); - } + const warningMessage = computeSplitWarningMessage({ + splitExpenses, + transactionDetailsAmount, + currency: transactionDetails?.currency ?? CONST.CURRENCY.USD, + translate, + }); const footerContent = ( diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 2bb5e0eaf108..84aa6620cb55 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -1,33 +1,25 @@ -import {useFocusEffect} from '@react-navigation/native'; -import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {Keyboard, View} from 'react-native'; +import React, {useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelector from '@components/TabSelector/TabSelector'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; -import usePrevious from '@hooks/usePrevious'; +import useResetIOUType from '@hooks/useResetIOUType'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; -import {hasOnlyPersonalPolicies as hasOnlyPersonalPoliciesUtil} from '@libs/PolicyUtils'; import {getPayeeName} from '@libs/ReportUtils'; import {endSpan} from '@libs/telemetry/activeSpans'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import type {IOURequestType} from '@userActions/IOU'; -import {initMoneyRequest} from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {SelectedTabRequest} from '@src/types/onyx'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import IOURequestStepDistanceGPS from './step/IOURequestStepDistanceGPS'; import IOURequestStepDistanceManual from './step/IOURequestStepDistanceManual'; @@ -51,22 +43,13 @@ function DistanceRequestStartPage({ const styles = useThemeStyles(); const {translate} = useLocalize(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${getNonEmptyStringOnyxID(route?.params.transactionID)}`); const {policy} = usePolicyForTransaction({transaction, reportPolicyID: report?.policyID, action, iouType}); const [selectedTab, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.DISTANCE_REQUEST_TYPE}`); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const isLoadingSelectedTab = isLoadingOnyxValue(selectedTabResult); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); - const [currentDate] = useOnyx(ONYXKEYS.CURRENT_DATE); const isTrackDistanceExpense = iouType === CONST.IOU.TYPE.TRACK; - const hasOnlyPersonalPolicies = useMemo(() => hasOnlyPersonalPoliciesUtil(allPolicies), [allPolicies]); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const personalPolicy = usePersonalPolicy(); - const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - const tabTitles = { [CONST.IOU.TYPE.REQUEST]: translate('iou.trackDistance'), [CONST.IOU.TYPE.SUBMIT]: translate('iou.trackDistance'), @@ -79,8 +62,6 @@ function DistanceRequestStartPage({ [CONST.IOU.TYPE.CREATE]: translate('iou.trackDistance'), }; - const isFromGlobalCreate = isEmptyObject(report?.reportID); - const transactionRequestType = useMemo(() => { if (!transaction?.iouRequestType) { return lastDistanceExpenseType ?? selectedTab ?? CONST.IOU.REQUEST_TYPE.DISTANCE_MAP; @@ -89,7 +70,15 @@ function DistanceRequestStartPage({ return transaction.iouRequestType; }, [transaction?.iouRequestType, selectedTab, lastDistanceExpenseType]); - const prevTransactionReportID = usePrevious(transaction?.reportID); + const resetIOUTypeIfChanged = useResetIOUType({ + reportID, + report, + transaction, + isLoadingSelectedTab, + transactionRequestType, + policy, + isTrackDistanceExpense, + }); useEffect(() => { endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); @@ -99,60 +88,6 @@ function DistanceRequestStartPage({ Navigation.closeRHPFlow(); }; - const resetIOUTypeIfChanged = useCallback( - (newIOUType: IOURequestType) => { - Keyboard.dismiss(); - if (transaction?.iouRequestType === newIOUType) { - return; - } - initMoneyRequest({ - reportID, - policy, - personalPolicy, - isFromGlobalCreate, - isTrackDistanceExpense, - isFromFloatingActionButton: transaction?.isFromFloatingActionButton ?? transaction?.isFromGlobalCreate ?? isFromGlobalCreate, - currentIouRequestType: transaction?.iouRequestType, - newIouRequestType: newIOUType, - report, - parentReport, - currentDate, - lastSelectedDistanceRates, - currentUserPersonalDetails, - hasOnlyPersonalPolicies, - draftTransactionIDs, - }); - }, - [ - transaction?.iouRequestType, - transaction?.isFromGlobalCreate, - transaction?.isFromFloatingActionButton, - reportID, - policy, - personalPolicy, - isFromGlobalCreate, - report, - parentReport, - currentDate, - lastSelectedDistanceRates, - currentUserPersonalDetails, - isTrackDistanceExpense, - hasOnlyPersonalPolicies, - draftTransactionIDs, - ], - ); - - // Clear out the temporary expense if the reportID in the URL has changed from the transaction's reportID. - useFocusEffect( - useCallback(() => { - // The test transaction can change the reportID of the transaction on the flow so we should prevent the reportID from being reverted again. - if (transaction?.reportID === reportID || isLoadingSelectedTab || !transactionRequestType || prevTransactionReportID !== transaction?.reportID) { - return; - } - resetIOUTypeIfChanged(transactionRequestType); - }, [transaction?.reportID, reportID, resetIOUTypeIfChanged, transactionRequestType, isLoadingSelectedTab, prevTransactionReportID]), - ); - const [headerWithBackBtnContainerElement, setHeaderWithBackButtonContainerElement] = useState(null); const [tabBarContainerElement, setTabBarContainerElement] = useState(null); const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); @@ -167,7 +102,6 @@ function DistanceRequestStartPage({ iouType={iouType} policyID={policy?.id} accessVariants={[CONST.IOU.ACCESS_VARIANTS.CREATE]} - allPolicies={allPolicies} > hasOnlyPersonalPoliciesUtil(allPolicies), [allPolicies]); const perDiemInputRef = useRef(null); @@ -147,23 +135,58 @@ function IOURequestStartPage({ const hasCurrentPolicyPerDiemWithRates = !isFromGlobalCreate && hasCurrentPolicyPerDiemEnabled && hasPolicyPerDiemRates; const hasAnyPolicyPerDiemWithRates = (iouType === CONST.IOU.TYPE.TRACK || isFromGlobalCreate) && doesPerDiemPolicyExist; const shouldShowPerDiemOption = iouType !== CONST.IOU.TYPE.SPLIT && (hasCurrentPolicyPerDiemWithRates || hasAnyPolicyPerDiemWithRates); + const shouldShowTimeOption = + (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE) && + ((!isFromGlobalCreate && hasCurrentPolicyTimeTrackingEnabled) || (isFromGlobalCreate && !!policiesWithTimeEnabled.length)); - const transactionRequestType = useMemo(() => { - if (!transaction?.iouRequestType) { - if (shouldUseTab) { - if (selectedTab === CONST.TAB_REQUEST.PER_DIEM && !shouldShowPerDiemOption) { - return undefined; - } - return selectedTab; - } + // Mirrors the tabs rendered below so a stale persisted selectedTab that isn't valid for this iouType is rejected. + const availableTabs = useMemo>(() => { + if (!shouldUseTab) { + return new Set(); + } + const tabs = new Set([CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN]); + if (iouType === CONST.IOU.TYPE.SPLIT) { + tabs.add(CONST.TAB_REQUEST.DISTANCE); + } + if (shouldShowPerDiemOption) { + tabs.add(CONST.TAB_REQUEST.PER_DIEM); + } + if (shouldShowTimeOption) { + tabs.add(CONST.TAB_REQUEST.TIME); + } + return tabs; + }, [shouldUseTab, iouType, shouldShowPerDiemOption, shouldShowTimeOption]); + + // A quick-action deeplink (e.g. iOS home-screen "Scan receipt") bypasses startMoneyRequest + // and leaves the previous flow's draft in place under OPTIMISTIC_TRANSACTION_ID. Detect it + // by comparing the draft's reportID to the URL's so we don't inherit its stale iouRequestType. + const isStaleTransactionDraft = !!transaction?.reportID && transaction.reportID !== reportID; + const transactionRequestType = useMemo(() => { + if (transaction?.iouRequestType && !isStaleTransactionDraft) { + return transaction.iouRequestType; + } + if (!shouldUseTab) { return CONST.IOU.REQUEST_TYPE.MANUAL; } + // selectedTab must be valid for the currently-rendered tab set; otherwise let + // OnyxTabNavigator.onTabSelected initialize from the URL (which is authoritative). + if (selectedTab && availableTabs.has(selectedTab)) { + return selectedTab; + } + return undefined; + }, [transaction?.iouRequestType, isStaleTransactionDraft, shouldUseTab, selectedTab, availableTabs]); - return transaction.iouRequestType; - }, [transaction?.iouRequestType, shouldUseTab, selectedTab, shouldShowPerDiemOption]); - - const prevTransactionReportID = usePrevious(transaction?.reportID); + const resetIOUTypeIfChanged = useResetIOUType({ + reportID, + report, + transaction, + isLoadingTransaction, + isLoadingSelectedTab, + transactionRequestType, + policy, + skipKeyboardDismissForPerDiem: true, + }); useEffect(() => { // Don't end span for scan flows - it will be ended when camera initializes (or canceled if permission is denied). @@ -179,74 +202,6 @@ function IOURequestStartPage({ Navigation.closeRHPFlow(); }; - const resetIOUTypeIfChanged = useCallback( - (newIOUType: IOURequestType) => { - if (newIOUType !== CONST.IOU.REQUEST_TYPE.PER_DIEM) { - Keyboard.dismiss(); - } - if (transaction?.iouRequestType === newIOUType) { - return; - } - initMoneyRequest({ - reportID, - policy, - personalPolicy, - isFromGlobalCreate: transaction?.isFromGlobalCreate ?? isFromGlobalCreate, - isFromFloatingActionButton: transaction?.isFromFloatingActionButton ?? transaction?.isFromGlobalCreate ?? isFromGlobalCreate, - currentIouRequestType: transaction?.iouRequestType, - newIouRequestType: newIOUType, - report, - parentReport, - currentDate, - lastSelectedDistanceRates, - currentUserPersonalDetails, - hasOnlyPersonalPolicies, - draftTransactionIDs, - }); - }, - [ - transaction?.iouRequestType, - transaction?.isFromGlobalCreate, - transaction?.isFromFloatingActionButton, - reportID, - policy, - personalPolicy, - isFromGlobalCreate, - report, - parentReport, - currentDate, - lastSelectedDistanceRates, - currentUserPersonalDetails, - hasOnlyPersonalPolicies, - draftTransactionIDs, - ], - ); - - const onTabSelected = useCallback( - (newIouType: IOURequestType) => { - resetIOUTypeIfChanged(newIouType); - }, - [resetIOUTypeIfChanged], - ); - - // Clear out the temporary expense if the reportID in the URL has changed from the transaction's reportID. - useFocusEffect( - useCallback(() => { - // The test transaction can change the reportID of the transaction on the flow so we should prevent the reportID from being reverted again. - if ( - transaction?.reportID === reportID || - isLoadingTransaction || - isLoadingSelectedTab || - !transactionRequestType || - prevTransactionReportID !== transaction?.reportID || - !personalPolicy?.id - ) { - return; - } - resetIOUTypeIfChanged(transactionRequestType); - }, [transaction?.reportID, reportID, resetIOUTypeIfChanged, transactionRequestType, isLoadingSelectedTab, prevTransactionReportID, isLoadingTransaction, personalPolicy?.id]), - ); - const [headerWithBackBtnContainerElement, setHeaderWithBackButtonContainerElement] = useState(null); const [tabBarContainerElement, setTabBarContainerElement] = useState(null); const [activeTabContainerElement, setActiveTabContainerElement] = useState(null); @@ -275,10 +230,6 @@ function IOURequestStartPage({ }, }, ); - const shouldShowTimeOption = - (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE) && - ((!isFromGlobalCreate && hasCurrentPolicyTimeTrackingEnabled) || (isFromGlobalCreate && !!policiesWithTimeEnabled.length)); - const onBackButtonPress = () => { navigateBack(); return true; @@ -294,7 +245,7 @@ function IOURequestStartPage({ iouType={iouType} policyID={policy?.id} accessVariants={[CONST.IOU.ACCESS_VARIANTS.CREATE]} - allPolicies={allPolicies} + allPolicies={iouType === CONST.IOU.TYPE.INVOICE ? allPolicies : undefined} > void; @@ -46,193 +40,71 @@ type MoneyRequestAccountantSelectorProps = { /** The type of IOU report, i.e. split, request, send, track */ iouType: IOUType; - - /** The action of the IOU, i.e. create, split, move */ - action: IOUAction; }; -function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType, action}: MoneyRequestAccountantSelectorProps) { - const {translate} = useLocalize(); +function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType}: MoneyRequestAccountantSelectorProps) { + const {translate, formatPhoneNumber} = useLocalize(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {isOffline} = useNetwork(); - const personalDetails = usePersonalDetails(); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const [betas] = useOnyx(ONYXKEYS.BETAS); const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); - const {options, areOptionsInitialized} = useOptionsList({ - shouldInitialize: didScreenTransitionEnd, - }); - const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; - const reportAttributesDerived = useReportAttributes(); - const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); - const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; - const currentUserAccountID = currentUserPersonalDetails.accountID; - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const privateIsArchivedMap = usePrivateIsArchivedMap(); + const {options: personalDetailOptions} = usePersonalDetailOptions({enabled: didScreenTransitionEnd}); + const areOptionsInitialized = (personalDetailOptions?.length ?? 0) > 0; + const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; useEffect(() => { searchUserInServer(debouncedSearchTerm.trim()); }, [debouncedSearchTerm]); - const defaultOptions = useMemo(() => { + const optionsList = useMemo(() => { if (!areOptionsInitialized || !didScreenTransitionEnd) { - getEmptyOptions(); + return defaultListOptions; } - const optionList = memoizedGetValidOptions( - { - reports: options.reports, - personalDetails: options.personalDetails, - }, - allPolicies, - draftComments, - nvpDismissedProductTraining, - loginList, - currentUserAccountID, - currentUserEmail, - { - betas, - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - action, - personalDetails, - countryCode, - }, - ); - - const orderedOptions = orderOptions(optionList); - - return { - ...optionList, - ...orderedOptions, - }; - }, [ - areOptionsInitialized, - didScreenTransitionEnd, - options.reports, - options.personalDetails, - allPolicies, - draftComments, - nvpDismissedProductTraining, - loginList, - betas, - action, - countryCode, - currentUserAccountID, - currentUserEmail, - personalDetails, - ]); - - const chatOptions = useMemo(() => { - if (!areOptionsInitialized) { - return { - userToInvite: null, - recentReports: [], - personalDetails: [], - currentUserOption: null, - headerMessage: '', - }; - } - const newOptions = filterAndOrderOptions(defaultOptions, debouncedSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, { + return memoizedGetValidOptions(personalDetailOptions ?? [], currentUserEmail, formatPhoneNumber, countryCode, loginList, { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + searchString: debouncedSearchTerm, + includeUserToInvite: true, }); - return newOptions; - }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, countryCode, loginList, currentUserAccountID, currentUserEmail, personalDetails]); - - const {userToInviteExpenseReport} = useUserToInviteReports(chatOptions?.userToInvite); - const userToInviteExpenseReportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${userToInviteExpenseReport?.policyID}`]; + }, [areOptionsInitialized, didScreenTransitionEnd, personalDetailOptions, currentUserEmail, formatPhoneNumber, countryCode, loginList, debouncedSearchTerm]); - /** - * Returns the sections needed for the OptionsSelector - */ - const [sections, header] = useMemo(() => { - const newSections: SelectionListSections = []; + const sections = useMemo(() => { + const newSections: Array> = []; if (!areOptionsInitialized || !didScreenTransitionEnd) { - return [newSections, '']; + return newSections; } - const fiveRecents = [...chatOptions.recentReports].slice(0, 5); - const restOfRecents = [...chatOptions.recentReports].slice(5); - const contactsWithRestOfRecents = [...restOfRecents, ...chatOptions.personalDetails]; - - const formatResults = formatSectionsFromSearchTerm( - debouncedSearchTerm, - [], - chatOptions.recentReports, - chatOptions.personalDetails, - privateIsArchivedMap, - currentUserAccountID, - allPolicies, - personalDetails, - true, - undefined, - reportAttributesDerived, - ); - newSections.push({...formatResults.section, sectionIndex: 0}); - newSections.push({ - title: translate('common.recents'), - data: fiveRecents, - sectionIndex: 1, - }); + let sectionIndex = 0; - newSections.push({ - title: translate('common.contacts'), - data: contactsWithRestOfRecents, - sectionIndex: 2, - }); + if (optionsList.recentOptions.length > 0) { + newSections.push({ + title: translate('common.recents'), + data: optionsList.recentOptions, + sectionIndex: sectionIndex++, + }); + } - if ( - chatOptions.userToInvite && - !isCurrentUser( - {...chatOptions.userToInvite, accountID: chatOptions.userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, status: chatOptions.userToInvite?.status ?? undefined}, - loginList, - currentUserEmail, - ) - ) { + if (optionsList.personalDetails.length > 0) { newSections.push({ - data: [chatOptions.userToInvite].map((participant) => { - const isPolicyExpenseChat = participant?.isPolicyExpenseChat ?? false; - const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${userToInviteExpenseReport?.reportID}`]; - return isPolicyExpenseChat - ? getPolicyExpenseReportOption(participant, privateIsArchived, personalDetails, userToInviteExpenseReport, userToInviteExpenseReportPolicy, reportAttributesDerived) - : getParticipantsOption(participant, personalDetails); - }), - sectionIndex: 3, + title: translate('common.contacts'), + data: optionsList.personalDetails, + sectionIndex: sectionIndex++, }); } - const headerMessage = getHeaderMessage( - (chatOptions.personalDetails ?? []).length + (chatOptions.recentReports ?? []).length !== 0, - !!chatOptions?.userToInvite, - debouncedSearchTerm.trim(), - countryCode, - false, - ); + if (optionsList.userToInvite) { + newSections.push({ + data: [optionsList.userToInvite], + sectionIndex: sectionIndex++, + }); + } - return [newSections, headerMessage]; - }, [ - areOptionsInitialized, - didScreenTransitionEnd, - chatOptions.recentReports, - chatOptions.personalDetails, - chatOptions.userToInvite, - debouncedSearchTerm, - personalDetails, - userToInviteExpenseReport, - userToInviteExpenseReportPolicy, - reportAttributesDerived, - translate, - loginList, - countryCode, - privateIsArchivedMap, - currentUserAccountID, - currentUserEmail, - allPolicies, - ]); + return newSections; + }, [areOptionsInitialized, didScreenTransitionEnd, optionsList.recentOptions, optionsList.personalDetails, optionsList.userToInvite, translate]); const selectAccountant = useCallback( (option: Accountant) => { @@ -253,6 +125,17 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType [selectAccountant], ); + const getHeaderMessageText = () => { + if (sections.length > 0) { + return ''; + } + const searchValue = debouncedSearchTerm.trim().toLowerCase(); + if (CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { + return translate('messages.errorMessageInvalidEmail'); + } + return getHeaderMessage(translate, debouncedSearchTerm, countryCode); + }; + const shouldShowLoadingPlaceholder = useMemo(() => !areOptionsInitialized || !didScreenTransitionEnd, [areOptionsInitialized, didScreenTransitionEnd]); const optionLength = useMemo(() => { @@ -268,7 +151,7 @@ function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType value: searchTerm, label: translate('selectionList.nameEmailOrPhoneNumber'), onChangeText: setSearchTerm, - headerMessage: header, + headerMessage: getHeaderMessageText(), hint: offlineMessage, }; diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 7493ef69f358..285ee91769c8 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -26,11 +26,11 @@ import { getFilteredRecentAttendees, getHeaderMessage, getParticipantsOption, - getPersonalDetailSearchTerms, getPolicyExpenseReportOption, isCurrentUser, orderOptions, } from '@libs/OptionsListUtils'; +import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy as isPaidGroupPolicyFn} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -83,12 +83,13 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde const initialSelectedOptions = attendees.map((attendee) => ({ ...attendee, reportID: CONST.DEFAULT_NUMBER_ID.toString(), - keyForList: String(attendee.accountID) ?? (attendee.email || attendee.displayName), + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + keyForList: String(attendee.accountID) || attendee.email || attendee.displayName, selected: true, // Use || to fall back to displayName for name-only attendees (empty email) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing login: attendee.email || attendee.displayName, - ...getPersonalDetailByEmail(attendee.email), + ...getPersonalDetailByEmail(attendee?.email), })); const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized, onListEndReached} = useSearchSelector({ @@ -272,7 +273,7 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde !!orderedAvailableOptions?.userToInvite, cleanSearchTerm, countryCode, - attendees.some((attendee) => getPersonalDetailSearchTerms(attendee, currentUserAccountID).join(' ').toLowerCase().includes(cleanSearchTerm)), + attendees.some((attendee) => doesPersonalDetailMatchSearchTerm(attendee, currentUserAccountID, cleanSearchTerm)), ); sections = newSections; } diff --git a/src/pages/iou/request/MoneyRequestCreateVerifyAccountPage.tsx b/src/pages/iou/request/MoneyRequestCreateVerifyAccountPage.tsx new file mode 100644 index 000000000000..38735597f3e4 --- /dev/null +++ b/src/pages/iou/request/MoneyRequestCreateVerifyAccountPage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; +import VerifyAccountPageBase from '@pages/settings/VerifyAccountPageBase'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type MoneyRequestCreateVerifyAccountPageProps = PlatformStackScreenProps; + +function MoneyRequestCreateVerifyAccountPage({route}: MoneyRequestCreateVerifyAccountPageProps) { + const createRoute = ROUTES.MONEY_REQUEST_CREATE.getRoute(route.params.action, route.params.iouType, route.params.transactionID, route.params.reportID); + + return ( + + ); +} + +export default MoneyRequestCreateVerifyAccountPage; diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index c833f68f19f4..0c2c233456a8 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -1,56 +1,11 @@ -import {deepEqual} from 'fast-equals'; -import lodashPick from 'lodash/pick'; -import React, {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useImperativeHandle, useRef, useState} from 'react'; import type {Ref} from 'react'; -import type {GestureResponderEvent} from 'react-native'; -import {InteractionManager} from 'react-native'; -import {RESULTS} from 'react-native-permissions'; -import Button from '@components/Button'; -import ContactPermissionModal from '@components/ContactPermissionModal'; -import EmptySelectionListContent from '@components/EmptySelectionListContent'; -import FormHelpMessage from '@components/FormHelpMessage'; -import MenuItem from '@components/MenuItem'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import ReferralProgramCTA from '@components/ReferralProgramCTA'; -import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; -import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; -import type {Section, SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; -import useContactImport from '@hooks/useContactImport'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDismissedReferralBanners from '@hooks/useDismissedReferralBanners'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import usePreferredPolicy from '@hooks/usePreferredPolicy'; -import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; -import useReportAttributes from '@hooks/useReportAttributes'; -import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; -import useSearchSelector from '@hooks/useSearchSelector'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useTransactionDraftValues from '@hooks/useTransactionDraftValues'; -import useUserToInviteReports from '@hooks/useUserToInviteReports'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import type {SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import getPlatform from '@libs/getPlatform'; -import goToSettings from '@libs/goToSettings'; -import {isMovingTransactionFromTrackExpense} from '@libs/IOUUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import type {Option} from '@libs/OptionsListUtils'; -import {formatSectionsFromSearchTerm, getHeaderMessage, getParticipantsOption, getPersonalDetailSearchTerms, getPolicyExpenseReportOption, isCurrentUser} from '@libs/OptionsListUtils'; -import type {OptionWithKey} from '@libs/OptionsListUtils/types'; -import {getActiveAdminWorkspaces, isPaidGroupPolicy as isPaidGroupPolicyUtil} from '@libs/PolicyUtils'; -import type {OptionData} from '@libs/ReportUtils'; -import {isInvoiceRoom} from '@libs/ReportUtils'; -import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {getInvoicePrimaryWorkspace} from '@userActions/Policy/Policy'; -import {searchUserInServer} from '@userActions/Report'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type {Participant} from '@src/types/onyx/IOU'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import ImportContactButton from './ImportContactButton'; +import ParticipantSearchResults from './ParticipantSearchResults'; type MoneyRequestParticipantsSelectorProps = { /** Callback to request parent modal to go to next step, which should be split */ @@ -88,12 +43,6 @@ type InputFocusRef = { focus?: () => void; }; -const sanitizedSelectedParticipant = (option: Option | OptionData, iouType: IOUType) => ({ - ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText', 'policyID', 'isSelfDM', 'text', 'phoneNumber', 'displayName'), - selected: true, - iouType, -}); - function MoneyRequestParticipantsSelector({ participants = CONST.EMPTY_ARRAY, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -107,482 +56,10 @@ function MoneyRequestParticipantsSelector({ isCorporateCardTransaction = false, ref, }: MoneyRequestParticipantsSelectorProps) { - const icons = useMemoizedLazyExpensifyIcons(['UserPlus']); - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const {contactPermissionState, contacts, setContactPermissionState, importAndSaveContacts} = useContactImport(); const platform = getPlatform(); const isNative = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS; - const referralContentType = CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE; - const {isOffline} = useNetwork(); - const personalDetails = usePersonalDetails(); - const {isDismissed} = useDismissedReferralBanners({referralContentType}); - const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); - const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]; - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserLogin = currentUserPersonalDetails.login; - const currentUserEmail = currentUserPersonalDetails.email ?? ''; - const currentUserAccountID = currentUserPersonalDetails.accountID; - const reportAttributesDerived = useReportAttributes(); - const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); - const privateIsArchivedMap = usePrivateIsArchivedMap(); - const [textInputAutoFocus, setTextInputAutoFocus] = useState(!isNative); const selectionListRef = useRef(null); - const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; - - const isPaidGroupPolicy = useMemo(() => isPaidGroupPolicyUtil(policy), [policy]); - const activeAdminWorkspaces = useMemo(() => getActiveAdminWorkspaces(allPolicies, currentUserLogin), [allPolicies, currentUserLogin]); - const isIOUSplit = iouType === CONST.IOU.TYPE.SPLIT; - const isCategorizeOrShareAction = [CONST.IOU.ACTION.CATEGORIZE, CONST.IOU.ACTION.SHARE].some((option) => option === action); - const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT); - const hasBeenAddedToNudgeMigration = !!tryNewDot?.nudgeMigration?.timestamp; - const optimisticTransactions = useTransactionDraftValues(); - - // This is necessary to prevent showing the Manager McTest when there are multiple transactions being created - const hasMultipleTransactions = optimisticTransactions.length > 1; - const canShowManagerMcTest = useMemo(() => !hasBeenAddedToNudgeMigration && action !== CONST.IOU.ACTION.SUBMIT, [hasBeenAddedToNudgeMigration, action]) && !hasMultipleTransactions; - - /** - * Adds a single participant to the expense - * - * @param {Object} option - */ - const addSingleParticipant = useCallback( - (option: Participant & Option) => { - const newParticipants: Participant[] = [sanitizedSelectedParticipant(option, iouType)]; - - if (iouType === CONST.IOU.TYPE.INVOICE) { - const policyID = option.item && isInvoiceRoom(option.item) ? option.policyID : getInvoicePrimaryWorkspace(policy, activeAdminWorkspaces)?.id; - newParticipants.push({ - policyID, - isSender: true, - selected: false, - iouType, - }); - } - - onParticipantsAdded(newParticipants); - - if (!option.isSelfDM) { - onFinish(undefined, newParticipants); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to trigger this callback when iouType changes - [onFinish, onParticipantsAdded, policy, activeAdminWorkspaces], - ); - - const getValidOptionsConfig = useMemo( - () => ({ - selectedOptions: participants as Participant[], - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - includeOwnedWorkspaceChats: iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK, - excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, - includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isCorporateCardTransaction, - includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, - action, - shouldSeparateSelfDMChat: iouType !== CONST.IOU.TYPE.INVOICE, - shouldSeparateWorkspaceChat: true, - includeSelfDM: !isMovingTransactionFromTrackExpense(action) && iouType !== CONST.IOU.TYPE.INVOICE, - canShowManagerMcTest, - isPerDiemRequest, - isTimeRequest, - showRBR: false, - preferPolicyExpenseChat: isPaidGroupPolicy, - preferRecentExpenseReports: action === CONST.IOU.ACTION.CREATE, - isRestrictedToPreferredPolicy, - preferredPolicyID, - }), - [ - participants, - iouType, - action, - isCategorizeOrShareAction, - isPerDiemRequest, - isTimeRequest, - isCorporateCardTransaction, - canShowManagerMcTest, - isPaidGroupPolicy, - isRestrictedToPreferredPolicy, - preferredPolicyID, - ], - ); - - const handleSelectionChange = useCallback( - (options: OptionData[]) => { - if (!isIOUSplit) { - return; - } - const sanitizedParticipants: Participant[] = options.map((option) => sanitizedSelectedParticipant(option, iouType)); - onParticipantsAdded(sanitizedParticipants); - }, - [isIOUSplit, iouType, onParticipantsAdded], - ); - - const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized, onListEndReached, contactState} = useSearchSelector({ - selectionMode: isIOUSplit ? CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI : CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, - includeUserToInvite: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest, - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - includeRecentReports: true, - maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - getValidOptionsConfig, - shouldInitialize: didScreenTransitionEnd, - enablePhoneContacts: isNative, - contactOptions: contacts, - initialSelected: participants as OptionData[], - onSelectionChange: handleSelectionChange, - onSingleSelect: (option: OptionData) => { - if (isIOUSplit) { - return; - } - addSingleParticipant(option); - }, - }); - - const cleanSearchTerm = useMemo(() => debouncedSearchTerm.trim().toLowerCase(), [debouncedSearchTerm]); - - const {userToInviteExpenseReport} = useUserToInviteReports(availableOptions?.userToInvite); - const userToInviteExpenseReportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${userToInviteExpenseReport?.policyID}`]; - - useEffect(() => { - searchUserInServer(debouncedSearchTerm.trim()); - }, [debouncedSearchTerm]); - - const inputHelperText = useMemo( - () => - getHeaderMessage( - (availableOptions.personalDetails ?? []).length + (availableOptions.recentReports ?? []).length + (availableOptions.workspaceChats ?? []).length !== 0 || - !isEmptyObject(availableOptions.selfDMChat), - !!availableOptions?.userToInvite, - debouncedSearchTerm.trim(), - countryCode, - participants.some((participant) => getPersonalDetailSearchTerms(participant, currentUserAccountID).join(' ').toLowerCase().includes(cleanSearchTerm)), - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - availableOptions.personalDetails?.length, - availableOptions.recentReports?.length, - availableOptions.selfDMChat, - availableOptions?.userToInvite, - availableOptions.workspaceChats, - cleanSearchTerm, - debouncedSearchTerm, - participants, - countryCode, - currentUserAccountID, - ], - ); - - const showImportContacts = - isNative && - !isCategorizeOrShareAction && - !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED) && - inputHelperText === translate('common.noResultsFound'); - - /** - * Returns the sections needed for the OptionsSelector - * @returns {Array} - */ - const [sections, header] = useMemo(() => { - const newSections: Array> = []; - if (!areOptionsInitialized || !didScreenTransitionEnd) { - return [newSections, '']; - } - - const formatResults = formatSectionsFromSearchTerm( - searchTerm, - participants.map((participant) => ({...participant, reportID: participant.reportID})) as OptionData[], - [], - [], - privateIsArchivedMap, - currentUserAccountID, - allPolicies, - personalDetails, - true, - undefined, - reportAttributesDerived, - ); - // Just a temporary fix to satisfy the type checker - // Will be fixed when migrating to use new SelectionListWithSections - newSections.push({...formatResults.section, sectionIndex: 0}); - - if ((availableOptions.workspaceChats ?? []).length > 0) { - newSections.push({ - title: translate('workspace.common.workspace'), - data: availableOptions.workspaceChats ?? [], - sectionIndex: 1, - }); - } - - if (availableOptions.selfDMChat) { - newSections.push({ - title: translate('workspace.invoices.paymentMethods.personal'), - data: availableOptions.selfDMChat ? [availableOptions.selfDMChat] : [], - sectionIndex: 2, - }); - } - - if (!isWorkspacesOnly) { - const shouldFilterRecentReportsToWorkspaceOnly = isPerDiemRequest || isTimeRequest; - if ((shouldFilterRecentReportsToWorkspaceOnly ? availableOptions.recentReports.filter((report) => report.isPolicyExpenseChat) : availableOptions.recentReports).length > 0) { - newSections.push({ - title: translate('common.recents'), - data: shouldFilterRecentReportsToWorkspaceOnly ? availableOptions.recentReports.filter((report) => report.isPolicyExpenseChat) : availableOptions.recentReports, - sectionIndex: 3, - }); - } - - if (availableOptions.personalDetails.length > 0 && !isPerDiemRequest && !isTimeRequest) { - newSections.push({ - title: translate('common.contacts'), - data: availableOptions.personalDetails, - sectionIndex: 4, - }); - } - } - - if ( - !isWorkspacesOnly && - availableOptions.userToInvite && - !isCurrentUser( - { - ...availableOptions.userToInvite, - accountID: availableOptions.userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, - status: availableOptions.userToInvite?.status ?? undefined, - }, - loginList, - currentUserEmail, - ) && - !isPerDiemRequest && - !isTimeRequest - ) { - newSections.push({ - title: undefined, - data: [availableOptions.userToInvite].map((participant) => { - const isPolicyExpenseChat = participant?.isPolicyExpenseChat ?? false; - const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${userToInviteExpenseReport?.reportID}`]; - return isPolicyExpenseChat - ? getPolicyExpenseReportOption(participant, privateIsArchived, personalDetails, userToInviteExpenseReport, userToInviteExpenseReportPolicy, reportAttributesDerived) - : getParticipantsOption(participant, personalDetails); - }), - sectionIndex: 5, - }); - } - - let headerMessage; - if (!showImportContacts) { - headerMessage = inputHelperText; - } - - return [newSections, headerMessage]; - }, [ - areOptionsInitialized, - didScreenTransitionEnd, - searchTerm, - participants, - personalDetails, - reportAttributesDerived, - translate, - availableOptions.workspaceChats, - availableOptions.selfDMChat, - availableOptions.userToInvite, - availableOptions.recentReports, - availableOptions.personalDetails, - userToInviteExpenseReport, - userToInviteExpenseReportPolicy, - isWorkspacesOnly, - loginList, - isPerDiemRequest, - isTimeRequest, - showImportContacts, - inputHelperText, - privateIsArchivedMap, - currentUserAccountID, - currentUserEmail, - allPolicies, - ]); - - /** - * Removes a selected option from list if already selected. If not already selected add this option to the list. - * @param {Object} option - */ - const addParticipantToSelection = useCallback( - (option: Participant) => { - toggleSelection(option as OptionData); - }, - [toggleSelection], - ); - - // Right now you can't split a request with a workspace and other additional participants - // This is getting properly fixed in https://github.com/Expensify/App/issues/27508, but as a stop-gap to prevent - // the app from crashing on native when you try to do this, we'll going to hide the button if you have a workspace and other participants - const hasPolicyExpenseChatParticipant = selectedOptions.some((participant) => participant.isPolicyExpenseChat); - const shouldShowSplitBillErrorMessage = selectedOptions.length > 1 && hasPolicyExpenseChatParticipant; - - const isAllowedToSplit = - ![CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK, CONST.IOU.TYPE.INVOICE].some((option) => option === iouType) && - ![CONST.IOU.ACTION.SHARE, CONST.IOU.ACTION.SUBMIT, CONST.IOU.ACTION.CATEGORIZE].some((option) => option === action); - - const handleConfirmSelection = useCallback( - (keyEvent?: GestureResponderEvent | KeyboardEvent, option?: Participant) => { - const shouldAddSingleParticipant = option && !selectedOptions.length; - if (shouldShowSplitBillErrorMessage || (!selectedOptions.length && !option)) { - return; - } - - if (shouldAddSingleParticipant) { - addSingleParticipant(option); - return; - } - - onFinish(CONST.IOU.TYPE.SPLIT); - }, - [shouldShowSplitBillErrorMessage, onFinish, addSingleParticipant, selectedOptions.length], - ); - - const shouldShowLoadingPlaceholder = useMemo(() => !areOptionsInitialized || !didScreenTransitionEnd, [areOptionsInitialized, didScreenTransitionEnd]); - - const optionLength = useMemo(() => { - if (!areOptionsInitialized) { - return 0; - } - let length = 0; - for (const section of sections) { - length += section.data.length; - } - return length; - }, [areOptionsInitialized, sections]); - - const shouldShowListEmptyContent = useMemo(() => optionLength === 0 && !shouldShowLoadingPlaceholder, [optionLength, shouldShowLoadingPlaceholder]); - - const shouldShowReferralBanner = !isDismissed && iouType !== CONST.IOU.TYPE.INVOICE && !shouldShowListEmptyContent; - - const initiateContactImportAndSetState = useCallback(() => { - setContactPermissionState(RESULTS.GRANTED); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(importAndSaveContacts); - }, [importAndSaveContacts, setContactPermissionState]); - - const footerContent = useMemo(() => { - if (isDismissed && !shouldShowSplitBillErrorMessage && !selectedOptions.length) { - return; - } - - return ( - <> - {shouldShowReferralBanner && !isCategorizeOrShareAction && ( - - )} - - {shouldShowSplitBillErrorMessage && ( - - )} - - {!!selectedOptions.length && !isCategorizeOrShareAction && ( -