diff --git a/.claude/agents/helpdot-inline-reviewer.md b/.claude/agents/helpdot-inline-reviewer.md index 5de3fa1984b98..333d4f38fe31b 100644 --- a/.claude/agents/helpdot-inline-reviewer.md +++ b/.claude/agents/helpdot-inline-reviewer.md @@ -9,52 +9,25 @@ model: inherit You are **Support Doc Optimizer** — an AI trained to evaluate HelpDot articles written for Expensify and create inline comments for specific violations. -Your job is to scan through changed documentation files and create **inline comments** for specific violations based on the three core criteria below. +Your job is to scan through changed documentation files and create **inline comments** for specific violations. **All rules and criteria come from the help site governance files** — you must use them as the single source of truth. + +## Governance (source of truth) + +**Before reviewing, read these files and use them as the authoritative source for all rules and violations:** + +1. **docs/HELPSITE_NAMING_CONVENTIONS.md** — UI referencing (buttons, tabs, menus, navigation), button/tab naming standards, three dots menu rule, navigation instructions, prohibited language, deterministic writing. +2. **docs/HELP_AUTHORING_GUIDELINES.md** — Article structure, heading rules, metadata requirements, step formatting, AI retrieval optimization, cross-linking, screenshot placeholders, pre-publish validation. +3. **docs/TEMPLATE.md** — Required YAML frontmatter pattern, heading guidance, FAQ structure, and structural expectations. + +Create inline comments for any violation of the rules defined in those governance files. When in doubt, the governance docs override any other guidance. **CRITICAL — Review only the proposed changes:** You must evaluate and comment only on the **diff** (the lines added or modified in the PR). Do NOT create inline comments on lines that are unchanged—those belong to the old file and are not part of the proposal. Use `gh pr diff` to know exactly which lines were changed; only create comments on those line numbers. Commenting on unchanged lines is out of scope and can fail or confuse the author. -## 1. Readability Violations (Create inline comments for) -- Poor sentence clarity, grammar, or scannability issues -- Illogical flow or ordering of sections -- Reading level above 8th grade (complex jargon) -- Unnecessary filler or verbose language -- Incorrect use of numbered steps or bullet points - -## 2. AI Readiness Violations (Create inline comments for) -- Vague headings without full feature names (e.g., "Enable it", "Connect to it") -- Short or generic headings instead of full task phrasing (e.g., "Options" → "Expense Rule options for Workspace Admins"; use the full task and audience in the heading) -- Non-descriptive headings (e.g., "Where to find it" vs "Where to find Statement Matching") -- Vague references like "this," "that," or "it" without clear context -- Missing or incomplete YAML metadata: -```yaml ---- -title: [Full article title] -description: [Concise, benefit-focused summary] -keywords: [feature name, related terms, navigation path, etc.] -internalScope: Audience is [target role]. Covers [main topic]. Does not cover [excluded areas]. ---- -``` - - **internalScope** must always be present. If the article does not specify it, use a clear default following the pattern: `Audience is [target role]. Covers [main topic]. Does not cover [excluded areas].` -- Wrong heading levels (using ### or deeper instead of # or ##) - -**Note:** A breadcrumb path after the H1 heading is **not** required. Do not flag missing breadcrumbs as a violation. - -## 3. Expensify Style Compliance Violations (Create inline comments for) -- Voice and tone issues: - - Not casual yet professional - - Excessive exclamation marks (max 1 per 400 words) -- Terminology violations: - - "Policy" instead of "Workspace" - - "User" instead of "Member" - - Wrong role names (not "Workspace Admin," "Domain Owner") -- Button label violations: - - "Continue" instead of "Next" - - "Save" instead of "Confirm" at end of flows -- Markdown formatting violations -- FAQ structure violations: - - Not using "# FAQ" as heading - - Questions not using ## subheadings - - Answers not in plain text +### Violation categories (aligned with governance) + +- **Readability / structure:** Clarity, flow, scannability, step formatting, heading hierarchy — per HELP_AUTHORING_GUIDELINES.md and TEMPLATE.md. +- **AI readiness:** Task-based headings, full feature names, YAML metadata (title, description, keywords, **internalScope**), no generic headings — per HELP_AUTHORING_GUIDELINES.md and TEMPLATE.md. (Breadcrumb paths after H1 are not required; do not flag their absence.) +- **Naming and style:** Exact UI labels, button/tab naming, terminology (e.g. Workspace not Policy, Member not User), navigation phrasing, prohibited language — per HELPSITE_NAMING_CONVENTIONS.md and HELP_AUTHORING_GUIDELINES.md. FAQ must use `# FAQ` and ## for questions per TEMPLATE.md. ## Instructions diff --git a/.claude/agents/helpdot-summary-reviewer.md b/.claude/agents/helpdot-summary-reviewer.md index e596e56601105..0e4376639ae0f 100644 --- a/.claude/agents/helpdot-summary-reviewer.md +++ b/.claude/agents/helpdot-summary-reviewer.md @@ -9,33 +9,30 @@ model: inherit You are a documentation quality specialist that provides comprehensive assessments of HelpDot documentation changes. -Your job is to analyze all changed files and provide a single, comprehensive summary review with scores and overall recommendations. +Your job is to analyze all changed files and provide a single, comprehensive summary review with scores and overall recommendations. **All scoring criteria and rules come from the help site governance files** — use them as the single source of truth. + +## Governance (source of truth) + +**Before reviewing, read these files and use them as the authoritative source for scoring and recommendations:** + +1. **docs/HELPSITE_NAMING_CONVENTIONS.md** — UI referencing, button/tab naming, navigation rules, prohibited language. +2. **docs/HELP_AUTHORING_GUIDELINES.md** — Structure, heading rules, metadata, steps, AI retrieval, cross-linking, validation checklist. +3. **docs/TEMPLATE.md** — YAML frontmatter, heading guidance, FAQ structure. **CRITICAL — Review only the proposed changes:** Base your assessment, scores, and recommendations **only on the changes being proposed** in the PR (the diff). Use `gh pr diff` to see what was added or modified. Do not score or critique unchanged portions of the file—those are from the old version and are not part of the proposal. Evaluate and feedback only on the diff. ## Scoring Criteria -### 1. Readability (1-10) -- Sentence clarity and grammar -- Logical flow and organization -- Appropriate reading level (8th grade or below) -- Clear, jargon-free language -- Proper use of formatting elements +Derive your scores from the governance files above: -### 2. AI Readiness (1-10) -- Descriptive headings with full feature names and full task phrasing (e.g., "Expense Rule options for Workspace Admins" not "Options") -- Clear context without vague references -- Proper YAML metadata structure, including **internalScope** in the form: `Audience is [target role]. Covers [main topic]. Does not cover [excluded areas].` (use a clear default if not provided) -- Consistent heading hierarchy (# and ## only) +### 1. Readability (1-10) +- Sentence clarity, flow, scannability, step formatting — per HELP_AUTHORING_GUIDELINES.md and TEMPLATE.md. -**Note:** Breadcrumb paths after H1 are not required; do not penalize for their absence. +### 2. AI Readiness (1-10) +- Task-based headings, full feature names, YAML metadata (including **internalScope**), heading hierarchy (# and ## only) — per HELP_AUTHORING_GUIDELINES.md and TEMPLATE.md. (Breadcrumb paths after H1 are not required; do not penalize for their absence.) ### 3. Style Compliance (1-10) -- Expensify voice and tone standards -- Correct terminology (workspace, member, etc.) -- Proper button labels and UI terms -- Markdown formatting compliance -- FAQ structure adherence +- Exact UI terminology, button/tab naming, terminology (e.g. Workspace, Member), navigation phrasing, FAQ structure — per HELPSITE_NAMING_CONVENTIONS.md and HELP_AUTHORING_GUIDELINES.md. ## Output Format diff --git a/.claude/commands/review-helpdot-pr.md b/.claude/commands/review-helpdot-pr.md index e7a5441e3d53b..d132b32647bdb 100644 --- a/.claude/commands/review-helpdot-pr.md +++ b/.claude/commands/review-helpdot-pr.md @@ -3,17 +3,21 @@ allowed-tools: Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),mcp__ description: Review a HelpDot documentation pull request --- -Perform a comprehensive HelpDot documentation review using two specialized subagents: +Perform a comprehensive HelpDot documentation review using two specialized subagents. Both reviewers use the **help site governance files** in the repo as the source of truth for rules and scoring: + +- **docs/HELPSITE_NAMING_CONVENTIONS.md** — UI referencing, button/tab naming, navigation +- **docs/HELP_AUTHORING_GUIDELINES.md** — Structure, headings, metadata, AI retrieval, validation +- **docs/TEMPLATE.md** — YAML frontmatter, heading guidance, FAQ structure ## Step 1: Inline Review Use the helpdot-inline-reviewer agent to: - Scan all changed documentation files -- Create inline comments for specific HelpDot rule violations +- Create inline comments for violations of the governance rules above - Focus on line-specific, actionable feedback ## Step 2: Summary Review Use the helpdot-summary-reviewer agent to: -- Analyze the overall quality of all changes +- Analyze the overall quality of all changes using the governance criteria - Provide comprehensive assessment with scoring - Post one top-level PR comment with summary and recommendations diff --git a/Mobile-Expensify b/Mobile-Expensify index 3d8daef358359..65761aa22cb01 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 3d8daef3583592dc43bb6eb05ff46377aba2c833 +Subproject commit 65761aa22cb01930272fd06a557d3dffcdc99c8a diff --git a/__mocks__/react-native-permissions.ts b/__mocks__/react-native-permissions.ts index 9ee11ea473f55..4bf9f434f9efb 100644 --- a/__mocks__/react-native-permissions.ts +++ b/__mocks__/react-native-permissions.ts @@ -1,6 +1,14 @@ -import {PERMISSIONS, RESULTS} from 'react-native-permissions/dist/commonjs/permissions'; +import {PERMISSIONS} from 'react-native-permissions/dist/commonjs/permissions'; import type {ValueOf} from 'type-fest'; +const RESULTS = { + UNAVAILABLE: 'unavailable', + BLOCKED: 'blocked', + DENIED: 'denied', + GRANTED: 'granted', + LIMITED: 'limited', +} as const; + type Results = ValueOf; type ResultsCollection = Record; type NotificationSettings = Record; @@ -8,8 +16,8 @@ type Notification = {status: Results; settings: NotificationSettings}; const openLimitedPhotoLibraryPicker: jest.Mock = jest.fn(() => {}); const openSettings: jest.Mock = jest.fn(() => {}); -const check = jest.fn(() => RESULTS.GRANTED as string); -const request = jest.fn(() => RESULTS.GRANTED as string); +const check = jest.fn(() => Promise.resolve(RESULTS.GRANTED as string)); +const request = jest.fn(() => Promise.resolve(RESULTS.GRANTED as string)); const checkLocationAccuracy: jest.Mock = jest.fn(() => 'full'); const requestLocationAccuracy: jest.Mock = jest.fn(() => 'full'); diff --git a/android/app/build.gradle b/android/app/build.gradle index 6a0b5243a56a1..1b5773244181f 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 1009033600 - versionName "9.3.36-0" + versionCode 1009033709 + versionName "9.3.37-9" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/envelope-open-star.svg b/assets/images/envelope-open-star.svg new file mode 100644 index 0000000000000..74652c126f5fe --- /dev/null +++ b/assets/images/envelope-open-star.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/babel.config.js b/babel.config.js index 7e63e59581e91..66094c956fac8 100644 --- a/babel.config.js +++ b/babel.config.js @@ -63,7 +63,7 @@ const webpack = { }; const metro = { - presets: [[require('@react-native/babel-preset'), {disableImportExportTransform: true}]], + presets: [require('@react-native/babel-preset')], plugins: [ ['babel-plugin-react-compiler', ReactCompilerConfig], // must run first! @@ -174,14 +174,5 @@ module.exports = (api) => { const runningIn = api.caller((args = {}) => args.name); console.debug(' - running in: ', runningIn); - // Jest runs in Node.js without Metro's experimentalImportSupport transform, - // so Babel must handle import/export transforms for tests. - if (runningIn === 'babel-jest') { - return { - ...metro, - presets: [[require('@react-native/babel-preset'), {disableImportExportTransform: false}]], - }; - } - - return runningIn === 'metro' ? metro : webpack; + return ['metro', 'babel-jest'].includes(runningIn) ? metro : webpack; }; diff --git a/contributingGuides/AI_ETIQUETTE.md b/contributingGuides/AI_ETIQUETTE.md new file mode 100644 index 0000000000000..d3ef1bbcb725a --- /dev/null +++ b/contributingGuides/AI_ETIQUETTE.md @@ -0,0 +1,44 @@ +# AI Etiquette + +AI is a great tool for drafting, searching, summarizing, and exploring. Humans must own judgment, accountability, and quality. + +--- + +## You Own What You Post + +If you submit a message, pull request, comment, or code with AI assistance, you are fully accountable for it. Before posting anything AI helped produce, you should be able to explain it without AI's help and feel confident putting your name behind it. + +## Avoid AI Slop + +"AI slop" is AI generated content copied and pasted with minimal to no human review. Use AI for brainstorming, research, and drafting, but treat it as a starting point, not the final result. Add your own context and nuance before sharing. + +## Don't Trust AI Blindly + +AI confidently says things that sound correct but are wrong. Always verify claims against source material. It's your job to understand and validate any AI-generated information you act on or share. + +## When AI Isn't Enough + +If a task can be fully automated with no human review, it should be automated for everyone, not done manually with AI. Our [AI Reviewers](/contributingGuides/philosophies/AI-REVIEWER.md) are an example: they do a first pass on code review, but they don't replace human review. You should still apply your own judgment when reviewing code, writing updates, or making decisions. + +--- + +## Best Practices + +### Don't + +- Paste AI output directly without adding your own value +- Submit AI-generated code you haven't tested, debugged, and confirmed follows our [coding standards](./STYLE.md) +- Submit AI-generated code you don't fully understand +- Post poorly formatted AI output or long walls of text without distilling the key points +- Blame AI for mistakes. The mistake is yours regardless of the source +- Use AI to generate responses during live conversations instead of engaging directly + +### Do + +- Use AI to summarize, draft, research, and explore +- Review and edit all AI output before sharing. Put it in your own voice and make sure it's accurate +- Verify AI claims against source information +- Review all AI-generated code and content before asking others to review it +- Verify AI-written PR descriptions and add context needed to explain the change +- Provide evidence of manual testing on PRs, especially when test steps were AI-generated +- Invest time in learning to write effective prompts and recognize quality output \ No newline at end of file diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index bc5418406433e..e388b96e3b044 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -211,6 +211,9 @@ This helps future investigators understand the history and current status of err ### Important note about JavaScript Style - Read our official [JavaScript and React style guide](https://github.com/Expensify/App/blob/main/contributingGuides/STYLE.md). Please refer to our Style Guide before asking for a review. +### Using AI tools +- If you use AI tools (Copilot, Cursor, ChatGPT, etc.) to help write code or PR descriptions, please read our [AI Etiquette guide](https://github.com/Expensify/App/blob/main/contributingGuides/AI_ETIQUETTE.md). You are accountable for all AI output you submit. + ### For external agencies that Expensify partners with Follow all the above above steps and processes. When you find a job you'd like to work on: - Post “I’m from [agency], I’d like to work on this job” diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index fb76ad9dd99ad..9cee8c3b9f0a9 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -26,6 +26,8 @@ The navigation in the app is built on top of the `react-navigation` library. To - [Dynamic routes configuration](#dynamic-routes-configuration) - [Entry screens (access control)](#entry-screens-access-control) - [Current limitations (work in progress)](#current-limitations-work-in-progress) + - [Multi-segment dynamic routes](#multi-segment-dynamic-routes) + - [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) - [How to remove backTo from URL (Legacy)](#how-to-remove-backto-from-url) @@ -700,7 +702,6 @@ A dynamic route is a URL suffix (e.g. `verify-account`) that can be appended to Do not use dynamic routes when: - Your use case falls under the [current limitations](#current-limitations-work-in-progress): - You need to stack multiple dynamic route suffixes (e.g. `/a/verify-account/another-flow`). - - Your suffix includes path or query parameters (e.g. `verify-account/:id` or `verify-account?tab=details`). - The screen has a single, fixed entry and a fixed back destination. In this case, use a normal static route instead. ### Dynamic routes configuration @@ -732,21 +733,119 @@ When adding or extending a dynamic route, list every screen that should be able ### Current limitations (work in progress) - **Stacking:** Multiple dynamic route suffixes on top of each other (e.g. `/a/verify-account/another-flow`) are not supported. Only one dynamic suffix per path is allowed. -- **Suffix shape:** Suffixes must be a single path segment. Compound suffixes with extra path segments (e.g. `a/b`) are not supported. -- **Parameters:** Suffixes must not include path params (e.g. `a/:reportID`) or query params (e.g. `a?foo=bar`). Use a single literal segment like `verify-account` only. +- **Path parameters:** Suffixes must not include path params (e.g. `a/:reportID`). Query parameters are supported - see [Dynamic routes with query parameters](#dynamic-routes-with-query-parameters). If you try to use dynamic routes for these cases now, you will either fail to navigate to the page at all or end up on a non-existent page, and the navigation will be broken. -### How to add a new dynamic route +### Multi-segment dynamic routes + +Dynamic route suffixes are not limited to a single path segment - +they can span multiple segments separated by `/`. +For example, the suffix `add-bank-account/verify-account` is a valid +multi-segment suffix that combines two segments into one dynamic route. + +When the URL is parsed, the matching algorithm +iterates from the longest candidate suffix to the shortest, +so overlapping registrations are resolved deterministically. +For instance, if both `verify-account` and `add-bank-account/verify-account` +are registered, a path ending with `/add-bank-account/verify-account` +will always match the longer, more specific suffix. + +### Dynamic routes with query parameters + +Dynamic route suffixes can carry query parameters +(e.g. `country?country=US`). +This is useful when a dynamic screen needs initial data passed +through the URL - for example, a country selector that +pre-selects the current country. + +#### How to add query parameters to a dynamic route + +1. In `DYNAMIC_ROUTES` (in [`src/ROUTES.ts`](../src/ROUTES.ts)), add a `getRoute` function +that returns the suffix with query parameters and a `queryParams` +array listing every parameter key that the suffix may add: + +```ts +ADDRESS_COUNTRY: { + path: 'country', + entryScreens: [SCREENS.SETTINGS.PROFILE.ADDRESS, /* ... */], + getRoute: (country = '') => `country${country ? `?country=${country}` : ''}`, + queryParams: ['country'], +}, +``` -1. Add to `DYNAMIC_ROUTES` in `src/ROUTES.ts`: define `path` and `entryScreens` (screen names that may open this route). -2. Add a screen constant in `src/SCREENS.ts`. The name must start with the `DYNAMIC_` prefix (e.g. `SETTINGS.DYNAMIC_VERIFY_ACCOUNT`) so dynamic screens can be distinguished from static ones. -3. Register in linking config in `src/libs/Navigation/linkingConfig/config.ts`: map the new screen to `DYNAMIC_ROUTES..path`. -4. Implement the page component: Use `createDynamicRoute(DYNAMIC_ROUTES..path)` to navigate to the flow and `useDynamicBackPath(DYNAMIC_ROUTES..path)` to get the back path. Pass these into your base UI (e.g. `VerifyAccountPageBase` with `navigateBackTo` / `navigateForwardTo`). -5. Register the screen in the appropriate modal/stack navigator (e.g. `src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx`). -6. Types: Add the screen to the navigator param list in `src/libs/Navigation/types.ts` (no params for the new screen). +2. Navigate using `createDynamicRoute` with the output of `getRoute`: + +```ts +Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.ADDRESS_COUNTRY.getRoute(countryCode))); +// Produces e.g. /settings/profile/address/country?country=US +``` + +3. In the page component, read query params from `route.params` as usual: + +```ts +const currentCountry = route.params?.country ?? ''; +``` + +> [!CAUTION] +> **`queryParams` array is mandatory.** +> When you define a `getRoute` function that adds query parameters +> after `?` in a dynamic route, you **must** also define a `queryParams` +> array containing **every** parameter key that `getRoute` may produce. +> The `queryParams` array is used to strip suffix-specific parameters when navigating back. +> Without it, query parameters will leak into the parent path +> and break back navigation. + +> [!CAUTION] +> **Query parameter names must not collide with inherited entry screen parameters.** +> Do not use a query parameter name in a dynamic suffix that +> already exists as a parameter of any entry screen listed +> in `entryScreens`. +> For example, if an entry screen has a `country` query parameter, +> do not add `country` as a query parameter in your dynamic route. +> When both paths carry the same parameter key, +> `createDynamicRoute` will throw an error: +> `Query param "X" exists in both base path and dynamic suffix.` +> `This is not allowed.` +> This guard prevents non-deterministic parameter handling +> and accidental overwriting of inherited parameters. + +The Address Country flow is the reference implementation for +dynamic routes with query parameters: +see [`src/components/CountrySelector.tsx`](../src/components/CountrySelector.tsx) (navigation call) +and [`src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx`](../src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx) +(page component). + +### How to add a new dynamic route -The Verify Account flow (Wallet → Dynamic Verify Account) is the reference implementation: see `src/pages/settings/DynamicVerifyAccountPage.tsx` and the Wallet entry point in `src/pages/settings/Wallet/WalletPage/index.tsx`. +1. Add to `DYNAMIC_ROUTES` in [`src/ROUTES.ts`](../src/ROUTES.ts): define `path` and +`entryScreens` (screen names that may open this route). +If the suffix needs query parameters, also define `getRoute` +and `queryParams` - see +[Dynamic routes with query parameters](#dynamic-routes-with-query-parameters). +2. Add a screen constant in [`src/SCREENS.ts`](../src/SCREENS.ts). +The name must start with the `DYNAMIC_` prefix +(e.g. `SETTINGS.DYNAMIC_VERIFY_ACCOUNT`) so dynamic screens +can be distinguished from static ones. +3. Register in linking config in +[`src/libs/Navigation/linkingConfig/config.ts`](../src/libs/Navigation/linkingConfig/config.ts): +map the new screen to `DYNAMIC_ROUTES..path`. +4. Implement the page component: +Use `createDynamicRoute(DYNAMIC_ROUTES..path)` to navigate +to the flow and `useDynamicBackPath(DYNAMIC_ROUTES..path)` +to get the back path. Pass these into your base UI +(e.g. `VerifyAccountPageBase` with `navigateBackTo` / +`navigateForwardTo`). +5. Register the screen in the appropriate modal/stack navigator +(e.g. [`src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx`](../src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx)). +6. Types: Add the screen to the navigator param list in +[`src/libs/Navigation/types.ts`](../src/libs/Navigation/types.ts) (no params for the new screen). + +The Verify Account flow (Wallet → Dynamic Verify Account) is the +reference implementation: +see [`src/pages/settings/DynamicVerifyAccountPage.tsx`](../src/pages/settings/DynamicVerifyAccountPage.tsx) and the +Wallet entry point in +[`src/pages/settings/Wallet/WalletPage/index.tsx`](../src/pages/settings/Wallet/WalletPage/index.tsx). ### Migrating from backTo to dynamic routes diff --git a/contributingGuides/PAYMENT_VIA_EXPENSIFY.md b/contributingGuides/PAYMENT_VIA_EXPENSIFY.md index 59426b5d1a497..eeefc24395dc2 100644 --- a/contributingGuides/PAYMENT_VIA_EXPENSIFY.md +++ b/contributingGuides/PAYMENT_VIA_EXPENSIFY.md @@ -11,27 +11,14 @@ Contributors are eligible to be paid via Expensify 18 months after they were ass After approval it can take between 1 business day and a week, depending on the country and service you use. ## For issues with deposits -If you're having an issue with the deposit, ie. a report gets _stuck_ in a state like Approved, start a chat with Concierge. Ask them to create a Github issue using [this template](https://github.com/Expensify/Expensify/issues/new?template=MISSING_REIMBURSEMENT_TEMPLATE.md). Provide as many details below as possible +If you're having an issue with the deposit, ie. a report gets _stuck_ in a state like Approved, start a chat with Concierge to ask for help. Provide as many details below as possible -### Please provide the following details: > - Report ID: > - Reimbursement ID: > - Amount of reimbursement: > - Date reimbursement was initiated: > - Date reimbursement was expected: > - Last 4-digits of the deposit account: -> -> ### Is the missing reimbursement international (CAD > CAD, USD > GBP, etc.) or domestic? -> -> ### If this is an international reimbursement, please provide the following details -> - Location of reimbursement account (AUD, CAD, GBP, EUR, USD) -> - Location/currency of deposit account -> -> ### Have you received reimbursements to the connected deposit account successfully in the past? -> - Yes or No -> -> ### Please select which option best describes the scenario: -> - The recipient connected a deposit account that was closed -> - The recipient entered the bank details incorrectly when connecting their deposit account -> - The recipient entered the correct bank details, but the reimbursement is still missing -> - Something else (please describe below) +> - Location of reimbursement account +> - Have you received reimbursements to the connected deposit account successfully in the past? + diff --git a/cspell.json b/cspell.json index 4e4f03a01f23c..1e1382d4174f9 100644 --- a/cspell.json +++ b/cspell.json @@ -843,6 +843,7 @@ "xmlgateway", "Xours", "Xtheirs", + "XYWH", "yalc", "Yapl", "YAPL", diff --git a/docs/articles/expensify-classic/connections/Perk.md b/docs/articles/expensify-classic/connections/Perk.md new file mode 100644 index 0000000000000..f60ad6451e76a --- /dev/null +++ b/docs/articles/expensify-classic/connections/Perk.md @@ -0,0 +1,66 @@ +--- + +title: Connecting Perk to Expensify +description: Learn how to integrate Perk with Expensify to automate travel expense tracking. +keywords: [Expensify Classic, Perk integration, connect Perk to Expensify, Travel Perk, travel booking sync, receipts@expensify.com, travel expenses, Receipt upload problem] +internalScope: Audience is Workspace Admins. Covers connecting Perk (formerly TravelPerk) to Expensify Classic to sync travel bookings as expenses. Does not cover Perk account setup or New Expensify. +--- + +Connect your Perk (formerly TravelPerk) account to Expensify to automatically sync travel bookings as expenses. Once connected, Perk sends invoices and itineraries to Expensify as soon as a trip is booked, reducing manual work and giving you better visibility into travel spend. + +--- + +# Connecting Perk to Expensify + +## What are the requirements to connect Perk with Expensify + +Before you begin connecting Perk and Expensify, make sure you: + +- Have an active Expensify account +- Are a **Workspace Admin** in Expensify +- Have an active Perk account +- Are an **Account Admin** in Perk + +--- + +## How to set up the Perk integration with Expensify + +The Perk integration is configured and managed directly in Perk. + +To enable the integration, follow Perk’s setup guide: [Set up the Perk integration with Expensify](https://support.perk.com/hc/en-us/articles/21926013804188-Integrate-Expensify-with-Perk) + +**Note:** All configuration steps take place in Perk, not in Expensify. + +--- + +## How Perk automatically creates expenses in Expensify + +After the integration is enabled: + +1. A trip is booked in Perk. +2. Perk forwards the trip costs to receipts@expensify.com. +3. Expensify automatically creates a new expense for the selected member. +4. The expense appears in the member's Inbox with travel details, invoices, and itineraries attached. +5. The member can review the expense and add it to a report for approval. + +This eliminates the need to manually upload receipts or enter trip details. + +--- + +# FAQ + +## When are Perk travel expenses created in Expensify? + +Expenses are created automatically after a booking is confirmed in Perk. + +This typically happens when the traveler does not have an Expensify account or the traveler's email address is different in Perk and Expensify. + +To resolve this, confirm the traveler has an active Expensify account and make sure the email address matches in both Perk and Expensify. + +## Where do I manage the Perk integration settings? + +All integration settings are managed in your Perk account. Expensify does not host the setup controls for this integration. + +## Can I add out-of-pocket expenses to a Perk travel report? + +Yes, if you have additional travel costs (such as meals or transportation not booked through Perk), you can include them in the same expense report. [Learn how to add expenses to a report](https://help.expensify.com/articles/expensify-classic/expenses/Add-an-expense). diff --git a/docs/articles/expensify-classic/connections/TravelPerk.md b/docs/articles/expensify-classic/connections/TravelPerk.md deleted file mode 100644 index 6747d836374f3..0000000000000 --- a/docs/articles/expensify-classic/connections/TravelPerk.md +++ /dev/null @@ -1,52 +0,0 @@ ---- - -title: Connecting TravelPerk to Expensify -description: Learn how to seamlessly integrate TravelPerk with Expensify to automate travel expense tracking and improve financial workflows. -keywords: [Expensify Classic, TravelPerk, travel expenses, corporate travel] ---- - - -Connect your TravelPerk and Expensify accounts to automatically sync travel bookings as expenses, making travel management effortless and efficient. - ---- - -# Prerequisites - -Before you begin, make sure you have: - -- An **active Expensify account** -- **Workspace admin** level permissions in Expensify -- An **active TravelPerk account** -- **Admin access** in TravelPerk - ---- - -# Connect TravelPerk to Expensify - -1. Navigate to **Settings > Workspaces > Workspace Name > Accounting > TravelPerk**. -2. **Connect TravelPerk** and follow the prompts to log in to your TravelPerk account. -3. **Authorize the integration**: Review the requested permissions and click **Authorize**. -4. **Customize your settings**: Choose how you want expenses to be categorized and tagged. -5. Click **Save** when done. -6. Test the connection by booking a test trip in TravelPerk -- The expense should appear automatically in Expensify. - ---- - -# How to Book Travel with TravelPerk - -1. In TravelPerk, go to **Trips > Create Trip**. -2. Enter a trip name and select flights and hotels. -3. Review your itinerary and click **Confirm payment**. -4. Your TravelPerk invoice and itinerary will automatically sync to Expensify. - ---- - -# Benefits of the TravelPerk integration - -- **Real-time expense syncing** – No manual uploads needed -- **Policy compliance** – Bookings follow your company’s rules -- **Finance visibility** – See travel spend as it happens -- **Faster approvals** – Managers can review expenses more easily - -By connecting TravelPerk and Expensify, you eliminate manual data entry and simplify your travel workflow. - 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 28283bee1e090..e739c310d35d5 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 @@ -9,7 +9,8 @@ Workspace Admins can enable and issue Expensify Visa® Commercial Cards to manag **The Expensify Card offers powerful spend control tools, including:** - Unlimited virtual cards -- Individual monthly or fixed spend limits +- Individual Smart, Monthly, Fixed, or Single-use spend limits +- Optional expiration dates for time-bound spending - Custom names for easier categorization - Spend restrictions by employee and merchant - Real-time visibility and cash back rewards @@ -25,7 +26,7 @@ To turn on Expensify Cards for your workspace: 1. From the left-hand menu, select **Settings > Workspaces > [Workspace Name] > More features** 2. Under **Spend**, toggle on **Expensify Card** -![Click the toggle next to Expensify Card]({{site.url}}/assets/images/ExpensifyHelp-WorkspaceFeeds_01.png){:width="100%"} +![Click the toggle next to Expensify Card]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_01.png){:width="100%"} --- @@ -37,7 +38,7 @@ Link a U.S. business bank account to pay the card balance: 2. Click **Issue new card** 3. Choose an existing account or [add a new bank account](https://help.expensify.com/articles/new-expensify/expenses-and-payments/Connect-a-Business-Bank-Account) as the settlement account. -![Click the issue card button]({{site.url}}/assets/images/ExpensifyHelp-WorkspaceFeeds_02.png){:width="100%"} +![Click the issue card button]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_02.png){:width="100%"} --- @@ -49,15 +50,23 @@ You can issue virtual or physical cards to employees: 2. Click **Issue new card** 3. Select the employee 4. Choose **Virtual** or **Physical** -5. Pick a limit type: +5. Choose a limit type: - **Smart limit**: Spend up to a threshold before needing approval - - **Monthly limit**: Capped monthly spend - - **Fixed limit**: One-time cap, card closes when reached + - **Monthly limit**: Limit renews monthly + - **Fixed limit**: Spend until the limit is reached + - **Single-use (virtual only)**: Expires after one transaction 6. Enter the spending limit -7. Name the card for easier tracking -8. Click **Issue card** to confirm +7. (Optional for virtual cards) Toggle **Set expiration date** to define: + - **Start date** + - **End date** + - **When enabled:** Both dates are required. The card activates at 12:00 AM local time on the Start date and expires at 11:59 PM local time on the End date. + - **When disabled:** The card does not expire automatically. +8. Name the card for easier tracking +9. Click **Issue card** to confirm -![Click issue card to confirm and issue the card]({{site.url}}/assets/images/ExpensifyHelp-WorkspaceFeeds_04.png){:width="100%"} +![Choose a Smart limit type]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_03.png){:width="100%"} + +![Click issue card to confirm and issue the card]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_04.png){:width="100%"} --- @@ -74,11 +83,14 @@ You can issue virtual or physical cards to employees: - Deactivation 4. To change the linked bank account or update settlement frequency, click **Settings**. -![Click Expensify Card in the left menu to see a list of cards]({{site.url}}/assets/images/ExpensifyHelp-WorkspaceFeeds_05.png){:width="100%"} +![Click Expensify Card in the left menu to see a list of cards]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_05.png){:width="100%"} + +![Click the card row to view the card details and make settings adjustments]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_06.png){:width="100%"} -![Click the card row to view the card details and make settings adjustments]({{site.url}}/assets/images/ExpensifyHelp-WorkspaceFeeds_06.png){:width="100%"} +![Click Settings to adjust the settlement account or frequency]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_08.png){:width="100%"} -![Click Settings to adjust the settlement account or frequency]({{site.url}}/assets/images/ExpensifyHelp-WorkspaceFeeds_07.png){:width="100%"} +If a Single-use card completes its first successful transaction, it automatically deactivates. +If a card reaches its expiration date, it automatically deactivates and declines new transactions. --- @@ -112,3 +124,11 @@ The limit is the maximum combined spending limit for all Expensify Cards in your - **Pending expenses:** Large, unprocessed purchases temporarily reduce your spending capacity. - **Processing settlements:** Until the previous cycle settles, your limit adjusts dynamically. +## What is a Single-use Expensify Card? + +A Single-use virtual card automatically deactivates after its first successful authorization. It's ideal for one-time purchases like flights, vendor payments, or event registration. + +## What happens when a card reaches its expiration date? + +The card automatically deactivates at 11:59 PM local time on the selected End date and declines new transactions. + diff --git a/docs/assets/images/ExpensifyHelp-ExpensifyCard_01.png b/docs/assets/images/ExpensifyHelp-ExpensifyCard_01.png new file mode 100644 index 0000000000000..4ad51ce473360 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ExpensifyCard_01.png differ diff --git a/docs/assets/images/ExpensifyHelp-ExpensifyCard_02.png b/docs/assets/images/ExpensifyHelp-ExpensifyCard_02.png new file mode 100644 index 0000000000000..7f30685a0e47b Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ExpensifyCard_02.png differ diff --git a/docs/assets/images/ExpensifyHelp-ExpensifyCard_03.png b/docs/assets/images/ExpensifyHelp-ExpensifyCard_03.png new file mode 100644 index 0000000000000..c3b6c5ed15d7c Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ExpensifyCard_03.png differ diff --git a/docs/assets/images/ExpensifyHelp-ExpensifyCard_04.png b/docs/assets/images/ExpensifyHelp-ExpensifyCard_04.png new file mode 100644 index 0000000000000..2956bb2c365e5 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ExpensifyCard_04.png differ diff --git a/docs/assets/images/ExpensifyHelp-ExpensifyCard_05.png b/docs/assets/images/ExpensifyHelp-ExpensifyCard_05.png new file mode 100644 index 0000000000000..292f4a64a3af6 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ExpensifyCard_05.png differ diff --git a/docs/assets/images/ExpensifyHelp-ExpensifyCard_06.png b/docs/assets/images/ExpensifyHelp-ExpensifyCard_06.png new file mode 100644 index 0000000000000..c491f7c32b782 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ExpensifyCard_06.png differ diff --git a/docs/assets/images/ExpensifyHelp-ExpensifyCard_07.png b/docs/assets/images/ExpensifyHelp-ExpensifyCard_07.png new file mode 100644 index 0000000000000..cd63ecde7a1b1 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ExpensifyCard_07.png differ diff --git a/docs/assets/images/ExpensifyHelp-ExpensifyCard_08.png b/docs/assets/images/ExpensifyHelp-ExpensifyCard_08.png new file mode 100644 index 0000000000000..d30d35bf2e2f3 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-ExpensifyCard_08.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index 7020d13086a22..bd0c9a580b13c 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -906,6 +906,7 @@ https://help.expensify.com/articles/expensify-classic/connections/Zenefits,https https://help.expensify.com/articles/expensify-classic/connections/TriNet-Integration,https://help.expensify.com/articles/expensify-classic/connections/TriNet https://help.expensify.com/unlisted/avoiding-duplicates,https://help.expensify.com/expensify-classic/hubs/expenses/How-to-prevent-duplicate-expenses https://help.expensify.com/articles/new-expensify/settings/title:%20Personal-Expense-Rules,https://help.expensify.com/articles/new-expensify/settings/Personal-Expense-Rules +https://help.expensify.com/articles/expensify-classic/connections/TravelPerk,https://help.expensify.com/articles/expensify-classic/connections/Perk https://help.expensify.com/articles/expensify-classic/expenses/Export-%20Expenses-from-the-Expenses-Page,https://help.expensify.com/articles/expensify-classic/expenses/Export-Expenses-from-the-Expenses-Page https://help.expensify.com/articles/new-expensify/expensify-card/docs/articles/new-expensify/expensify-card/UK-and-EU-Expensify-Card,https://help.expensify.com/unlisted/UK-and-EU-Expensify-Card.md https://help.expensify.com/articles/new-expensify/workspaces/Create-expense-tags,https://help.expensify.com/articles/new-expensify/workspaces/Create-and-manage-expense-tags diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index c98512d761742..39a7381c60b76 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.3.36 + 9.3.37 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.3.36.0 + 9.3.37.9 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 414cf52abe13e..bae659acf92c0 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.3.36 + 9.3.37 CFBundleVersion - 9.3.36.0 + 9.3.37.9 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index dccc0d04e043e..4f24716fc927d 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.3.36 + 9.3.37 CFBundleVersion - 9.3.36.0 + 9.3.37.9 NSExtension NSExtensionAttributes diff --git a/jest/setup.ts b/jest/setup.ts index d5726068b0bdf..a452f7d1e945e 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -315,3 +315,23 @@ jest.mock('@components/ActionSheetAwareScrollView/index.android'); jest.mock('@components/ActionSheetAwareScrollView/ActionSheetAwareScrollViewContext'); jest.mock('@src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext'); + +// Mock document title hooks as no-ops in tests. The web implementation of setPageTitle uses +// setTimeout(fn, 0) which accumulates in the fake timer queue. Combined with lodash debounce +// in triggerUnreadUpdate (also timer-based), this creates excessive timer churn that causes +// heavy integration tests like SessionTest to exceed their timeout. +jest.mock('@src/hooks/useDocumentTitle', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: jest.fn(), +})); +jest.mock('@src/hooks/useWorkspaceDocumentTitle', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: jest.fn(), +})); +jest.mock('@src/hooks/useDomainDocumentTitle', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: jest.fn(), +})); diff --git a/metro.config.js b/metro.config.js index 0f24889b6868c..470cd07af6e64 100644 --- a/metro.config.js +++ b/metro.config.js @@ -35,7 +35,6 @@ const config = { transformer: { getTransformOptions: async () => ({ transform: { - experimentalImportSupport: true, inlineRequires: true, }, }), diff --git a/package-lock.json b/package-lock.json index 6e4f7dd0fff79..bddd3b82363fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.3.36-0", + "version": "9.3.37-9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.3.36-0", + "version": "9.3.37-9", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 38a10796e7c3f..c632c8519cce3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.3.36-0", + "version": "9.3.37-9", "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.", @@ -45,7 +45,7 @@ "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=334 --cache --cache-location=node_modules/.cache/eslint --cache-strategy content --concurrency=auto", + "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=316 --cache --cache-location=node_modules/.cache/eslint --cache-strategy content --concurrency=auto", "lint-changed": "NODE_OPTIONS=--max_old_space_size=8192 ./scripts/lintChanged.sh", "check-lazy-loading": "ts-node scripts/checkLazyLoading.ts", "lint-watch": "npx eslint-watch --watch --changed", diff --git a/patches/react-native-web/details.md b/patches/react-native-web/details.md index df0cad52449ef..49ca1a391a819 100644 --- a/patches/react-native-web/details.md +++ b/patches/react-native-web/details.md @@ -134,3 +134,13 @@ - Upstream PR/issue: https://github.com/necolas/react-native-web/issues/2817 - E/App issue: https://github.com/Expensify/App/issues/73782 - PR introducing patch: https://github.com/Expensify/App/pull/76332 + +### [react-native-web+0.21.2+013+fix-selection-bug.patch](react-native-web+0.21.2+013+fix-selection-bug.patch) + +- Reason: + ``` + Fix selection bug for InvertedFlatlist by reversing the DOM tree elements using `pushOrUnshift` method + ``` +- Upstream PR/issue: https://github.com/necolas/react-native-web/issues/1807, it has been closed because of [this](https://github.com/necolas/react-native-web/issues/1807#issuecomment-725689704) +- E/App issue: https://github.com/Expensify/App/issues/37447 +- PR introducing patch: https://github.com/Expensify/App/pull/82507 \ No newline at end of file diff --git a/patches/react-native-web/react-native-web+0.21.2+013+fix-selection-bug.patch b/patches/react-native-web/react-native-web+0.21.2+013+fix-selection-bug.patch new file mode 100644 index 0000000000000..6ddc31e8e80ee --- /dev/null +++ b/patches/react-native-web/react-native-web+0.21.2+013+fix-selection-bug.patch @@ -0,0 +1,194 @@ +diff --git a/node_modules/react-native-web/dist/exports/ScrollView/index.js b/node_modules/react-native-web/dist/exports/ScrollView/index.js +index c4f9b5b..fa32056 100644 +--- a/node_modules/react-native-web/dist/exports/ScrollView/index.js ++++ b/node_modules/react-native-web/dist/exports/ScrollView/index.js +@@ -558,8 +558,9 @@ class ScrollView extends React.Component { + var children = hasStickyHeaderIndices || pagingEnabled ? React.Children.map(this.props.children, (child, i) => { + var isSticky = hasStickyHeaderIndices && stickyHeaderIndices.indexOf(i) > -1; + if (child != null && (isSticky || pagingEnabled)) { ++ var stickyItemIndex = (this.props.children.length - 1) - i + 10; + return /*#__PURE__*/React.createElement(View, { +- style: [isSticky && styles.stickyHeader, pagingEnabled && styles.pagingEnabledChild] ++ style: [isSticky && styles.stickyHeader, pagingEnabled && styles.pagingEnabledChild, isSticky && {zIndex: stickyItemIndex}] + }, child); + } else { + return child; +@@ -636,7 +637,6 @@ var styles = StyleSheet.create({ + stickyHeader: { + position: 'sticky', + top: 0, +- zIndex: 10 + }, + pagingEnabledHorizontal: { + scrollSnapType: 'x mandatory' +diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +index 42c4984..caf22bb 100644 +--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js ++++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js +@@ -108,6 +108,15 @@ function windowSizeOrDefault(windowSize) { + * + */ + class VirtualizedList extends StateSafePureComponent { ++ // reverse push order logic when props.inverted = true ++ pushOrUnshift(input, item) { ++ if (this.props.inverted) { ++ input.unshift(item); ++ } else { ++ input.push(item); ++ } ++ } ++ + // scrollToEnd may be janky without getItemLayout prop + scrollToEnd(params) { + var animated = params ? params.animated : true; +@@ -343,6 +352,7 @@ class VirtualizedList extends StateSafePureComponent { + }; + this._defaultRenderScrollComponent = props => { + var onRefresh = props.onRefresh; ++ var inversionStyle = this.props.inverted ? this.props.horizontal ? styles.rowReverse : styles.columnReverse : null; + if (this._isNestedWithSameOrientation()) { + // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors + return /*#__PURE__*/React.createElement(View, props); +@@ -354,6 +364,7 @@ class VirtualizedList extends StateSafePureComponent { + // $FlowFixMe[prop-missing] Invalid prop usage + // $FlowFixMe[incompatible-use] + React.createElement(ScrollView, _extends({}, props, { ++ contentContainerStyle: StyleSheet.compose(inversionStyle, this.props.contentContainerStyle), + refreshControl: props.refreshControl == null ? /*#__PURE__*/React.createElement(RefreshControl + // $FlowFixMe[incompatible-type] + , { +@@ -366,7 +377,9 @@ class VirtualizedList extends StateSafePureComponent { + } else { + // $FlowFixMe[prop-missing] Invalid prop usage + // $FlowFixMe[incompatible-use] +- return /*#__PURE__*/React.createElement(ScrollView, props); ++ return /*#__PURE__*/React.createElement(ScrollView, _extends({}, props, { ++ contentContainerStyle: StyleSheet.compose(inversionStyle, this.props.contentContainerStyle) ++ })); + } + }; + this._onCellLayout = (e, cellKey, index) => { +@@ -568,6 +581,14 @@ class VirtualizedList extends StateSafePureComponent { + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + this.setState((state, props) => { + var cellsAroundViewport = this._adjustCellsAroundViewport(props, state.cellsAroundViewport, state.pendingScrollUpdateCount); ++ ++ // revert the state if calculations are off ++ // this would only happen on the inverted flatlist (probably a bug with overscroll-behavior) ++ // when scrolled from bottom all the way up until onEndReached is triggered ++ if (cellsAroundViewport.first === cellsAroundViewport.last) { ++ cellsAroundViewport = state.cellsAroundViewport; ++ } ++ + var renderMask = VirtualizedList._createRenderMask(props, cellsAroundViewport, this._getNonViewportRenderRegions(props)); + if (cellsAroundViewport.first === state.cellsAroundViewport.first && cellsAroundViewport.last === state.cellsAroundViewport.last && renderMask.equals(state.renderMask)) { + return null; +@@ -679,7 +700,7 @@ class VirtualizedList extends StateSafePureComponent { + onViewableItemsChanged = _this$props3.onViewableItemsChanged, + viewabilityConfig = _this$props3.viewabilityConfig; + if (onViewableItemsChanged) { +- this._viewabilityTuples.push({ ++ this.pushOrUnshift(this._viewabilityTuples, { + viewabilityHelper: new ViewabilityHelper(viewabilityConfig), + onViewableItemsChanged: onViewableItemsChanged + }); +@@ -991,15 +1012,15 @@ class VirtualizedList extends StateSafePureComponent { + var end = getItemCount(data) - 1; + var prevCellKey; + last = Math.min(end, last); +- var _loop = function _loop() { ++ var _loop = () => { + var item = getItem(data, ii); + var key = VirtualizedList._keyExtractor(item, ii, _this.props); + _this._indicesToKeys.set(ii, key); + if (stickyIndicesFromProps.has(ii + stickyOffset)) { +- stickyHeaderIndices.push(cells.length); ++ this.pushOrUnshift(stickyHeaderIndices, cells.length); + } + var shouldListenForLayout = getItemLayout == null || debug || _this._fillRateHelper.enabled(); +- cells.push(/*#__PURE__*/React.createElement(CellRenderer, _extends({ ++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(CellRenderer, _extends({ + CellRendererComponent: CellRendererComponent, + ItemSeparatorComponent: ii < end ? ItemSeparatorComponent : undefined, + ListItemComponent: ListItemComponent, +@@ -1074,14 +1095,14 @@ class VirtualizedList extends StateSafePureComponent { + // 1. Add cell for ListHeaderComponent + if (ListHeaderComponent) { + if (stickyIndicesFromProps.has(0)) { +- stickyHeaderIndices.push(0); ++ this.pushOrUnshift(stickyHeaderIndices, 0); + } + var _element = /*#__PURE__*/React.isValidElement(ListHeaderComponent) ? ListHeaderComponent : + /*#__PURE__*/ + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + React.createElement(ListHeaderComponent, null); +- cells.push(/*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { ++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { + cellKey: this._getCellKey() + '-header', + key: "$header" + }, /*#__PURE__*/React.createElement(View +@@ -1105,7 +1126,7 @@ class VirtualizedList extends StateSafePureComponent { + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + React.createElement(ListEmptyComponent, null); +- cells.push(/*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { ++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { + cellKey: this._getCellKey() + '-empty', + key: "$empty" + }, /*#__PURE__*/React.cloneElement(_element2, { +@@ -1145,7 +1166,7 @@ class VirtualizedList extends StateSafePureComponent { + var firstMetrics = this.__getFrameMetricsApprox(section.first, this.props); + var lastMetrics = this.__getFrameMetricsApprox(last, this.props); + var spacerSize = lastMetrics.offset + lastMetrics.length - firstMetrics.offset; +- cells.push(/*#__PURE__*/React.createElement(View, { ++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(View, { + key: "$spacer-" + section.first, + style: { + [spacerKey]: spacerSize +@@ -1168,7 +1189,7 @@ class VirtualizedList extends StateSafePureComponent { + // $FlowFixMe[not-a-component] + // $FlowFixMe[incompatible-type-arg] + React.createElement(ListFooterComponent, null); +- cells.push(/*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { ++ this.pushOrUnshift(cells, /*#__PURE__*/React.createElement(VirtualizedListCellContextProvider, { + cellKey: this._getFooterCellKey(), + key: "$footer" + }, /*#__PURE__*/React.createElement(View, { +@@ -1179,6 +1200,14 @@ class VirtualizedList extends StateSafePureComponent { + _element3))); + } + ++ if (this.props.inverted && stickyHeaderIndices.length > 0) { ++ var totalCells = cells.length; ++ stickyHeaderIndices = stickyHeaderIndices.map(function(recordedIndex) { ++ return totalCells - 1 - recordedIndex; ++ }); ++ } ++ ++ + // 4. Render the ScrollView + var scrollProps = _objectSpread(_objectSpread({}, this.props), {}, { + onContentSizeChange: this._onContentSizeChange, +@@ -1353,7 +1382,7 @@ class VirtualizedList extends StateSafePureComponent { + * suppresses an error found when Flow v0.68 was deployed. To see the + * error delete this comment and run Flow. */ + if (frame.inLayout) { +- framesInLayout.push(frame); ++ this.pushOrUnshift(framesInLayout, frame); + } + } + var windowTop = this.__getFrameMetricsApprox(this.state.cellsAroundViewport.first, this.props).offset; +@@ -1526,6 +1555,12 @@ var styles = StyleSheet.create({ + horizontallyInverted: { + transform: 'scaleX(-1)' + }, ++ rowReverse: { ++ flexDirection: 'row-reverse', ++ }, ++ columnReverse: { ++ flexDirection: 'column-reverse', ++ }, + debug: { + flex: 1 + }, diff --git a/patches/react-native-worklets/details.md b/patches/react-native-worklets/details.md new file mode 100644 index 0000000000000..f5b09fa180aa4 --- /dev/null +++ b/patches/react-native-worklets/details.md @@ -0,0 +1,9 @@ +# `react-native-worklets` patches + +### [react-native-worklets+0.7.2+001+fix-app-crash-SerializableRemoteFunction.patch](react-native-worklets+0.7.2+001+fix-app-crash-SerializableRemoteFunction.patch) + +- Reason: Fixes SIGSEGV crash in `SerializableRemoteFunction` destructor caused by a data race on `globalMarkdownWorkletRuntime`. The fix replaces the stored `jsi::Value` function reference with an ID-based lookup via a `__remoteFunctionCache` map, avoiding the unsafe cross-thread `~jsi::Value()` call during runtime teardown. + +- Upstream PR/issue: N/A (patch authored by the `react-native-worklets` maintainer in https://github.com/Expensify/react-native-live-markdown/pull/752#issuecomment-3953415007) +- E/App issue: https://github.com/Expensify/App/issues/82146 +- PR Introducing Patch: [#83792](https://github.com/Expensify/App/pull/83792) diff --git a/patches/react-native-worklets/react-native-worklets+0.7.2+001+fix-app-crash-SerializableRemoteFunction.patch b/patches/react-native-worklets/react-native-worklets+0.7.2+001+fix-app-crash-SerializableRemoteFunction.patch new file mode 100644 index 0000000000000..6ece64537d75b --- /dev/null +++ b/patches/react-native-worklets/react-native-worklets+0.7.2+001+fix-app-crash-SerializableRemoteFunction.patch @@ -0,0 +1,165 @@ +diff --git a/node_modules/react-native-worklets/Common/cpp/worklets/NativeModules/JSIWorkletsModuleProxy.cpp b/node_modules/react-native-worklets/Common/cpp/worklets/NativeModules/JSIWorkletsModuleProxy.cpp +index a7cb25ddf3..c6bd17a90d 100644 +--- a/node_modules/react-native-worklets/Common/cpp/worklets/NativeModules/JSIWorkletsModuleProxy.cpp ++++ b/node_modules/react-native-worklets/Common/cpp/worklets/NativeModules/JSIWorkletsModuleProxy.cpp +@@ -321,8 +321,8 @@ jsi::Value JSIWorkletsModuleProxy::get(jsi::Runtime &rt, const jsi::PropNameID & + + if (name == "createSerializableFunction") { + return jsi::Function::createFromHostFunction( +- rt, propName, 1, [](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t count) { +- return makeSerializableFunction(rt, args[0].asObject(rt).asFunction(rt)); ++ rt, propName, 2, [](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t count) { ++ return makeSerializableFunction(rt, args[0].asObject(rt).asFunction(rt), args[1].asNumber()); + }); + } + +diff --git a/node_modules/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp b/node_modules/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp +index 67ff75a9a5..6f8a29055d 100644 +--- a/node_modules/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp ++++ b/node_modules/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.cpp +@@ -31,7 +31,7 @@ jsi::Value makeSerializableClone( + } else if (!object.getProperty(rt, "__init").isUndefined()) { + return makeSerializableInitializer(rt, object); + } else if (object.isFunction(rt)) { +- return makeSerializableFunction(rt, object.asFunction(rt)); ++ return makeSerializableFunction(rt, object.asFunction(rt), -1); + } else if (object.isArray(rt)) { + if (shouldRetainRemote.isBool() && shouldRetainRemote.getBool()) { + serializable = std::make_shared>(rt, object.asArray(rt)); +@@ -121,12 +121,12 @@ jsi::Value makeSerializableInitializer(jsi::Runtime &rt, const jsi::Object &init + return SerializableJSRef::newNativeStateObject(rt, serializable); + } + +-jsi::Value makeSerializableFunction(jsi::Runtime &rt, jsi::Function function) { ++jsi::Value makeSerializableFunction(jsi::Runtime &rt, jsi::Function function, double id) { + std::shared_ptr serializable; + if (function.isHostFunction(rt)) { + serializable = std::make_shared(rt, std::move(function)); + } else { +- serializable = std::make_shared(rt, std::move(function)); ++ serializable = std::make_shared(rt, id); + } + return SerializableJSRef::newNativeStateObject(rt, serializable); + } +@@ -369,7 +369,11 @@ jsi::Value SerializableImport::toJSValue(jsi::Runtime &rt) { + + jsi::Value SerializableRemoteFunction::toJSValue(jsi::Runtime &rt) { + if (&rt == runtime_) { +- return jsi::Value(rt, *function_); ++ auto global = rt.global(); ++ auto remoteFunctionCache = global.getPropertyAsObject(rt, "__remoteFunctionCache"); ++ auto cached = ++ remoteFunctionCache.getPropertyAsFunction(rt, "get").callWithThis(rt, remoteFunctionCache, jsi::Value(id_)); ++ return cached; + } else { + #ifndef NDEBUG + return getValueUnpacker(rt).call( +diff --git a/node_modules/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.h b/node_modules/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.h +index 771b6be96c..5e60aa7e11 100644 +--- a/node_modules/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.h ++++ b/node_modules/react-native-worklets/Common/cpp/worklets/SharedItems/Serializable.h +@@ -162,7 +162,7 @@ jsi::Value makeSerializableSet(jsi::Runtime &rt, const jsi::Array &values); + + jsi::Value makeSerializableInitializer(jsi::Runtime &rt, const jsi::Object &initializerObject); + +-jsi::Value makeSerializableFunction(jsi::Runtime &rt, jsi::Function function); ++jsi::Value makeSerializableFunction(jsi::Runtime &rt, jsi::Function function, double id); + + jsi::Value makeSerializableWorklet(jsi::Runtime &rt, const jsi::Object &object, const bool &shouldRetainRemote); + +@@ -295,21 +295,19 @@ class SerializableRemoteFunction : public Serializable, + #ifndef NDEBUG + const std::string name_; + #endif +- std::unique_ptr function_; ++ int id_; + + public: +- SerializableRemoteFunction(jsi::Runtime &rt, jsi::Function &&function) ++ SerializableRemoteFunction(jsi::Runtime &rt, jsi::Value id) + : Serializable(ValueType::RemoteFunctionType), + runtime_(&rt), + #ifndef NDEBUG +- name_(function.getProperty(rt, "name").asString(rt).utf8(rt)), ++ name_("placeholder"), + #endif +- function_(std::make_unique(rt, std::move(function))) { ++ id_(id.asNumber()) { + } + +- ~SerializableRemoteFunction() override { +- cleanupIfRuntimeExists(runtime_, function_); +- } ++ ~SerializableRemoteFunction() override {} + + jsi::Value toJSValue(jsi::Runtime &rt) override; + }; +diff --git a/node_modules/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletRuntimeDecorator.cpp b/node_modules/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletRuntimeDecorator.cpp +index d64c04647e..6d5b604d3a 100644 +--- a/node_modules/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletRuntimeDecorator.cpp ++++ b/node_modules/react-native-worklets/Common/cpp/worklets/WorkletRuntime/WorkletRuntimeDecorator.cpp +@@ -141,7 +141,7 @@ void WorkletRuntimeDecorator::decorate( + }); + + jsi_utils::installJsiFunction(rt, "_createSerializableFunction", [](jsi::Runtime &rt, const jsi::Value &value) { +- return makeSerializableFunction(rt, value.asObject(rt).asFunction(rt)); ++ return makeSerializableFunction(rt, value.asObject(rt).asFunction(rt), -1); + }); + + jsi_utils::installJsiFunction(rt, "_createSerializableSynchronizable", [](jsi::Runtime &rt, const jsi::Value &value) { +diff --git a/node_modules/react-native-worklets/src/WorkletsModule/NativeWorklets.native.ts b/node_modules/react-native-worklets/src/WorkletsModule/NativeWorklets.native.ts +index 9a12fada57..ada9bf467e 100644 +--- a/node_modules/react-native-worklets/src/WorkletsModule/NativeWorklets.native.ts ++++ b/node_modules/react-native-worklets/src/WorkletsModule/NativeWorklets.native.ts +@@ -140,9 +140,10 @@ See https://docs.swmansion.com/react-native-worklets/docs/guides/troubleshooting + } + + createSerializableFunction( +- func: (...args: TArgs) => TReturn +- ) { +- return this.#workletsModuleProxy.createSerializableFunction(func); ++ func: (...args: TArgs) => TReturn, ++ id: number ++ ): SerializableRef { ++ return this.#workletsModuleProxy.createSerializableFunction(func, id); + } + + createSerializableWorklet(worklet: object, shouldPersistRemote: boolean) { +diff --git a/node_modules/react-native-worklets/src/WorkletsModule/workletsModuleProxy.ts b/node_modules/react-native-worklets/src/WorkletsModule/workletsModuleProxy.ts +index b7d9fb2f11..c9bfd6dca1 100644 +--- a/node_modules/react-native-worklets/src/WorkletsModule/workletsModuleProxy.ts ++++ b/node_modules/react-native-worklets/src/WorkletsModule/workletsModuleProxy.ts +@@ -61,7 +61,8 @@ export interface WorkletsModuleProxy { + createSerializableInitializer(obj: object): SerializableRef; + + createSerializableFunction( +- func: (...args: TArgs) => TReturn ++ func: (...args: TArgs) => TReturn, ++ id: number + ): SerializableRef; + + createSerializableWorklet( +diff --git a/node_modules/react-native-worklets/src/memory/serializable.native.ts b/node_modules/react-native-worklets/src/memory/serializable.native.ts +index 1b5f579011..fb82e3127e 100644 +--- a/node_modules/react-native-worklets/src/memory/serializable.native.ts ++++ b/node_modules/react-native-worklets/src/memory/serializable.native.ts +@@ -428,10 +428,18 @@ function cloneArray( + return clone; + } + ++// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type ++const remoteFunctionCache = new Map(); ++// @ts-expect-error it's ok ++globalThis.__remoteFunctionCache = remoteFunctionCache; ++let index = 0; ++ + function cloneRemoteFunction( + value: (...args: TArgs) => TReturn + ): SerializableRef { +- const clone = WorkletsModule.createSerializableFunction(value); ++ const currentIndex = index++; ++ remoteFunctionCache.set(currentIndex, value); ++ const clone = WorkletsModule.createSerializableFunction(value, currentIndex); + serializableMappingCache.set(value, clone); + serializableMappingCache.set(clone); + diff --git a/patches/react-native/details.md b/patches/react-native/details.md index f7b4c34790d15..a869464f06849 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -217,3 +217,10 @@ - Upstream PR/issue: This should ideally be the default behavior upstream, but no PR has been filed yet. - E/App issue: [#83000](https://github.com/Expensify/App/issues/83000) - PR introducing patch: [#83256](https://github.com/Expensify/App/pull/83256) + +### [react-native+0.81.4+029+log-soft-exception-if-viewState-not-found.patch](react-native+0.81.4+029+log-soft-exception-if-viewState-not-found.patch) + +- Reason: This patch prevents app crashes by soft-logging the exception when JS try to send events to native views even if they are removed from view hierarchy. The approach follows existing patterns in the same file where similar events are already handled this way and is based on suggestions from other developers in upstream discussions. +- Upstream PR/issue: [#49077](https://github.com/facebook/react-native/issues/49077) [#7493](https://github.com/software-mansion/react-native-reanimated/issues/7493) +- E/App issue: [#82611](https://github.com/Expensify/App/issues/82611) +- PR introducing patch: [#84303](https://github.com/Expensify/App/pull/84303) diff --git a/patches/react-native/react-native+0.81.4+029+log-soft-exception-if-viewState-not-found.patch b/patches/react-native/react-native+0.81.4+029+log-soft-exception-if-viewState-not-found.patch new file mode 100644 index 0000000000000..6a39a1946748c --- /dev/null +++ b/patches/react-native/react-native+0.81.4+029+log-soft-exception-if-viewState-not-found.patch @@ -0,0 +1,20 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java +index cdcd812..03e09d4 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/SurfaceMountingManager.java +@@ -685,7 +685,14 @@ public class SurfaceMountingManager { + return; + } + +- ViewState viewState = getViewState(reactTag); ++ ViewState viewState = getNullableViewState(reactTag); ++ if (viewState == null) { ++ ReactSoftExceptionLogger.logSoftException( ++ ReactSoftExceptionLogger.Categories.SURFACE_MOUNTING_MANAGER_MISSING_VIEWSTATE, ++ new ReactNoCrashSoftException( ++ "Unable to find viewState for tag: " + reactTag + " for updateProps")); ++ return; ++ } + viewState.mCurrentProps = new ReactStylesDiffMap(props); + View view = viewState.mView; + diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ddb994ba8f988..f2d66c6656c65 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -248,7 +248,6 @@ const CONST = { POPOVER_MENU_MAX_HEIGHT: 496, POPOVER_MENU_MAX_HEIGHT_MOBILE: 432, POPOVER_DATE_WIDTH: 338, - POPOVER_DATE_RANGE_WIDTH: 672, POPOVER_DATE_MAX_HEIGHT: 366, POPOVER_DATE_MIN_HEIGHT: 322, TOOLTIP_ANIMATION_DURATION: 500, @@ -774,7 +773,6 @@ const CONST = { NO_OPTIMISTIC_TRANSACTION_THREADS: 'noOptimisticTransactionThreads', UBER_FOR_BUSINESS: 'uberForBusiness', NEW_DOT_DEW: 'newDotDEW', - GPS_MILEAGE: 'gpsMileage', ODOMETER_EXPENSES: 'odometerExpenses', SINGLE_USE_AND_EXPIRE_BY_CARDS: 'singleUseAndExpireByCards', PAY_INVOICE_VIA_EXPENSIFY: 'payInvoiceViaExpensify', @@ -1136,6 +1134,7 @@ const CONST = { BANCORP_WALLET_AGREEMENT_URL: `${EXPENSIFY_URL}/bancorp-bank-wallet-terms-of-service`, EXPENSIFY_APPROVED_PROGRAM_URL: `${USE_EXPENSIFY_URL}/accountants-program`, TRAVEL_TERMS_URL: `${EXPENSIFY_URL}/travelterms`, + FEES_URL: `${EXPENSIFY_URL}/fees`, }, OLDDOT_URLS: { ADMIN_POLICIES_URL: 'admin_policies', @@ -1267,6 +1266,12 @@ const CONST = { ADD_UNREPORTED_EXPENSE: 'addUnreportedExpense', TRACK_DISTANCE_EXPENSE: 'trackDistanceExpense', }, + ACTION_BADGE: { + SUBMIT: 'submit', + APPROVE: 'approve', + PAY: 'pay', + FIX: 'fix', + }, ACTIONS: { LIMIT: 50, // OldDot Actions render getMessage from Web-Expensify/lib/Report/Action PHP files via getMessageOfOldDotReportAction in ReportActionsUtils.ts @@ -1803,7 +1808,6 @@ const CONST = { SPAN_SEARCH_ROUTER_LIST_RENDER: 'SearchRouter.ListRender', SPAN_SEARCH_PAGE_VISIBLE: 'ManualOpenSearchRouterPageVisible', SPAN_OPEN_CREATE_EXPENSE: 'ManualOpenCreateExpense', - SPAN_SCAN_SHORTCUT: 'ScanShortcut', SPAN_CAMERA_INIT: 'ManualCameraInit', SPAN_SHUTTER_TO_CONFIRMATION: 'ManualShutterToConfirmation', SPAN_RECEIPT_CAPTURE: 'ManualReceiptCapture', @@ -2880,11 +2884,13 @@ const CONST = { MISSING_PERSONAL_DETAILS: { STEP_INDEX_LIST: ['1', '2', '3', '4'], + STEP_INDEX_LIST_WITH_PIN: ['1', '2', '3', '4', '5'], PAGE_NAME: { LEGAL_NAME: 'legal-name', DATE_OF_BIRTH: 'date-of-birth', ADDRESS: 'address', PHONE_NUMBER: 'phone-number', + PIN: 'pin', CONFIRM: 'confirm', }, }, @@ -2904,6 +2910,14 @@ const CONST = { PHONE_NUMBER: 3, CONFIRM: 4, }, + MAPPING_WITH_PIN: { + LEGAL_NAME: 0, + DATE_OF_BIRTH: 1, + ADDRESS: 2, + PHONE_NUMBER: 3, + PIN: 4, + CONFIRM: 5, + }, INDEX_LIST: ['1', '2', '3', '4'], }, @@ -3776,6 +3790,42 @@ const CONST = { DAMAGED: 'damaged', }, MANAGE_EXPENSIFY_CARDS_ARTICLE_LINK: 'https://help.expensify.com/articles/new-expensify/expensify-card/Manage-Expensify-Cards', + PIN: { + LENGTH: 4, + INVALID_PINS: [ + '0000', + '1111', + '2222', + '3333', + '4444', + '5555', + '6666', + '7777', + '8888', + '9999', + '1234', + '2345', + '3456', + '4567', + '5678', + '6789', + '7890', + '0123', + '0987', + '9876', + '8765', + '7654', + '6543', + '5432', + '4321', + '3210', + '1212', + '1004', + '6969', + '2000', + '2015', + ], + }, }, PERSONAL_CARDS: { FEED_KEY_SEPARATOR: '#', @@ -5815,6 +5865,8 @@ const CONST = { LINK: 'link', /** Use to identify a list of items. */ LIST: 'list', + /** Use for a list of selectable options (single or multi-select). */ + LISTBOX: 'listbox', /** Use for individual items within a list. */ LISTITEM: 'listitem', /** Use for a list of choices or options. */ @@ -5823,6 +5875,8 @@ const CONST = { MENUBAR: 'menubar', /** Use for items within a menu. */ MENUITEM: 'menuitem', + /** Use for selectable options within a listbox. */ + OPTION: 'option', /** Use when no specific role is needed. */ NONE: 'none', /** Use for elements that don't require a specific role. */ @@ -5892,8 +5946,6 @@ const CONST = { ENABLED: 'ENABLED', DISABLED: 'DISABLED', DISABLE: 'DISABLE', - REPLACE_VERIFY_OLD: 'REPLACE_VERIFY_OLD', - REPLACE_VERIFY_NEW: 'REPLACE_VERIFY_NEW', }, MERGE_ACCOUNT_RESULTS: { SUCCESS: 'success', @@ -5998,7 +6050,7 @@ const CONST = { PM: 'PM', }, INDENTS: ' ', - PARENT_CHILD_SEPARATOR: ': ', + PARENT_CHILD_SEPARATOR: ':', DISTANCE_MERCHANT_SEPARATOR: '@', COLON: ':', MAPBOX: { @@ -7514,7 +7566,6 @@ const CONST = { EQUAL_TO: 'eq', CONTAINS: 'contains', NOT_EQUAL_TO: 'neq', - RANGE: 'range', GREATER_THAN: 'gt', GREATER_THAN_OR_EQUAL_TO: 'gte', LOWER_THAN: 'lt', @@ -7589,7 +7640,6 @@ const CONST = { ON_PREFIX: 'reportFieldOn-', AFTER_PREFIX: 'reportFieldAfter-', BEFORE_PREFIX: 'reportFieldBefore-', - RANGE_PREFIX: 'reportFieldRange-', }, TAG_EMPTY_VALUE: 'none', CATEGORY_EMPTY_VALUE: 'none', @@ -7719,10 +7769,6 @@ const CONST = { ON: 'On', AFTER: 'After', BEFORE: 'Before', - RANGE: 'Range', - }, - get CUSTOM_DATE_MODIFIERS() { - return [this.DATE_MODIFIERS.ON, this.DATE_MODIFIERS.BEFORE, this.DATE_MODIFIERS.AFTER] as const; }, AMOUNT_MODIFIERS: { LESS_THAN: 'LessThan', @@ -9197,6 +9243,10 @@ const CONST = { HEADER: 'header', ROW: 'row', }, + + CACHE_NAME: { + AUTH_IMAGES: 'auth-images', + }, } as const; const CONTINUATION_DETECTION_SEARCH_FILTER_KEYS = [ diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 401caa0abfe74..23188706a6e21 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -56,6 +56,8 @@ const VERIFY_ACCOUNT = 'verify-account'; type DynamicRouteConfig = { path: string; entryScreens: Screen[]; + getRoute?: (...args: never[]) => string; + queryParams?: readonly string[]; }; type DynamicRoutes = Record; @@ -97,6 +99,19 @@ const DYNAMIC_ROUTES = { path: 'owner-selector', entryScreens: [], }, + ADDRESS_COUNTRY: { + path: 'country', + entryScreens: [ + SCREENS.SETTINGS.PROFILE.ADDRESS, + SCREENS.WORKSPACE.ADDRESS, + SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, + SCREENS.DOMAIN_CARD.DOMAIN_CARD_UPDATE_ADDRESS, + SCREENS.TRAVEL.WORKSPACE_ADDRESS, + SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT, + ], + getRoute: (country = '') => `country?country=${country}`, + queryParams: ['country'], + }, } as const satisfies DynamicRoutes; const ROUTES = { @@ -523,12 +538,6 @@ const ROUTES = { SETTINGS_DATE_OF_BIRTH: 'settings/profile/date-of-birth', SETTINGS_PHONE_NUMBER: 'settings/profile/phone', SETTINGS_ADDRESS: 'settings/profile/address', - SETTINGS_ADDRESS_COUNTRY: { - route: 'settings/profile/address/country', - - // eslint-disable-next-line no-restricted-syntax -- Legacy route generation - getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/address/country?country=${country}`, backTo), - }, SETTINGS_ADDRESS_STATE: { route: 'settings/profile/address/state', @@ -600,8 +609,6 @@ const ROUTES = { }, SETTINGS_2FA_DISABLED: 'settings/security/two-factor-auth/disabled', SETTINGS_2FA_DISABLE: 'settings/security/two-factor-auth/disable', - SETTINGS_2FA_REPLACE_VERIFY_OLD: 'settings/security/two-factor-auth/replace/verify-old', - SETTINGS_2FA_REPLACE_VERIFY_NEW: 'settings/security/two-factor-auth/replace/verify-new', SETTINGS_STATUS: 'settings/profile/status', @@ -3408,15 +3415,18 @@ const ROUTES = { getRoute: (policyID: string) => `restricted-action/workspace/${policyID}` as const, }, MISSING_PERSONAL_DETAILS: { - route: 'missing-personal-details/:subPage?/:action?', - getRoute: (subPage?: string, action?: 'edit') => { + route: 'missing-personal-details/:cardID/:subPage?/:action?', + getRoute: (cardID: string, subPage?: string, action?: 'edit') => { if (!subPage) { - return 'missing-personal-details' as const; + return `missing-personal-details/${cardID}` as const; } - return `missing-personal-details/${subPage}${action ? `/${action}` : ''}` as const; + return `missing-personal-details/${cardID}/${subPage}${action ? `/${action}` : ''}` as const; }, }, - MISSING_PERSONAL_DETAILS_CONFIRM_MAGIC_CODE: 'missing-personal-details/confirm-magic-code', + MISSING_PERSONAL_DETAILS_CONFIRM_MAGIC_CODE: { + route: 'missing-personal-details/:cardID/confirm-magic-code', + getRoute: (cardID: string) => `missing-personal-details/${cardID}/confirm-magic-code` as const, + }, POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR: { route: 'workspaces/:policyID/accounting/netsuite/subsidiary-selector', getRoute: (policyID: string | undefined) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 8731b0182194c..6c2599a91dc50 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -142,7 +142,7 @@ const SCREENS = { PHONE_NUMBER: 'Settings_PhoneNumber', ADDRESS: 'Settings_Address', AVATAR: 'Settings_Avatar', - ADDRESS_COUNTRY: 'Settings_Address_Country', + DYNAMIC_ADDRESS_COUNTRY: 'Dynamic_Address_Country', ADDRESS_STATE: 'Settings_Address_State', }, @@ -254,8 +254,6 @@ const SCREENS = { SUCCESS: 'Settings_TwoFactorAuth_Success', DISABLED: 'Settings_TwoFactorAuth_Disabled', DISABLE: 'Settings_TwoFactorAuth_Disable', - REPLACE_VERIFY_OLD: 'Settings_TwoFactorAuth_Replace_VerifyOld', - REPLACE_VERIFY_NEW: 'Settings_TwoFactorAuth_Replace_VerifyNew', }, SAVE_THE_WORLD: { ROOT: 'SaveTheWorld_Root', diff --git a/src/components/ActionSheetAwareScrollView/index.tsx b/src/components/ActionSheetAwareScrollView/index.tsx index 408149163968d..799f4742a94b5 100644 --- a/src/components/ActionSheetAwareScrollView/index.tsx +++ b/src/components/ActionSheetAwareScrollView/index.tsx @@ -2,18 +2,21 @@ // On all other platforms, the action sheet is implemented using the Animated.ScrollView import React from 'react'; import Reanimated from 'react-native-reanimated'; +import useThemeStyles from '@hooks/useThemeStyles'; import {Actions, ActionSheetAwareScrollViewProvider, useActionSheetAwareScrollViewActions, useActionSheetAwareScrollViewState} from './ActionSheetAwareScrollViewContext'; import type {ActionSheetAwareScrollViewProps, RenderActionSheetAwareScrollViewComponent} from './types'; import useActionSheetAwareScrollViewRef from './useActionSheetAwareScrollViewRef'; function ActionSheetAwareScrollView({children, ref, ...restProps}: ActionSheetAwareScrollViewProps) { const {onRef} = useActionSheetAwareScrollViewRef(ref); + const styles = useThemeStyles(); return ( {children} diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index dd9634274207e..c68624505d5ac 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {handlePlaidError, openPlaidBankAccountSelector, openPlaidBankLogin, setPlaidEvent} from '@libs/actions/BankAccounts'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import Log from '@libs/Log'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {handleRestrictedEvent} from '@userActions/App'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -229,9 +230,13 @@ function AddPlaidBankAccount({ } if (plaidData?.isLoading) { + const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'AddPlaidBankAccount', isLoading: !!plaidData.isLoading}; return ( - + ); } diff --git a/src/components/AddToWalletButton/index.native.tsx b/src/components/AddToWalletButton/index.native.tsx index 9b9c4bbcb2584..9acac11b019df 100644 --- a/src/components/AddToWalletButton/index.native.tsx +++ b/src/components/AddToWalletButton/index.native.tsx @@ -10,6 +10,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getPaymentMethods} from '@libs/actions/PaymentMethods'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {checkIfWalletIsAvailable, handleAddCardToWallet, isCardInWallet} from '@libs/Wallet/index'; import CONST from '@src/CONST'; import type AddToWalletButtonProps from './types'; @@ -91,7 +92,13 @@ function AddToWalletButton({card, cardHolderName, cardDescription, style}: AddTo } if (isLoading) { - return ; + const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'AddToWalletButton', isLoading}; + return ( + + ); } if (isInWallet) { diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 0131f8f99e2a8..5dd3a1184f7bc 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -102,7 +102,7 @@ function AttachmentCarousel({ if (page == null) { return ( - + ); } diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index bfdc63cf71193..855f2805908e8 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -102,7 +102,7 @@ function Badge({ accessible={false} > {!!icon && ( - + { if ('children' in rest) { return rest.children; @@ -535,6 +540,7 @@ function Button({ color={success || danger ? theme.textLight : theme.text} style={[styles.pAbsolute, styles.l0, styles.r0]} size={extraSmall ? 12 : undefined} + reasonAttributes={buttonLoadingReasonAttributes} /> )} diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 1ada8050cfbd0..044ae9850e656 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -2,6 +2,7 @@ import {useFont} from '@shopify/react-native-skia'; import React, {useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; +import {GestureDetector} from 'react-native-gesture-handler'; import {useSharedValue} from 'react-native-reanimated'; import type {CartesianChartRenderArg, ChartBounds, PointsArray, Scale} from 'victory-native'; import {Bar, CartesianChart} from 'victory-native'; @@ -9,12 +10,20 @@ import ActivityIndicator from '@components/ActivityIndicator'; import ChartHeader from '@components/Charts/components/ChartHeader'; import ChartTooltip from '@components/Charts/components/ChartTooltip'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; -import {AXIS_LABEL_GAP, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; +import { + AXIS_LABEL_GAP, + CHART_CONTENT_MIN_HEIGHT, + CHART_PADDING, + DIAGONAL_ANGLE_RADIAN_THRESHOLD, + X_AXIS_LINE_WIDTH, + Y_AXIS_LINE_WIDTH, + Y_AXIS_TICK_COUNT, +} from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; -import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import type {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, getChartColor, rotatedLabelYOffset} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -29,6 +38,35 @@ const BAR_INNER_PADDING = 0.3; */ const BASE_DOMAIN_PADDING = {top: 32, bottom: 1, left: 0, right: 0}; +/** + * Bar chart geometry for label hit-testing. + * Labels are center-anchored: the 45° parallelogram's upper-right corner is offset + * by (halfLabelWidth * sinA) right and up, so the box straddles the tick symmetrically. + */ +const computeBarLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => { + const maxLabelWidth = labelWidths.length > 0 ? Math.max(...labelWidths) : 0; + const centeredUpwardOffset = angleRad > 0 ? (maxLabelWidth / 2) * sinA : 0; + const halfLabelSins = labelWidths.map((w) => (w / 2) * sinA - variables.iconSizeExtraSmall / 3); + const halfWidths = labelWidths.map((w) => w / 2); + let additionalOffset = 0; + if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) { + additionalOffset = variables.iconSizeExtraSmall / 1.5; + } else if (angleRad > 1) { + additionalOffset = variables.iconSizeExtraSmall / 3; + } + return { + // variables.iconSizeExtraSmall / 3 is the vertical offset of label from the axis line + labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) + centeredUpwardOffset - additionalOffset, + iconSin: variables.iconSizeExtraSmall * sinA, + labelSins: labelWidths.map((w) => w * sinA), + halfWidths, + cornerAnchorDX: halfLabelSins, + cornerAnchorDY: halfLabelSins.map((v) => -v), + yMin90Offsets: halfWidths.map((hw) => -hw + padding), + yMax90Offsets: halfWidths.map((hw) => hw + padding), + }; +}; + type BarChartProps = CartesianChartProps & { /** Callback when a bar is pressed */ onBarPress?: (dataPoint: ChartDataPoint, index: number) => void; @@ -100,6 +138,15 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni const chartBottom = useSharedValue(0); const yZero = useSharedValue(0); + const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ + font, + truncatedLabels, + labelRotation, + labelSkipInterval, + chartBottom, + computeGeometry: computeBarLabelGeometry, + }); + const handleChartBoundsChange = (bounds: ChartBounds) => { const domainWidth = bounds.right - bounds.left; const calculatedBarWidth = ((1 - BAR_INNER_PADDING) * domainWidth) / data.length; @@ -111,10 +158,6 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni setBoundsRight(bounds.right); }; - const handleScaleChange = (_xScale: Scale, yScale: Scale) => { - yZero.set(yScale(0)); - }; - const checkIsOverBar = (args: HitTestArgs) => { 'worklet'; @@ -125,19 +168,31 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni } const barLeft = args.targetX - currentBarWidth / 2; const barRight = args.targetX + currentBarWidth / 2; + const barTop = Math.min(args.targetY, currentYZero); const barBottom = Math.max(args.targetY, currentYZero); return args.cursorX >= barLeft && args.cursorX <= barRight && args.cursorY >= barTop && args.cursorY <= barBottom; }; - const {actionsRef, customGestures, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ + const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handleBarPress, checkIsOver: checkIsOverBar, + isCursorOverLabel, + resolveLabelTouchX: findLabelCursorX, chartBottom, yZero, }); + const handleScaleChange = (xScale: Scale, yScale: Scale) => { + yZero.set(yScale(0)); + updateTickPositions(xScale, data.length); + setPointPositions( + chartData.map((point) => xScale(point.x)), + chartData.map((point) => yScale(point.y)), + ); + }; + const tooltipData = useTooltipData(activeDataIndex, data, formatValue); const renderBar = (point: PointsArray[number], chartBounds: ChartBounds, barCount: number) => { @@ -202,53 +257,53 @@ function BarChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUni title={title} titleIcon={titleIcon} /> - - {chartWidth > 0 && ( - - {({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}} - - )} - {isTooltipActive && !!tooltipData && ( - - )} - + + + {chartWidth > 0 && ( + + {({points, chartBounds}) => <>{points.y.map((point) => renderBar(point, chartBounds, points.y.length))}} + + )} + {isTooltipActive && !!tooltipData && ( + + )} + + ); } diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index b99f78a91c54c..d79f90ead5911 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -2,7 +2,9 @@ import {useFont} from '@shopify/react-native-skia'; import React, {useState} from 'react'; import type {LayoutChangeEvent} from 'react-native'; import {View} from 'react-native'; -import type {CartesianChartRenderArg, ChartBounds} from 'victory-native'; +import {GestureDetector} from 'react-native-gesture-handler'; +import {useSharedValue} from 'react-native-reanimated'; +import type {CartesianChartRenderArg, ChartBounds, Scale} from 'victory-native'; import {CartesianChart, Line} from 'victory-native'; import ActivityIndicator from '@components/ActivityIndicator'; import ChartHeader from '@components/Charts/components/ChartHeader'; @@ -10,12 +12,20 @@ import ChartTooltip from '@components/Charts/components/ChartTooltip'; import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import LeftFrameLine from '@components/Charts/components/LeftFrameLine'; import ScatterPoints from '@components/Charts/components/ScatterPoints'; -import {AXIS_LABEL_GAP, CHART_CONTENT_MIN_HEIGHT, CHART_PADDING, X_AXIS_LINE_WIDTH, Y_AXIS_LINE_WIDTH, Y_AXIS_TICK_COUNT} from '@components/Charts/constants'; +import { + AXIS_LABEL_GAP, + CHART_CONTENT_MIN_HEIGHT, + CHART_PADDING, + DIAGONAL_ANGLE_RADIAN_THRESHOLD, + X_AXIS_LINE_WIDTH, + Y_AXIS_LINE_WIDTH, + Y_AXIS_TICK_COUNT, +} from '@components/Charts/constants'; import fontSource from '@components/Charts/font'; -import type {HitTestArgs} from '@components/Charts/hooks'; -import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useTooltipData} from '@components/Charts/hooks'; +import type {ComputeGeometryFn, HitTestArgs} from '@components/Charts/hooks'; +import {useChartInteractions, useChartLabelFormats, useChartLabelLayout, useDynamicYDomain, useLabelHitTesting, useTooltipData} from '@components/Charts/hooks'; import type {CartesianChartProps, ChartDataPoint} from '@components/Charts/types'; -import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, measureTextWidth} from '@components/Charts/utils'; +import {calculateMinDomainPadding, DEFAULT_CHART_COLOR, measureTextWidth, rotatedLabelYOffset} from '@components/Charts/utils'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -34,6 +44,31 @@ const MIN_SAFE_PADDING = DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS; /** Base domain padding applied to all sides */ const BASE_DOMAIN_PADDING = {top: 16, bottom: 16, left: 0, right: 0}; +/** + * Line chart geometry for label hit-testing. + * Labels are start-anchored at the tick: the 45° parallelogram's upper-right corner is + * offset by (iconSize/3 * sinA) left and down, placing the box just below the axis line. + */ +const computeLineLabelGeometry: ComputeGeometryFn = ({ascent, descent, sinA, angleRad, labelWidths, padding}) => { + const iconThirdSin = (variables.iconSizeExtraSmall / 3) * sinA; + let additionalOffset = 0; + if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) { + additionalOffset = variables.iconSizeExtraSmall / 1.5; + } else if (angleRad > 1) { + additionalOffset = variables.iconSizeExtraSmall / 3; + } + return { + labelYOffset: AXIS_LABEL_GAP + rotatedLabelYOffset(ascent, descent, angleRad) - additionalOffset, + iconSin: variables.iconSizeExtraSmall * sinA, + labelSins: labelWidths.map((w) => w * sinA), + halfWidths: labelWidths.map((w) => w / 2), + cornerAnchorDX: labelWidths.map(() => -iconThirdSin), + cornerAnchorDY: labelWidths.map(() => iconThirdSin), + yMin90Offsets: labelWidths.map(() => padding), + yMax90Offsets: labelWidths.map((w) => w + padding), + }; +}; + type LineChartProps = CartesianChartProps & { /** Callback when a data point is pressed */ onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; @@ -69,11 +104,7 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn setChartWidth(event.nativeEvent.layout.width); }; - const handleChartBoundsChange = (bounds: ChartBounds) => { - setPlotAreaWidth(bounds.right - bounds.left); - setBoundsLeft(bounds.left); - setBoundsRight(bounds.right); - }; + const chartBottom = useSharedValue(0); const domainPadding = (() => { if (chartWidth === 0 || data.length === 0) { @@ -126,6 +157,22 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn unitPosition: yAxisUnitPosition, }); + const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ + font, + truncatedLabels, + labelRotation, + labelSkipInterval, + chartBottom, + computeGeometry: computeLineLabelGeometry, + }); + + const handleChartBoundsChange = (bounds: ChartBounds) => { + setPlotAreaWidth(bounds.right - bounds.left); + setBoundsLeft(bounds.left); + setBoundsRight(bounds.right); + chartBottom.set(bounds.bottom); + }; + const checkIsOverDot = (args: HitTestArgs) => { 'worklet'; @@ -134,11 +181,22 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn return Math.sqrt(dx * dx + dy * dy) <= DOT_RADIUS + DOT_HOVER_EXTRA_RADIUS; }; - const {actionsRef, customGestures, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ + const {customGestures, setPointPositions, activeDataIndex, isTooltipActive, initialTooltipPosition} = useChartInteractions({ handlePress: handlePointPress, checkIsOver: checkIsOverDot, + isCursorOverLabel, + resolveLabelTouchX: findLabelCursorX, + chartBottom, }); + const handleScaleChange = (xScale: Scale, yScale: Scale) => { + updateTickPositions(xScale, data.length); + setPointPositions( + chartData.map((point) => xScale(point.x)), + chartData.map((point) => yScale(point.y)), + ); + }; + const tooltipData = useTooltipData(activeDataIndex, data, formatValue); const renderOutside = (args: CartesianChartRenderArg<{x: number; y: number}, 'y'>) => { @@ -196,59 +254,60 @@ function LineChartContent({data, title, titleIcon, isLoading, yAxisUnit, yAxisUn title={title} titleIcon={titleIcon} /> - - {chartWidth > 0 && ( - - {({points}) => ( - - )} - - )} - {isTooltipActive && !!tooltipData && ( - - )} - + + + {chartWidth > 0 && ( + + {({points}) => ( + + )} + + )} + {isTooltipActive && !!tooltipData && ( + + )} + + ); } diff --git a/src/components/Charts/PieChart/PieChartContent.tsx b/src/components/Charts/PieChart/PieChartContent.tsx index 3c889453bbb9b..a7419bc431867 100644 --- a/src/components/Charts/PieChart/PieChartContent.tsx +++ b/src/components/Charts/PieChart/PieChartContent.tsx @@ -44,18 +44,18 @@ function PieChartContent({data, title, titleIcon, isLoading, valueUnit, valueUni setCanvasHeight(event.nativeEvent.layout.height); }; + // Calculate pie geometry + const pieGeometry = {radius: Math.min(canvasWidth, canvasHeight) / 2, centerX: canvasWidth / 2, centerY: canvasHeight / 2}; + // Slices are sorted by absolute value (largest first) for color assignment, // so slice indices don't match the original data array. We map back via // originalIndex so the tooltip can display the original (possibly negative) value. - const processedSlices = processDataIntoSlices(data, PIE_CHART_START_ANGLE); + const processedSlices = processDataIntoSlices(data, PIE_CHART_START_ANGLE, pieGeometry); const activeOriginalDataIndex = activeSliceIndex >= 0 ? (processedSlices.at(activeSliceIndex)?.originalIndex ?? -1) : -1; const {formatValue} = useChartLabelFormats({data, unit: valueUnit, unitPosition: valueUnitPosition}); const tooltipData = useTooltipData(activeOriginalDataIndex, data, formatValue); - // Calculate pie geometry - const pieGeometry = {radius: Math.min(canvasWidth, canvasHeight) / 2, centerX: canvasWidth / 2, centerY: canvasHeight / 2}; - // Handle hover state updates const updateActiveSlice = (x: number, y: number) => { const {radius, centerX, centerY} = pieGeometry; @@ -126,6 +126,13 @@ function PieChartContent({data, title, titleIcon, isLoading, valueUnit, valueUni { + tooltipPosition.set(slice.tooltipPosition); + setActiveSliceIndex(slice.ordinalIndex); + }} + onMouseLeave={() => { + setActiveSliceIndex(-1); + }} > {slice.label} diff --git a/src/components/Charts/constants.ts b/src/components/Charts/constants.ts index 8a952af70b641..2f175d5798c44 100644 --- a/src/components/Charts/constants.ts +++ b/src/components/Charts/constants.ts @@ -36,6 +36,11 @@ const ELLIPSIS = '...'; /** Minimum visible characters (excluding ellipsis) for truncation to be worthwhile */ const MIN_TRUNCATED_CHARS = 10; +/** Radian threshold separating diagonal from vertical label hit-test */ +const DIAGONAL_ANGLE_RADIAN_THRESHOLD = 1; + +const PIE_CHART_TOOLTIP_RADIUS_DISTANCE = 2 / 3; + export { Y_AXIS_TICK_COUNT, AXIS_LABEL_GAP, @@ -49,4 +54,6 @@ export { LABEL_PADDING, ELLIPSIS, MIN_TRUNCATED_CHARS, + DIAGONAL_ANGLE_RADIAN_THRESHOLD, + PIE_CHART_TOOLTIP_RADIUS_DISTANCE, }; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index 962dd8188f397..6a12830acb06a 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -4,3 +4,5 @@ export type {HitTestArgs} from './useChartInteractions'; export {default as useChartLabelFormats} from './useChartLabelFormats'; export {default as useDynamicYDomain} from './useDynamicYDomain'; export {useTooltipData} from './useTooltipData'; +export {default as useLabelHitTesting} from './useLabelHitTesting'; +export type {ComputeGeometryFn, ComputeGeometryInput} from './useLabelHitTesting'; diff --git a/src/components/Charts/hooks/useChartInteractions.ts b/src/components/Charts/hooks/useChartInteractions.ts index 62bd4c218a05b..6bcaabffb5251 100644 --- a/src/components/Charts/hooks/useChartInteractions.ts +++ b/src/components/Charts/hooks/useChartInteractions.ts @@ -1,7 +1,7 @@ -import {useMemo, useRef, useState} from 'react'; +import {useCallback, useMemo, useState} from 'react'; import {Gesture} from 'react-native-gesture-handler'; import type {SharedValue} from 'react-native-reanimated'; -import {useAnimatedReaction, useDerivedValue} from 'react-native-reanimated'; +import {useAnimatedReaction, useDerivedValue, useSharedValue} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; import {useChartInteractionState} from './useChartInteractionState'; @@ -14,12 +14,16 @@ const TOOLTIP_BAR_GAP = 8; type HitTestArgs = { /** Current raw X position of the cursor */ cursorX: number; + /** Current raw Y position of the cursor */ cursorY: number; + /** Calculated X position of the matched data point */ targetX: number; + /** Calculated Y position of the matched data point */ targetY: number; + /** The bottom boundary of the chart area */ chartBottom: number; }; @@ -30,41 +34,112 @@ type HitTestArgs = { type UseChartInteractionsProps = { /** Callback triggered when a valid data point is tapped/clicked */ handlePress: (index: number) => void; + /** * Worklet function to determine if the cursor is technically "hovering" * over a specific chart element (e.g., within a bar's width or a point's radius). */ checkIsOver: (args: HitTestArgs) => boolean; + + /** Worklet function to determine if the cursor is hovering over the label area */ + isCursorOverLabel?: (args: HitTestArgs, activeIndex: number) => boolean; + + /** + * Optional worklet that, when the cursor is in the label area, scans all label bounding + * boxes and returns the tick X of the label the cursor is inside (or the raw cursor X if + * no label matches). Used to correct Victory's nearest-point-by-X algorithm for rotated + * labels whose bounding boxes extend past the midpoint between adjacent ticks. + */ + resolveLabelTouchX?: (cursorX: number, cursorY: number) => number; + /** Optional shared value containing bar dimensions used for hit-testing in bar charts */ chartBottom?: SharedValue; + + /** Optional shared value containing the y-axis zero position */ yZero?: SharedValue; }; /** - * Type for Victory's actionsRef handle. - * Used to manually trigger Victory's internal touch handling logic. + * Binary search over canvas x positions to find the index of the closest data point. + * Equivalent to victory-native's internal findClosestPoint utility. */ -type CartesianActionsHandle = { - handleTouch: (state: unknown, x: number, y: number) => void; -}; +function findClosestPoint(xValues: number[], targetX: number): number { + 'worklet'; + + const n = xValues.length; + if (n === 0) { + return -1; + } + if (targetX <= (xValues.at(0) ?? 0)) { + return 0; + } + if (targetX >= (xValues.at(-1) ?? 0)) { + return n - 1; + } + let lo = 0; + let hi = n; + let mid = 0; + while (lo < hi) { + mid = Math.floor((lo + hi) / 2); + const midVal = xValues.at(mid) ?? 0; + if (midVal === targetX) { + return mid; + } + if (targetX < midVal) { + if (mid > 0 && targetX > (xValues.at(mid - 1) ?? 0)) { + const prevVal = xValues.at(mid - 1) ?? 0; + return targetX - prevVal >= midVal - targetX ? mid : mid - 1; + } + hi = mid; + } else { + if (mid < n - 1 && targetX < (xValues.at(mid + 1) ?? 0)) { + const nextVal = xValues.at(mid + 1) ?? 0; + return targetX - midVal >= nextVal - targetX ? mid + 1 : mid; + } + lo = mid + 1; + } + } + return mid; +} /** * Manages chart interactions (hover, tap, hit-testing) and animated tooltip positioning. + * Uses react native gesture handler gestures directly — no dependency on Victory's actionsRef/handleTouch. * Synchronizes high-frequency UI thread data to React state for tooltip display and navigation. */ -function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: UseChartInteractionsProps) { +function useChartInteractions({handlePress, checkIsOver, isCursorOverLabel, resolveLabelTouchX, chartBottom, yZero}: UseChartInteractionsProps) { /** Interaction state compatible with Victory Native's internal logic */ const {state: chartInteractionState, isActive: isTooltipActiveState} = useChartInteractionState(); - /** Ref passed to CartesianChart to allow manual touch injection */ - const actionsRef = useRef(null); - /** React state for the index of the point currently being interacted with */ const [activeDataIndex, setActiveDataIndex] = useState(-1); /** React state indicating if the cursor is currently "hitting" a target based on checkIsOver */ const [isOverTarget, setIsOverTarget] = useState(false); + /** + * Canvas-space x positions for each data point, set by the chart content via setPointPositions. + * These replace Victory's internal tData.ox array, enabling worklet-safe nearest-point lookup. + */ + const pointOX = useSharedValue([]); + + /** + * Canvas-space y positions for each data point, set by the chart content via setPointPositions. + */ + const pointOY = useSharedValue([]); + + /** + * Called by chart content from handleScaleChange to populate canvas positions. + * Must be called with the positions derived from the current d3 scale. + */ + const setPointPositions = useCallback( + (ox: number[], oy: number[]) => { + pointOX.set(ox); + pointOY.set(oy); + }, + [pointOX, pointOY], + ); + /** * Derived value performing the hit-test on the UI thread. * Runs whenever cursor position or matched data points change. @@ -76,14 +151,16 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us const targetY = chartInteractionState.y.y.position.get(); const currentChartBottom = chartBottom?.get() ?? 0; - - return checkIsOver({ - cursorX, - cursorY, - targetX, - targetY, - chartBottom: currentChartBottom, - }); + return ( + checkIsOver({ + cursorX, + cursorY, + targetX, + targetY, + chartBottom: currentChartBottom, + }) || + (isCursorOverLabel?.({cursorX, cursorY, targetX, targetY, chartBottom: currentChartBottom}, chartInteractionState.matchedIndex.get()) ?? false) + ); }); /** Syncs the matched data index from the UI thread to React state */ @@ -103,8 +180,12 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us ); /** - * Hover gesture configuration. - * Primarily used for web/desktop to track mouse movement without clicking. + * Hover gesture to be placed on the full-height outer container (chart + label area). + * Clamps the y coordinate to chartBottom before passing to Victory so that hovering + * over x-axis labels below the plot area still resolves the nearest data point. + * This gesture is returned separately and must NOT be passed to CartesianChart's + * customGestures prop, because Victory's internal GestureHandler view only covers + * the plot area and would drop events from the label area. */ const hoverGesture = useMemo( () => @@ -115,60 +196,87 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us chartInteractionState.isActive.set(true); chartInteractionState.cursor.x.set(e.x); chartInteractionState.cursor.y.set(e.y); - actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); + const bottom = chartBottom?.get() ?? e.y; + const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; + const ox = pointOX.get(); + const oy = pointOY.get(); + const idx = findClosestPoint(ox, touchX); + if (idx >= 0) { + chartInteractionState.matchedIndex.set(idx); + chartInteractionState.x.position.set(ox.at(idx) ?? 0); + chartInteractionState.x.value.set(idx); + chartInteractionState.y.y.position.set(oy.at(idx) ?? 0); + } }) .onUpdate((e) => { 'worklet'; chartInteractionState.cursor.x.set(e.x); chartInteractionState.cursor.y.set(e.y); - actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); + // Only update the matched index when the cursor is not over the current target. + // This keeps the active index locked while hovering over a bar/point/label, + // preventing it from jumping to a different point during continuous movement. + if (!isCursorOverTarget.get()) { + const bottom = chartBottom?.get() ?? e.y; + const touchX = e.y >= bottom && resolveLabelTouchX ? resolveLabelTouchX(e.x, e.y) : e.x; + const ox = pointOX.get(); + const oy = pointOY.get(); + const idx = findClosestPoint(ox, touchX); + if (idx >= 0) { + chartInteractionState.matchedIndex.set(idx); + chartInteractionState.x.position.set(ox.at(idx) ?? 0); + chartInteractionState.x.value.set(idx); + chartInteractionState.y.y.position.set(oy.at(idx) ?? 0); + } + } }) .onEnd(() => { 'worklet'; chartInteractionState.isActive.set(false); }), - [chartInteractionState], + [chartInteractionState, chartBottom, isCursorOverTarget, resolveLabelTouchX, pointOX, pointOY], ); /** - * Tap gesture configuration. - * Handles clicks/touches and triggers handlePress if Victory matched a data point. + * Tap gesture. Resolves the nearest data point entirely on the UI thread, + * then schedules handlePress on the JS thread if the cursor is over the target. */ const tapGesture = useMemo( () => Gesture.Tap().onEnd((e) => { 'worklet'; - // Update cursor position chartInteractionState.cursor.x.set(e.x); chartInteractionState.cursor.y.set(e.y); - - // Let Victory calculate which data point was tapped - actionsRef.current?.handleTouch(chartInteractionState, e.x, e.y); - const matchedIndex = chartInteractionState.matchedIndex.get(); - - // If Victory matched a valid data point, trigger the press handler + const ox = pointOX.get(); + const oy = pointOY.get(); + const idx = findClosestPoint(ox, e.x); + if (idx < 0) { + return; + } + const targetX = ox.at(idx) ?? 0; + const targetY = oy.at(idx) ?? 0; + chartInteractionState.matchedIndex.set(idx); + chartInteractionState.x.position.set(targetX); + chartInteractionState.x.value.set(idx); + chartInteractionState.y.y.position.set(targetY); + const currentChartBottom = chartBottom?.get() ?? 0; if ( - matchedIndex >= 0 && checkIsOver({ cursorX: e.x, cursorY: e.y, - targetX: chartInteractionState.x.position.get(), - targetY: chartInteractionState.y.y.position.get(), - chartBottom: chartBottom?.get() ?? 0, + targetX, + targetY, + chartBottom: currentChartBottom, }) ) { - scheduleOnRN(handlePress, matchedIndex); + scheduleOnRN(handlePress, idx); } }), - [chartInteractionState, checkIsOver, chartBottom, handlePress], + [chartInteractionState, pointOX, pointOY, chartBottom, checkIsOver, handlePress], ); - /** Combined gesture object to be passed to CartesianChart's customGestures prop */ - const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]); - /** * Raw tooltip positioning data. * We return these as individual derived values so the caller can @@ -186,11 +294,16 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us }; }); + const customGestures = useMemo(() => Gesture.Race(hoverGesture, tapGesture), [hoverGesture, tapGesture]); + return { - /** Ref to be passed to CartesianChart */ - actionsRef, - /** Gestures to be passed to CartesianChart */ + /** Custom gestures to be passed to CartesianChart */ customGestures, + /** + * Call this from handleScaleChange with the canvas x/y positions of each data point. + * Derived from the d3 scale: ox[i] = xScale(i), oy[i] = yScale(data[i].total). + */ + setPointPositions, /** The currently active data index (React state) */ activeDataIndex, /** Whether the tooltip should currently be rendered and visible */ @@ -200,5 +313,5 @@ function useChartInteractions({handlePress, checkIsOver, chartBottom, yZero}: Us }; } -export {useChartInteractions, TOOLTIP_BAR_GAP}; +export {useChartInteractions, findClosestPoint, TOOLTIP_BAR_GAP}; export type {HitTestArgs}; diff --git a/src/components/Charts/hooks/useLabelHitTesting.ts b/src/components/Charts/hooks/useLabelHitTesting.ts new file mode 100644 index 0000000000000..3065f038942a4 --- /dev/null +++ b/src/components/Charts/hooks/useLabelHitTesting.ts @@ -0,0 +1,194 @@ +import type {SkFont} from '@shopify/react-native-skia'; +import {useMemo} from 'react'; +import type {SharedValue} from 'react-native-reanimated'; +import {useSharedValue} from 'react-native-reanimated'; +import type {Scale} from 'victory-native'; +import {DIAGONAL_ANGLE_RADIAN_THRESHOLD} from '@components/Charts/constants'; +import type {LabelRotation} from '@components/Charts/types'; +import {isCursorOverChartLabel, measureTextWidth} from '@components/Charts/utils'; +import variables from '@styles/variables'; +import type {HitTestArgs} from './useChartInteractions'; + +type LabelHitGeometry = { + /** Constant vertical offset from chartBottom to the label Y baseline */ + labelYOffset: number; + + /** iconSize * sin(angle) — diagonal step from upper to lower corner */ + iconSin: number; + + /** Per-label: labelWidth * sin(angle) — left-corner offset for the 45° parallelogram */ + labelSins: number[]; + + /** Per-label: labelWidth / 2 — half-extent for 0° and 90° hit bounds */ + halfWidths: number[]; + + /** Per-label: rightUpperCorner.x = targetX + cornerAnchorDX[i] */ + cornerAnchorDX: number[]; + + /** Per-label: rightUpperCorner.y = labelY + cornerAnchorDY[i] */ + cornerAnchorDY: number[]; + + /** Per-label: yMin90 = labelY + yMin90Offsets[i] */ + yMin90Offsets: number[]; + + /** Per-label: yMax90 = labelY + yMax90Offsets[i] */ + yMax90Offsets: number[]; +}; + +type ComputeGeometryInput = { + /** The ascent of the font */ + ascent: number; + + /** The descent of the font */ + descent: number; + + /** The sine of the angle */ + sinA: number; + + /** The angle in radians */ + angleRad: number; + + /** The widths of the labels */ + labelWidths: number[]; + + /** The padding of the labels */ + padding: number; +}; + +type ComputeGeometryFn = (input: ComputeGeometryInput) => LabelHitGeometry; + +type UseLabelHitTestingParams = { + font: SkFont | null | undefined; + truncatedLabels: string[]; + labelRotation: LabelRotation; + labelSkipInterval: number; + chartBottom: SharedValue; + + /** + * Chart-specific geometry factory. + * Receives font metrics, trig values, and per-label widths; returns the + * normalized geometry shape. Define as a module-level constant to keep + * the useMemo dependency stable. + */ + computeGeometry: ComputeGeometryFn; +}; + +/** + * Shared hook for x-axis label hit-testing in cartesian charts. + * + * Encapsulates label width measurement, angle conversion, pre-computed hit geometry, + * and the isCursorOverLabel / findLabelCursorX worklets — all of which are identical + * between bar and line chart except for how the hit geometry is computed. + * + * Chart-specific geometry (45° corner anchor offsets, 90° vertical bounds) is supplied + * via the `computeGeometry` callback, which should be a stable module-level constant. + */ +function useLabelHitTesting({font, truncatedLabels, labelRotation, labelSkipInterval, chartBottom, computeGeometry}: UseLabelHitTestingParams) { + const tickXPositions = useSharedValue([]); + + const labelWidths = useMemo(() => { + if (!font) { + return [] as number[]; + } + return truncatedLabels.map((label) => measureTextWidth(label, font)); + }, [font, truncatedLabels]); + + const angleRad = (Math.abs(labelRotation) * Math.PI) / 180; + + /** + * Pre-computed geometry for label hit-testing. + * All per-label arrays and trig values are resolved once per layout/rotation change + * rather than on every hover event. The `computeGeometry` callback supplies the + * chart-specific differences (bar vs. line anchor offsets). + */ + const labelHitGeometry = useMemo((): LabelHitGeometry | null => { + if (!font) { + return null; + } + const metrics = font.getMetrics(); + const ascent = Math.abs(metrics.ascent); + const descent = Math.abs(metrics.descent); + const sinA = Math.sin(angleRad); + const padding = variables.iconSizeExtraSmall / 2; + return computeGeometry({ascent, descent, sinA, angleRad, labelWidths, padding}); + }, [font, angleRad, labelWidths, computeGeometry]); + + /** + * Hit-tests whether the cursor is over the x-axis label at `activeIndex`. + * Supports 0°, ~45° (parallelogram), and 90° label orientations. + */ + const isCursorOverLabel = (args: HitTestArgs, activeIndex: number): boolean => { + 'worklet'; + + if (!labelHitGeometry || activeIndex % labelSkipInterval !== 0) { + return false; + } + + const {labelYOffset, iconSin, labelSins, halfWidths, cornerAnchorDX, cornerAnchorDY, yMin90Offsets, yMax90Offsets} = labelHitGeometry; + const padding = variables.iconSizeExtraSmall / 2; + const halfWidth = halfWidths.at(activeIndex) ?? 0; + const labelY = args.chartBottom + labelYOffset; + + let corners45: Array<{x: number; y: number}> | undefined; + if (angleRad > 0 && angleRad < DIAGONAL_ANGLE_RADIAN_THRESHOLD) { + const labelSin = labelSins.at(activeIndex) ?? 0; + const anchorDX = cornerAnchorDX.at(activeIndex) ?? 0; + const anchorDY = cornerAnchorDY.at(activeIndex) ?? 0; + const rightUpperCorner = {x: args.targetX + anchorDX, y: labelY + anchorDY}; + const rightLowerCorner = {x: rightUpperCorner.x + iconSin, y: rightUpperCorner.y + iconSin}; + const leftUpperCorner = {x: rightUpperCorner.x - labelSin, y: rightUpperCorner.y + labelSin}; + const leftLowerCorner = {x: rightLowerCorner.x - labelSin, y: rightLowerCorner.y + labelSin}; + corners45 = [rightUpperCorner, rightLowerCorner, leftLowerCorner, leftUpperCorner]; + } + + return isCursorOverChartLabel({ + cursorX: args.cursorX, + cursorY: args.cursorY, + targetX: args.targetX, + labelY, + angleRad, + halfWidth, + padding, + corners45, + yMin90: labelY + (yMin90Offsets.at(activeIndex) ?? 0), + yMax90: labelY + (yMax90Offsets.at(activeIndex) ?? 0), + }); + }; + + /** + * Scans every visible label's bounding box using its own tick X as the anchor. + * Returns that tick's X position when the cursor is inside, otherwise returns + * the raw cursor X unchanged. + * Used to correct Victory's nearest-point-by-X algorithm for rotated labels whose + * bounding boxes can extend past the midpoint to the adjacent tick. + */ + const findLabelCursorX = (cursorX: number, cursorY: number): number => { + 'worklet'; + + const positions = tickXPositions.get(); + const currentChartBottom = chartBottom.get(); + for (let i = 0; i < positions.length; i++) { + if (i % labelSkipInterval !== 0) { + continue; + } + const tickX = positions.at(i); + if (tickX === undefined) { + continue; + } + if (isCursorOverLabel({cursorX, cursorY, targetX: tickX, targetY: 0, chartBottom: currentChartBottom}, i)) { + return tickX; + } + } + return cursorX; + }; + + /** Updates the tick X positions from the chart's x scale. Call from `onScaleChange`. */ + const updateTickPositions = (xScale: Scale, dataLength: number) => { + tickXPositions.set(Array.from({length: dataLength}, (_, i) => xScale(i))); + }; + + return {isCursorOverLabel, findLabelCursorX, updateTickPositions}; +} + +export default useLabelHitTesting; +export type {ComputeGeometryFn, ComputeGeometryInput, LabelHitGeometry}; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index 72c7f008c49fd..c7654c2c41f8e 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -65,6 +65,12 @@ type PieSlice = { /** Index in the original unsorted data array, used to map back for tooltips */ originalIndex: number; + + /** Ordinal position in the processed slice list (0 = largest slice). */ + ordinalIndex: number; + + /** Position of the tooltip on label hover. */ + tooltipPosition: {x: number; y: number}; }; type LabelRotation = ValueOf; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils.ts index 07a2de4d7589b..2eec6d6617318 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils.ts @@ -1,6 +1,6 @@ import type {SkFont} from '@shopify/react-native-skia'; import colors from '@styles/theme/colors'; -import {ELLIPSIS, LABEL_PADDING, LABEL_ROTATIONS, SIN_45} from './constants'; +import {ELLIPSIS, LABEL_PADDING, LABEL_ROTATIONS, PIE_CHART_TOOLTIP_RADIUS_DISTANCE, SIN_45} from './constants'; import type {ChartDataPoint, LabelRotation, PieSlice} from './types'; /** @@ -152,7 +152,7 @@ function findSliceAtPosition(cursorX: number, cursorY: number, centerX: number, /** * Process raw data into pie chart slices sorted by absolute value descending. */ -function processDataIntoSlices(data: ChartDataPoint[], startAngle: number): PieSlice[] { +function processDataIntoSlices(data: ChartDataPoint[], startAngle: number, pieGeometry: {centerX: number; centerY: number; radius: number}): PieSlice[] { const total = data.reduce((sum, point) => sum + Math.abs(point.total), 0); if (total === 0) { return []; @@ -165,6 +165,9 @@ function processDataIntoSlices(data: ChartDataPoint[], startAngle: number): PieS (acc, slice, index) => { const fraction = slice.absTotal / total; const sweepAngle = fraction * 360; + const angle = acc.angle + sweepAngle / 2; + const tooltipX = pieGeometry.centerX + pieGeometry.radius * PIE_CHART_TOOLTIP_RADIUS_DISTANCE * Math.cos((angle * Math.PI) / 180); + const tooltipY = pieGeometry.centerY + pieGeometry.radius * PIE_CHART_TOOLTIP_RADIUS_DISTANCE * Math.sin((angle * Math.PI) / 180); acc.slices.push({ label: slice.label, value: slice.absTotal, @@ -173,6 +176,8 @@ function processDataIntoSlices(data: ChartDataPoint[], startAngle: number): PieS startAngle: acc.angle, endAngle: acc.angle + sweepAngle, originalIndex: slice.originalIndex, + ordinalIndex: index, + tooltipPosition: {x: tooltipX, y: tooltipY}, }); acc.angle += sweepAngle; return acc; @@ -283,6 +288,63 @@ function edgeMaxLabelWidth(edgeSpace: number, lineHeight: number, rotation: Labe } return Infinity; } +// Point-in-convex-polygon test using cross products +// Vertices in clockwise order: rightUpper -> rightLower -> leftLower -> leftUpper +function isCursorInSkewedLabel(cursorX: number, cursorY: number, corners: Array<{x: number; y: number}>): boolean { + 'worklet'; + + let sign = 0; + for (let i = 0; i < corners.length; i++) { + const a = corners.at(i); + const b = corners.at((i + 1) % corners.length); + if (a == null || b == null) { + continue; + } + const cross = (b.x - a.x) * (cursorY - a.y) - (b.y - a.y) * (cursorX - a.x); + if (cross !== 0) { + const crossSign = cross > 0 ? 1 : -1; + if (sign === 0) { + sign = crossSign; + } else if (crossSign !== sign) { + return false; + } + } + } + return true; +} + +/** Params for axis-aligned and 45° label hit-test; 90° uses yMin90/yMax90. */ +type ChartLabelHitTestParams = { + cursorX: number; + cursorY: number; + targetX: number; + labelY: number; + angleRad: number; + halfWidth: number; + padding: number; + /** For 45°: corners [rightUpper, rightLower, leftLower, leftUpper]. */ + corners45?: Array<{x: number; y: number}>; + /** For 90° vertical label: vertical bounds. */ + yMin90: number; + yMax90: number; +}; + +/** + * Shared hit-test for chart x-axis labels at 0°, 45°, or 90°. + * Used by BarChart and LineChart to detect cursor over rotated labels. + */ +function isCursorOverChartLabel({cursorX, cursorY, targetX, labelY, angleRad, halfWidth, padding, corners45, yMin90, yMax90}: ChartLabelHitTestParams): boolean { + 'worklet'; + + if (angleRad === 0) { + return cursorY >= labelY - padding && cursorY <= labelY + padding && cursorX >= targetX - halfWidth && cursorX <= targetX + halfWidth; + } + if (angleRad < 1 && corners45?.length === 4) { + return isCursorInSkewedLabel(cursorX, cursorY, corners45); + } + // 90° + return cursorX >= targetX - padding && cursorX <= targetX + padding && cursorY >= yMin90 && cursorY <= yMax90; +} export { getChartColor, @@ -302,4 +364,8 @@ export { labelOverhang, edgeLabelsFit, edgeMaxLabelWidth, + isCursorInSkewedLabel, + isCursorOverChartLabel, }; + +export type {ChartLabelHitTestParams}; diff --git a/src/components/ConnectToQuickbooksOnlineFlow/index.native.tsx b/src/components/ConnectToQuickbooksOnlineFlow/index.native.tsx index 653e1a1bf48e2..aebe60ac8b097 100644 --- a/src/components/ConnectToQuickbooksOnlineFlow/index.native.tsx +++ b/src/components/ConnectToQuickbooksOnlineFlow/index.native.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ConnectToQuickbooksOnlineFlowProps} from './types'; -const renderLoading = () => ; +const renderLoading = () => ; function ConnectToQuickbooksOnlineFlow({policyID}: ConnectToQuickbooksOnlineFlowProps) { const {translate} = useLocalize(); diff --git a/src/components/ConnectToXeroFlow/index.native.tsx b/src/components/ConnectToXeroFlow/index.native.tsx index 054155e786b77..b5a24da77f229 100644 --- a/src/components/ConnectToXeroFlow/index.native.tsx +++ b/src/components/ConnectToXeroFlow/index.native.tsx @@ -27,7 +27,7 @@ function ConnectToXeroFlow({policyID}: ConnectToXeroFlowProps) { const isUserValidated = account?.validated; const is2FAEnabled = account?.requiresTwoFactorAuth ?? false; - const renderLoading = () => ; + const renderLoading = () => ; const [isRequire2FAModalOpen, setIsRequire2FAModalOpen] = useState(false); useEffect(() => { diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 9c64311a1aeac..20e495670d138 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -5,10 +5,11 @@ import type {View} from 'react-native'; import useGeographicalStateAndCountryFromRoute from '@hooks/useGeographicalStateAndCountryFromRoute'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; type CountrySelectorProps = { @@ -78,9 +79,8 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange = () description={translate('common.country')} errorText={errorText} onPress={() => { - const activeRoute = Navigation.getActiveRoute(); didOpenCountrySelector.current = true; - Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.ADDRESS_COUNTRY.getRoute(countryCode ?? ''))); }} /> ); diff --git a/src/components/DatePicker/CalendarPicker/Day.tsx b/src/components/DatePicker/CalendarPicker/Day.tsx index 67ada6b3abf0e..3db884bb7fdc0 100644 --- a/src/components/DatePicker/CalendarPicker/Day.tsx +++ b/src/components/DatePicker/CalendarPicker/Day.tsx @@ -29,11 +29,11 @@ function Day({disabled, selected, pressed, hovered, children}: DayProps) { - {children} + {children} ); } diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 0364dc61c5ec9..b520650c8c74c 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,7 +1,6 @@ import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useRef, useState} from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; @@ -36,9 +35,6 @@ type CalendarPickerProps = { /** A function called when the date is selected */ onSelected?: (selectedDate: string) => void; - - /** Optional style override for the header container */ - headerContainerStyle?: StyleProp; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -60,7 +56,6 @@ function CalendarPicker({ onSelected, DayComponent = Day, selectableDates, - headerContainerStyle, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -180,14 +175,13 @@ function CalendarPicker({ const webOnlyMarginStyle = isSmallScreenWidth ? {} : styles.mh1; const calendarContainerStyle = isSmallScreenWidth ? [webOnlyMarginStyle, themeStyles.calendarBodyContainer] : [webOnlyMarginStyle, animatedStyle]; - const headerPaddingStyle = headerContainerStyle ?? themeStyles.ph5; const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); return ( {isLoading ? ( - + ) : ( <> diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index 022bc2047acc5..e30c50b128c1d 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -112,7 +112,7 @@ function MVCPFlatList({ const contentViewLength = contentView.childNodes.length; for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) { - const subview = contentView.childNodes[i] as HTMLElement; + const subview = contentView.childNodes[restProps.inverted ? contentViewLength - i - 1 : i] as HTMLElement; const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop; if (subviewOffset > scrollOffset) { prevFirstVisibleOffsetRef.current = subviewOffset; @@ -120,7 +120,7 @@ function MVCPFlatList({ break; } } - }, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal]); + }, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal, restProps.inverted]); const adjustForMaintainVisibleContentPosition = useCallback( (animated = true) => { diff --git a/src/components/FloatingCameraButton/BaseFloatingCameraButton.tsx b/src/components/FloatingCameraButton/BaseFloatingCameraButton.tsx index c72258a096cf2..e21ba453c89a8 100644 --- a/src/components/FloatingCameraButton/BaseFloatingCameraButton.tsx +++ b/src/components/FloatingCameraButton/BaseFloatingCameraButton.tsx @@ -13,7 +13,6 @@ import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import {generateReportID, getWorkspaceChats} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {startSpan} from '@libs/telemetry/activeSpans'; import variables from '@styles/variables'; import Tab from '@userActions/Tab'; import CONST from '@src/CONST'; @@ -67,10 +66,6 @@ function BaseFloatingCameraButton({icon}: BaseFloatingCameraButtonProps) { const quickActionReportID = policyChatForActivePolicy?.reportID ?? reportID; Tab.setSelectedTab(CONST.TAB.IOU_REQUEST_TYPE, CONST.IOU.REQUEST_TYPE.SCAN); - startSpan(CONST.TELEMETRY.SPAN_SCAN_SHORTCUT, { - name: CONST.TELEMETRY.SPAN_SCAN_SHORTCUT, - op: CONST.TELEMETRY.SPAN_SCAN_SHORTCUT, - }); startMoneyRequest(CONST.IOU.TYPE.CREATE, quickActionReportID, draftTransactionIDs, CONST.IOU.REQUEST_TYPE.SCAN, !!policyChatForActivePolicy?.reportID, undefined, true); }); }; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index ad0f0b671049c..36817b2c4659c 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -9,7 +9,6 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import useDebounceNonReactive from '@hooks/useDebounceNonReactive'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePrevious from '@hooks/usePrevious'; import {isSafari} from '@libs/Browser'; import {prepareValues} from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; @@ -130,14 +129,13 @@ function FormProvider({ const touchedInputs = useRef>({}); const [inputValues, setInputValues] = useState
(() => ({...draftValues})); const isLoadingDraftValues = isLoadingOnyxValue(draftValuesMetadata); - const prevIsLoadingDraftValues = usePrevious(isLoadingDraftValues); + const previousDraftValues = useRef(draftValues); + + if (!isLoadingDraftValues && draftValues !== previousDraftValues.current) { + previousDraftValues.current = draftValues; + setInputValues({...inputValues, ...draftValues}); + } - useEffect(() => { - if (isLoadingDraftValues || !prevIsLoadingDraftValues) { - return; - } - setInputValues({...draftValues}); - }, [isLoadingDraftValues, draftValues, prevIsLoadingDraftValues]); const [errors, setErrors] = useState({}); const hasServerError = useMemo(() => !!formState && !isEmptyObject(formState?.errors), [formState]); const {setIsBlurred} = useInputBlurActions(); diff --git a/src/components/FormHelpMessage.tsx b/src/components/FormHelpMessage.tsx index cf96b6ace7f5c..e4e40bec393b0 100644 --- a/src/components/FormHelpMessage.tsx +++ b/src/components/FormHelpMessage.tsx @@ -2,6 +2,7 @@ import isEmpty from 'lodash/isEmpty'; import React, {useMemo} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -11,7 +12,6 @@ import CONST from '@src/CONST'; import Icon from './Icon'; import RenderHTML from './RenderHTML'; import Text from './Text'; -import useFormHelpMessageAccessibilityAnnouncement from './utils/useFormHelpMessageAccessibilityAnnouncement'; type FormHelpMessageProps = { /** Error or hint text. Ignored when children is not empty */ @@ -69,7 +69,7 @@ function FormHelpMessage({ return `${replacedText}`; }, [isError, message, shouldRenderMessageAsHTML]); - useFormHelpMessageAccessibilityAnnouncement(message, shouldAnnounceError); + useAccessibilityAnnouncement(message, shouldAnnounceError); const errorIconLabel = isError && shouldShowRedDotIndicator ? CONST.ACCESSIBILITY_LABELS.ERROR : undefined; diff --git a/src/components/FullScreenLoaderContext.tsx b/src/components/FullScreenLoaderContext.tsx index 23831c139360b..7082c9ed82a99 100644 --- a/src/components/FullScreenLoaderContext.tsx +++ b/src/components/FullScreenLoaderContext.tsx @@ -50,7 +50,7 @@ function FullScreenLoaderContextProvider({children}: FullScreenLoaderContextProv {children} - {isLoaderVisible && } + {isLoaderVisible && } ); diff --git a/src/components/FullscreenLoadingIndicator.tsx b/src/components/FullscreenLoadingIndicator.tsx index 6a3fe27edfebd..6702b81628d4d 100644 --- a/src/components/FullscreenLoadingIndicator.tsx +++ b/src/components/FullscreenLoadingIndicator.tsx @@ -65,6 +65,7 @@ function FullScreenLoadingIndicator({ size={iconSize} testID={testID} extraLoadingContext={extraLoadingContext} + reasonAttributes={reasonAttributes} /> {showGoBackButton && shouldUseGoBackButton && ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx index ddaab1a55994b..85b1b0d8f723e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/MentionReportContext.tsx @@ -3,6 +3,7 @@ import {createContext} from 'react'; type MentionReportContextProps = { currentReportID: string | undefined; exactlyMatch?: boolean; + policyID?: string; }; const MentionReportContext = createContext({ diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx index c0a17325bb898..61356097deee9 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionReportRenderer/index.tsx @@ -23,7 +23,7 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const htmlAttributeReportID = tnode.attributes.reportid; - const {currentReportID: currentReportIDContext, exactlyMatch} = useContext(MentionReportContext); + const {currentReportID: currentReportIDContext, exactlyMatch, policyID} = useContext(MentionReportContext); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const {currentReportID} = useCurrentReportIDState(); @@ -32,9 +32,12 @@ function MentionReportRenderer({style, tnode, TDefaultRenderer, ...defaultRender const [currentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentReportIDValue}`); // When we invite someone to a room they don't have the policy object, but we still want them to be able to see and click on report mentions, so we only check if the policyID in the report is from a workspace - const isGroupPolicyReport = useMemo(() => currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE, [currentReport]); + const isGroupPolicyReport = useMemo( + () => (!!currentReport && !isEmptyObject(currentReport) && !!currentReport.policyID && currentReport.policyID !== CONST.POLICY.ID_FAKE) || !!policyID, + [currentReport, policyID], + ); - const mentionDetails = getReportMentionDetails(htmlAttributeReportID, currentReport, reports, tnode); + const mentionDetails = getReportMentionDetails(htmlAttributeReportID, currentReport, reports, tnode, policyID); if (!mentionDetails) { return null; } diff --git a/src/components/HighlightableMenuItem.tsx b/src/components/HighlightableMenuItem.tsx index 347a2298e8d2d..8a54d5270cc87 100644 --- a/src/components/HighlightableMenuItem.tsx +++ b/src/components/HighlightableMenuItem.tsx @@ -19,8 +19,8 @@ function HighlightableMenuItem({wrapperStyle, highlighted, ...restOfProps}: Prop const flattenedWrapperStyles = StyleSheet.flatten(wrapperStyle); const animatedHighlightStyle = useAnimatedHighlightStyle({ shouldHighlight: highlighted ?? false, - height: flattenedWrapperStyles?.height ? Number(flattenedWrapperStyles.height) : styles.sectionMenuItem.height, - borderRadius: flattenedWrapperStyles?.borderRadius ? Number(flattenedWrapperStyles.borderRadius) : styles.sectionMenuItem.borderRadius, + height: flattenedWrapperStyles?.height ? Number(flattenedWrapperStyles.height) : styles.sectionMenuItem(true).height, + borderRadius: flattenedWrapperStyles?.borderRadius ? Number(flattenedWrapperStyles.borderRadius) : styles.sectionMenuItem(true).borderRadius, highlightColor: theme.messageHighlightBG, highlightEndDelay: CONST.ANIMATED_HIGHLIGHT_WORKSPACE_FEATURE_ITEM_END_DELAY, highlightEndDuration: CONST.ANIMATED_HIGHLIGHT_WORKSPACE_FEATURE_ITEM_END_DURATION, diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index ac51f4f4ceecf..d595fd80213d2 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -48,6 +48,7 @@ import Download from '@assets/images/download.svg'; import DragAndDrop from '@assets/images/drag-and-drop.svg'; import DragHandles from '@assets/images/drag-handles.svg'; import Emoji from '@assets/images/emoji.svg'; +import EnvelopeOpenStar from '@assets/images/envelope-open-star.svg'; import EReceiptIcon from '@assets/images/eReceiptIcon.svg'; import Exclamation from '@assets/images/exclamation.svg'; import Exit from '@assets/images/exit.svg'; @@ -213,6 +214,7 @@ export { DragHandles, EReceiptIcon, Emoji, + EnvelopeOpenStar, ExpenseCopy, Exclamation, Exit, diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index ffcc6da25634a..e8e51555cdf6a 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -79,6 +79,7 @@ import Emoji from '@assets/images/emoji.svg'; import Lightbulb from '@assets/images/emojiCategoryIcons/light-bulb.svg'; import EmptyStateRoutePending from '@assets/images/emptystate__routepending.svg'; import EmptyStateSpyPigeon from '@assets/images/emptystate__spy-pigeon.svg'; +import EnvelopeOpenStar from '@assets/images/envelope-open-star.svg'; import EReceiptIcon from '@assets/images/eReceiptIcon.svg'; import Exclamation from '@assets/images/exclamation.svg'; import Exit from '@assets/images/exit.svg'; @@ -315,6 +316,7 @@ const Expensicons = { DragHandles, EReceiptIcon, Emoji, + EnvelopeOpenStar, EmptyStateRoutePending, ExpenseCopy, Exclamation, diff --git a/src/components/Image/BaseImage.tsx b/src/components/Image/BaseImage.tsx index 7a20a332cb3dd..b8324912afef8 100644 --- a/src/components/Image/BaseImage.tsx +++ b/src/components/Image/BaseImage.tsx @@ -2,11 +2,15 @@ import {Image as ExpoImage} from 'expo-image'; import type {ImageLoadEventData} from 'expo-image'; import React, {useCallback, useContext, useEffect} from 'react'; import type {AttachmentSource} from '@components/Attachments/types'; +import useCachedImageSource from '@hooks/useCachedImageSource'; import getImageRecyclingKey from '@libs/getImageRecyclingKey'; import {AttachmentStateContext} from '@pages/media/AttachmentModalScreen/AttachmentModalBaseContent/AttachmentStateContextProvider'; import type {BaseImageProps} from './types'; function BaseImage({onLoad, onLoadStart, source, ...props}: BaseImageProps) { + const cachedSource = useCachedImageSource(typeof source === 'object' && !Array.isArray(source) ? source : undefined); + const resolvedSource = cachedSource !== undefined ? cachedSource : source; + const {setAttachmentLoaded, isAttachmentLoaded} = useContext(AttachmentStateContext); useEffect(() => { if (isAttachmentLoaded?.(source as AttachmentSource)) { @@ -43,7 +47,7 @@ function BaseImage({onLoad, onLoadStart, source, ...props}: BaseImageProps) { ; + /** Reason attributes for skeleton span telemetry */ + reasonAttributes?: SkeletonSpanReasonAttributes; + /** Event for when the image begins loading */ onLoadStart?: () => void; diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index afde73cf29a51..71ed1df43cb4f 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -2,6 +2,7 @@ import React, {useMemo} from 'react'; import type {ImageResizeMode, ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import CONST from '@src/CONST'; import type {FullScreenLoadingIndicatorIconSize} from './FullscreenLoadingIndicator'; import RESIZE_MODES from './Image/resizeModes'; @@ -49,6 +50,9 @@ type ImageWithSizeCalculationProps = { /** The resize mode of the image */ resizeMode?: ImageResizeMode; + + /** Reason attributes for skeleton span telemetry */ + reasonAttributes?: SkeletonSpanReasonAttributes; }; /** @@ -69,6 +73,7 @@ function ImageWithSizeCalculation({ loadingIndicatorStyles, onLoad, resizeMode, + reasonAttributes, }: ImageWithSizeCalculationProps) { const styles = useThemeStyles(); @@ -98,6 +103,7 @@ function ImageWithSizeCalculation({ objectPosition={objectPosition} loadingIconSize={loadingIconSize} loadingIndicatorStyles={loadingIndicatorStyles} + reasonAttributes={reasonAttributes} /> ); } diff --git a/src/components/InteractiveStepSubHeader.tsx b/src/components/InteractiveStepSubHeader.tsx index bb94b38c370b4..3481aa35427f7 100644 --- a/src/components/InteractiveStepSubHeader.tsx +++ b/src/components/InteractiveStepSubHeader.tsx @@ -3,7 +3,6 @@ import React, {useImperativeHandle, useState} from 'react'; import type {ViewStyle} from 'react-native'; import {View} from 'react-native'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import colors from '@styles/theme/colors'; import variables from '@styles/variables'; @@ -42,7 +41,6 @@ const MIN_AMOUNT_OF_STEPS = 2; function InteractiveStepSubHeader({stepNames, startStepIndex = 0, onStepSelected, ref}: InteractiveStepSubHeaderProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); const containerWidthStyle: ViewStyle = stepNames.length < MIN_AMOUNT_FOR_EXPANDING ? styles.mnw60 : styles.mnw100; if (stepNames.length < MIN_AMOUNT_OF_STEPS) { @@ -72,8 +70,8 @@ function InteractiveStepSubHeader({stepNames, startStepIndex = 0, onStepSelected return ( {stepNames.map((stepName, index) => { const isCompletedStep = currentStep > index; diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index 0e794a1187eca..57682109a7c2b 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -200,7 +200,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio itemOneTransactionThreadReport?.reportID, ); - const iouReportIDOfLastAction = getIOUReportIDOfLastAction(item, visibleReportActionsData, lastAction); + const iouReportIDOfLastAction = getIOUReportIDOfLastAction(item, itemReportNameValuePairs?.private_isArchived, visibleReportActionsData, lastAction); const itemIouReportReportActions = iouReportIDOfLastAction ? reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportIDOfLastAction}`] : undefined; const lastReportActionTransactionID = isMoneyRequestAction(lastAction) ? (getOriginalMessage(lastAction)?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID) : CONST.DEFAULT_NUMBER_ID; diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 5102cb35bf3e5..1165c5da5e505 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -1,6 +1,7 @@ import React, {useMemo, useRef, useState} from 'react'; import type {GestureResponderEvent, ViewStyle} from 'react-native'; import {StyleSheet, View} from 'react-native'; +import Badge from '@components/Badge'; import DisplayNames from '@components/DisplayNames'; import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; @@ -12,6 +13,7 @@ import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -55,6 +57,7 @@ function OptionRowLHN({ testID, conciergeReportID, }: OptionRowLHNProps) { + const {isProduction} = useEnvironment(); const theme = useTheme(); const styles = useThemeStyles(); const popoverAnchor = useRef(null); @@ -158,6 +161,13 @@ function OptionRowLHN({ } const brickRoadIndicator = optionItem.brickRoadIndicator; + const actionBadgeText = !isProduction && optionItem.actionBadge ? translate(`common.actionBadge.${optionItem.actionBadge}`) : ''; + let accessibilityLabelForBadge = ''; + if (brickRoadIndicator) { + accessibilityLabelForBadge = `. ${translate('common.yourReviewIsRequired')}, ${actionBadgeText}`; + } else if (optionItem.isPinned) { + accessibilityLabelForBadge = `. ${translate('common.pinned')}`; + } const textStyle = isOptionFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; const textUnreadStyle = shouldUseBoldText(optionItem) ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; const displayNameStyle = [styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, textUnreadStyle, styles.flexShrink0, style]; @@ -293,7 +303,7 @@ function OptionRowLHN({ (hovered || isContextMenuActive) && !isOptionFocused ? styles.sidebarLinkHover : null, ]} role={CONST.ROLE.BUTTON} - accessibilityLabel={`${translate('accessibilityHints.navigatesToChat')} ${optionItem.text}. ${optionItem.isUnread ? `${translate('common.unread')}.` : ''} ${optionItem.alternateText}${brickRoadIndicator ? `. ${translate('common.yourReviewIsRequired')}` : ''}`} + accessibilityLabel={`${translate('accessibilityHints.navigatesToChat')} ${optionItem.text}. ${optionItem.isUnread ? `${translate('common.unread')}.` : ''} ${optionItem.alternateText}${accessibilityLabelForBadge}`} onLayout={onLayout} needsOffscreenAlphaCompositing={(optionItem?.icons?.length ?? 0) >= 2} sentryLabel={CONST.SENTRY_LABEL.LHN.OPTION_ROW} @@ -376,25 +386,42 @@ function OptionRowLHN({ ) : null} {brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( - + {actionBadgeText ? ( + + ) : ( + + )} )} - {brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO && ( - - - - )} + ) : ( + + + + ))} {hasDraftComment && !!optionItem.isAllowedToComment && ( )} - {!brickRoadIndicator && !!optionItem.isPinned && ( - - + + + ) : ( + - - )} + ))} ); diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index 2467c721d31f0..afaaee65c4098 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -113,6 +113,7 @@ function OptionRowLHNData({ }, [ fullReport, reportAttributes?.brickRoadStatus, + reportAttributes?.actionBadge, reportAttributes?.reportName, areReportErrorsEqual, oneTransactionThreadReport, diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index cf569fa23bee9..b43b0e6ecb2fd 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -107,6 +107,9 @@ type MagicCodeInputProps = { /** Reference to the outer element */ ref?: ForwardedRef; + + /** Whether to mask the input characters (display as dots) */ + secureTextEntry?: boolean; }; type MagicCodeInputHandle = { @@ -157,6 +160,7 @@ function MagicCodeInput({ testID = '', accessibilityLabel, ref, + secureTextEntry = false, }: MagicCodeInputProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -502,6 +506,7 @@ function MagicCodeInput({ {getInputPlaceholderSlots(maxLength).map((index) => { const char = decomposeString(value, maxLength).at(index)?.trim() ?? ''; + const displayChar = secureTextEntry && char ? '•' : char; const cursorMargin = char ? {marginLeft: 2} : {}; const isFocused = focusedIndex === index; @@ -521,7 +526,7 @@ function MagicCodeInput({ ]} > - {char} + {displayChar} {isFocused && !isDisableKeyboard && ( { @@ -41,6 +47,7 @@ function MapView({ref, ...props}: MapViewProps) { } > diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index ae9ac11f917e7..b4d71df7d8e1a 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -18,6 +18,7 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import getButtonState from '@libs/getButtonState'; import mergeRefs from '@libs/mergeRefs'; import Parser from '@libs/Parser'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import TextWithEmojiFragment from '@pages/inbox/report/comment/TextWithEmojiFragment'; import {showContextMenu} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; @@ -597,6 +598,9 @@ function MenuItem({ const isCompact = viewMode === CONST.OPTION_MODE.COMPACT; const isDeleted = style && Array.isArray(style) ? style.includes(styles.offlineFeedbackDeleted) : false; const descriptionVerticalMargin = shouldShowDescriptionOnTop ? styles.mb1 : styles.mt1; + const menuItemLoadingReasonAttributes: SkeletonSpanReasonAttributes = { + context: 'MenuItem', + }; const defaultAccessibilityLabel = (shouldShowDescriptionOnTop ? [description, title] : [title, description]).filter(Boolean).join(', '); const combinedTitleTextStyle = StyleUtils.combineStyles( @@ -869,7 +873,10 @@ function MenuItem({ additionalStyles={additionalIconStyles} /> ) : ( - + ))} {!!icon && iconType === CONST.ICON_TYPE_WORKSPACE && ( (); const [requestType, setRequestType] = useState(); + const [selectedVBBAToPayFromHoldMenu, setSelectedVBBAToPayFromHoldMenu] = useState(undefined); const canAllowSettlement = hasUpdatedTotal(moneyRequestReport, policy); const policyType = policy?.type; const connectedIntegration = getValidConnectedIntegration(policy); @@ -620,6 +638,8 @@ function MoneyReportHeader({ const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyRequestReport?.reportID); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {isAccountLocked} = useLockedAccountState(); + const {showLockedAccountModal} = useLockedAccountActions(); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); const kycWallRef = useContext(KYCWallContext); const [betas] = useOnyx(ONYXKEYS.BETAS); @@ -630,6 +650,7 @@ function MoneyReportHeader({ const isChatReportDM = isDM(chatReport); const existingB2BInvoiceReport = useParticipantsInvoiceReport(activePolicyID, CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, chatReport?.policyID); + const isSelectionModePaymentRef = useRef(false); const confirmPayment = useCallback( ({paymentType: type, payAsBusiness, methodID, paymentMethod}: PaymentActionParams) => { if (!type || !chatReport) { @@ -637,9 +658,11 @@ function MoneyReportHeader({ } setPaymentType(type); setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); + const isFromSelectionMode = isSelectionModePaymentRef.current; if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else if (isAnyTransactionOnHold) { + setSelectedVBBAToPayFromHoldMenu(type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); if (getPlatform() === CONST.PLATFORM.IOS) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => setIsHoldMenuVisible(true)); @@ -647,7 +670,9 @@ function MoneyReportHeader({ setIsHoldMenuVisible(true); } } else if (isInvoiceReport) { - startAnimation(); + if (!isFromSelectionMode) { + startAnimation(); + } payInvoice({ paymentMethodType: type, chatReport, @@ -664,8 +689,13 @@ function MoneyReportHeader({ betas, isSelfTourViewed, }); + if (isFromSelectionMode) { + clearSelectedTransactions(true); + } } else { - startAnimation(); + if (!isFromSelectionMode) { + startAnimation(); + } payMoneyRequest({ paymentType: type, chatReport, @@ -679,6 +709,7 @@ function MoneyReportHeader({ isSelfTourViewed, userBillingGraceEndPeriods, amountOwed, + methodID: type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined, }); if (currentSearchQueryJSON && !isOffline) { search({ @@ -690,6 +721,9 @@ function MoneyReportHeader({ isLoading: !!currentSearchResults?.search?.isLoading, }); } + if (isFromSelectionMode) { + clearSelectedTransactions(true); + } } }, [ @@ -715,11 +749,19 @@ function MoneyReportHeader({ betas, isSelfTourViewed, userBillingGraceEndPeriods, + clearSelectedTransactions, amountOwed, ], ); - const showDWEModal = async () => { + useEffect(() => { + if (selectedTransactionIDs.length !== 0) { + return; + } + isSelectionModePaymentRef.current = false; + }, [selectedTransactionIDs.length]); + + const showDWEModal = useCallback(async () => { const result = await showConfirmModal({ confirmText: translate('customApprovalWorkflow.goToExpensifyClassic'), title: translate('customApprovalWorkflow.title'), @@ -730,35 +772,111 @@ function MoneyReportHeader({ if (result.action === ModalActions.CONFIRM) { openOldDotLink(CONST.OLDDOT_URLS.INBOX); } - }; + }, [showConfirmModal, translate]); - const confirmApproval = () => { - if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { - showDWEModal(); - return; - } - setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); - if (isDelegateAccessRestricted) { - showDelegateNoAccessModal(); - } else if (isAnyTransactionOnHold) { - setIsHoldMenuVisible(true); - } else { - startApprovedAnimation(); - approveMoneyRequest({ - expenseReport: moneyRequestReport, - policy, - currentUserAccountIDParam: accountID, - currentUserEmailParam: email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - expenseReportCurrentNextStepDeprecated: nextStep, - betas, - userBillingGraceEndPeriods, - amountOwed, - full: true, - }); - } - }; + const confirmApproval = useCallback( + (skipAnimation = false) => { + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { + showDWEModal(); + return; + } + setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); + if (isDelegateAccessRestricted) { + showDelegateNoAccessModal(); + } else if (isAnyTransactionOnHold) { + setIsHoldMenuVisible(true); + } else { + if (!skipAnimation) { + startApprovedAnimation(); + } + approveMoneyRequest({ + expenseReport: moneyRequestReport, + policy, + currentUserAccountIDParam: accountID, + currentUserEmailParam: email ?? '', + hasViolations, + isASAPSubmitBetaEnabled, + expenseReportCurrentNextStepDeprecated: nextStep, + betas, + userBillingGraceEndPeriods, + amountOwed, + full: true, + }); + if (skipAnimation) { + clearSelectedTransactions(true); + } + } + }, + [ + policy, + isDEWBetaEnabled, + showDWEModal, + isDelegateAccessRestricted, + showDelegateNoAccessModal, + isAnyTransactionOnHold, + startApprovedAnimation, + moneyRequestReport, + accountID, + email, + hasViolations, + isASAPSubmitBetaEnabled, + nextStep, + betas, + userBillingGraceEndPeriods, + amountOwed, + clearSelectedTransactions, + ], + ); + + const handleSubmitReport = useCallback( + (skipAnimation = false) => { + if (!moneyRequestReport || shouldBlockSubmit) { + return; + } + if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { + showDWEModal(); + return; + } + if (!skipAnimation) { + startSubmittingAnimation(); + } + submitReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, userBillingGraceEndPeriods, amountOwed); + if (currentSearchQueryJSON && !isOffline) { + search({ + searchKey: currentSearchKey, + shouldCalculateTotals, + offset: 0, + queryJSON: currentSearchQueryJSON, + isOffline, + isLoading: !!currentSearchResults?.search?.isLoading, + }); + } + if (skipAnimation) { + clearSelectedTransactions(true); + } + }, + [ + moneyRequestReport, + shouldBlockSubmit, + policy, + isDEWBetaEnabled, + showDWEModal, + startSubmittingAnimation, + accountID, + email, + hasViolations, + isASAPSubmitBetaEnabled, + nextStep, + userBillingGraceEndPeriods, + amountOwed, + currentSearchQueryJSON, + isOffline, + currentSearchKey, + shouldCalculateTotals, + currentSearchResults?.search?.isLoading, + clearSelectedTransactions, + ], + ); const markAsCash = useCallback(() => { if (!requestParentReportAction) { @@ -773,7 +891,10 @@ function MoneyReportHeader({ }, [iouTransactionID, requestParentReportAction, transactionThreadReport?.reportID, transactionViolations]); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const targetPolicyTags = defaultExpensePolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy.id}`] ?? {}) : {}; + const targetPolicyTags = useMemo( + () => (defaultExpensePolicy ? (allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy.id}`] ?? {}) : {}), + [defaultExpensePolicy, allPolicyTags], + ); const duplicateExpenseTransaction = useCallback( (transactionList: OnyxTypes.Transaction[]) => { @@ -944,7 +1065,7 @@ function MoneyReportHeader({ setIsHoldEducationalModalVisible(false); setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !shouldFailAllRequests); if (requestParentReportAction) { - changeMoneyRequestHoldStatus(requestParentReportAction); + changeMoneyRequestHoldStatus(requestParentReportAction, transaction); } }; @@ -952,7 +1073,7 @@ function MoneyReportHeader({ if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD) { dismissRejectUseExplanation(); if (requestParentReportAction) { - changeMoneyRequestHoldStatus(requestParentReportAction); + changeMoneyRequestHoldStatus(requestParentReportAction, transaction); } } else if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK) { dismissRejectUseExplanation(); @@ -1018,7 +1139,7 @@ function MoneyReportHeader({ if (exportModalStatus === CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION) { exportToIntegration(moneyRequestReport?.reportID, connectedIntegration); } else if (exportModalStatus === CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED) { - markAsManuallyExported(moneyRequestReport?.reportID, connectedIntegration); + markAsManuallyExported([moneyRequestReport?.reportID ?? CONST.DEFAULT_NUMBER_ID], connectedIntegration); } }, [connectedIntegration, exportModalStatus, moneyRequestReport?.reportID]); @@ -1041,6 +1162,51 @@ function MoneyReportHeader({ onlyShowPayElsewhere, }); + const activeAdminPolicies = useActiveAdminPolicies(); + + const workspacePolicyOptions = useMemo(() => { + if (!isIOUReportUtil(moneyRequestReport)) { + return []; + } + + const hasPersonalPaymentOption = paymentButtonOptions.some((opt) => opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY); + if (!hasPersonalPaymentOption || !activeAdminPolicies.length) { + return []; + } + + const canUseBusinessBankAccount = moneyRequestReport?.reportID && !hasRequestFromCurrentAccount(moneyRequestReport.reportID, accountID ?? CONST.DEFAULT_NUMBER_ID); + if (!canUseBusinessBankAccount) { + return []; + } + + return sortPoliciesByName(activeAdminPolicies, localeCompare); + }, [moneyRequestReport, paymentButtonOptions, activeAdminPolicies, accountID, localeCompare]); + + const buildPaymentSubMenuItems = useCallback( + (onWorkspaceSelected: (workspacePolicy: OnyxTypes.Policy) => void): PopoverMenuItem[] => { + if (!workspacePolicyOptions.length) { + return Object.values(paymentButtonOptions); + } + + const result: PopoverMenuItem[] = []; + for (const opt of Object.values(paymentButtonOptions)) { + result.push(opt); + if (opt.value === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + for (const wp of workspacePolicyOptions) { + result.push({ + text: translate('iou.payWithPolicy', truncate(wp.name, {length: CONST.ADDITIONAL_ALLOWED_CHARACTERS}), ''), + icon: expensifyIcons.Building, + onSelected: () => onWorkspaceSelected(wp), + }); + } + } + } + + return result; + }, + [workspacePolicyOptions, paymentButtonOptions, translate, expensifyIcons.Building], + ); + const addExpenseDropdownOptions = useMemo( () => getAddExpenseDropdownOptions({ @@ -1124,7 +1290,7 @@ function MoneyReportHeader({ setExportModalStatus(CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED); return; } - markAsManuallyExported(moneyRequestReport?.reportID, connectedIntegration); + markAsManuallyExported([moneyRequestReport?.reportID ?? CONST.DEFAULT_NUMBER_ID], connectedIntegration); }, }, }; @@ -1160,27 +1326,7 @@ function MoneyReportHeader({ { - if (!moneyRequestReport || shouldBlockSubmit) { - return; - } - if (hasDynamicExternalWorkflow(policy) && !isDEWBetaEnabled) { - showDWEModal(); - return; - } - startSubmittingAnimation(); - submitReport(moneyRequestReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, nextStep, userBillingGraceEndPeriods, amountOwed); - if (currentSearchQueryJSON && !isOffline) { - search({ - searchKey: currentSearchKey, - shouldCalculateTotals, - offset: 0, - queryJSON: currentSearchQueryJSON, - isOffline, - isLoading: !!currentSearchResults?.search?.isLoading, - }); - } - }} + onPress={() => handleSubmitReport()} isSubmittingAnimationRunning={isSubmittingAnimationRunning} onAnimationFinish={stopAnimation} isDisabled={shouldBlockSubmit} @@ -1189,7 +1335,7 @@ function MoneyReportHeader({ [CONST.REPORT.PRIMARY_ACTIONS.APPROVE]: (