From ee037acb0aa5735d342671341cf4dad8b3ae7966 Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Wed, 19 Nov 2025 18:31:14 +0100 Subject: [PATCH 1/6] fix: remove crypto compare fallback (#22772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** remove crypto compare fallback ## **Changelog** CHANGELOG entry: remove crypto compare fallback ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds `NetworkController:getState` delegation to `CurrencyRateController` messenger and updates MetaMask controller dependencies. > > - **Engine**: > - `app/core/Engine/messengers/currency-rate-controller-messenger/currency-rate-controller-messenger.ts`: Delegate `NetworkController:getState` in addition to `NetworkController:getNetworkClientById` for the `CurrencyRateController` messenger. > - **Dependencies**: > - Bump `@metamask/assets-controllers` to `^89.0.1`. > - Bump `@metamask/bridge-controller` and `@metamask/bridge-status-controller` to `^61.0.0`. > - Align related peer deps (e.g., `@metamask/core-backend` `^4.1.0`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 60d9a61f4e4beefd33764d49dde2abeb4869554c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../currency-rate-controller-messenger.ts | 5 ++- package.json | 6 ++-- yarn.lock | 36 +++++++++---------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/app/core/Engine/messengers/currency-rate-controller-messenger/currency-rate-controller-messenger.ts b/app/core/Engine/messengers/currency-rate-controller-messenger/currency-rate-controller-messenger.ts index be769ea074b..048d758f6ff 100644 --- a/app/core/Engine/messengers/currency-rate-controller-messenger/currency-rate-controller-messenger.ts +++ b/app/core/Engine/messengers/currency-rate-controller-messenger/currency-rate-controller-messenger.ts @@ -25,7 +25,10 @@ export function getCurrencyRateControllerMessenger( parent: rootExtendedMessenger, }); rootExtendedMessenger.delegate({ - actions: ['NetworkController:getNetworkClientById'], + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + ], events: [], messenger, }); diff --git a/package.json b/package.json index a9fc058119b..c2f20084927 100644 --- a/package.json +++ b/package.json @@ -196,11 +196,11 @@ "@metamask/address-book-controller": "^7.0.0", "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^8.0.0", - "@metamask/assets-controllers": "^88.0.0", + "@metamask/assets-controllers": "^89.0.1", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.6.0", - "@metamask/bridge-controller": "^60.1.0", - "@metamask/bridge-status-controller": "^60.1.0", + "@metamask/bridge-controller": "^61.0.0", + "@metamask/bridge-status-controller": "^61.0.0", "@metamask/chain-agnostic-permission": "^1.2.2", "@metamask/composable-controller": "^12.0.0", "@metamask/controller-utils": "^11.11.0", diff --git a/yarn.lock b/yarn.lock index 0567bc7df99..2658abf817d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6950,9 +6950,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^88.0.0": - version: 88.0.0 - resolution: "@metamask/assets-controllers@npm:88.0.0" +"@metamask/assets-controllers@npm:^89.0.1": + version: 89.0.1 + resolution: "@metamask/assets-controllers@npm:89.0.1" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -6988,7 +6988,7 @@ __metadata: "@metamask/account-tree-controller": ^3.0.0 "@metamask/accounts-controller": ^34.0.0 "@metamask/approval-controller": ^8.0.0 - "@metamask/core-backend": ^4.0.0 + "@metamask/core-backend": ^4.1.0 "@metamask/keyring-controller": ^24.0.0 "@metamask/network-controller": ^25.0.0 "@metamask/permission-controller": ^12.0.0 @@ -6998,7 +6998,7 @@ __metadata: "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^61.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/edb9c932a0fc64c8dd9fd437a38aacb4b7e1a3c0e22619b1ef8fc2d9ef5a8d48830d28c79f7bbb557663bb839bd8dac0b40466777af68bf1993e2bbfa7f283e1 + checksum: 10/ba1652a8ba929b4dde8d2dfb953cf3f680d358a5d3cfda739b956e3e86e3c139131405f3565d04d8e0082801fd6c855efd574e257b2e734f635da89fe15cf41b languageName: node linkType: hard @@ -7071,9 +7071,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^60.1.0": - version: 60.1.0 - resolution: "@metamask/bridge-controller@npm:60.1.0" +"@metamask/bridge-controller@npm:^61.0.0": + version: 61.0.0 + resolution: "@metamask/bridge-controller@npm:61.0.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7094,18 +7094,18 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^34.0.0 - "@metamask/assets-controllers": ^88.0.0 + "@metamask/assets-controllers": ^89.0.0 "@metamask/network-controller": ^25.0.0 "@metamask/remote-feature-flag-controller": ^2.0.0 "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^61.0.0 - checksum: 10/f308a325d8c13bf21b5b4cd3823660312a4bc2721f635f533baee64fc6141979a83a5de9cc4e5be99a7735e838b1e3520bc5b26f4759198ca3eff16d8422496a + checksum: 10/f1e8f4e6ec44130711a1ebf2bef082cd13b63b2e5043ca7b3a37eabce694913d2ca512f9178dc9e3925ec6eff28bdd75c7b52e78ba50f2c9b502f2a47a38f6bc languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^60.1.0": - version: 60.1.0 - resolution: "@metamask/bridge-status-controller@npm:60.1.0" +"@metamask/bridge-status-controller@npm:^61.0.0": + version: 61.0.0 + resolution: "@metamask/bridge-status-controller@npm:61.0.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.15.0" @@ -7116,12 +7116,12 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@metamask/accounts-controller": ^34.0.0 - "@metamask/bridge-controller": ^60.0.0 + "@metamask/bridge-controller": ^61.0.0 "@metamask/gas-fee-controller": ^25.0.0 "@metamask/network-controller": ^25.0.0 "@metamask/snaps-controllers": ^14.0.0 "@metamask/transaction-controller": ^61.0.0 - checksum: 10/94552840a28eae74465db95a01033fb19d5db190ce58359a1bad0719cd735db3e271581f27682102573dc457a7ce2516b76c0d219313f3a29c57a34674a2246d + checksum: 10/5f84b9c46b57079a3d39dd570d5b9e1a0c475435e05c6ec183fd832e563ed362df927ce83c88f9026dbd3ccd279309686e900e15a2b5f17dcff2ebb39959b7b5 languageName: node linkType: hard @@ -34326,12 +34326,12 @@ __metadata: "@metamask/address-book-controller": "npm:^7.0.0" "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^8.0.0" - "@metamask/assets-controllers": "npm:^88.0.0" + "@metamask/assets-controllers": "npm:^89.0.1" "@metamask/auto-changelog": "npm:^5.1.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.6.0" - "@metamask/bridge-controller": "npm:^60.1.0" - "@metamask/bridge-status-controller": "npm:^60.1.0" + "@metamask/bridge-controller": "npm:^61.0.0" + "@metamask/bridge-status-controller": "npm:^61.0.0" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/build-utils": "npm:^3.0.0" "@metamask/chain-agnostic-permission": "npm:^1.2.2" From cfa5ba074b9ae41fcf169de5661a67dbdb6e0ec1 Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:51:21 +0100 Subject: [PATCH 2/6] feat: Add skeleton loading for the Send flow (#22853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Replaces blank page with a loader for the Send flow with skeleton loading ## **Changelog** CHANGELOG entry: Replaces blank page with a loader for the Send flow with skeleton loading ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/19676 https://github.com/MetaMask/metamask-mobile/issues/22763 https://github.com/MetaMask/metamask-mobile/issues/17838 ## **Manual testing steps** 1. Click Send on the main page 2. Fill in the form, submit it 3. You will see skeleton loading now ## **Screenshots/Recordings** ### **Before** image ### **After** image image ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Adds a Transfer-specific skeleton loader (including footer skeleton) to the confirmation screen and routes Send flow to use it, plus skeletons across key info rows. > > - **Confirmations UI** > - Adds `ConfirmationLoader.Transfer` and renders `TransferInfoSkeleton` in `confirm-component.tsx`. > - Extends `InfoLoader` to accept `loader` and shows `FooterSkeleton` when `loader === 'transfer'`. > - Keeps default spinner; retains existing loaders (`customAmount`, `predictClaim`). > - **Footer** > - Introduces `FooterSkeleton` with styles (`footerSkeletonContainer`, `footerButtonSkeleton`). > - Refactors base footer styles for reuse. > - **Transfer Info Skeletons** > - Adds skeleton components for rows: `HeroRowSkeleton`, `FromToRowSkeleton`, `NetworkAndOriginRowSkeleton`, `GasFeesDetailsRowSkeleton`, `AdvancedDetailsRowSkeleton` with corresponding style additions. > - Replaces `react-native-skeleton-placeholder` usage with shared `Skeleton` component in gas fee row. > - **Navigation/Send Flow** > - `useSendActions`: navigates to `RedesignedConfirmations` with `{ loader: 'transfer', params: { maxValueMode } }`. > - **Tests** > - Updates `confirm-component.test.tsx` and `useSendActions.test.ts` to cover new loaders, SafeArea + `ScrollView`, defaults, and navigation params. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2f5656ebe3cdb2502465c2c997deb8fd75723d4d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- .../confirm/confirm-component.test.tsx | 160 ++++++++++++++++++ .../components/confirm/confirm-component.tsx | 19 ++- .../components/footer/footer.styles.ts | 20 ++- .../components/footer/footer.tsx | 17 ++ .../confirmations/components/footer/index.ts | 2 +- .../components/info/transfer/transfer.tsx | 34 +++- .../advanced-details-row.styles.ts | 10 ++ .../advanced-details-row.tsx | 17 ++ .../advanced-details-row/index.ts | 2 +- .../from-to-row/from-to-row.styles.ts | 6 + .../transactions/from-to-row/from-to-row.tsx | 33 ++++ .../rows/transactions/from-to-row/index.ts | 2 +- .../gas-fee-details-row.styles.ts | 11 ++ .../gas-fee-details-row.tsx | 36 ++-- .../transactions/gas-fee-details-row/index.ts | 2 +- .../transactions/hero-row/hero-row.styles.ts | 12 ++ .../rows/transactions/hero-row/hero-row.tsx | 28 ++- .../rows/transactions/hero-row/index.ts | 2 +- .../network-and-origin-row.styles.ts | 10 ++ .../network-and-origin-row.tsx | 14 ++ .../hooks/send/useSendActions.test.ts | 1 + .../hooks/send/useSendActions.ts | 2 + 22 files changed, 409 insertions(+), 31 deletions(-) diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx index 62084c04d60..cbb470dd581 100644 --- a/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.test.tsx @@ -1,6 +1,7 @@ import { act } from '@testing-library/react-native'; import React from 'react'; import { cloneDeep } from 'lodash'; +import { ScrollView } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { generateContractInteractionState, @@ -361,6 +362,165 @@ describe('Confirm', () => { expect(getByTestId('confirm-loader-custom-amount')).toBeDefined(); }); + it('displays PredictClaim loader when specified', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.PredictClaim, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId } = renderWithProvider(, { + state: stateWithoutRequest, + }); + + expect(getByTestId('confirm-loader-predict-claim')).toBeDefined(); + }); + + it('displays Transfer loader when specified', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.Transfer, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId } = renderWithProvider(, { + state: stateWithoutRequest, + }); + + expect(getByTestId('confirm-loader-transfer')).toBeDefined(); + }); + + it('renders InfoLoader with SafeAreaView for CustomAmount loader', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.CustomAmount, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId, UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithoutRequest, + }, + ); + + const loaderContainer = getByTestId('confirm-loader-custom-amount'); + const scrollViews = UNSAFE_queryAllByType(ScrollView); + + expect(loaderContainer).toBeDefined(); + expect(scrollViews.length).toBeGreaterThan(0); + }); + + it('renders InfoLoader with SafeAreaView for PredictClaim loader', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.PredictClaim, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId, UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithoutRequest, + }, + ); + + const loaderContainer = getByTestId('confirm-loader-predict-claim'); + const scrollViews = UNSAFE_queryAllByType(ScrollView); + + expect(loaderContainer).toBeDefined(); + expect(scrollViews.length).toBeGreaterThan(0); + }); + + it('renders InfoLoader with SafeAreaView for Transfer loader', () => { + useParamsMock.mockReturnValue({ + loader: ConfirmationLoader.Transfer, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId, UNSAFE_queryAllByType } = renderWithProvider( + , + { + state: stateWithoutRequest, + }, + ); + + const loaderContainer = getByTestId('confirm-loader-transfer'); + const scrollViews = UNSAFE_queryAllByType(ScrollView); + + expect(loaderContainer).toBeDefined(); + expect(scrollViews.length).toBeGreaterThan(0); + }); + + it('defaults to Default loader when no loader param is provided', () => { + useParamsMock.mockReturnValue({}); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId } = renderWithProvider(, { + state: stateWithoutRequest, + }); + + expect(getByTestId('confirm-loader-default')).toBeDefined(); + }); + + it('defaults to Default loader when loader param is undefined', () => { + useParamsMock.mockReturnValue({ + loader: undefined, + }); + + const stateWithoutRequest = cloneDeep(typedSignV1ConfirmationState); + stateWithoutRequest.engine.backgroundState.ApprovalController = { + pendingApprovals: {}, + pendingApprovalCount: 0, + approvalFlows: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const { getByTestId } = renderWithProvider(, { + state: stateWithoutRequest, + }); + + expect(getByTestId('confirm-loader-default')).toBeDefined(); + }); + it('sets navigation options with header hidden for modal confirmations', () => { renderWithProvider(, { state: typedSignV1ConfirmationState, diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index 2b8e9a85508..30878c0e0db 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -19,7 +19,7 @@ import { ConfirmationAssetPollingProvider } from '../confirmation-asset-polling- import AlertBanner from '../alert-banner'; import Info from '../info-root'; import Title from '../title'; -import { Footer } from '../footer'; +import { Footer, FooterSkeleton } from '../footer'; import { Splash } from '../splash'; import styleSheet from './confirm-component.styles'; import { TransactionType } from '@metamask/transaction-controller'; @@ -30,6 +30,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useTransactionMetadataRequest } from '../../hooks/transactions/useTransactionMetadataRequest'; import { hasTransactionType } from '../../utils/transaction'; import { PredictClaimInfoSkeleton } from '../info/predict-claim-info'; +import { TransferInfoSkeleton } from '../info/transfer/transfer'; const TRANSACTION_TYPES_DISABLE_SCROLL = [TransactionType.predictClaim]; @@ -43,6 +44,7 @@ export enum ConfirmationLoader { Default = 'default', CustomAmount = 'customAmount', PredictClaim = 'predictClaim', + Transfer = 'transfer', } export interface ConfirmationParams { @@ -172,7 +174,7 @@ function Loader() { if (loader === ConfirmationLoader.CustomAmount) { return ( - + ); @@ -180,12 +182,20 @@ function Loader() { if (loader === ConfirmationLoader.PredictClaim) { return ( - + ); } + if (loader === ConfirmationLoader.Transfer) { + return ( + + + + ); + } + return ( @@ -196,9 +206,11 @@ function Loader() { function InfoLoader({ children, testId, + loader, }: { children: ReactNode; testId?: string; + loader: ConfirmationLoader; }) { const { styles } = useStyles(styleSheet, { isFullScreenConfirmation: true }); @@ -214,6 +226,7 @@ function InfoLoader({ > {children} + {loader === ConfirmationLoader.Transfer && } ); } diff --git a/app/components/Views/confirmations/components/footer/footer.styles.ts b/app/components/Views/confirmations/components/footer/footer.styles.ts index 8a0917039aa..ebd56cfe642 100644 --- a/app/components/Views/confirmations/components/footer/footer.styles.ts +++ b/app/components/Views/confirmations/components/footer/footer.styles.ts @@ -41,11 +41,15 @@ const styleSheet = (params: { isFullScreenConfirmation, ); + const baseFooterStyle = { + backgroundColor: colors.background.alternative, + paddingHorizontal: 16, + paddingTop: 16, + }; + return StyleSheet.create({ base: { - backgroundColor: colors.background.alternative, - paddingHorizontal: 16, - paddingTop: 16, + ...baseFooterStyle, paddingBottom: basePaddingBottom, }, linkText: { @@ -60,6 +64,16 @@ const styleSheet = (params: { flexDirection: 'row', justifyContent: 'center', }, + footerSkeletonContainer: { + ...baseFooterStyle, + flexDirection: 'row', + paddingBottom: 32, + gap: 16, + }, + footerButtonSkeleton: { + flex: 1, + borderRadius: 99, + }, }); }; diff --git a/app/components/Views/confirmations/components/footer/footer.tsx b/app/components/Views/confirmations/components/footer/footer.tsx index d7bdec6be8a..12305b4e60b 100644 --- a/app/components/Views/confirmations/components/footer/footer.tsx +++ b/app/components/Views/confirmations/components/footer/footer.tsx @@ -38,6 +38,7 @@ import { import { hasTransactionType } from '../../utils/transaction'; import { PredictClaimFooter } from '../predict-confirmations/predict-claim-footer/predict-claim-footer'; import { useIsTransactionPayLoading } from '../../hooks/pay/useTransactionPayData'; +import { Skeleton } from '../../../../../component-library/components/Skeleton'; const HIDE_FOOTER_BY_DEFAULT_TYPES = [ TransactionType.perpsDeposit, @@ -246,3 +247,19 @@ export const Footer = () => { ); }; + +export function FooterSkeleton() { + const { isFullScreenConfirmation } = useFullScreenConfirmation(); + const { styles } = useStyles(styleSheet, { + confirmDisabled: false, + isStakingConfirmationBool: false, + isFullScreenConfirmation, + }); + + return ( + + + + + ); +} diff --git a/app/components/Views/confirmations/components/footer/index.ts b/app/components/Views/confirmations/components/footer/index.ts index 4248c0b12e1..c1155ac7505 100644 --- a/app/components/Views/confirmations/components/footer/index.ts +++ b/app/components/Views/confirmations/components/footer/index.ts @@ -1 +1 @@ -export { Footer } from './footer'; +export { Footer, FooterSkeleton } from './footer'; diff --git a/app/components/Views/confirmations/components/info/transfer/transfer.tsx b/app/components/Views/confirmations/components/info/transfer/transfer.tsx index bdb27c58062..003c0240e86 100644 --- a/app/components/Views/confirmations/components/info/transfer/transfer.tsx +++ b/app/components/Views/confirmations/components/info/transfer/transfer.tsx @@ -12,11 +12,20 @@ import useNavbar from '../../../hooks/ui/useNavbar'; import { useMaxValueRefresher } from '../../../hooks/useMaxValueRefresher'; import { useTokenAmount } from '../../../hooks/useTokenAmount'; import { useTransferAssetType } from '../../../hooks/useTransferAssetType'; -import { HeroRow } from '../../rows/transactions/hero-row'; -import { NetworkAndOriginRow } from '../../rows/transactions/network-and-origin-row'; -import FromToRow from '../../rows/transactions/from-to-row'; -import GasFeesDetailsRow from '../../rows/transactions/gas-fee-details-row'; -import AdvancedDetailsRow from '../../rows/transactions/advanced-details-row'; +import { HeroRow, HeroRowSkeleton } from '../../rows/transactions/hero-row'; +import { + NetworkAndOriginRow, + NetworkAndOriginRowSkeleton, +} from '../../rows/transactions/network-and-origin-row'; +import FromToRow, { + FromToRowSkeleton, +} from '../../rows/transactions/from-to-row'; +import GasFeesDetailsRow, { + GasFeesDetailsRowSkeleton, +} from '../../rows/transactions/gas-fee-details-row'; +import AdvancedDetailsRow, { + AdvancedDetailsRowSkeleton, +} from '../../rows/transactions/advanced-details-row'; const Transfer = () => { // Set navbar as first to prevent Android navigation flickering @@ -55,4 +64,19 @@ const Transfer = () => { ); }; +export function TransferInfoSkeleton() { + // Set navbar for loading state + useNavbar(strings('confirm.review')); + + return ( + + + + + + + + ); +} + export default Transfer; diff --git a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.styles.ts index 590e7ec41bc..875d3ea6fdb 100644 --- a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.styles.ts @@ -26,6 +26,16 @@ const styleSheet = (params: { dataScrollContainer: { height: 200, }, + skeletonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: 8, + paddingHorizontal: 8, + }, + skeletonBorderRadius: { + borderRadius: 4, + }, }); }; diff --git a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.tsx b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.tsx index ade47ab5a52..64302ef88f1 100644 --- a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/advanced-details-row.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import { View } from 'react-native'; import { Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; import { ScrollView } from 'react-native-gesture-handler'; @@ -27,6 +28,7 @@ import InfoRow from '../../../UI/info-row'; import InfoSection from '../../../UI/info-row/info-section'; import NestedTransactionData from '../../../nested-transaction-data/nested-transaction-data'; import SmartContractWithLogo from '../../../smart-contract-with-logo'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './advanced-details-row.styles'; const MAX_DATA_LENGTH_FOR_SCROLL = 200; @@ -161,4 +163,19 @@ const AdvancedDetailsRow = () => { ); }; +export function AdvancedDetailsRowSkeleton() { + const { styles } = useStyles(styleSheet, { + isNonceChangeDisabled: false, + }); + + return ( + + + + + + + ); +} + export default AdvancedDetailsRow; diff --git a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/index.ts b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/index.ts index 89f5f178831..557a872b81b 100644 --- a/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/index.ts +++ b/app/components/Views/confirmations/components/rows/transactions/advanced-details-row/index.ts @@ -1 +1 @@ -export { default } from './advanced-details-row'; +export { default, AdvancedDetailsRowSkeleton } from './advanced-details-row'; diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts index f8957b1daa6..6f759856caa 100644 --- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.styles.ts @@ -23,6 +23,12 @@ const styleSheet = () => iconContainer: { paddingHorizontal: 8, }, + skeletonBorderRadiusLarge: { + borderRadius: 18, + }, + skeletonBorderRadiusSmall: { + borderRadius: 4, + }, }); export default styleSheet; diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx index b3ab3735c00..ec836f59a19 100644 --- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/from-to-row.tsx @@ -15,6 +15,7 @@ import { useTransferRecipient } from '../../../../hooks/transactions/useTransfer import { RowAlertKey } from '../../../UI/info-row/alert-row/constants'; import InfoSection from '../../../UI/info-row/info-section'; import AlertRow from '../../../UI/info-row/alert-row'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './from-to-row.styles'; const FromToRow = () => { @@ -71,4 +72,36 @@ const FromToRow = () => { ); }; +export function FromToRowSkeleton() { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + + + + + + + + + + + + ); +} + export default FromToRow; diff --git a/app/components/Views/confirmations/components/rows/transactions/from-to-row/index.ts b/app/components/Views/confirmations/components/rows/transactions/from-to-row/index.ts index 525d37a0115..507361c2529 100644 --- a/app/components/Views/confirmations/components/rows/transactions/from-to-row/index.ts +++ b/app/components/Views/confirmations/components/rows/transactions/from-to-row/index.ts @@ -1 +1 @@ -export { default } from './from-to-row'; +export { default, FromToRowSkeleton } from './from-to-row'; diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.styles.ts index 7bbff8b85bf..c1a842ae6bc 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.styles.ts @@ -42,6 +42,17 @@ const styleSheet = (params: { theme: Theme }) => { textAlign: 'left', flex: 1, }, + skeletonBorderRadius: { + borderRadius: 4, + }, + skeletonRowContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + minHeight: 24, + paddingBottom: 8, + paddingHorizontal: 8, + }, }); }; diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx index f692f49471f..446df01237e 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx @@ -4,7 +4,6 @@ import { } from '@metamask/transaction-controller'; import React, { useState } from 'react'; import { TouchableOpacity, View } from 'react-native'; -import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import { ConfirmationRowComponentIDs } from '../../../../../../../../e2e/selectors/Confirmation/ConfirmationView.selectors'; import { strings } from '../../../../../../../../locales/i18n'; import Icon, { @@ -35,6 +34,7 @@ import { GasFeeModal } from '../../../modals/gas-fee-modal'; import AlertRow from '../../../UI/info-row/alert-row'; import { RowAlertKey } from '../../../UI/info-row/alert-row/constants'; import InfoSection from '../../../UI/info-row/info-section'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './gas-fee-details-row.styles'; const PaidByMetaMask = () => ( @@ -43,16 +43,13 @@ const PaidByMetaMask = () => ( ); -const SkeletonEstimationInfo = () => ( - - - -); +const SkeletonEstimationInfo = () => { + const { styles } = useStyles(styleSheet, {}); + + return ( + + ); +}; const EstimationInfo = ({ hideFiatForTestnet, @@ -331,4 +328,21 @@ const GasFeesDetailsRow = ({ ); }; +export function GasFeesDetailsRowSkeleton() { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + + + + + + + + + ); +} + export default GasFeesDetailsRow; diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/index.ts b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/index.ts index fe85e110c7c..a5e61333fde 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/index.ts +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/index.ts @@ -1 +1 @@ -export { default } from './gas-fee-details-row'; +export { default, GasFeesDetailsRowSkeleton } from './gas-fee-details-row'; diff --git a/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.styles.ts index 9395b422b17..b53dfe388d0 100644 --- a/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.styles.ts @@ -16,6 +16,18 @@ const styleSheet = (params: { theme: Theme }) => { borderRadius: 39, backgroundColor: theme.colors.background.alternativePressed, }, + skeletonBorderRadiusLarge: { + borderRadius: 32, + }, + skeletonBorderRadiusMedium: { + borderRadius: 6, + marginTop: 16, + }, + skeletonBorderRadiusSmall: { + borderRadius: 4, + marginTop: 8, + marginBottom: 14, + }, }); }; diff --git a/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.tsx b/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.tsx index 1b1af48a347..6a217cac77f 100644 --- a/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/hero-row/hero-row.tsx @@ -6,12 +6,32 @@ import { useIsNft } from '../../../../hooks/nft/useIsNft'; import { HeroNft } from '../../../hero-nft'; import { HeroToken } from '../../../hero-token'; import { useStyles } from '../../../../../../../component-library/hooks'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './hero-row.styles'; -const LoadingHeroRow = () => { +export function HeroRowSkeleton() { const { styles } = useStyles(styleSheet, {}); - return ; -}; + + return ( + + + + + + ); +} export const HeroRow = ({ amountWei }: { amountWei?: string }) => { const { isNft, isPending } = useIsNft(); @@ -22,7 +42,7 @@ export const HeroRow = ({ amountWei }: { amountWei?: string }) => { style={styles.wrapper} testID={ConfirmationRowComponentIDs.TOKEN_HERO} > - {isPending && } + {isPending && } {!isPending && (isNft ? : )} diff --git a/app/components/Views/confirmations/components/rows/transactions/hero-row/index.ts b/app/components/Views/confirmations/components/rows/transactions/hero-row/index.ts index eada9c737e6..d4094df4f6d 100644 --- a/app/components/Views/confirmations/components/rows/transactions/hero-row/index.ts +++ b/app/components/Views/confirmations/components/rows/transactions/hero-row/index.ts @@ -1 +1 @@ -export { HeroRow } from './hero-row'; +export { HeroRow, HeroRowSkeleton } from './hero-row'; diff --git a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.styles.ts b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.styles.ts index 0e3a10d2339..492522189a7 100644 --- a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.styles.ts +++ b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.styles.ts @@ -12,6 +12,16 @@ const styleSheet = () => avatarNetwork: { marginRight: 4, }, + skeletonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: 8, + paddingHorizontal: 8, + }, + skeletonBorderRadius: { + borderRadius: 4, + }, }); export default styleSheet; diff --git a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.tsx b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.tsx index 60f6f6cf6e6..8645fb1507d 100644 --- a/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/network-and-origin-row/network-and-origin-row.tsx @@ -22,6 +22,7 @@ import { MMM_ORIGIN } from '../../../../constants/confirmations'; import InfoSection from '../../../UI/info-row/info-section'; import InfoRow from '../../../UI/info-row/info-row'; import Address from '../../../UI/info-row/info-value/address'; +import { Skeleton } from '../../../../../../../component-library/components/Skeleton'; import styleSheet from './network-and-origin-row.styles'; import { RowAlertKey } from '../../../UI/info-row/alert-row/constants'; import AlertRow from '../../../UI/info-row/alert-row'; @@ -89,3 +90,16 @@ export const NetworkAndOriginRow = () => { ); }; + +export function NetworkAndOriginRowSkeleton() { + const { styles } = useStyles(styleSheet, {}); + + return ( + + + + + + + ); +} diff --git a/app/components/Views/confirmations/hooks/send/useSendActions.test.ts b/app/components/Views/confirmations/hooks/send/useSendActions.test.ts index e826ceda34e..41d0ba684e3 100644 --- a/app/components/Views/confirmations/hooks/send/useSendActions.test.ts +++ b/app/components/Views/confirmations/hooks/send/useSendActions.test.ts @@ -73,6 +73,7 @@ describe('useSendActions', () => { result.current.handleSubmitPress(); expect(mockNavigate).toHaveBeenCalledWith('RedesignedConfirmations', { params: { maxValueMode: undefined }, + loader: 'transfer', }); }); diff --git a/app/components/Views/confirmations/hooks/send/useSendActions.ts b/app/components/Views/confirmations/hooks/send/useSendActions.ts index c02257b0e40..9ebe8531af5 100644 --- a/app/components/Views/confirmations/hooks/send/useSendActions.ts +++ b/app/components/Views/confirmations/hooks/send/useSendActions.ts @@ -11,6 +11,7 @@ import { addLeadingZeroIfNeeded, submitEvmTransaction } from '../../utils/send'; import { useSendContext } from '../../context/send-context'; import { useSendType } from './useSendType'; import { useSendExitMetrics } from './metrics/useSendExitMetrics'; +import { ConfirmationLoader } from '../../components/confirm/confirm-component'; export const useSendActions = () => { const { asset, chainId, fromAccount, from, maxValueMode, to, value } = @@ -41,6 +42,7 @@ export const useSendActions = () => { params: { maxValueMode, }, + loader: ConfirmationLoader.Transfer, }, ); } else { From 8118bf1780b0ff8c6b154591ac827bae335aac39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Wed, 19 Nov 2025 10:53:32 -0700 Subject: [PATCH 3/6] fix(predict): cp-7.60.0 refresh balance after claim (#22910) ## **Description** This PR fixes an issue where the user's balance was not being refreshed after successfully claiming winnings from a prediction market. **What is the reason for the change?** After a user claims their winnings, the balance displayed in the UI would not update to reflect the newly claimed funds until the user manually refreshed or navigated away and back. **What is the improvement/solution?** Added a call to `loadBalance()` in the `onConfirmed` callback of `usePredictClaimToasts` to automatically refresh the user's balance after a successful claim transaction is confirmed. This ensures the UI displays the updated balance immediately after claiming. ## **Changelog** CHANGELOG entry: Added a predict balance refresh after a successful claim ## **Related issues** Fixes: [PRED-311](https://consensyssoftware.atlassian.net/browse/PRED-311) ## **Manual testing steps** ```gherkin Feature: Balance refresh after claim Scenario: user claims prediction market winnings Given user has won positions available to claim And user's balance is displayed in the UI When user claims their winnings And the claim transaction is confirmed Then user's balance updates automatically to reflect the claimed funds And user does not need to manually refresh the page ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Refreshes predict balance upon claim confirmation and updates tests to cover the new behavior. > > - **Predict hooks**: > - Update `app/components/UI/Predict/hooks/usePredictClaimToasts.tsx` to use `usePredictBalance` and refresh balance (`loadBalance({ isRefresh: true })`) on claim confirmation via new `handleClaimConfirmed` (`useCallback`). > - Preserve and invoke `PredictController.confirmClaim` and `loadPositions({ isRefresh: true })` in the unified confirmation handler. > - **Tests**: > - Extend `app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx` to mock `usePredictBalance` and assert `loadBalance` is called on confirmed transactions; verify `loadPositions({ isRefresh: true })` and `PredictController.confirmClaim` calls. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 84d9526a72cf9060e9ddd024126a78bc6137a6ca. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [PRED-311]: https://consensyssoftware.atlassian.net/browse/PRED-311?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .../hooks/usePredictClaimToasts.test.tsx | 75 +++++++++++++++++++ .../Predict/hooks/usePredictClaimToasts.tsx | 25 ++++--- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx b/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx index a5265bdbcc7..aa8e120c449 100644 --- a/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictClaimToasts.test.tsx @@ -29,6 +29,19 @@ jest.mock('./usePredictPositions', () => ({ })), })); +// Mock usePredictBalance +const mockLoadBalance = jest.fn().mockResolvedValue(undefined); +jest.mock('./usePredictBalance', () => ({ + usePredictBalance: jest.fn(() => ({ + balance: 100, + hasNoBalance: false, + isLoading: false, + isRefreshing: false, + error: null, + loadBalance: mockLoadBalance, + })), +})); + // Create a mock toast ref const mockToastRef = { current: { @@ -145,6 +158,8 @@ describe('usePredictClaimToasts', () => { jest.clearAllMocks(); mockToastRef.current.showToast.mockClear(); mockClaim.mockClear(); + mockLoadBalance.mockClear(); + mockLoadPositions.mockClear(); // Capture the subscribe callback mockSubscribeCallback = null; @@ -413,6 +428,66 @@ describe('usePredictClaimToasts', () => { }); }); + describe('onConfirmed callback', () => { + it('calls loadBalance when transaction is confirmed', async () => { + // Arrange + renderHook(() => usePredictClaimToasts(), { wrapper }); + + // Act + await act(async () => { + mockSubscribeCallback?.({ + transactionMeta: { + status: TransactionStatus.confirmed, + nestedTransactions: [{ type: TransactionType.predictClaim }], + }, + }); + }); + + // Assert + expect(mockLoadBalance).toHaveBeenCalled(); + }); + + it('calls loadPositions with isRefresh when transaction is confirmed', async () => { + // Arrange + renderHook(() => usePredictClaimToasts(), { wrapper }); + + // Act + await act(async () => { + mockSubscribeCallback?.({ + transactionMeta: { + status: TransactionStatus.confirmed, + nestedTransactions: [{ type: TransactionType.predictClaim }], + }, + }); + }); + + // Assert + expect(mockLoadPositions).toHaveBeenCalledWith({ isRefresh: true }); + }); + + it('calls confirmClaim on PredictController when transaction is confirmed', async () => { + // Arrange + renderHook(() => usePredictClaimToasts(), { wrapper }); + + // Act + await act(async () => { + mockSubscribeCallback?.({ + transactionMeta: { + status: TransactionStatus.confirmed, + nestedTransactions: [{ type: TransactionType.predictClaim }], + }, + }); + }); + + // Assert + expect( + Engine.context.PredictController.confirmClaim, + ).toHaveBeenCalledWith({ + providerId: 'polymarket', + }); + }); + }); + describe('claimable positions', () => { it('calculates total claimable amount from won positions', async () => { // Arrange diff --git a/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx b/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx index a8a5ddd4ab9..2cd7ca10996 100644 --- a/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx +++ b/app/components/UI/Predict/hooks/usePredictClaimToasts.tsx @@ -1,5 +1,5 @@ import { TransactionType } from '@metamask/transaction-controller'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; import { selectPredictWonPositions } from '../selectors/predictController'; @@ -10,6 +10,7 @@ import { usePredictPositions } from './usePredictPositions'; import { usePredictToasts } from './usePredictToasts'; import Engine from '../../../../core/Engine'; import { getEvmAccountFromSelectedAccountGroup } from '../utils/accounts'; +import { usePredictBalance } from './usePredictBalance'; export const usePredictClaimToasts = () => { const { claim } = usePredictClaim(); @@ -17,6 +18,7 @@ export const usePredictClaimToasts = () => { claimable: true, loadOnMount: true, }); + const { loadBalance } = usePredictBalance({ loadOnMount: false }); const evmAccount = getEvmAccountFromSelectedAccountGroup(); const selectedAddress = evmAccount?.address ?? '0x0'; @@ -37,6 +39,18 @@ export const usePredictClaimToasts = () => { maximumDecimals: 2, }); + const handleClaimConfirmed = useCallback(() => { + Engine.context.PredictController.confirmClaim({ + providerId: 'polymarket', + }); + loadPositions({ isRefresh: true }).catch(() => { + // Ignore errors when refreshing positions + }); + loadBalance({ isRefresh: true }).catch(() => { + // Ignore errors when refreshing balance + }); + }, [loadBalance, loadPositions]); + usePredictToasts({ transactionType: TransactionType.predictClaim, pendingToastConfig: { @@ -61,13 +75,6 @@ export const usePredictClaimToasts = () => { retryLabel: strings('predict.claim.toasts.error.try_again'), onRetry: claim, }, - onConfirmed: () => { - Engine.context.PredictController.confirmClaim({ - providerId: 'polymarket', - }); - loadPositions({ isRefresh: true }).catch(() => { - // Ignore errors when refreshing positions - }); - }, + onConfirmed: handleClaimConfirmed, }); }; From d619ca55f47294c9d81472284e130bf79d8d302f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20=C5=81ucka?= <5708018+PatrykLucka@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:31:41 +0100 Subject: [PATCH 4/6] feat: Enhance AccountSelector with full-page layout and animations (#22797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds feature flag changing the way accounts list is rendered. When `fullPageAccountList` is enabled, then accounts list is rendered as full page, when disabled as bottom sheet. ### Performance improvement measurements | BottomSheet | Full Page | | -------- | ------- | | 631ms | 648ms | | 814ms | 619ms | | 614ms | 646ms | | 746ms | 623ms | | 729ms | 614ms | | 697ms | 660ms | | 697ms | 642ms | | 847ms | 625ms | | 732ms | 698ms | | 782ms | 639ms | | **Average** | **Average** | | **728.9ms** | **641.4ms** | **Improvement ~12%** ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-174 ## **Manual testing steps** ```gherkin Feature: Open accounts list Scenario: user opens accounts list Given fullPageAccountList is enabled When user clicks accounts selector on the home page Then accounts list opens as full page ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/ab9458a2-ffeb-42da-b95e-b8d1b2adaeaa ### **After** https://github.com/user-attachments/assets/aa7fd4ed-424a-44fb-b13c-d91cccefb736 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Introduces a feature-flagged full-page AccountSelector with animated presentation/backdrop, centralized headers, updated navigation options, new styles, and comprehensive tests. > > - **UI – `AccountSelector`**: > - Add full-page mode gated by `FeatureFlagNames.fullPageAccountList` using `react-native-reanimated` (slide-in/out, animated backdrop), `KeyboardAvoidingView`, and safe-area padding. > - Centralize headers: render `SheetHeader`/`BottomSheetHeader` within `AccountSelector`; remove inline headers from `MultichainAddWalletActions` and `AddAccountActions`. > - Add `closeModal` to close appropriately (animate + `navigation.goBack()` in full-page; close sheet otherwise). Selection/actions now call `closeModal`. > - Tracing: end `TraceName.ShowAccountList` on animation complete (full-page) or `onOpen` (bottom sheet). > - Styles: add `backdrop`, `keyboardAvoidingView`, `container`; adjust layout accordingly. > - **Navigation**: > - Update `Routes.SHEET.ACCOUNT_SELECTOR` options (transparent `cardStyle`, no overlay fade, `detachPreviousScreen: false`). > - **Feature Flags**: > - Add `fullPageAccountList` to `FeatureFlagNames` and `useFeatureFlag`. > - **Tests**: > - Extend `AccountSelector` tests for full-page vs bottom sheet, navigation/closing behavior, syncing states; refine timer handling. Update snapshots. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b8aee13bddb79aac2fd95c649fa13c702ddbb7cc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../MultichainAddWalletActions.tsx | 4 - .../MultichainAddWalletActions.test.tsx.snap | 40 --- app/components/Nav/App/App.tsx | 9 + .../AccountSelector/AccountSelector.styles.ts | 13 +- .../AccountSelector/AccountSelector.test.tsx | 294 +++++++++++++++++- .../Views/AccountSelector/AccountSelector.tsx | 165 +++++++++- .../AccountSelector.test.tsx.snap | 1 - .../AddAccountActions/AddAccountActions.tsx | 5 - .../AddAccountActions.test.tsx.snap | 76 ----- app/components/hooks/useFeatureFlag.ts | 1 + 10 files changed, 465 insertions(+), 143 deletions(-) diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx index 229e44bb073..4bc474be387 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx @@ -4,7 +4,6 @@ import { SafeAreaView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; // External dependencies. -import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; import { IconName } from '@metamask/design-system-react-native'; import ActionListItem from '../../ActionListItem'; import { strings } from '../../../../../locales/i18n'; @@ -88,9 +87,6 @@ const MultichainAddWalletActions = ({ return ( - - {strings('multichain_accounts.add_wallet')} - {actionConfigs.map( (config) => config.isVisible && ( diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/__snapshots__/MultichainAddWalletActions.test.tsx.snap b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/__snapshots__/MultichainAddWalletActions.test.tsx.snap index 21a3154e832..4ec3865a5df 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/__snapshots__/MultichainAddWalletActions.test.tsx.snap +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/__snapshots__/MultichainAddWalletActions.test.tsx.snap @@ -313,46 +313,6 @@ exports[`MultichainAddWalletActions renders correctly 1`] = ` } > - - - - Add wallet - - - ( ({ + overlayStyle: { + opacity: 0, + }, + }), + detachPreviousScreen: false, + }} /> { const { theme } = params; @@ -10,9 +11,17 @@ const styleSheet = (params: { theme: Theme }) => { marginVertical: 16, marginHorizontal: 16, }, - bottomSheetContent: { + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.overlay.default, + }, + keyboardAvoidingView: { + flex: 1, + backgroundColor: importedColors.transparent, + }, + container: { + flex: 1, backgroundColor: colors.background.default, - display: 'flex', }, }); }; diff --git a/app/components/Views/AccountSelector/AccountSelector.test.tsx b/app/components/Views/AccountSelector/AccountSelector.test.tsx index 1faacd1e014..a431b6545e3 100644 --- a/app/components/Views/AccountSelector/AccountSelector.test.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { screen, fireEvent } from '@testing-library/react-native'; +import { screen, fireEvent, waitFor } from '@testing-library/react-native'; import AccountSelector from './AccountSelector'; import { renderScreen } from '../../../util/test/renderWithProvider'; import { AccountListBottomSheetSelectorsIDs } from '../../../../e2e/selectors/wallet/AccountListBottomSheet.selectors'; import { AddAccountBottomSheetSelectorsIDs } from '../../../../e2e/selectors/wallet/AddAccountBottomSheet.selectors'; +import { CellComponentSelectorsIDs } from '../../../../e2e/selectors/wallet/CellComponent.selectors'; import Routes from '../../../constants/navigation/Routes'; +import Engine from '../../../core/Engine'; import { AccountSelectorParams, AccountSelectorProps, @@ -18,6 +20,15 @@ import { internalSolanaAccount1, } from '../../../util/test/accountsControllerTestUtils'; +jest.mock('../../hooks/useFeatureFlag', () => ({ + useFeatureFlag: jest.fn(() => false), // Default to BottomSheet version for tests + FeatureFlagNames: { + rewardsEnabled: 'rewardsEnabled', + otaUpdatesEnabled: 'otaUpdatesEnabled', + fullPageAccountList: 'fullPageAccountList', + }, +})); + const mockAvatarAccountType = 'Maskicon' as const; const mockAccounts = [ @@ -201,6 +212,7 @@ const AccountSelectorWrapper = () => ; describe('AccountSelector', () => { beforeEach(() => { + jest.useFakeTimers(); jest.clearAllMocks(); // Reset multichain selectors to disabled state by default mockSelectMultichainAccountsState2Enabled.mockReturnValue(false); @@ -214,6 +226,17 @@ describe('AccountSelector', () => { }); }); + afterEach(() => { + // Only flush timers if fake timers are active + try { + jest.runOnlyPendingTimers(); + jest.clearAllTimers(); + } catch (e) { + // Fake timers not active, skip + } + jest.useRealTimers(); + }); + it('should render correctly', () => { const wrapper = renderScreen( AccountSelectorWrapper, @@ -355,6 +378,9 @@ describe('AccountSelector', () => { }); it('handles navigation to add account actions', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); const routeWithNavigation = { @@ -378,9 +404,15 @@ describe('AccountSelector', () => { ); expect(screen.getAllByText('Import a wallet')).toBeDefined(); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); it('clicks Add wallet button and displays MultichainAddWalletActions bottomsheet', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + // Enable the multichain accounts state 2 feature flag for this test mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); @@ -408,6 +440,9 @@ describe('AccountSelector', () => { expect( screen.getByTestId(AddAccountBottomSheetSelectorsIDs.IMPORT_SRP_BUTTON), ).toBeDefined(); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); }); @@ -470,6 +505,9 @@ describe('AccountSelector', () => { }); it('shows activity indicator when syncing is in progress', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + mockUseAccountsOperationsLoadingStates.mockReturnValue({ isAccountSyncingInProgress: true, areAnyOperationsLoading: true, @@ -493,9 +531,15 @@ describe('AccountSelector', () => { ); expect(addButton).toBeDefined(); expect(addButton).toHaveTextContent('Syncing...'); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); it('shows different button text based on multichain feature flag when not syncing', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + // Test with multichain enabled mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); @@ -514,6 +558,9 @@ describe('AccountSelector', () => { AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, ); expect(addButton).toHaveTextContent('Add wallet'); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); it('shows default button text when multichain is disabled and not syncing', () => { @@ -538,6 +585,9 @@ describe('AccountSelector', () => { }); it('prioritizes syncing message over feature flag text', () => { + // Use real timers for this test to avoid animation timing issues + jest.useRealTimers(); + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); mockUseAccountsOperationsLoadingStates.mockReturnValue({ isAccountSyncingInProgress: true, @@ -561,6 +611,9 @@ describe('AccountSelector', () => { ); // Should show syncing message, not "Add wallet" expect(addButton).toHaveTextContent('Syncing...'); + + // Restore fake timers for other tests + jest.useFakeTimers(); }); it('enables button when syncing completes', () => { @@ -615,4 +668,243 @@ describe('AccountSelector', () => { expect(addButton).toHaveTextContent('Add account or hardware wallet'); }); }); + + describe('Feature Flag: Full-Page Account List', () => { + let mockUseFeatureFlag: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseFeatureFlag = jest.requireMock( + '../../hooks/useFeatureFlag', + ).useFeatureFlag; + }); + + it('renders BottomSheet when feature flag is disabled', () => { + mockUseFeatureFlag.mockReturnValue(false); + + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // BottomSheet version renders the sheet header + expect(screen.getByText('Accounts')).toBeDefined(); + // Accounts list is present + expect( + screen.getByTestId(AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ID), + ).toBeDefined(); + }); + + it('renders full-page modal when feature flag is enabled', () => { + mockUseFeatureFlag.mockReturnValue(true); + + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Full-page version has sheet header with back button + expect(screen.getByText('Accounts')).toBeDefined(); + // Accounts list is present + expect( + screen.getByTestId(AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ID), + ).toBeDefined(); + }); + + it('renders add button in both modes', () => { + // Arrange: BottomSheet mode + mockUseFeatureFlag.mockReturnValue(false); + + // Act: Render in BottomSheet mode + const { unmount } = renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Assert: Add button is present + expect( + screen.getByTestId( + AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, + ), + ).toBeDefined(); + + unmount(); + + // Arrange: Full-page mode + jest.useRealTimers(); + mockUseFeatureFlag.mockReturnValue(true); + + // Act: Render in full-page mode + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Assert: Add button is present + expect( + screen.getByTestId( + AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, + ), + ).toBeDefined(); + + jest.useFakeTimers(); + }); + + it('switches between multichain screens in full-page mode', () => { + // Arrange + jest.useRealTimers(); + mockUseFeatureFlag.mockReturnValue(true); + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); + + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + const addButton = screen.getByTestId( + AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ADD_BUTTON_ID, + ); + + // Act + fireEvent.press(addButton); + + // Assert: MultichainAddWalletActions screen is displayed + expect(screen.getByText('Add wallet')).toBeDefined(); + + jest.useFakeTimers(); + }); + + it('closes BottomSheet when account is selected with feature flag disabled', async () => { + // Arrange + mockUseFeatureFlag.mockReturnValue(false); + + const { getAllByTestId } = renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Wait for account cells to render + await waitFor(() => { + const cells = getAllByTestId( + CellComponentSelectorsIDs.SELECT_WITH_MENU, + ); + expect(cells.length).toBeGreaterThan(0); + }); + + const accountCells = getAllByTestId( + CellComponentSelectorsIDs.SELECT_WITH_MENU, + ); + + // Act + fireEvent.press(accountCells[0]); + + // Assert: Account was selected + expect(Engine.setSelectedAddress).toHaveBeenCalled(); + }); + + it('renders SheetHeader with title in full-page mode', () => { + // Arrange + mockUseFeatureFlag.mockReturnValue(true); + + renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Assert: SheetHeader with title is present in full-page mode + expect(screen.getByText('Accounts')).toBeDefined(); + // Verify accounts list is also present (confirms we're on the right screen) + expect( + screen.getByTestId(AccountListBottomSheetSelectorsIDs.ACCOUNT_LIST_ID), + ).toBeDefined(); + }); + + it('closes full-page modal when account is selected with feature flag enabled', async () => { + // Arrange + jest.useRealTimers(); + mockUseFeatureFlag.mockReturnValue(true); + + // Mock the useNavigation hook to prevent navigation warnings + const mockGoBack = jest.fn(); + const useNavigationMock = jest.requireMock('@react-navigation/native'); + useNavigationMock.useNavigation = jest.fn(() => ({ + goBack: mockGoBack, + navigate: jest.fn(), + dispatch: jest.fn(), + })); + + const { getAllByTestId } = renderScreen( + AccountSelectorWrapper, + { + name: Routes.SHEET.ACCOUNT_SELECTOR, + }, + { + state: mockInitialState, + }, + mockRoute.params, + ); + + // Wait for account cells to render + await waitFor(() => { + const cells = getAllByTestId( + CellComponentSelectorsIDs.SELECT_WITH_MENU, + ); + expect(cells.length).toBeGreaterThan(0); + }); + + const accountCells = getAllByTestId( + CellComponentSelectorsIDs.SELECT_WITH_MENU, + ); + + // Act + fireEvent.press(accountCells[0]); + + // Assert: Account was selected + expect(Engine.setSelectedAddress).toHaveBeenCalled(); + + jest.useFakeTimers(); + }); + }); }); diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index 04d720f5991..da16539bf73 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -3,10 +3,28 @@ import React, { Fragment, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, } from 'react'; +import { + KeyboardAvoidingView, + Platform, + ActivityIndicator, + useWindowDimensions, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withSpring, + runOnJS, + useDerivedValue, + interpolate, +} from 'react-native-reanimated'; // External dependencies. import EvmAccountSelectorList from '../../UI/EvmAccountSelectorList'; @@ -15,8 +33,10 @@ import { MultichainAddWalletActions } from '../../../component-library/component import BottomSheet, { BottomSheetRef, } from '../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; import Engine from '../../../core/Engine'; +import { useFeatureFlag, FeatureFlagNames } from '../../hooks/useFeatureFlag'; import { store } from '../../../store'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { strings } from '../../../../locales/i18n'; @@ -57,19 +77,29 @@ import BottomSheetFooter from '../../../component-library/components/BottomSheet import { ButtonProps } from '../../../component-library/components/Buttons/Button/Button.types'; import { useSyncSRPs } from '../../hooks/useSyncSRPs'; import { useAccountsOperationsLoadingStates } from '../../../util/accounts/useAccountsOperationsLoadingStates'; -import { ActivityIndicator } from 'react-native'; import { Box } from '../../UI/Box/Box'; import { AlignItems, FlexDirection, JustifyContent, } from '../../UI/Box/box.types'; +import { AnimationDuration } from '../../../component-library/constants/animation.constants'; const AccountSelector = ({ route }: AccountSelectorProps) => { const { styles } = useStyles(styleSheet, {}); const dispatch = useDispatch(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { height: screenHeight } = useWindowDimensions(); const { trackEvent, createEventBuilder } = useMetrics(); const routeParams = useMemo(() => route?.params, [route?.params]); + + // Feature flag for full-page account list + const isFullPageAccountList = useFeatureFlag( + FeatureFlagNames.fullPageAccountList, + ); + const sheetRef = useRef(null); + const { onSelectAccount, disablePrivacyMode, @@ -85,7 +115,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { selectMultichainAccountsState2Enabled, ); const selectedAccountGroup = useSelector(selectSelectedAccountGroup); - const sheetRef = useRef(null); const { isAccountSyncingInProgress, @@ -132,16 +161,64 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { const [keyboardAvoidingViewEnabled, setKeyboardAvoidingViewEnabled] = useState(false); + // Animation using react-native-reanimated - only for full-page version + const translateY = useSharedValue(screenHeight); + + // Backdrop opacity animation - fades in as screen slides up + const backdropOpacity = useDerivedValue(() => + interpolate(translateY.value, [screenHeight, 0], [0, 0.5]), + ); + useEffect(() => { if (reloadAccounts) { dispatch(setReloadAccounts(false)); } }, [dispatch, reloadAccounts]); + useLayoutEffect(() => { + if (!isFullPageAccountList) return; + if (screen !== AccountSelectorScreens.AccountSelector) return; + + const onAnimationComplete = () => { + endTrace({ + name: TraceName.ShowAccountList, + }); + }; + + translateY.value = withSpring( + 0, + { + damping: 20, + stiffness: 500, + mass: 0.3, + }, + () => runOnJS(onAnimationComplete)(), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFullPageAccountList, screen]); + + const closeModal = useCallback(() => { + if (isFullPageAccountList) { + // Full-page version: animate out then navigate back + const onCloseComplete = () => { + navigation.goBack(); + }; + + translateY.value = withTiming( + screenHeight, + { duration: AnimationDuration.Fast }, + () => runOnJS(onCloseComplete)(), + ); + } else { + // BottomSheet version: close the sheet + sheetRef.current?.onCloseBottomSheet(); + } + }, [isFullPageAccountList, translateY, navigation, screenHeight]); + const _onSelectAccount = useCallback( (address: string) => { Engine.setSelectedAddress(address); - sheetRef.current?.onCloseBottomSheet(); + closeModal(); onSelectAccount?.(address); // Track Event: "Switched Account" @@ -154,7 +231,13 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { .build(), ); }, - [accounts?.length, onSelectAccount, trackEvent, createEventBuilder], + [ + accounts?.length, + onSelectAccount, + trackEvent, + createEventBuilder, + closeModal, + ], ); const _onSelectMultichainAccount = useCallback( @@ -162,7 +245,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { Engine.context.AccountTreeController.setSelectedAccountGroup( accountGroup.id, ); - sheetRef.current?.onCloseBottomSheet(); + closeModal(); trackEvent( createEventBuilder(MetaMetricsEvents.SWITCHED_ACCOUNT) @@ -173,7 +256,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { .build(), ); }, - [accounts?.length, trackEvent, createEventBuilder], + [accounts?.length, trackEvent, createEventBuilder, closeModal], ); const handleAddAccount = useCallback(() => { @@ -207,17 +290,18 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { op: TraceOperation.AccountUi, tags: getTraceTags(store.getState()), }); + // Trace ends in animation callback } }, [isAccountSelector]); - // We want to track the full render of the account list, meaning when the full animation is done, so - // we hook the open animation and end the trace there. - const onOpen = useCallback(() => { - if (isAccountSelector) { + + // End trace when bottom sheet opens (only for non-full-page version) + const onBottomSheetOpen = useCallback(() => { + if (!isFullPageAccountList && isAccountSelector) { endTrace({ name: TraceName.ShowAccountList, }); } - }, [isAccountSelector]); + }, [isFullPageAccountList, isAccountSelector]); const addAccountButtonProps: ButtonProps[] = useMemo( () => [ @@ -260,7 +344,6 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { const renderAccountSelector = useCallback( () => ( - {isMultichainAccountsState2Enabled && selectedAccountGroup ? ( { renderMultichainAddWalletActions, ]); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + })); + + const backdropStyle = useAnimatedStyle(() => ({ + opacity: backdropOpacity.value, + })); + + if ( + isFullPageAccountList && + screen === AccountSelectorScreens.AccountSelector + ) { + return ( + <> + + + + + {renderAccountScreens()} + + + + ); + } + + // Render BottomSheet version return ( + {screen === AccountSelectorScreens.AccountSelector && ( + + )} + {screen === AccountSelectorScreens.AddAccountActions && ( + + {strings('account_actions.add_account')} + + )} + {screen === AccountSelectorScreens.MultichainAddWalletActions && ( + + {strings('multichain_accounts.add_wallet')} + + )} {renderAccountScreens()} ); diff --git a/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap b/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap index fc510949c73..f396c9ec8a6 100644 --- a/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap +++ b/app/components/Views/AccountSelector/__snapshots__/AccountSelector.test.tsx.snap @@ -388,7 +388,6 @@ exports[`AccountSelector should render correctly 1`] = ` "borderTopLeftRadius": 24, "borderTopRightRadius": 24, "borderWidth": 1, - "display": "flex", "maxHeight": 1334, "overflow": "hidden", "paddingBottom": 0, diff --git a/app/components/Views/AddAccountActions/AddAccountActions.tsx b/app/components/Views/AddAccountActions/AddAccountActions.tsx index 0a0c2ac02d1..2acb192cc7f 100644 --- a/app/components/Views/AddAccountActions/AddAccountActions.tsx +++ b/app/components/Views/AddAccountActions/AddAccountActions.tsx @@ -5,7 +5,6 @@ import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; // External dependencies. -import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; import AccountAction from '../AccountAction/AccountAction'; import { IconName } from '../../../component-library/components/Icons/Icon'; import { strings } from '../../../../locales/i18n'; @@ -137,10 +136,6 @@ const AddAccountActions = ({ onBack }: AddAccountActionsProps) => { return ( - - - - - - - - - Create a new account - - - { From b2d6630158ea1e304eaedcd664ecac4cb55c9f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Wed, 19 Nov 2025 15:10:00 -0700 Subject: [PATCH 5/6] fix(predict): cp-7.60.0 claimable positions logic (#22963) ## **Description** This PR fixes the claimable positions logic in the Predict feature to ensure we always fetch **ALL** claimable positions and filter them afterward, rather than fetching only positions for a specific market. ### Why this change? 1. The claim operation works at the account level - users can only claim **all** positions at once, never individual market positions 2. We need to keep all claimable positions in the `PredictController` state to support the global claim functionality 3. Previously, when viewing a specific market's details, we would fetch only that market's claimable positions, which caused the controller state to be incomplete ### What was fixed? 1. **Always fetch ALL claimable positions**: Modified `usePredictPositions` to ignore the `marketId` parameter when `claimable=true`, ensuring we always fetch all claimable positions for the account 2. **Filter at display time**: Introduced `filteredClaimablePositions` using `useMemo` to filter positions by `marketId` when displaying them in market-specific screens 3. **State management**: Clarified that `claimablePositions` in the controller should always contain ALL claimable positions, and `positions` state only stores active positions 4. **Fixed display bug**: Market details screens now correctly show only the claimable positions for that specific market, rather than incorrectly showing positions from other markets ## **Changelog** CHANGELOG entry: Fixed claimable positions logic to always fetch all positions and filter correctly by market ## **Related issues** Fixes: [PRED-318](https://consensyssoftware.atlassian.net/browse/PRED-318) ## **Manual testing steps** ```gherkin Feature: Claimable positions management Scenario: user views claimable positions in market details Given user has claimable positions in multiple markets And user is viewing a specific market's details screen When the market details screen loads Then only claimable positions for that specific market should be displayed And all claimable positions should be stored in the PredictController state Scenario: user claims all positions Given user has multiple claimable positions across different markets And user is on any market details screen When user initiates a claim operation Then all claimable positions from all markets should be claimed And the controller state should correctly reflect all claimable positions Scenario: user switches between markets with claimable positions Given user has claimable positions in Market A and Market B And user is viewing Market A details When user navigates to Market B details Then only Market B's claimable positions should be displayed And the controller state should still contain all claimable positions from both markets ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/52255fb4-dcd0-48a9-ac1d-29eca1c09d0e ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Ensure claimable positions always load globally and are filtered per-market in the hook, with local state storing only active positions. > > - **PredictController**: > - Clarifies `claimablePositions` should always contain ALL claimable positions. > - **Hook `usePredictPositions`**: > - Always fetches all positions when `claimable=true` (ignores `marketId`) and filters by `marketId` via `useMemo`. > - Returns filtered claimable positions; only stores active (non-claimable) positions in local `positions` state. > - Updates option docs; adds `useMemo` import. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2c00940ccbd4cf39a0d84641efb3bc630cd6927e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). [PRED-318]: https://consensyssoftware.atlassian.net/browse/PRED-318?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .../Predict/controllers/PredictController.ts | 2 +- .../UI/Predict/hooks/usePredictPositions.ts | 24 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 8269dc88dee..b6aff6f6e06 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -101,7 +101,7 @@ export type PredictControllerState = { // Account balances balances: { [providerId: string]: { [address: string]: PredictBalance } }; - // Claim management + // Claim management (this should always be ALL claimable positions) claimablePositions: { [address: string]: PredictPosition[] }; // Deposit management diff --git a/app/components/UI/Predict/hooks/usePredictPositions.ts b/app/components/UI/Predict/hooks/usePredictPositions.ts index 064f1040ca0..3e5fffa16a6 100644 --- a/app/components/UI/Predict/hooks/usePredictPositions.ts +++ b/app/components/UI/Predict/hooks/usePredictPositions.ts @@ -1,5 +1,5 @@ import { useFocusEffect } from '@react-navigation/native'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import Logger from '../../../../util/Logger'; import type { PredictPosition } from '../types'; @@ -32,7 +32,8 @@ interface UsePredictPositionsOptions { marketId?: string; /** - * The parameters to load positions for + * Only load claimable positions. When this is set to true, marketId is ignored when fetching positions. + * However, the positions returned will be filtered to only include the specific market positions. */ claimable?: boolean; /** @@ -70,7 +71,9 @@ export function usePredictPositions( const { getPositions } = usePredictTrading(); const { ensurePolygonNetworkExists } = usePredictNetworkManagement(); + // `positions` state only stores active positions const [positions, setPositions] = useState([]); + const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); @@ -84,6 +87,13 @@ export function usePredictPositions( }), ); + const filteredClaimablePositions = useMemo(() => { + if (!marketId) return [...claimablePositions]; + return claimablePositions.filter( + (position) => position.marketId === marketId, + ); + }, [claimablePositions, marketId]); + const loadPositions = useCallback( async (loadOptions?: { isRefresh?: boolean }) => { const { isRefresh = false } = loadOptions || {}; @@ -114,11 +124,15 @@ export function usePredictPositions( address: selectedInternalAccountAddress, providerId, claimable, - marketId, + // Always load ALL positions when claimable is true + marketId: claimable ? undefined : marketId, }); const validPositions = positionsData ?? []; - setPositions(validPositions); + if (!claimable) { + // `positions` state only stores active positions + setPositions(validPositions); + } DevLogger.log('usePredictPositions: Loaded positions', { originalCount: validPositions.length, @@ -211,7 +225,7 @@ export function usePredictPositions( // Get claimable positions from controller state if claimable is true. // This will ensure that we can refresh claimable positions when the user // performs a claim operation. - positions: claimable ? [...claimablePositions] : positions, + positions: claimable ? filteredClaimablePositions : positions, isLoading, isRefreshing, error, From 56fd2cab2d8c2640275b87d42c7ea91c4cd40b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caain=C3=A3=20Jeronimo?= Date: Wed, 19 Nov 2025 19:16:08 -0300 Subject: [PATCH 6/6] fix(predict): cp-7.60.0 improve price formatting and position display (#22882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR improves the accuracy and clarity of financial display formatting in the Predict feature, with a focus on the cash-out preview flow. ### Key Changes 1. **PnL Calculation in Sell Preview** - Recalculates `cashPnl` and `percentPnl` dynamically based on real-time preview data (`minAmountReceived`) instead of using stale values from the position object - Formula: `cashPnl = currentValue - initialValue` where `currentValue` comes from the latest preview - This ensures users see accurate profit/loss based on the actual cash-out amount 2. **formatPrice Enhancement** - Changed from truncation to proper rounding (fixes issue where small values like $0.001 showed as $0) - Respects `maximumDecimals` parameter for precision control (was previously hardcoded to 2) - Hides `.00` for integer values (e.g., `$50` instead of `$50.00`) for cleaner UI - Properly handles `minimumDecimals` parameter values 3. **formatPercentage Enhancement** - Added `truncate` option (default: false) for more precise percentage display - `truncate: true` - Shows ">99%", "<1%", rounded integers (old behavior) - `truncate: false` - Shows actual decimals up to 2 places (e.g., "5.25%") - Binary outcome displays now use truncated percentages for simplicity 4. **Position Display UX Improvement** - Changed position info format from `$X on Y • Z shares at W¢` to `$X on Y to win $Z` - More intuitive for users to understand potential winnings - Updated localization strings: `cashout_info`, `cashout_info_multiple`, `position_info` - Simplified share price display: "Selling X shares at Y" instead of "At price: Y¢ per share" 5. **Test Updates** - Updated all affected test files to match new formatting behavior - Added proper mocking for `formatPositionSize` and `formatCents` - Fixed assertions to expect new display formats ### Technical Details - The `formatPrice` function now uses dynamic multiplier based on `maximumDecimals`: `Math.pow(10, maximumDecimals)` - Sell preview now properly displays small PnL values (e.g., -$0.0011 instead of $0) - All format changes are backward compatible through optional parameters ### Trade-offs - Existing code relying on `formatPrice` truncation behavior may see slight differences (addressed in tests) - Added complexity to format functions with additional options, but improves flexibility ## Changelog ### Fixed - Fixed cash-out preview showing incorrect PnL by recalculating from real-time preview data - Fixed formatPrice showing $0 for small values under $0.01 - Fixed percentage display truncating to whole numbers when precision needed ### Changed - Changed position display format to show potential winnings instead of share price - Changed formatPrice to round instead of truncate for better accuracy - Changed formatPercentage to support decimal precision with new truncate option ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-316 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** https://www.loom.com/share/08b302afff9e4553ac445503851f630c ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > Switches to rounded price formatting and precise percentages, updates position/sell preview displays (with recalculated PnL), and aligns i18n/tests with the new formats. > > - **Utilities (formatting)**: > - `formatPrice`: switch from truncation to rounding; hide `.00` for integers; respect `minimumDecimals`/`maximumDecimals`. > - `formatPercentage`: add `truncate` option; default shows up to 2 decimals; use truncation for market percentages. > - Minor helpers: keep cents/size formatting; expand tests and edge cases. > - **UI/Logic**: > - **Sell Preview** (`PredictSellPreview`): recalc PnL from preview (`minAmountReceived`), show "Selling X shares at Y"; add loading skeletons; update analytics props. > - **Positions** (`PredictPosition`, `PredictPositionDetail`, `Resolved`): change subtitle to `"{{initialValue}} on {{outcome}} to win {{shares}}"`; display precise `% PnL`; rounded values; conditionals for optimistic/closed states. > - **Market cards** (`PredictMarketOutcome`, `PredictMarketMultiple`): use truncated percentage display for outcome prices. > - **Header**: Unrealized P&L shows precise percent (e.g., `3.9%`, `-2.1%`). > - **i18n**: > - Update keys: `position_info`, `cashout_info(_multiple)`, `at_price_per_share` (now "Selling {{size}} shares at {{price}}"), remove leading `$` from `amount_on_outcome` template. > - **Tests**: > - Update expectations across Predict tests for new rounding, percent precision, and revised strings; add mocks for `formatPositionSize`/`formatCents`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 81676c0c040fd6fc6b373e4a6b5225353e9cec76. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PredictActivity/PredictActivity.test.tsx | 2 +- .../PredictActivityDetail.test.tsx | 2 +- .../PredictBalance/PredictBalance.test.tsx | 6 +- .../PredictMarketMultiple.tsx | 2 +- .../PredictMarketOutcome.tsx | 2 +- .../PredictPosition/PredictPosition.test.tsx | 53 +-- .../PredictPosition/PredictPosition.tsx | 34 +- .../PredictPositionDetail.test.tsx | 20 +- .../PredictPositionDetail.tsx | 45 +- .../PredictPositionResolved.test.tsx | 8 +- .../PredictPositionResolved.tsx | 2 +- .../PredictPositionsHeader.test.tsx | 12 +- .../UI/Predict/utils/format.test.ts | 441 ++++++++++++++---- app/components/UI/Predict/utils/format.ts | 100 ++-- .../PredictMarketDetails.test.tsx | 25 +- .../PredictSellPreview.test.tsx | 23 +- .../PredictSellPreview/PredictSellPreview.tsx | 156 +++++-- .../predict-claim-amount.test.tsx | 4 +- locales/languages/en.json | 10 +- 19 files changed, 655 insertions(+), 292 deletions(-) diff --git a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx index 68d060fada6..6f7f063273e 100644 --- a/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx +++ b/app/components/UI/Predict/components/PredictActivity/PredictActivity.test.tsx @@ -94,7 +94,7 @@ describe('PredictActivity', () => { expect(screen.getByText('Buy')).toBeOnTheScreen(); expect(screen.getByText(baseItem.marketTitle)).toBeOnTheScreen(); expect(screen.getByText('-$1,234.50')).toBeOnTheScreen(); - expect(screen.getByText('2%')).toBeOnTheScreen(); + expect(screen.getByText('1.5%')).toBeOnTheScreen(); }); it('renders SELL activity with plus-signed amount and negative percent', () => { diff --git a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx index b90fa5d2c82..1911d2fe563 100644 --- a/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx +++ b/app/components/UI/Predict/components/PredictActivityDetail/PredictActivityDetail.test.tsx @@ -199,7 +199,7 @@ describe('PredictActivityDetail', () => { expect(screen.getByText(expectedPricePerShare)).toBeOnTheScreen(); expect(screen.getByText('Price impact')).toBeOnTheScreen(); - expect(screen.getByText('2%')).toBeOnTheScreen(); + expect(screen.getByText('1.5%')).toBeOnTheScreen(); expect(screen.queryByLabelText('USDC')).toBeNull(); }); diff --git a/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx b/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx index 317f9d182c4..f131c537f70 100644 --- a/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx +++ b/app/components/UI/Predict/components/PredictBalance/PredictBalance.test.tsx @@ -169,7 +169,7 @@ describe('PredictBalance', () => { }); // Assert - expect(getByText(/\$123\.45/)).toBeOnTheScreen(); + expect(getByText(/\$123\.46/)).toBeOnTheScreen(); }); it('displays zero balance', () => { @@ -189,7 +189,7 @@ describe('PredictBalance', () => { }); // Assert - expect(getByText(/\$0\.00/)).toBeOnTheScreen(); + expect(getByText(/\$0/)).toBeOnTheScreen(); }); it('displays large balance correctly', () => { @@ -209,7 +209,7 @@ describe('PredictBalance', () => { }); // Assert - expect(getByText(/\$1,234,567\.88/)).toBeOnTheScreen(); + expect(getByText(/\$1,234,567\.89/)).toBeOnTheScreen(); }); it('renders container with correct test ID', () => { diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index d2a6e7f7759..778591eb91d 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -81,7 +81,7 @@ const PredictMarketMultiple: React.FC = ({ const parsed = outcomePrices; if (Array.isArray(parsed) && parsed.length > 0) { const firstValue = parsed[0]; - return formatPercentage(firstValue * 100); + return formatPercentage(firstValue * 100, { truncate: true }); } } catch (error) { DevLogger.log('PredictMarketMultiple: Failed to parse outcomePrices', { diff --git a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx index 7f3e9b06dbc..2001f39b3ce 100644 --- a/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx +++ b/app/components/UI/Predict/components/PredictMarketOutcome/PredictMarketOutcome.tsx @@ -73,7 +73,7 @@ const PredictMarketOutcome: React.FC = ({ const getYesPercentage = (): string => { const prices = getOutcomePrices(); if (prices.length > 0) { - return formatPercentage(prices[0] * 100); + return formatPercentage(prices[0] * 100, { truncate: true }); } return '0%'; }; diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx b/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx index 91e2f9a1d31..c85595a6927 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.test.tsx @@ -7,6 +7,15 @@ import { } from '../../types'; import { PredictPositionSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, vars?: Record) => { + if (key === 'predict.position_info' && vars) { + return `${vars.initialValue} on ${vars.outcome} to win ${vars.shares}`; + } + return key; + }), +})); + const basePosition: PredictPositionType = { id: 'pos-1', providerId: 'polymarket', @@ -46,17 +55,15 @@ describe('PredictPosition', () => { renderComponent(); expect(screen.getByText(basePosition.title)).toBeOnTheScreen(); - expect( - screen.getByText('$123.45 on Yes · 10 shares at 34¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$123.45 on Yes to win $10')).toBeOnTheScreen(); expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); - expect(screen.getByText('5%')).toBeOnTheScreen(); + expect(screen.getByText('5.25%')).toBeOnTheScreen(); }); it.each([ - { value: -3.5, expected: '-3%' }, + { value: -3.5, expected: '-3.5%' }, { value: 0, expected: '0%' }, - { value: 7.5, expected: '8%' }, + { value: 7.5, expected: '7.5%' }, ])('formats percentPnl $value as $expected', ({ value, expected }) => { renderComponent({ percentPnl: value }); @@ -71,9 +78,7 @@ describe('PredictPosition', () => { size: 10, }); - expect( - screen.getByText('$50.00 on No · 10 shares at 70¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$50 on No to win $10')).toBeOnTheScreen(); }); it('displays singular share when size is 1', () => { @@ -84,7 +89,7 @@ describe('PredictPosition', () => { size: 1, }); - expect(screen.getByText('$50.00 on No · 1 share at 70¢')).toBeOnTheScreen(); + expect(screen.getByText('$50 on No to win $1')).toBeOnTheScreen(); }); it('renders icon image with correct URI', () => { @@ -154,33 +159,25 @@ describe('PredictPosition', () => { it('formats avgPrice with 1 decimal precision in cents', () => { renderComponent({ avgPrice: 0.456, size: 5 }); - expect( - screen.getByText('$123.45 on Yes · 5 shares at 45.6¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$123.45 on Yes to win $5')).toBeOnTheScreen(); }); it('formats avgPrice as whole cents when no decimals needed', () => { renderComponent({ avgPrice: 0.5, size: 2 }); - expect( - screen.getByText('$123.45 on Yes · 2 shares at 50¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$123.45 on Yes to win $2')).toBeOnTheScreen(); }); it('formats initialValue without decimals when minimumDecimals is 0', () => { renderComponent({ initialValue: 100, size: 3 }); - expect( - screen.getByText('$100.00 on Yes · 3 shares at 34¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$100 on Yes to win $3')).toBeOnTheScreen(); }); it('formats size with 2 decimal places', () => { renderComponent({ size: 10.5555, initialValue: 200 }); - expect( - screen.getByText('$200.00 on Yes · 10.56 shares at 34¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$200 on Yes to win $10.56')).toBeOnTheScreen(); }); it('renders all position properties correctly', () => { @@ -209,11 +206,9 @@ describe('PredictPosition', () => { render(); expect(screen.getByText('Test Market Question?')).toBeOnTheScreen(); - expect( - screen.getByText('$75.25 on Maybe · 7.50 shares at 62.5¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$75.25 on Maybe to win $7.50')).toBeOnTheScreen(); expect(screen.getByText('$100.75')).toBeOnTheScreen(); - expect(screen.getByText('16%')).toBeOnTheScreen(); + expect(screen.getByText('15.75%')).toBeOnTheScreen(); }); describe('optimistic updates UI', () => { @@ -233,15 +228,13 @@ describe('PredictPosition', () => { renderComponent({ optimistic: false }); expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); - expect(screen.getByText('5%')).toBeOnTheScreen(); + expect(screen.getByText('5.25%')).toBeOnTheScreen(); }); it('shows initial value line when optimistic', () => { renderComponent({ optimistic: true, initialValue: 123.45 }); - expect( - screen.getByText('$123.45 on Yes · 10 shares at 34¢'), - ).toBeOnTheScreen(); + expect(screen.getByText('$123.45 on Yes to win $10')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx b/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx index 95a9a52d3d6..4797299833e 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.tsx @@ -6,12 +6,7 @@ import Text, { } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks'; import { PredictPosition as PredictPositionType } from '../../types'; -import { - formatCents, - formatPercentage, - formatPositionSize, - formatPrice, -} from '../../utils/format'; +import { formatPercentage, formatPrice } from '../../utils/format'; import styleSheet from './PredictPosition.styles'; import { PredictPositionSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; import { strings } from '../../../../../../locales/i18n'; @@ -32,7 +27,6 @@ const PredictPosition: React.FC = ({ initialValue, percentPnl, outcome, - avgPrice, currentValue, size, optimistic, @@ -53,23 +47,15 @@ const PredictPosition: React.FC = ({ {title} - {strings( - size !== 1 - ? 'predict.position_info_plural' - : 'predict.position_info_singular', - { - amount: formatPrice(initialValue, { - minimumDecimals: 0, - maximumDecimals: 2, - }), - outcome, - shares: formatPositionSize(size, { - minimumDecimals: 2, - maximumDecimals: 2, - }), - priceCents: formatCents(avgPrice), - }, - )} + {strings('predict.position_info', { + initialValue: formatPrice(initialValue, { + maximumDecimals: 2, + }), + outcome, + shares: formatPrice(size, { + maximumDecimals: 2, + }), + })} diff --git a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx index de8616aab96..d20652b1031 100644 --- a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.test.tsx @@ -17,7 +17,7 @@ declare global { } jest.mock('../../../../../../locales/i18n', () => ({ - strings: (key: string, _vars?: Record) => { + strings: (key: string, vars?: Record) => { switch (key) { case 'predict.market_details.won': return 'Won'; @@ -25,6 +25,8 @@ jest.mock('../../../../../../locales/i18n', () => ({ return 'Lost'; case 'predict.cash_out': return 'Cash out'; + case 'predict.position_info': + return `${vars?.initialValue} on ${vars?.outcome} to win ${vars?.shares}`; default: return key; } @@ -187,18 +189,18 @@ describe('PredictPositionDetail', () => { expect(screen.getByText('Group')).toBeOnTheScreen(); expect( - screen.getByText('$123.45 on Yes • 34¢', { exact: false }), + screen.getByText('$123.45 on Yes to win $10', { exact: false }), ).toBeOnTheScreen(); expect(screen.getByText('$2,345.67')).toBeOnTheScreen(); - expect(screen.getByText('5%')).toBeOnTheScreen(); + expect(screen.getByText('5.25%')).toBeOnTheScreen(); expect(screen.getByText('Cash out')).toBeOnTheScreen(); }); it.each([ - { value: -3.5, expected: '-3%' }, + { value: -3.5, expected: '-3.5%' }, { value: 0, expected: '0%' }, - { value: 7.5, expected: '8%' }, + { value: 7.5, expected: '7.5%' }, ])('formats percentPnl %p as %p for open market', ({ value, expected }) => { renderComponent({ percentPnl: value }); @@ -210,7 +212,7 @@ describe('PredictPositionDetail', () => { expect(screen.getByText('Group')).toBeOnTheScreen(); expect( - screen.getByText('$50.00 on No • 70¢', { exact: false }), + screen.getByText('$50 on No to win $10', { exact: false }), ).toBeOnTheScreen(); }); @@ -221,7 +223,7 @@ describe('PredictPositionDetail', () => { PredictMarketStatus.CLOSED, ); - expect(screen.getByText('Won $500.00')).toBeOnTheScreen(); + expect(screen.getByText('Won $500')).toBeOnTheScreen(); expect(screen.queryByText('+12.34%')).toBeNull(); expect(screen.queryByText('Cash out')).toBeNull(); }); @@ -233,7 +235,7 @@ describe('PredictPositionDetail', () => { PredictMarketStatus.CLOSED, ); - expect(screen.getByText('Lost $321.08')).toBeOnTheScreen(); + expect(screen.getByText('Lost $321.09')).toBeOnTheScreen(); expect(screen.queryByText('Cash out')).toBeNull(); }); @@ -268,7 +270,7 @@ describe('PredictPositionDetail', () => { renderComponent({ optimistic: true, initialValue: 123.45 }); expect( - screen.getByText('$123.45 on Yes • 34¢', { exact: false }), + screen.getByText('$123.45 on Yes to win $10', { exact: false }), ).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx index daf1539a765..7ec5e379a26 100644 --- a/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx +++ b/app/components/UI/Predict/components/PredictPositionDetail/PredictPositionDetail.tsx @@ -1,30 +1,30 @@ +import { Box } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; import React from 'react'; import { Image } from 'react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { Box } from '@metamask/design-system-react-native'; +import { PredictMarketDetailsSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; +import { strings } from '../../../../../../locales/i18n'; +import Button, { + ButtonSize, + ButtonVariants, + ButtonWidthTypes, +} from '../../../../../component-library/components/Buttons/Button'; +import { Skeleton } from '../../../../../component-library/components/Skeleton'; import Text, { TextColor, TextVariant, } from '../../../../../component-library/components/Texts/Text'; +import Routes from '../../../../../constants/navigation/Routes'; +import { PredictEventValues } from '../../constants/eventNames'; +import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; import { - PredictPosition as PredictPositionType, PredictMarket, PredictMarketStatus, + PredictPosition as PredictPositionType, } from '../../types'; -import { formatCents, formatPercentage, formatPrice } from '../../utils/format'; -import Button, { - ButtonVariants, - ButtonSize, - ButtonWidthTypes, -} from '../../../../../component-library/components/Buttons/Button'; -import Routes from '../../../../../constants/navigation/Routes'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; import { PredictNavigationParamList } from '../../types/navigation'; -import { PredictEventValues } from '../../constants/eventNames'; -import { strings } from '../../../../../../locales/i18n'; -import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; -import { PredictMarketDetailsSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; +import { formatPercentage, formatPrice } from '../../utils/format'; interface PredictPositionProps { position: PredictPositionType; @@ -44,10 +44,10 @@ const PredictPosition: React.FC = ({ initialValue, percentPnl, outcome, - avgPrice, currentValue, title, optimistic, + size, } = position; const navigation = useNavigation>(); @@ -133,8 +133,15 @@ const PredictPosition: React.FC = ({ variant={TextVariant.BodySMMedium} color={TextColor.Alternative} > - {formatPrice(initialValue, { maximumDecimals: 2 })} on {outcome} •{' '} - {formatCents(avgPrice)} + {strings('predict.position_info', { + initialValue: formatPrice(initialValue, { + maximumDecimals: 2, + }), + outcome, + shares: formatPrice(size, { + maximumDecimals: 2, + }), + })} diff --git a/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.test.tsx b/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.test.tsx index 187f7966747..a15ecfa10b0 100644 --- a/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.test.tsx @@ -12,7 +12,7 @@ jest.mock('../../../../../../locales/i18n', () => ({ const translations: Record = { 'predict.market_details.resolved_early': 'Resolved early', 'predict.market_details.ended': 'Ended', - 'predict.market_details.amount_on_outcome': `$${params?.amount} on ${params?.outcome}`, + 'predict.market_details.amount_on_outcome': `${params?.amount} on ${params?.outcome}`, 'predict.market_details.won': 'Won', 'predict.market_details.lost': 'Lost', }; @@ -87,9 +87,9 @@ describe('PredictPositionResolved', () => { percentPnl: -50, }); - expect(screen.getByText(/\$100\.00 on Yes/)).toBeOnTheScreen(); + expect(screen.getByText(/\$100 on Yes/)).toBeOnTheScreen(); expect(screen.getByText(/Ended 2 days ago/)).toBeOnTheScreen(); - expect(screen.getByText(/Lost \$50\.00/)).toBeOnTheScreen(); + expect(screen.getByText(/Lost\s+\$50/)).toBeOnTheScreen(); }); it('renders different outcome text', () => { @@ -106,7 +106,7 @@ describe('PredictPositionResolved', () => { percentPnl: 0, }); - expect(screen.getByText(/Lost \$0\.00/)).toBeOnTheScreen(); + expect(screen.getByText('Lost $0')).toBeOnTheScreen(); }); it('calls onPress when position is tapped', () => { diff --git a/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.tsx b/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.tsx index 441d70de07b..4f4f7d9be56 100644 --- a/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.tsx +++ b/app/components/UI/Predict/components/PredictPositionResolved/PredictPositionResolved.tsx @@ -76,7 +76,7 @@ const PredictPositionResolved: React.FC = ({ ellipsizeMode="tail" > {strings('predict.market_details.amount_on_outcome', { - amount: initialValue.toFixed(2), + amount: formatPrice(initialValue, { maximumDecimals: 2 }), outcome, })}{' '} • {formatMarketEndDate(endDate)} diff --git a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx index d9869954786..6ee71454bad 100644 --- a/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx +++ b/app/components/UI/Predict/components/PredictPositionsHeader/PredictPositionsHeader.test.tsx @@ -509,7 +509,7 @@ describe('MarketsWonCard', () => { expect(screen.getByText('Available Balance')).toBeOnTheScreen(); expect(screen.getByText('$100.50')).toBeOnTheScreen(); expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('+$8.63 (+4%)')).toBeOnTheScreen(); + expect(screen.getByText('+$8.63 (+3.9%)')).toBeOnTheScreen(); }); it('renders claim button without loading indicator when isLoading is false', () => { setupMarketsWonCardTest({ isLoading: false }); @@ -532,7 +532,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$123.46 (+6%)')).toBeOnTheScreen(); + expect(screen.getByText('+$123.46 (+5.67%)')).toBeOnTheScreen(); }); it('formats negative unrealized amount correctly', () => { @@ -547,7 +547,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('-$50.25 (-2%)')).toBeOnTheScreen(); + expect(screen.getByText('-$50.25 (-2.1%)')).toBeOnTheScreen(); }); it('handles zero unrealized amount correctly', () => { @@ -649,7 +649,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$999999.99 (+>99%)')).toBeOnTheScreen(); + expect(screen.getByText('+$999999.99 (+999.9%)')).toBeOnTheScreen(); }); it('handles very small unrealized amounts', () => { @@ -664,7 +664,7 @@ describe('MarketsWonCard', () => { }, ); - expect(screen.getByText('+$0.01 (+<1%)')).toBeOnTheScreen(); + expect(screen.getByText('+$0.01 (+0.1%)')).toBeOnTheScreen(); }); it('handles very large available balance', () => { @@ -757,7 +757,7 @@ describe('MarketsWonCard', () => { ); expect(screen.getByText('Unrealized P&L')).toBeOnTheScreen(); - expect(screen.getByText('-$15.75 (-8%)')).toBeOnTheScreen(); + expect(screen.getByText('-$15.75 (-8.2%)')).toBeOnTheScreen(); }); it('does not show unrealized P&L section when hook returns null data', () => { diff --git a/app/components/UI/Predict/utils/format.test.ts b/app/components/UI/Predict/utils/format.test.ts index 629247f8aac..61e93c64993 100644 --- a/app/components/UI/Predict/utils/format.test.ts +++ b/app/components/UI/Predict/utils/format.test.ts @@ -40,118 +40,344 @@ describe('format utils', () => { }); describe('formatPercentage', () => { - it('formats positive decimal percentage with no decimals', () => { - // Arrange & Act - const result = formatPercentage(5.25); + describe('default behavior (truncate=false)', () => { + it('formats positive decimal percentage with 2 decimals', () => { + // Arrange & Act + const result = formatPercentage(5.25); - // Assert - expect(result).toBe('5%'); - }); + // Assert + expect(result).toBe('5.25%'); + }); - it('formats large percentage as >99%', () => { - // Arrange & Act - const result = formatPercentage(100); + it('formats large percentage without truncation', () => { + // Arrange & Act + const result = formatPercentage(100); - // Assert - expect(result).toBe('>99%'); - }); + // Assert + expect(result).toBe('100%'); + }); - it('formats negative decimal percentage with no decimals', () => { - // Arrange & Act - const result = formatPercentage(-2.75); + it('formats negative decimal percentage with 2 decimals', () => { + // Arrange & Act + const result = formatPercentage(-2.75); - // Assert - expect(result).toBe('-3%'); - }); + // Assert + expect(result).toBe('-2.75%'); + }); - it('formats negative whole number percentage without decimals', () => { - // Arrange & Act - const result = formatPercentage(-50); + it('formats negative whole number percentage without decimals', () => { + // Arrange & Act + const result = formatPercentage(-50); - // Assert - expect(result).toBe('-50%'); - }); + // Assert + expect(result).toBe('-50%'); + }); - it('formats zero as 0%', () => { - // Arrange & Act - const result = formatPercentage(0); + it('formats zero as 0%', () => { + // Arrange & Act + const result = formatPercentage(0); - // Assert - expect(result).toBe('0%'); - }); + // Assert + expect(result).toBe('0%'); + }); - it('handles string input with decimal value', () => { - // Arrange & Act - const result = formatPercentage('3.14159'); + it('handles string input with decimal value', () => { + // Arrange & Act + const result = formatPercentage('3.14159'); - // Assert - expect(result).toBe('3%'); - }); + // Assert + expect(result).toBe('3.14%'); + }); - it('handles string input with whole number', () => { - // Arrange & Act - const result = formatPercentage('42'); + it('handles string input with whole number', () => { + // Arrange & Act + const result = formatPercentage('42'); - // Assert - expect(result).toBe('42%'); - }); + // Assert + expect(result).toBe('42%'); + }); - it('handles string input with negative value', () => { - // Arrange & Act - const result = formatPercentage('-7.89'); + it('handles string input with negative value', () => { + // Arrange & Act + const result = formatPercentage('-7.89'); - // Assert - expect(result).toBe('-8%'); - }); + // Assert + expect(result).toBe('-7.89%'); + }); - it('returns default value for NaN input', () => { - // Arrange & Act - const result = formatPercentage('not-a-number'); + it('returns default value for NaN input', () => { + // Arrange & Act + const result = formatPercentage('not-a-number'); - // Assert - expect(result).toBe('0%'); - }); + // Assert + expect(result).toBe('0%'); + }); - it('returns default value for invalid string', () => { - // Arrange & Act - const result = formatPercentage('abc'); + it('returns default value for invalid string', () => { + // Arrange & Act + const result = formatPercentage('abc'); - // Assert - expect(result).toBe('0%'); + // Assert + expect(result).toBe('0%'); + }); + + it('returns default value for empty string', () => { + // Arrange & Act + const result = formatPercentage(''); + + // Assert + expect(result).toBe('0%'); + }); + + it.each([ + [0.01, '0.01%'], + [0.001, '0%'], + [0.5, '0.5%'], + [0.9, '0.9%'], + [1.999, '2%'], + [99, '99%'], + [99.999, '100%'], + [100, '100%'], + [-0.01, '-0.01%'], + [-0.001, '0%'], + [-1.999, '-2%'], + ])('formats %f correctly as %s', (input, expected) => { + expect(formatPercentage(input)).toBe(expected); + }); }); - it('returns default value for empty string', () => { - // Arrange & Act - const result = formatPercentage(''); + describe('with truncate=true', () => { + it('formats positive decimal percentage with no decimals', () => { + // Arrange & Act + const result = formatPercentage(5.25, { truncate: true }); - // Assert - expect(result).toBe('0%'); + // Assert + expect(result).toBe('5%'); + }); + + it('formats large percentage as >99%', () => { + // Arrange & Act + const result = formatPercentage(100, { truncate: true }); + + // Assert + expect(result).toBe('>99%'); + }); + + it('formats negative decimal percentage with no decimals', () => { + // Arrange & Act + const result = formatPercentage(-2.75, { truncate: true }); + + // Assert + expect(result).toBe('-3%'); + }); + + it('formats negative whole number percentage without decimals', () => { + // Arrange & Act + const result = formatPercentage(-50, { truncate: true }); + + // Assert + expect(result).toBe('-50%'); + }); + + it('formats zero as 0%', () => { + // Arrange & Act + const result = formatPercentage(0, { truncate: true }); + + // Assert + expect(result).toBe('0%'); + }); + + it('handles string input with decimal value', () => { + // Arrange & Act + const result = formatPercentage('3.14159', { truncate: true }); + + // Assert + expect(result).toBe('3%'); + }); + + it('handles string input with whole number', () => { + // Arrange & Act + const result = formatPercentage('42', { truncate: true }); + + // Assert + expect(result).toBe('42%'); + }); + + it('handles string input with negative value', () => { + // Arrange & Act + const result = formatPercentage('-7.89', { truncate: true }); + + // Assert + expect(result).toBe('-8%'); + }); + + it('returns default value for NaN input', () => { + // Arrange & Act + const result = formatPercentage('not-a-number', { truncate: true }); + + // Assert + expect(result).toBe('0%'); + }); + + it.each([ + [0.01, '<1%'], + [0.001, '<1%'], + [0.5, '<1%'], + [0.9, '<1%'], + [1.999, '2%'], + [99, '>99%'], + [99.999, '>99%'], + [100, '>99%'], + [-0.01, '0%'], + [-0.001, '0%'], + [-1.999, '-2%'], + ])('formats %f correctly as %s', (input, expected) => { + expect(formatPercentage(input, { truncate: true })).toBe(expected); + }); }); - it.each([ - [0.01, '<1%'], - [0.001, '<1%'], - [0.5, '<1%'], - [0.9, '<1%'], - [1.999, '2%'], - [99, '>99%'], - [99.999, '>99%'], - [100, '>99%'], - [-0.01, '0%'], - [-0.001, '0%'], - [-1.999, '-2%'], - ])('formats %f correctly as %s', (input, expected) => { - expect(formatPercentage(input)).toBe(expected); + describe('with truncate=false', () => { + it('displays integer percentage without decimals', () => { + // Arrange & Act + const result = formatPercentage(5, { truncate: false }); + + // Assert + expect(result).toBe('5%'); + }); + + it('displays percentage with 2 decimals when not integer', () => { + // Arrange & Act + const result = formatPercentage(5.25, { truncate: false }); + + // Assert + expect(result).toBe('5.25%'); + }); + + it('displays percentage with 1 decimal when second decimal is zero', () => { + // Arrange & Act + const result = formatPercentage(5.5, { truncate: false }); + + // Assert + expect(result).toBe('5.5%'); + }); + + it('displays values above 99 with actual percentage', () => { + // Arrange & Act + const result = formatPercentage(99.5, { truncate: false }); + + // Assert + expect(result).toBe('99.5%'); + }); + + it('displays values above 100 with actual percentage', () => { + // Arrange & Act + const result = formatPercentage(150, { truncate: false }); + + // Assert + expect(result).toBe('150%'); + }); + + it('displays values below 1 with actual percentage', () => { + // Arrange & Act + const result = formatPercentage(0.5, { truncate: false }); + + // Assert + expect(result).toBe('0.5%'); + }); + + it('displays small decimal values with 2 decimals', () => { + // Arrange & Act + const result = formatPercentage(0.01, { truncate: false }); + + // Assert + expect(result).toBe('0.01%'); + }); + + it('displays negative percentage with decimals', () => { + // Arrange & Act + const result = formatPercentage(-2.75, { truncate: false }); + + // Assert + expect(result).toBe('-2.75%'); + }); + + it('displays negative integer percentage without decimals', () => { + // Arrange & Act + const result = formatPercentage(-50, { truncate: false }); + + // Assert + expect(result).toBe('-50%'); + }); + + it('displays zero without decimals', () => { + // Arrange & Act + const result = formatPercentage(0, { truncate: false }); + + // Assert + expect(result).toBe('0%'); + }); + + it('rounds to 2 decimals when more decimals provided', () => { + // Arrange & Act + const result = formatPercentage(5.256, { truncate: false }); + + // Assert + expect(result).toBe('5.26%'); + }); + + it('handles string input with decimals', () => { + // Arrange & Act + const result = formatPercentage('3.14159', { truncate: false }); + + // Assert + expect(result).toBe('3.14%'); + }); + + it('handles string input with integer', () => { + // Arrange & Act + const result = formatPercentage('42', { truncate: false }); + + // Assert + expect(result).toBe('42%'); + }); + + it('returns default value for NaN input', () => { + // Arrange & Act + const result = formatPercentage('not-a-number', { truncate: false }); + + // Assert + expect(result).toBe('0%'); + }); + + it.each([ + [0.01, '0.01%'], + [0.001, '0%'], + [0.5, '0.5%'], + [0.9, '0.9%'], + [1.999, '2%'], + [99, '99%'], + [99.999, '100%'], + [99.5, '99.5%'], + [100, '100%'], + [150.75, '150.75%'], + [-0.01, '-0.01%'], + [-0.001, '0%'], + [-1.999, '-2%'], + [-50, '-50%'], + [-2.75, '-2.75%'], + ])('formats %f correctly as %s', (input, expected) => { + expect(formatPercentage(input, { truncate: false })).toBe(expected); + }); }); }); describe('formatPrice', () => { - it('formats prices with exactly 2 decimal places (truncated)', () => { + it('formats prices with exactly 2 decimal places (rounded up)', () => { // Arrange & Act const result = formatPrice(1234.5678); // Assert - expect(result).toBe('$1,234.56'); + expect(result).toBe('$1,234.57'); }); it('formats prices ignoring custom minimum decimals option', () => { @@ -159,18 +385,29 @@ describe('format utils', () => { const result = formatPrice(50000, { minimumDecimals: 0 }); // Assert - expect(result).toBe('$50,000.00'); + expect(result).toBe('$50,000'); + }); + + it('formats prices respecting custom minimum decimals option', () => { + // Arrange & Act + const result = formatPrice(1234.5678, { + minimumDecimals: 4, + maximumDecimals: 4, + }); + + // Assert + expect(result).toBe('$1,234.5678'); }); - it('formats prices ignoring custom maximum decimals option', () => { + it('respects minimumDecimals for integer values', () => { // Arrange & Act - const result = formatPrice(1234.5678, { minimumDecimals: 4 }); + const result = formatPrice(100, { minimumDecimals: 2 }); // Assert - expect(result).toBe('$1,234.56'); + expect(result).toBe('$100.00'); }); - it('formats small prices with 2 decimal places (truncated)', () => { + it('formats small prices with 2 decimal places (rounded)', () => { // Arrange & Act const result = formatPrice(0.1234); @@ -178,12 +415,12 @@ describe('format utils', () => { expect(result).toBe('$0.12'); }); - it('formats very small prices as $0.00', () => { + it('formats very small prices rounded', () => { // Arrange & Act const result = formatPrice(0.0001234); // Assert - expect(result).toBe('$0.00'); + expect(result).toBe('$0'); }); it('handles string input with decimal value', () => { @@ -191,7 +428,7 @@ describe('format utils', () => { const result = formatPrice('1234.5678'); // Assert - expect(result).toBe('$1,234.56'); + expect(result).toBe('$1,234.57'); }); it('handles string input with small value', () => { @@ -239,7 +476,7 @@ describe('format utils', () => { const result = formatPrice(1000); // Assert - expect(result).toBe('$1,000.00'); + expect(result).toBe('$1,000'); }); it('formats negative prices correctly', () => { @@ -255,7 +492,7 @@ describe('format utils', () => { const result = formatPrice(0); // Assert - expect(result).toBe('$0.00'); + expect(result).toBe('$0'); }); it('formats very large numbers correctly', () => { @@ -263,23 +500,23 @@ describe('format utils', () => { const result = formatPrice(1000000); // Assert - expect(result).toBe('$1,000,000.00'); + expect(result).toBe('$1,000,000'); }); - it('truncates not rounds - 1234.999 becomes $1,234.99 not $1,235.00', () => { + it('rounds up to next cent - 1234.999 becomes $1,235', () => { // Arrange & Act const result = formatPrice(1234.999); // Assert - expect(result).toBe('$1,234.99'); + expect(result).toBe('$1,235'); }); it.each([ - [999.999, '$999.99'], - [1000, '$1,000.00'], - [1000.001, '$1,000.00'], - [0.9999, '$0.99'], - [0.00009999, '$0.00'], + [999.999, '$1,000'], + [1000, '$1,000'], + [1000.001, '$1,000'], + [0.9999, '$1'], + [0.00009999, '$0'], ])('formats boundary value %f as %s', (input, expected) => { const result = formatPrice(input); expect(result).toBe(expected); @@ -316,13 +553,13 @@ describe('format utils', () => { expect(result).toBe(expected); }); - it('uses absolute value and 2 decimals (truncated) for values >= 1000', () => { + it('uses absolute value and 2 decimals (rounded) for values >= 1000', () => { const result = formatCurrencyValue(-1234.567); - expect(result).toBe('$1,234.56'); + expect(result).toBe('$1,234.57'); }); - it('uses absolute value and 2 decimals (truncated) for values < 1000', () => { + it('uses absolute value and 2 decimals (rounded) for values < 1000', () => { const result = formatCurrencyValue(-0.1234); expect(result).toBe('$0.12'); @@ -1302,7 +1539,7 @@ describe('format utils', () => { expect(result).toBe('6.75'); }); - it('handles very small decimal amounts precisely', () => { + it('handles very small decimal amounts', () => { const params = { totalFiat: '0.001', bridgeFeeFiat: '0.0001', diff --git a/app/components/UI/Predict/utils/format.ts b/app/components/UI/Predict/utils/format.ts index 10e733508c5..f6f2a8f557d 100644 --- a/app/components/UI/Predict/utils/format.ts +++ b/app/components/UI/Predict/utils/format.ts @@ -2,69 +2,107 @@ import { Dimensions } from 'react-native'; import { PredictSeries, Recurrence } from '../types'; /** - * Formats a percentage value with no decimals + * Formats a percentage value * @param value - Raw percentage value (e.g., 5.25 for 5.25%, not 0.0525) - * @returns Format: "X%" with no decimals - * - For values >= 99: ">99%" - * - For values < 1 (but > 0): "<1%" - * - For negative values: rounded normally (e.g., "-3%", "-99%") - * @example formatPercentage(5.25) => "5%" - * @example formatPercentage(99.5) => ">99%" - * @example formatPercentage(0.5) => "<1%" - * @example formatPercentage(-2.75) => "-3%" - * @example formatPercentage(-99.5) => "-100%" - * @example formatPercentage(0) => "0%" + * @param options - Optional formatting options + * @param options.truncate - Whether to truncate values with >99% and <1% (default: false) + * @returns Format depends on truncate option: + * - truncate=false (default): Shows actual percentage with up to 2 decimals, hides decimals for integers + * - truncate=true: ">99%" for values >= 99, "<1%" for values < 1, rounded integer otherwise + * @example formatPercentage(5.25) => "5.25%" + * @example formatPercentage(5.25, { truncate: true }) => "5%" + * @example formatPercentage(99.5) => "99.5%" + * @example formatPercentage(99.5, { truncate: true }) => ">99%" + * @example formatPercentage(0.5) => "0.5%" + * @example formatPercentage(0.5, { truncate: true }) => "<1%" + * @example formatPercentage(5) => "5%" + * @example formatPercentage(-2.75) => "-2.75%" + * @example formatPercentage(-2.75, { truncate: true }) => "-3%" */ -export const formatPercentage = (value: string | number): string => { +export const formatPercentage = ( + value: string | number, + options?: { truncate?: boolean }, +): string => { const num = typeof value === 'string' ? parseFloat(value) : value; + const truncate = options?.truncate ?? false; if (isNaN(num)) { return '0%'; } - // Handle special cases for positive numbers only - if (num >= 99) { - return '>99%'; + // Handle truncation mode (when explicitly enabled) + if (truncate) { + // Handle special cases for positive numbers only + if (num >= 99) { + return '>99%'; + } + + if (num > 0 && num < 1) { + return '<1%'; + } + + // Round to nearest integer + return `${Math.round(num)}%`; } - if (num > 0 && num < 1) { - return '<1%'; + // Non-truncated mode: show up to 2 decimals + // Check if the number is an integer + if (num === Math.floor(num)) { + return `${num}%`; } - // Round to nearest integer - return `${Math.round(num)}%`; + // Format with up to 2 decimals, removing trailing zeros + const formatted = num.toFixed(2).replace(/\.?0+$/, ''); + + // Handle edge case: toFixed can return "-0" for very small negative numbers + if (formatted === '-0') { + return '0%'; + } + + return `${formatted}%`; }; /** - * Formats a price value as USD currency with exactly 2 decimal places (truncated, no rounding) + * Formats a price value as USD currency with rounding up to nearest cent * @param price - Raw numeric price value * @param options - Optional formatting options (kept for backwards compatibility, but not used) - * @returns USD formatted string with exactly 2 decimals (truncated, not rounded) - * @example formatPrice(1234.5678) => "$1,234.56" - * @example formatPrice(0.1234) => "$0.12" - * @example formatPrice(50000) => "$50,000.00" - * @example formatPrice(1234.999) => "$1,234.99" (truncated, not rounded to $1,235.00) + * @returns USD formatted string, hiding .00 for integer values, rounding up to nearest cent for 3+ decimals + * @example formatPrice(1234.5678) => "$1,234.57" (rounds up from .5678) + * @example formatPrice(0.1234) => "$0.13" (rounds up from .1234) + * @example formatPrice(50000) => "$50,000" (no .00 for integers) + * @example formatPrice(1234.999) => "$1,235" (rounds up to next dollar) + * @example formatPrice(0.991) => "$1" (rounds up from .991) */ export const formatPrice = ( price: string | number, _options?: { minimumDecimals?: number; maximumDecimals?: number }, ): string => { const num = typeof price === 'string' ? parseFloat(price) : price; + const maximumDecimals = _options?.maximumDecimals ?? 2; + const minimumDecimals = _options?.minimumDecimals; if (isNaN(num)) { return '$0.00'; } - // Truncate to 2 decimal places (no rounding) - const truncated = Math.floor(num * 100) / 100; + // Round to the specified maximum decimal places + const multiplier = Math.pow(10, maximumDecimals); + const rounded = Math.round(num * multiplier) / multiplier; + + // Check if it's an integer (no decimal part) + const isInteger = rounded === Math.floor(rounded); + + // Format with appropriate decimal places + // If user explicitly set minimumDecimals, use it; otherwise, show no decimals for integers + const minFractionDigits = + minimumDecimals !== undefined ? minimumDecimals : isInteger ? 0 : 2; - // Format with exactly 2 decimal places return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(truncated); + minimumFractionDigits: minFractionDigits, + maximumFractionDigits: maximumDecimals, + }).format(rounded); }; /** diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index 2e08d6ce170..a18edfd388a 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -132,7 +132,12 @@ jest.mock('react-native-safe-area-context', () => { }); jest.mock('../../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string) => key), + strings: jest.fn((key: string, vars?: Record) => { + if (key === 'predict.position_info' && vars) { + return `${vars.initialValue} on ${vars.outcome} to win ${vars.shares}`; + } + return key; + }), })); jest.mock('../../../Navbar', () => ({ @@ -166,6 +171,12 @@ jest.mock('../../utils/format', () => ({ } return `${cents.toFixed(1)}¢`; }), + formatPositionSize: jest.fn( + ( + value: number, + options?: { minimumDecimals?: number; maximumDecimals?: number }, + ) => value.toFixed(options?.maximumDecimals || 2), + ), })); jest.mock('../../hooks/usePredictMarket', () => ({ @@ -1527,7 +1538,9 @@ describe('PredictMarketDetails', () => { expect(screen.getByText('predict.cash_out')).toBeOnTheScreen(); expect( - screen.getByText('$65.00 on Yes • 65¢', { exact: false }), + screen.getByText('$65.00 on Yes to win $100.00', { + exact: false, + }), ).toBeOnTheScreen(); expect(screen.getByText('+7.70%')).toBeOnTheScreen(); }); @@ -1705,7 +1718,9 @@ describe('PredictMarketDetails', () => { expect(screen.getByText('Yes Option')).toBeOnTheScreen(); expect( - screen.getByText('$65.00 on Yes • 65¢', { exact: false }), + screen.getByText('$65.00 on Yes to win $100.00', { + exact: false, + }), ).toBeOnTheScreen(); }); @@ -1736,7 +1751,9 @@ describe('PredictMarketDetails', () => { expect(screen.getByText('Yes')).toBeOnTheScreen(); expect( - screen.getByText('$65.00 on Yes • 65¢', { exact: false }), + screen.getByText('$65.00 on Yes to win $100.00', { + exact: false, + }), ).toBeOnTheScreen(); }); diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx index 9fd2f40d84b..a9ad0f5915e 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx @@ -130,11 +130,18 @@ jest.mock('react-native-safe-area-context', () => ({ // Mock format utilities const mockFormatPrice = jest.fn(); const mockFormatPercentage = jest.fn(); +const mockFormatPositionSize = jest.fn(); +const mockFormatCents = jest.fn(); jest.mock('../../utils/format', () => ({ formatPrice: (value: number, options?: { maximumDecimals?: number }) => mockFormatPrice(value, options), formatPercentage: (value: number) => mockFormatPercentage(value), + formatPositionSize: ( + value: number, + options?: { minimumDecimals?: number; maximumDecimals?: number }, + ) => mockFormatPositionSize(value, options), + formatCents: (value: number) => mockFormatCents(value), })); // Mock BottomSheetHeader to avoid Icon component issues @@ -361,6 +368,14 @@ describe('PredictSellPreview', () => { return `$${value}`; }); mockFormatPercentage.mockImplementation((value) => `${value}% return`); + mockFormatPositionSize.mockImplementation((value, options) => { + const decimals = options?.maximumDecimals ?? 2; + return value.toFixed(decimals); + }); + mockFormatCents.mockImplementation((value) => { + const cents = value * 100; + return `${cents.toFixed(0)}¢`; + }); }); afterEach(() => { @@ -378,7 +393,7 @@ describe('PredictSellPreview', () => { expect(getAllByText('Cash out').length).toBeGreaterThan(0); expect(getByText('Will Bitcoin reach $150,000?')).toBeOnTheScreen(); - expect(getByText('$50.00 on Yes at 50¢')).toBeOnTheScreen(); + expect(getByText('Selling 50.00 shares at 50¢')).toBeOnTheScreen(); expect( queryByText('Funds will be added to your available balance'), @@ -441,14 +456,14 @@ describe('PredictSellPreview', () => { expect(mockFormatPercentage).toHaveBeenCalledWith(-20); }); - it('uses position price when preview sharePrice is undefined', () => { + it('uses zero when preview sharePrice is zero', () => { mockPreview = { marketId: 'market-1', outcomeId: 'outcome-456', outcomeTokenId: 'outcome-token-789', timestamp: Date.now(), side: 'SELL', - sharePrice: undefined as unknown as number, + sharePrice: 0, maxAmountSpent: 100, minAmountReceived: 60, slippage: 0.005, @@ -461,7 +476,7 @@ describe('PredictSellPreview', () => { state: initialState, }); - expect(getByText('At price: 50¢ per share')).toBeOnTheScreen(); + expect(getByText('Selling 50.00 shares at 0¢')).toBeOnTheScreen(); }); it('renders position icon with correct source', () => { diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx index 9f8f33225ac..d484cb68c88 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx @@ -1,3 +1,8 @@ +import { + Box, + ButtonSize as ButtonSizeHero, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { NavigationProp, RouteProp, @@ -7,39 +12,40 @@ import { } from '@react-navigation/native'; import React, { useCallback, useEffect, useMemo } from 'react'; import { ActivityIndicator, Image, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { PredictCashOutSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; +import { strings } from '../../../../../../locales/i18n'; +import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; import Button, { ButtonSize, ButtonVariants, ButtonWidthTypes, } from '../../../../../component-library/components/Buttons/Button'; +import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; import Text, { TextColor, TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../component-library/hooks/useStyles'; import Engine from '../../../../../core/Engine'; +import { TraceName } from '../../../../../util/trace'; +import { + PredictEventValues, + PredictTradeStatus, +} from '../../constants/eventNames'; +import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; import { usePredictOrderPreview } from '../../hooks/usePredictOrderPreview'; import { usePredictPlaceOrder } from '../../hooks/usePredictPlaceOrder'; import { Side } from '../../types'; import { PredictNavigationParamList } from '../../types/navigation'; import { - PredictTradeStatus, - PredictEventValues, -} from '../../constants/eventNames'; -import { formatPercentage, formatPrice } from '../../utils/format'; + formatCents, + formatPercentage, + formatPositionSize, + formatPrice, +} from '../../utils/format'; import styleSheet from './PredictSellPreview.styles'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { PredictCashOutSelectorsIDs } from '../../../../../../e2e/selectors/Predict/Predict.selectors'; -import { strings } from '../../../../../../locales/i18n'; -import { - Box, - ButtonSize as ButtonSizeHero, -} from '@metamask/design-system-react-native'; -import ButtonHero from '../../../../../component-library/components-temp/Buttons/ButtonHero'; -import { TraceName } from '../../../../../util/trace'; -import { usePredictMeasurement } from '../../hooks/usePredictMeasurement'; const PredictSellPreview = () => { const tw = useTailwind(); @@ -50,8 +56,15 @@ const PredictSellPreview = () => { useRoute>(); const { market, position, outcome, entryPoint } = route.params; - const { icon, title, outcome: outcomeSideText, initialValue } = position; + const { + icon, + title, + outcome: outcomeSideText, + initialValue, + size, + } = position; + const outcomeGroupTitle = outcome?.groupItemTitle ?? ''; const outcomeTitle = title; // Prepare analytics properties for sell/cash-out action @@ -130,10 +143,26 @@ const PredictSellPreview = () => { }, [dispatch, result]); const currentValue = preview?.minAmountReceived ?? 0; - const currentPrice = preview?.sharePrice ?? position?.price; - const { cashPnl, percentPnl, avgPrice } = position; + const currentPrice = preview?.sharePrice ?? 0; + const { avgPrice } = position; + + // Recalculate PnL based on preview data + const cashPnl = useMemo( + () => currentValue - initialValue, + [currentValue, initialValue], + ); + + const percentPnl = useMemo( + () => (initialValue > 0 ? (cashPnl / initialValue) * 100 : 0), + [cashPnl, initialValue], + ); - const signal = useMemo(() => (cashPnl >= 0 ? '+' : '-'), [cashPnl]); + const signal = useMemo(() => { + if (cashPnl === 0) { + return ''; + } + return cashPnl > 0 ? '+' : '-'; + }, [cashPnl]); const onCashOut = useCallback(async () => { if (!preview) return; @@ -200,26 +229,58 @@ const PredictSellPreview = () => { style={styles.container} > - - {formatPrice(currentValue, { maximumDecimals: 2 })} - - - {strings('predict.at_price_per_share', { - price: (currentPrice * 100).toFixed(0), - })} - - 0 ? TextColor.Success : TextColor.Error} - variant={TextVariant.BodyMDMedium} - > - {`${signal}${formatPrice(Math.abs(cashPnl), { - maximumDecimals: 2, - })} (${formatPercentage(percentPnl)})`} - + {!preview ? ( + + + + + + ) : ( + <> + + {formatPrice(currentValue, { maximumDecimals: 2 })} + + + {strings('predict.at_price_per_share', { + size: formatPositionSize(size, { + minimumDecimals: 2, + maximumDecimals: 2, + }), + price: formatCents(currentPrice), + })} + + 0 ? TextColor.Success : TextColor.Error} + variant={TextVariant.BodyMDMedium} + > + {`${signal}${formatPrice(Math.abs(cashPnl), { + maximumDecimals: 4, + })} (${formatPercentage(percentPnl)})`} + + + )} {placeOrderError && ( @@ -243,11 +304,18 @@ const PredictSellPreview = () => { variant={TextVariant.BodySMMedium} color={TextColor.Alternative} > - {strings('predict.cashout_info', { - amount: formatPrice(initialValue, { maximumDecimals: 2 }), - outcome: outcomeSideText, - initialPrice: (avgPrice * 100).toFixed(0), - })} + {outcomeGroupTitle + ? strings('predict.cashout_info_multiple', { + amount: formatPrice(initialValue), + outcomeGroupTitle, + outcome: outcomeSideText, + initialPrice: formatCents(avgPrice), + }) + : strings('predict.cashout_info', { + amount: formatPrice(initialValue), + outcome: outcomeSideText, + initialPrice: formatCents(avgPrice), + })} diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-amount/predict-claim-amount.test.tsx b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-amount/predict-claim-amount.test.tsx index 92a760ae986..bd0137da58c 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-amount/predict-claim-amount.test.tsx +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-amount/predict-claim-amount.test.tsx @@ -23,7 +23,7 @@ describe('PredictClaimAmount', () => { const { getByText } = render(); // Then the formatted winnings amount is displayed - expect(getByText('$2,250.00')).toBeDefined(); + expect(getByText('$2,250')).toBeDefined(); }); it('renders formatted change and percentage', () => { @@ -31,6 +31,6 @@ describe('PredictClaimAmount', () => { const { getByText } = render(); // Then the formatted change and percentage is displayed - expect(getByText('+$750.00 (33%)')).toBeDefined(); + expect(getByText('+$750 (33.33%)')).toBeDefined(); }); }); diff --git a/locales/languages/en.json b/locales/languages/en.json index c9e04b8b8c5..fd34a2e459b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1787,7 +1787,7 @@ "won": "Won", "lost": "Lost", "market_unavailable": "Market unavailable", - "amount_on_outcome": "${{amount}} on {{outcome}}", + "amount_on_outcome": "{{amount}} on {{outcome}}", "outcome_at_price": "{{outcome}} at {{price}}¢", "fee_exemption": "We don't charge any fees on this market.", "ended": "Ended", @@ -1807,10 +1807,10 @@ "sell_position": "Sell Position", "cash_out": "Cash out", "cash_out_info": "Funds will be added to your available balance", - "at_price_per_share": "At price: {{price}}¢ per share", - "cashout_info": "{{amount}} on {{outcome}} at {{initialPrice}}¢", - "position_info_plural": "{{amount}} on {{outcome}} · {{shares}} shares at {{priceCents}}", - "position_info_singular": "{{amount}} on {{outcome}} · {{shares}} share at {{priceCents}}", + "at_price_per_share": "Selling {{size}} shares at {{price}}", + "cashout_info": "{{amount}} on {{outcome}} at {{initialPrice}}", + "cashout_info_multiple": "{{amount}} on {{outcomeGroupTitle}} • {{outcome}} at {{initialPrice}}", + "position_info": "{{initialValue}} on {{outcome}} to win {{shares}}", "buy_yes": "Yes", "buy_no": "No", "outcomes": "outcomes",