From 5e7b094d48feea6ff12c7ecb4d1a08b9995fe0dd Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Wed, 6 May 2026 18:49:42 -0300 Subject: [PATCH 01/27] add initial version of linearizing send flow --- .../SendScreen/SendSearchContacts.test.tsx | 131 +++++++ .../components/RecentContactsList.test.tsx | 4 +- .../screens/TransactionAmountScreen.test.tsx | 199 +++++++++- .../transactions/SendClassicTokenMainnet.yaml | 36 +- src/components/TokensCollectiblesInline.tsx | 160 ++++++++ .../screens/HomeScreen/HomeScreen.tsx | 3 +- .../screens/SendScreen/SendSearchContacts.tsx | 116 +++++- .../SendScreen/components/ContactRow.tsx | 8 +- .../components/HighlightedAmountDisplay.tsx | 15 +- .../components/RecentContactsList.tsx | 47 +-- .../screens/TransactionAmountScreen.tsx | 367 +++++++++++++----- .../screens/TransactionProcessingScreen.tsx | 12 +- .../screens/TransactionTokenScreen.tsx | 23 +- src/config/routes.ts | 14 +- src/i18n/locales/en/translations.json | 6 +- src/i18n/locales/pt/translations.json | 6 +- src/navigators/SendPaymentNavigator.tsx | 22 +- 17 files changed, 967 insertions(+), 202 deletions(-) create mode 100644 src/components/TokensCollectiblesInline.tsx diff --git a/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx b/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx index 9a410b4be..57611c079 100644 --- a/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx +++ b/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx @@ -7,6 +7,8 @@ import { SEND_PAYMENT_ROUTES, SendPaymentStackParamList, } from "config/routes"; +import { Account } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; import * as sendDuck from "ducks/sendRecipient"; import * as transactionSettingsDuck from "ducks/transactionSettings"; import { renderWithProviders } from "helpers/testUtils"; @@ -107,6 +109,10 @@ jest.mock("ducks/qrData", () => ({ useQRDataStore: () => ({ clearQRData: jest.fn() }), })); +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: jest.fn(), +})); + jest.mock("hooks/useInAppBrowser", () => ({ useInAppBrowser: () => ({ open: jest.fn() }), })); @@ -162,6 +168,7 @@ jest.mock("hooks/useAppTranslation", () => () => ({ "sendPaymentScreen.recents": "Recent", "sendPaymentScreen.suggestions": "Suggestions", "common.paste": "Paste", + "sendSearchContacts.myWallets": "My Wallets", "sendSearchContacts.unfunded.title": "The destination account doesn't exist", "sendSearchContacts.unfunded.action": @@ -172,6 +179,25 @@ jest.mock("hooks/useAppTranslation", () => () => ({ }, })); +const mockUseAuthenticationStore = + useAuthenticationStore as jest.MockedFunction; + +const mockAccounts: Account[] = [ + { + id: "wallet-1", + name: "My Second Wallet", + publicKey: "GBLS3IXAFSUWBSW3RXJMNXEGCHXEUL6VMBLFGVFPW47X2OL7BG7QQMUQ", + }, + { + id: "wallet-2", + name: "Savings", + publicKey: "GACJYENHYW2LGHBNNGNZ4NCBGZYVTGTZM4CJLQIOQQ5IUZU3SYWOW5EK", + }, +]; + +const activePublicKey = + "GDAS7BS4XKW27H2K5C25V6ZU46FCFGBTFQGFDZURAKVPA6QYQG4GTWBC"; + describe("SendSearchContacts", () => { beforeEach(() => { jest.clearAllMocks(); @@ -182,6 +208,10 @@ describe("SendSearchContacts", () => { loadRecentAddresses: mockLoadRecentAddresses, }), ); + mockUseAuthenticationStore.mockReturnValue({ + allAccounts: mockAccounts, + account: { publicKey: activePublicKey } as any, + } as any); }); it("renders correctly with the search input", async () => { @@ -394,4 +424,105 @@ describe("SendSearchContacts", () => { expect(screen.queryByText(unfundedTitle)).toBeNull(); }); }); + + describe("My Wallets section", () => { + beforeEach(() => { + jest.spyOn(sendDuck, "useSendRecipientStore").mockImplementation( + getSendStoreMock({ + recentAddresses: [], + loadRecentAddresses: mockLoadRecentAddresses, + }), + ); + mockUseAuthenticationStore.mockReturnValue({ + allAccounts: mockAccounts, + account: { publicKey: activePublicKey } as any, + } as any); + }); + + it("renders rows for wallets other than active account", async () => { + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("My Second Wallet")).toBeTruthy(); + expect(screen.getByText("Savings")).toBeTruthy(); + }); + }); + + it("does not render active account row", async () => { + mockUseAuthenticationStore.mockReturnValue({ + allAccounts: [ + ...mockAccounts, + { id: "active", name: "Active Wallet", publicKey: activePublicKey }, + ], + account: { publicKey: activePublicKey } as any, + } as any); + + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("My Second Wallet")).toBeTruthy(); + }); + expect(screen.queryByText("Active Wallet")).toBeNull(); + }); + + it("sets destination address when wallet row is tapped", async () => { + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("My Second Wallet")).toBeTruthy(); + }); + + await userEvent.press(screen.getByTestId("my-wallet-row-wallet-1")); + + await waitFor(() => { + expect(mockSetDestinationAddress).toHaveBeenCalledWith( + "GBLS3IXAFSUWBSW3RXJMNXEGCHXEUL6VMBLFGVFPW47X2OL7BG7QQMUQ", + ); + }); + }); + + it("shows My Wallets header when other accounts exist", async () => { + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("My Wallets")).toBeTruthy(); + }); + }); + + it("hides My Wallets section when there are no other accounts", async () => { + mockUseAuthenticationStore.mockReturnValue({ + allAccounts: [ + { id: "active", name: "Active Wallet", publicKey: activePublicKey }, + ], + account: { publicKey: activePublicKey } as any, + } as any); + + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Enter address")).toBeTruthy(); + }); + expect(screen.queryByText("My Wallets")).toBeNull(); + }); + }); }); diff --git a/__tests__/components/screens/SendScreen/components/RecentContactsList.test.tsx b/__tests__/components/screens/SendScreen/components/RecentContactsList.test.tsx index 74fccc5a9..7ee57d681 100644 --- a/__tests__/components/screens/SendScreen/components/RecentContactsList.test.tsx +++ b/__tests__/components/screens/SendScreen/components/RecentContactsList.test.tsx @@ -22,8 +22,8 @@ jest.mock("hooks/useColors", () => ({ __esModule: true, default: () => ({ themeColors: { - foreground: { - primary: "#000000", + text: { + secondary: "#000000", }, }, }), diff --git a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx index 02375e408..7648803ae 100644 --- a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx +++ b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx @@ -1,5 +1,5 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; -import { act, render } from "@testing-library/react-native"; +import { act, fireEvent, render } from "@testing-library/react-native"; import { BigNumber } from "bignumber.js"; import TransactionAmountScreen from "components/screens/SendScreen/screens/TransactionAmountScreen"; import { NETWORKS } from "config/constants"; @@ -55,6 +55,7 @@ jest.mock("services/analytics", () => ({ jest.mock("helpers/balances", () => ({ calculateSpendableAmount: jest.fn(), hasXLMForFees: jest.fn(), + isLiquidityPool: jest.fn(() => false), })); const mockCheckContractMuxedSupport = jest.fn().mockResolvedValue(false); @@ -94,6 +95,11 @@ jest.mock("providers/ToastProvider"); jest.mock("components/BalanceRow", () => ({ BalanceRow: "View", })); +jest.mock("components/TokenIcon", () => ({ + TokenIcon: function MockTokenIcon() { + return null; + }, +})); jest.mock("components/screens/SendScreen/components", () => ({ SendReviewBottomSheet: function MockSendReviewBottomSheet() { return null; @@ -1566,3 +1572,194 @@ describe("TransactionAmountScreen - Address Change Scenarios", () => { expect(mockCachedFetch).toHaveBeenCalled(); }); }); + +describe("TransactionAmountScreen - Native keyboard input", () => { + const mockHandleDisplayAmountChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUsePreferencesStore.mockReturnValue({ + isMemoValidationEnabled: true, + }); + + mockStellarSdkServer.mockReturnValue({ + checkMemoRequired: jest.fn().mockResolvedValue(undefined), + }); + + jest.doMock("services/stellar", () => ({ + stellarSdkServer: mockStellarSdkServer, + })); + + mockUseTokenFiatConverter.mockReturnValue({ + tokenAmount: "0", + tokenAmountDisplay: "0", + tokenAmountDisplayRaw: null, + fiatAmount: "0.00", + fiatAmountDisplay: "0.00", + fiatAmountDisplayRaw: null, + showFiatAmount: false, + setTokenAmount: jest.fn(), + setFiatAmount: jest.fn(), + setShowFiatAmount: jest.fn(), + handleDisplayAmountChange: mockHandleDisplayAmountChange, + updateFiatDisplay: jest.fn(), + }); + + mockUseTransactionBuilderStore.mockReturnValue({ + buildTransaction: jest.fn(), + signTransaction: jest.fn(), + submitTransaction: jest.fn(), + resetTransaction: jest.fn(), + isBuilding: false, + isSigning: false, + isSubmitting: false, + transactionXDR: null, + transactionHash: null, + error: null, + network: NETWORKS.TESTNET, + transaction: null, + } as any); + + mockUseTransactionSettingsStore.mockReturnValue({ + transactionMemo: "", + transactionFee: "0.00001", + transactionTimeout: 30, + recipientAddress: + "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF", + selectedTokenId: + "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + federationAddress: "", + saveMemo: jest.fn(), + saveTransactionFee: jest.fn(), + saveTransactionTimeout: jest.fn(), + saveRecipientAddress: jest.fn(), + saveSelectedTokenId: jest.fn(), + saveFederationAddress: jest.fn(), + saveMemoType: jest.fn(), + saveSelectedCollectibleDetails: jest.fn(), + resetSettings: jest.fn(), + } as any); + + mockUseAuthenticationStore.mockReturnValue({ + publicKey: "GDNF5WJ2BEPABVBXCF4C7KZKM3XYXP27VUE3SCGPZA3VXWWZ7OFA3VPM", + network: NETWORKS.TESTNET, + } as any); + + mockUseGetActiveAccount.mockReturnValue({ + account: { + publicKey: "GDNF5WJ2BEPABVBXCF4C7KZKM3XYXP27VUE3SCGPZA3VXWWZ7OFA3VPM", + privateKey: "mockPrivateKey", + accountName: "Test Account", + id: "test-id", + subentryCount: 0, + } as ActiveAccount, + isLoading: false, + error: null, + refreshAccount: jest.fn(), + signTransaction: jest.fn(), + signMessage: jest.fn(), + signAuthEntry: jest.fn(), + }); + + mockUseBalancesList.mockReturnValue({ + balanceItems: [ + { + id: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + tokenId: + "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + total: "1000", + available: "1000", + token: { + code: "USDC", + issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + }, + } as any, + ], + scanResults: {} as any, + isLoading: false, + error: null, + noBalances: false, + isRefreshing: false, + isFunded: true, + handleRefresh: jest.fn(), + }); + + mockCalculateSpendableAmount.mockReturnValue(new BigNumber("1000")); + mockHasXLMForFees.mockReturnValue(true); + mockUseDeviceSize.mockReturnValue(DeviceSize.MD); + mockUseRightHeaderMenu.mockReturnValue(undefined); + mockUseToast.mockReturnValue({ + showToast: jest.fn(), + dismissToast: jest.fn(), + }); + mockUseHistoryStore.mockReturnValue({ fetchAccountHistory: jest.fn() }); + mockUseSendRecipientStore.mockReturnValue({ + resetSendRecipient: jest.fn(), + isDestinationFunded: true, + } as any); + mockScanTransaction.mockReturnValue({ + scanTransaction: jest.fn().mockResolvedValue({ warnings: [] }), + } as any); + mockCachedFetch.mockResolvedValue({ _embedded: { records: [] } } as any); + jest + .spyOn(useValidateTransactionMemo, "useValidateTransactionMemo") + .mockReturnValue({ isValidatingMemo: false, isMemoMissing: false }); + }); + + it("renders hidden amount TextInput", () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId("amount-text-input")).toBeTruthy(); + }); + + it("calls handleDisplayAmountChange with typed character", () => { + const { getByTestId } = render( + , + ); + + fireEvent.changeText(getByTestId("amount-text-input"), "5"); + expect(mockHandleDisplayAmountChange).toHaveBeenCalledWith("5"); + }); + + it("calls handleDisplayAmountChange with empty string on delete", () => { + mockUseTokenFiatConverter.mockReturnValue({ + tokenAmount: "5", + tokenAmountDisplay: "5", + tokenAmountDisplayRaw: "5", + fiatAmount: "0.50", + fiatAmountDisplay: "0.50", + fiatAmountDisplayRaw: null, + showFiatAmount: false, + setTokenAmount: jest.fn(), + setFiatAmount: jest.fn(), + setShowFiatAmount: jest.fn(), + handleDisplayAmountChange: mockHandleDisplayAmountChange, + updateFiatDisplay: jest.fn(), + }); + + const { getByTestId } = render( + , + ); + + fireEvent.changeText(getByTestId("amount-text-input"), ""); + expect(mockHandleDisplayAmountChange).toHaveBeenCalledWith(""); + }); + + it("calls handleDisplayAmountChange for each pasted character", () => { + const { getByTestId } = render( + , + ); + + fireEvent.changeText(getByTestId("amount-text-input"), "123"); + // The current display starts as "0", so the handler first issues one backspace, + // then applies each pasted digit. + expect(mockHandleDisplayAmountChange).toHaveBeenCalledTimes(4); + expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(1, ""); + expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(2, "1"); + expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(3, "2"); + expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(4, "3"); + }); +}); diff --git a/e2e/flows/transactions/SendClassicTokenMainnet.yaml b/e2e/flows/transactions/SendClassicTokenMainnet.yaml index 0930e1c46..f8a33bbed 100644 --- a/e2e/flows/transactions/SendClassicTokenMainnet.yaml +++ b/e2e/flows/transactions/SendClassicTokenMainnet.yaml @@ -50,9 +50,14 @@ tags: # Navigate to send flow - tapOn: id: icon-button-send -# Tap recipient row to open search +# Select token first +- extendedWaitUntil: + visible: + id: send-token-option-XLM + timeout: 30000 - tapOn: - id: send-recipient-row + id: send-token-option-XLM + # Wait for recipient search screen - extendedWaitUntil: visible: @@ -82,16 +87,8 @@ tags: # Enter amount: 0.000001 XLM (minimal to avoid wasting mainnet funds) - tapOn: - id: numeric-key-0 -- tapOn: - id: numeric-key-decimal -- repeat: - times: 5 - commands: - - tapOn: - id: numeric-key-0 -- tapOn: - id: numeric-key-1 + id: send-amount-focus-trigger +- inputText: "0.000001" # Tap Review button - tapOn: @@ -104,11 +101,16 @@ tags: # Confirm the transaction - tapOn: id: send-review-confirm-button -# Verify processing screen -- extendedWaitUntil: - visible: - id: send-processing-screen - timeout: 30000 + +# Retry confirm once if the sheet is still present +- runFlow: + when: + visible: + id: send-review-confirm-button + commands: + - tapOn: + id: send-review-confirm-button + # Wait for completion and tap Done - extendedWaitUntil: visible: diff --git a/src/components/TokensCollectiblesInline.tsx b/src/components/TokensCollectiblesInline.tsx new file mode 100644 index 000000000..e12cc61ba --- /dev/null +++ b/src/components/TokensCollectiblesInline.tsx @@ -0,0 +1,160 @@ +import { BalancesList } from "components/BalancesList"; +import { CollectibleImage } from "components/CollectibleImage"; +import Spinner from "components/Spinner"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import { + DEFAULT_PADDING, + NETWORKS, + TransactionContext, +} from "config/constants"; +import { useCollectiblesStore } from "ducks/collectibles"; +import { pxValue } from "helpers/dimensions"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import { useFilteredCollectibles } from "hooks/useFilteredCollectibles"; +import React from "react"; +import { ScrollView, TouchableOpacity, View } from "react-native"; + +interface TokensCollectiblesInlineProps { + publicKey: string; + network: NETWORKS; + onTokenPress?: (tokenId: string) => void; + onCollectiblePress?: ({ + collectionAddress, + tokenId, + }: { + collectionAddress: string; + tokenId: string; + }) => void; + showSpendableAmount?: boolean; + feeContext?: TransactionContext; + balanceRowTestIDPrefix?: string; +} + +export const TokensCollectiblesInline: React.FC< + TokensCollectiblesInlineProps +> = ({ + publicKey, + network, + onTokenPress, + onCollectiblePress, + showSpendableAmount = false, + feeContext = TransactionContext.Send, + balanceRowTestIDPrefix, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const isLoading = useCollectiblesStore((state) => state.isLoading); + const error = useCollectiblesStore((state) => state.error); + const { visibleCollectibles } = useFilteredCollectibles(); + + let collectiblesContent: React.ReactNode; + + if (isLoading) { + collectiblesContent = ( + + + + ); + } else if (error) { + collectiblesContent = ( + + + {t("collectiblesGrid.error")} + + + ); + } else if (visibleCollectibles.length === 0) { + collectiblesContent = ( + + + + {t("collectiblesGrid.empty")} + + + ); + } else { + collectiblesContent = ( + + {visibleCollectibles.map((collection) => ( + + + + + + {collection.collectionName} + + + + {collection.items.length} + + + + {collection.items.map((item) => ( + + onCollectiblePress?.({ + collectionAddress: item.collectionAddress, + tokenId: item.tokenId, + }) + } + > + + + + + + {item.name || `${collection.collectionName} #${item.tokenId}`} + + + ))} + + ))} + + ); + } + + return ( + + + + + {t("balancesList.title")} + + + + + + + + + {t("collectiblesGrid.title")} + + + + {collectiblesContent} + + ); +}; diff --git a/src/components/screens/HomeScreen/HomeScreen.tsx b/src/components/screens/HomeScreen/HomeScreen.tsx index 1c8d6de48..4c0520135 100644 --- a/src/components/screens/HomeScreen/HomeScreen.tsx +++ b/src/components/screens/HomeScreen/HomeScreen.tsx @@ -183,8 +183,7 @@ export const HomeScreen: React.FC = React.memo( const handleSendPress = useCallback(() => { navigation.navigate(ROOT_NAVIGATOR_ROUTES.SEND_PAYMENT_STACK, { - screen: SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN, - params: { tokenId: NATIVE_TOKEN_CODE }, + screen: SEND_PAYMENT_ROUTES.TRANSACTION_TOKEN_SCREEN, }); }, [navigation]); diff --git a/src/components/screens/SendScreen/SendSearchContacts.tsx b/src/components/screens/SendScreen/SendSearchContacts.tsx index 6eaaa89f2..c69ac2a81 100644 --- a/src/components/screens/SendScreen/SendSearchContacts.tsx +++ b/src/components/screens/SendScreen/SendSearchContacts.tsx @@ -2,6 +2,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { BaseLayout } from "components/layout/BaseLayout"; import { + ContactRow, RecentContactsList, SearchSuggestionsList, } from "components/screens/SendScreen/components"; @@ -10,14 +11,19 @@ import { Input } from "components/sds/Input"; import { Notification } from "components/sds/Notification"; import { Text } from "components/sds/Typography"; import { AnalyticsEvent } from "config/analyticsConfig"; -import { CREATE_ACCOUNT_TUTORIAL_URL, QRCodeSource } from "config/constants"; +import { + CREATE_ACCOUNT_TUTORIAL_URL, + NATIVE_TOKEN_CODE, + QRCodeSource, +} from "config/constants"; import { ROOT_NAVIGATOR_ROUTES, RootStackParamList, SEND_PAYMENT_ROUTES, SendPaymentStackParamList, } from "config/routes"; -import { TokenTypeWithCustomToken } from "config/types"; +import { Account, TokenTypeWithCustomToken } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; import { useQRDataStore } from "ducks/qrData"; import { useSendRecipientStore } from "ducks/sendRecipient"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; @@ -29,7 +35,7 @@ import useColors from "hooks/useColors"; import { useInAppBrowser } from "hooks/useInAppBrowser"; import { useRightHeaderButton } from "hooks/useRightHeader"; import React, { useCallback, useEffect, useState } from "react"; -import { View } from "react-native"; +import { ScrollView, View } from "react-native"; import { analytics } from "services/analytics"; type SendSearchContactsProps = NativeStackScreenProps< @@ -48,9 +54,11 @@ type SendSearchContactsProps = NativeStackScreenProps< */ const SendSearchContacts: React.FC = ({ navigation, + route, }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); + const { allAccounts, account: activeAccount } = useAuthenticationStore(); const { open: openInAppBrowser } = useInAppBrowser(); const { getClipboardText } = useClipboard(); const [address, setAddress] = useState(""); @@ -91,6 +99,10 @@ const SendSearchContacts: React.FC = ({ const shouldShowUnfundedNotice = !isCollectibleSend && !isContractTokenSend && !isContractDestination; + const myWallets: Account[] = (allAccounts ?? []).filter( + (acc) => acc.publicKey !== activeAccount?.publicKey, + ); + // Load recent addresses when component mounts useEffect(() => { loadRecentAddresses(); @@ -141,15 +153,25 @@ const SendSearchContacts: React.FC = ({ saveRecipientAddress(contactAddress); if (selectedCollectibleDetails.tokenId) { - // Use popTo for collectible flow - // If Review exists in stack, pops back to it; otherwise adds it - navigation.popTo( - SEND_PAYMENT_ROUTES.SEND_COLLECTIBLE_REVIEW, - selectedCollectibleDetails, - ); - } else { - // For token sends, go back to the TransactionAmountScreen + if (typeof navigation.popTo === "function") { + // If Review exists in stack, pop back to it. + navigation.popTo( + SEND_PAYMENT_ROUTES.SEND_COLLECTIBLE_REVIEW, + selectedCollectibleDetails, + ); + } else { + navigation.navigate( + SEND_PAYMENT_ROUTES.SEND_COLLECTIBLE_REVIEW, + selectedCollectibleDetails, + ); + } + } else if (route.params?.returnToAmount) { navigation.goBack(); + } else { + navigation.navigate(SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN, { + tokenId: selectedTokenId || NATIVE_TOKEN_CODE, + recipientAddress: contactAddress, + }); } }, [ @@ -157,7 +179,9 @@ const SendSearchContacts: React.FC = ({ setDestinationAddress, saveRecipientAddress, navigation, + route.params, selectedCollectibleDetails, + selectedTokenId, ], ); @@ -179,6 +203,8 @@ const SendSearchContacts: React.FC = ({ iconSize: 20, }); + const hasActiveSearch = address.trim().length > 0; + return ( @@ -235,18 +261,72 @@ const SendSearchContacts: React.FC = ({ )} - {searchResults.length > 0 ? ( + {hasActiveSearch ? ( ) : ( - recentAddresses.length > 0 && ( - - ) + + {recentAddresses.length > 0 && ( + + )} + {myWallets.length > 0 && ( + + + + + + + {t("sendSearchContacts.myWallets")} + + + {myWallets.map((wallet) => ( + { + setDestinationAddress(wallet.publicKey); + saveRecipientAddress(wallet.publicKey); + + if (selectedCollectibleDetails.tokenId) { + if (typeof navigation.popTo === "function") { + navigation.popTo( + SEND_PAYMENT_ROUTES.SEND_COLLECTIBLE_REVIEW, + selectedCollectibleDetails, + ); + } else { + navigation.navigate( + SEND_PAYMENT_ROUTES.SEND_COLLECTIBLE_REVIEW, + selectedCollectibleDetails, + ); + } + } else if (route.params?.returnToAmount) { + navigation.goBack(); + } else { + navigation.navigate( + SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN, + { + tokenId: selectedTokenId || NATIVE_TOKEN_CODE, + recipientAddress: wallet.publicKey, + }, + ); + } + }} + className="mb-[24px]" + /> + ))} + + )} + )} diff --git a/src/components/screens/SendScreen/components/ContactRow.tsx b/src/components/screens/SendScreen/components/ContactRow.tsx index 5de8b2c6d..e64500dad 100644 --- a/src/components/screens/SendScreen/components/ContactRow.tsx +++ b/src/components/screens/SendScreen/components/ContactRow.tsx @@ -14,6 +14,7 @@ interface ContactRowProps { onPress?: () => void; onDotsPress?: () => void; isSingleRow?: boolean; + hasDarkBackground?: boolean; rightElement?: React.ReactNode; className?: string; testID?: string; @@ -31,6 +32,7 @@ export const ContactRow: React.FC = ({ onPress, onDotsPress, isSingleRow, + hasDarkBackground = false, rightElement, className, testID, @@ -62,7 +64,11 @@ export const ContactRow: React.FC = ({ return ( <> - + {name || slicedAddress} diff --git a/src/components/screens/SendScreen/components/HighlightedAmountDisplay.tsx b/src/components/screens/SendScreen/components/HighlightedAmountDisplay.tsx index 287db6a86..ebb550624 100644 --- a/src/components/screens/SendScreen/components/HighlightedAmountDisplay.tsx +++ b/src/components/screens/SendScreen/components/HighlightedAmountDisplay.tsx @@ -100,6 +100,8 @@ interface HighlightedAmountDisplayProps { highlightColor: string; normalColor: string; secondaryColor: string; + align?: "left" | "center"; + size?: "xs" | "lg" | "xl"; } /** @@ -116,6 +118,8 @@ export const HighlightedAmountDisplay: React.FC< highlightColor, normalColor, secondaryColor, + align = "center", + size, }) => { const { matches, nonMatchingDecimals } = findMatchingCharacters( rawInput || "", @@ -126,11 +130,14 @@ export const HighlightedAmountDisplay: React.FC< const nonMatchingStartIndex = formattedDisplay.length - nonMatchingDecimals.length; + const displaySize = size ?? (isSmallScreen ? "lg" : "xl"); + const alignmentClass = align === "left" ? "items-start" : "items-center"; + return ( - + {/* Background display - only shown when rawInput is empty, hidden when user starts typing to avoid overlapping with the overlay */} { const { themeColors } = useColors(); return ( - - - - + + + + + + {t("sendPaymentScreen.recents")} @@ -56,28 +57,18 @@ export const RecentContactsList: React.FC = ({ } return ( - - - ( - onContactPress(item.address)} - className="mb-[24px]" - testID={`recent-contact-${index}`} - /> - )} - keyExtractor={(item) => item.id} + + + {transactions.map((item, index) => ( + onContactPress(item.address)} + className="mb-[24px]" + testID={`recent-contact-${index}`} /> - - + ))} + ); }; diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index bbe5a07b3..3dfa442d5 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -2,13 +2,11 @@ import Blockaid from "@blockaid/client"; import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { BigNumber } from "bignumber.js"; -import { BalanceRow } from "components/BalanceRow"; import BottomSheet from "components/BottomSheet"; import FeeBreakdownBottomSheet from "components/FeeBreakdownBottomSheet"; -import { IconButton } from "components/IconButton"; import InformationBottomSheet from "components/InformationBottomSheet"; import MuxedAddressWarningBottomSheet from "components/MuxedAddressWarningBottomSheet"; -import NumericKeyboard from "components/NumericKeyboard"; +import { TokenIcon } from "components/TokenIcon"; import TransactionSettingsBottomSheet from "components/TransactionSettingsBottomSheet"; import SecurityDetailBottomSheet from "components/blockaid/SecurityDetailBottomSheet"; import { BaseLayout } from "components/layout/BaseLayout"; @@ -57,7 +55,7 @@ import { } from "helpers/formatAmount"; import { checkContractMuxedSupport } from "helpers/muxedAddress"; import { isSorobanTransaction } from "helpers/soroban"; -import { isMuxedAccount } from "helpers/stellar"; +import { isMuxedAccount, truncateAddress } from "helpers/stellar"; import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction"; import useAppTranslation from "hooks/useAppTranslation"; import { useBalancesList } from "hooks/useBalancesList"; @@ -76,7 +74,7 @@ import React, { useRef, useState, } from "react"; -import { TouchableOpacity, View, Text as RNText } from "react-native"; +import { Keyboard, TextInput, TouchableOpacity, View } from "react-native"; import { analytics } from "services/analytics"; import { TransactionOperationType } from "services/analytics/types"; import { SecurityContext } from "services/blockaid/constants"; @@ -127,7 +125,8 @@ const TransactionAmountScreen: React.FC = ({ resetSettings, } = useTransactionSettingsStore(); - const { resetSendRecipient, isDestinationFunded } = useSendRecipientStore(); + const { resetSendRecipient, isDestinationFunded, federationAddress } = + useSendRecipientStore(); const { fetchAccountHistory } = useHistoryStore(); // Ensure defaults when entering the screen @@ -193,6 +192,7 @@ const TransactionAmountScreen: React.FC = ({ useInitialRecommendedFee(recommendedFee, TransactionContext.Send); const publicKey = account?.publicKey; + const amountInputRef = useRef(null); const reviewBottomSheetModalRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); const [amountError, setAmountError] = useState(null); @@ -239,6 +239,17 @@ const TransactionAmountScreen: React.FC = ({ }, }); + const focusAmountInput = useCallback(() => { + amountInputRef.current?.focus(); + + // iOS can occasionally ignore focus on fully hidden inputs; retry on next tick. + setTimeout(() => { + if (!amountInputRef.current?.isFocused()) { + amountInputRef.current?.focus(); + } + }, 0); + }, []); + const onConfirmAddMemo = useCallback(() => { addMemoExplanationBottomSheetModalRef.current?.dismiss(); transactionSettingsBottomSheetModalRef.current?.present(); @@ -250,6 +261,7 @@ const TransactionAmountScreen: React.FC = ({ const handleConfirmTransactionSettings = () => { transactionSettingsBottomSheetModalRef.current?.dismiss(); + focusAmountInput(); }; const handleOpenSettingsFromReview = () => { @@ -259,14 +271,21 @@ const TransactionAmountScreen: React.FC = ({ const handleCancelTransactionSettings = () => { addMemoExplanationBottomSheetModalRef.current?.dismiss(); transactionSettingsBottomSheetModalRef.current?.dismiss(); + focusAmountInput(); }; const navigateToSelectTokenScreen = () => { - navigation.navigate(SEND_PAYMENT_ROUTES.TRANSACTION_TOKEN_SCREEN); + navigation.push(SEND_PAYMENT_ROUTES.TRANSACTION_TOKEN_SCREEN, { + returnToAmount: true, + transition: "slide_from_bottom", + }); }; const navigateToSelectContactScreen = () => { - navigation.navigate(SEND_PAYMENT_ROUTES.SEND_SEARCH_CONTACTS_SCREEN); + navigation.push(SEND_PAYMENT_ROUTES.SEND_SEARCH_CONTACTS_SCREEN, { + returnToAmount: true, + transition: "slide_from_bottom", + }); }; const { balanceItems } = useBalancesList({ @@ -365,6 +384,44 @@ const TransactionAmountScreen: React.FC = ({ updateFiatDisplay, } = useTokenFiatConverter({ selectedBalance }); + // Keep hidden TextInput value aligned with the visible formatted value. + // Using only raw buffers breaks token editing after programmatic updates + // (for example, percentage buttons), because raw can be null while the + // displayed amount is non-empty. + const amountInputValue = showFiatAmount + ? fiatAmountDisplay + : tokenAmountDisplay; + + const handleNativeAmountChange = useCallback( + (text: string) => { + const currentValue = amountInputValue; + + if (text.startsWith(currentValue) && text.length >= currentValue.length) { + text + .slice(currentValue.length) + .split("") + .forEach((char) => handleDisplayAmountChange(char)); + return; + } + + if (currentValue.startsWith(text) && currentValue.length >= text.length) { + Array.from({ length: currentValue.length - text.length }).forEach( + () => { + handleDisplayAmountChange(""); + }, + ); + return; + } + + Array.from({ length: currentValue.length }).forEach(() => { + handleDisplayAmountChange(""); + }); + + text.split("").forEach((char) => handleDisplayAmountChange(char)); + }, + [amountInputValue, handleDisplayAmountChange], + ); + const unfundedContext: UnfundedDestinationContext | undefined = useMemo( () => buildUnfundedContext({ @@ -753,7 +810,8 @@ const TransactionAmountScreen: React.FC = ({ const handleCancelReview = useCallback(() => { reviewBottomSheetModalRef.current?.dismiss(); - }, []); + focusAmountInput(); + }, [focusAmountInput]); const footerProps = useMemo( () => ({ @@ -867,6 +925,7 @@ const TransactionAmountScreen: React.FC = ({ return; } + Keyboard.dismiss(); prepareTransaction(true); }; @@ -882,99 +941,199 @@ const TransactionAmountScreen: React.FC = ({ return t("transactionAmountScreen.reviewButton"); }; + const secondaryConversionAmount = showFiatAmount + ? formatTokenForDisplay(tokenAmount, selectedBalance?.tokenCode) + : formatFiatInputDisplay(fiatAmountDisplay); + + const availableAmountText = spendableBalance + ? t("transactionAmountScreen.availableBalance", { + amount: formatTokenForDisplay( + spendableBalance.toString(), + selectedBalance?.tokenCode, + ), + }) + : null; + + // For very long values, stack into two rows: conversion on top, swap+available below. + const shouldSplitSecondaryAmounts = + !!availableAmountText && + secondaryConversionAmount.length + availableAmountText.length > 34; + return ( - + - - {showFiatAmount ? ( - - ) : ( - - 0 - ? { primary: true } - : { secondary: true })} - > - {tokenAmountDisplay}{" "} - - {selectedBalance?.tokenCode} - - - - )} - - - {showFiatAmount - ? formatTokenForDisplay( - tokenAmount, - selectedBalance?.tokenCode, - ) - : formatFiatInputDisplay(fiatAmountDisplay)} - - setShowFiatAmount(!showFiatAmount)} - > - - - - - - {selectedBalance && ( - - } - testID="send-token-row" - /> - )} - - + } /> + + + + {t("transactionAmountScreen.title")} + + + + + {showFiatAmount ? ( + + ) : ( + 0 + ? { primary: true } + : { secondary: true })} + > + {tokenAmountDisplay} + + )} + + + + + + {selectedBalance && ( + + )} + + {selectedBalance?.tokenCode} + + + + + + {shouldSplitSecondaryAmounts ? ( + + + {secondaryConversionAmount} + + + setShowFiatAmount(!showFiatAmount)} + > + + + {!!availableAmountText && ( + + {availableAmountText} + + )} + + + ) : ( + + + + {secondaryConversionAmount} + + setShowFiatAmount(!showFiatAmount)} + > + + + + + {!!availableAmountText && ( + + {availableAmountText} + + )} + + )} + + @@ -998,25 +1157,23 @@ const TransactionAmountScreen: React.FC = ({ - - - - - - + + + + reviewBottomSheetModalRef.current?.dismiss()} + handleCloseModal={handleCancelReview} analyticsEvent={AnalyticsEvent.VIEW_SEND_CONFIRM} scrollable bottomSheetModalProps={{ accessible: false }} @@ -1072,9 +1229,7 @@ const TransactionAmountScreen: React.FC = ({ /> - transactionSettingsBottomSheetModalRef.current?.dismiss() - } + handleCloseModal={handleCancelTransactionSettings} customContent={ - - + + {getStatusIcon()} {getStatusText()} - + {type === SendType.Token && selectedBalance && ( @@ -213,7 +213,7 @@ const TransactionProcessingScreen: React.FC< - + {type === SendType.Token && transactionAmount ? formatTokenForDisplay( @@ -238,7 +238,7 @@ const TransactionProcessingScreen: React.FC< {status === TransactionStatus.SENT ? ( - + From 04446e0d3acf673ff7d660272b17d19fab33eae2 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 19 May 2026 10:05:52 -0300 Subject: [PATCH 20/27] adjust safeguards for address truncation and fix scroll for tokens inline --- src/components/TokensCollectiblesInline.tsx | 163 ++++++++++---------- src/ducks/sendRecipient.ts | 6 +- 2 files changed, 84 insertions(+), 85 deletions(-) diff --git a/src/components/TokensCollectiblesInline.tsx b/src/components/TokensCollectiblesInline.tsx index e40ff48ea..0ec6e7c54 100644 --- a/src/components/TokensCollectiblesInline.tsx +++ b/src/components/TokensCollectiblesInline.tsx @@ -14,7 +14,7 @@ import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import { useFilteredCollectibles } from "hooks/useFilteredCollectibles"; import React from "react"; -import { SectionList, TouchableOpacity, View } from "react-native"; +import { ScrollView, TouchableOpacity, View } from "react-native"; interface TokensCollectiblesInlineProps { publicKey: string; @@ -45,19 +45,11 @@ export const TokensCollectiblesInline: React.FC< }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); - // Collectibles loading/error state is owned by collectibles store; - // token balances manage loading inside BalancesList. const isLoading = useCollectiblesStore((state) => state.isLoading); const error = useCollectiblesStore((state) => state.error); const { visibleCollectibles } = useFilteredCollectibles(); - const collectibleSections = visibleCollectibles.map((collection) => ({ - title: collection.collectionName, - collectionAddress: collection.collectionAddress, - data: collection.items, - })); - - const renderCollectiblesEmptyState = () => { + const renderCollectiblesContent = () => { if (isLoading) { return ( @@ -80,89 +72,94 @@ export const TokensCollectiblesInline: React.FC< ); } + if (visibleCollectibles.length === 0) { + return ( + + + + {t("collectiblesGrid.empty")} + + + ); + } + return ( - - - - {t("collectiblesGrid.empty")} - - + <> + {visibleCollectibles.map((collection) => ( + + + + {collection.collectionName} + + + + {collection.items.length} + + + + {collection.items.map((item) => ( + + onCollectiblePress?.({ + collectionAddress: item.collectionAddress, + tokenId: item.tokenId, + }) + } + > + + + + + + {item.name || `${collection.collectionName} #${item.tokenId}`} + + + ))} + + ))} + ); }; return ( - `${item.collectionAddress}-${item.tokenId}`} - ListHeaderComponent={ - - - - - {t("balancesList.title")} - - + contentContainerStyle={{ paddingHorizontal: pxValue(DEFAULT_PADDING) }} + > + + + + {t("balancesList.title")} + + - + - - - - {t("collectiblesGrid.title")} - - - - } - ListEmptyComponent={renderCollectiblesEmptyState()} - renderSectionHeader={({ section }) => ( - - - {section.title} - - - - {section.data.length} - - - )} - renderItem={({ item, section }) => ( - - onCollectiblePress?.({ - collectionAddress: item.collectionAddress, - tokenId: item.tokenId, - }) - } - > - - - + + + + {t("collectiblesGrid.title")} + + - - {item.name || `${section.title} #${item.tokenId}`} - - - )} - ListFooterComponent={} - /> + {renderCollectiblesContent()} + + + ); }; diff --git a/src/ducks/sendRecipient.ts b/src/ducks/sendRecipient.ts index b54b9ef29..e35dad212 100644 --- a/src/ducks/sendRecipient.ts +++ b/src/ducks/sendRecipient.ts @@ -101,8 +101,10 @@ export const useSendRecipientStore = create((set, get) => ({ }) .filter( (contact) => - !activePublicKey || - !isSameAccount(contact.address, activePublicKey), + typeof contact.address === "string" && + contact.address.length > 0 && + (!activePublicKey || + !isSameAccount(contact.address, activePublicKey)), ); set({ recentAddresses: contactList }); From 7b6f9a5fc00f89146b34d641ad8a987744946542 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 26 May 2026 16:02:37 -0300 Subject: [PATCH 21/27] adjust ui tweaks from figma and keyboard dismiss --- src/components/TokensCollectiblesInline.tsx | 8 ++-- .../screens/SendScreen/SendSearchContacts.tsx | 33 +++++++------- .../screens/SendCollectibleReview.tsx | 4 +- .../screens/TransactionAmountScreen.tsx | 45 ++++++++++--------- src/hooks/useSendFlowQrCodeScanner.tsx | 33 ++++++++++---- src/i18n/locales/en/translations.json | 3 +- src/i18n/locales/pt/translations.json | 3 +- src/navigators/RootNavigator.tsx | 1 + 8 files changed, 74 insertions(+), 56 deletions(-) diff --git a/src/components/TokensCollectiblesInline.tsx b/src/components/TokensCollectiblesInline.tsx index 0ec6e7c54..f5f37a595 100644 --- a/src/components/TokensCollectiblesInline.tsx +++ b/src/components/TokensCollectiblesInline.tsx @@ -133,9 +133,9 @@ export const TokensCollectiblesInline: React.FC< keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingHorizontal: pxValue(DEFAULT_PADDING) }} > - + - + {t("balancesList.title")} @@ -150,9 +150,9 @@ export const TokensCollectiblesInline: React.FC< balanceRowTestIDPrefix={balanceRowTestIDPrefix} /> - + - + {t("collectiblesGrid.title")} diff --git a/src/components/screens/SendScreen/SendSearchContacts.tsx b/src/components/screens/SendScreen/SendSearchContacts.tsx index 0a35b14bb..7ed9119a3 100644 --- a/src/components/screens/SendScreen/SendSearchContacts.tsx +++ b/src/components/screens/SendScreen/SendSearchContacts.tsx @@ -42,6 +42,7 @@ import { ListRenderItemInfo, View, } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { analytics } from "services/analytics"; type SendSearchContactsProps = NativeStackScreenProps< @@ -98,6 +99,7 @@ const SendSearchContacts: React.FC = ({ }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); + const { bottom: bottomInset } = useSafeAreaInsets(); const { allAccounts, account: activeAccount } = useAuthenticationStore(); const { open: openInAppBrowser } = useInAppBrowser(); const { getClipboardText } = useClipboard(); @@ -363,12 +365,10 @@ const SendSearchContacts: React.FC = ({ ({ item }: ListRenderItemInfo) => { if (item.type === ContactListItemType.ResultsHeader) { return ( - - - - - - + + + + {t("sendPaymentScreen.suggestions")} @@ -398,12 +398,10 @@ const SendSearchContacts: React.FC = ({ if (item.type === ContactListItemType.RecentHeader) { return ( - - - - - - + + + + {t("sendPaymentScreen.recents")} @@ -433,11 +431,9 @@ const SendSearchContacts: React.FC = ({ if (item.type === ContactListItemType.WalletsHeader) { return ( - - - - - + + + {t("sendSearchContacts.myWallets")} @@ -499,7 +495,7 @@ const SendSearchContacts: React.FC = ({ }); return ( - + = ({ keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} ListFooterComponent={DefaultListFooter} + contentContainerStyle={{ paddingBottom: bottomInset }} /> diff --git a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx index bdea140f3..20919acdc 100644 --- a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx +++ b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx @@ -63,7 +63,7 @@ import React, { useRef, useState, } from "react"; -import { View } from "react-native"; +import { Keyboard, View } from "react-native"; import { analytics } from "services/analytics"; import { TransactionOperationType } from "services/analytics/types"; @@ -239,6 +239,7 @@ const SendCollectibleReviewScreen: React.FC< const handleTransactionScanSuccess = useCallback( (scanResult: Blockaid.StellarTransactionScanResponse | undefined) => { + Keyboard.dismiss(); const security = getTransactionSecurity( scanResult, overriddenBlockaidResponse, @@ -553,6 +554,7 @@ const SendCollectibleReviewScreen: React.FC< !transactionScanResult || transactionSecurityAssessment.isUnableToScan; if (isUnableToScan) { + Keyboard.dismiss(); reviewBottomSheetModalRef.current?.present(); } else { handleTransactionConfirmation(); diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index fe4821cf9..b53146a95 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -634,6 +634,8 @@ const TransactionAmountScreen: React.FC = ({ setTransactionScanResult(scanResult); if (shouldOpenReview) { + amountInputRef.current?.blur(); + Keyboard.dismiss(); const security = getTransactionSecurity( scanResult, overriddenBlockaidResponse, @@ -653,6 +655,8 @@ const TransactionAmountScreen: React.FC = ({ (shouldOpenReview: boolean) => { setTransactionScanResult(undefined); if (shouldOpenReview) { + amountInputRef.current?.blur(); + Keyboard.dismiss(); // When scan fails, treat as unable to scan and open security detail sheet const security = getTransactionSecurity( undefined, @@ -928,6 +932,8 @@ const TransactionAmountScreen: React.FC = ({ !transactionScanResult || transactionSecurityAssessment.isUnableToScan; if (isUnableToScan) { + amountInputRef.current?.blur(); + Keyboard.dismiss(); reviewBottomSheetModalRef.current?.present(); } else { handleTransactionConfirmation(); @@ -985,6 +991,7 @@ const TransactionAmountScreen: React.FC = ({ return; } + amountInputRef.current?.blur(); Keyboard.dismiss(); prepareTransaction(true); }; @@ -1006,12 +1013,10 @@ const TransactionAmountScreen: React.FC = ({ : formatFiatInputDisplay(fiatAmountDisplay); const availableAmountText = spendableBalance - ? t("transactionAmountScreen.availableBalance", { - amount: formatTokenForDisplay( - spendableBalance.toString(), - selectedBalance?.tokenCode, - ), - }) + ? formatTokenForDisplay( + spendableBalance.toString(), + selectedBalance?.tokenCode, + ) : null; // For very long values, stack into two rows: conversion on top, swap+available below. @@ -1121,10 +1126,16 @@ const TransactionAmountScreen: React.FC = ({ {shouldSplitSecondaryAmounts ? ( - - {secondaryConversionAmount} - + + {secondaryConversionAmount} + setShowFiatAmount(!showFiatAmount)} @@ -1134,18 +1145,12 @@ const TransactionAmountScreen: React.FC = ({ color={themeColors.text.secondary} /> - {!!availableAmountText && ( - - {availableAmountText} - - )} + {!!availableAmountText && ( + + {availableAmountText} + + )} ) : ( diff --git a/src/hooks/useSendFlowQrCodeScanner.tsx b/src/hooks/useSendFlowQrCodeScanner.tsx index 0c1397667..f84fbf71a 100644 --- a/src/hooks/useSendFlowQrCodeScanner.tsx +++ b/src/hooks/useSendFlowQrCodeScanner.tsx @@ -84,7 +84,7 @@ export const useSendFlowQrCodeScanner = (): QRCodeScreenReturn => { isSearching, destinationAddress, } = useSendRecipientStore(); - const { saveRecipientAddress, selectedTokenId } = + const { saveRecipientAddress, selectedTokenId, selectedCollectibleDetails } = useTransactionSettingsStore(); // Configuration for Send Flow @@ -159,16 +159,30 @@ export const useSendFlowQrCodeScanner = (): QRCodeScreenReturn => { // The send recipient store already has the correct address from the search saveRecipientAddress(destinationAddress); - // Navigate directly to transaction amount screen with the selected token // Pop to main tab first to remove the QR scanner screen from the stack, then navigate to send payment stack navigation.popToTop(); - navigation.navigate(ROOT_NAVIGATOR_ROUTES.SEND_PAYMENT_STACK, { - screen: SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN, - params: { - tokenId: selectedTokenId, - recipientAddress: destinationAddress, - }, - }); + + const isCollectibleFlow = !!selectedCollectibleDetails?.tokenId; + + if (isCollectibleFlow) { + // Route collectible sends to the collectible review screen, + // not the token amount screen (which would force-select XLM). + navigation.navigate(ROOT_NAVIGATOR_ROUTES.SEND_PAYMENT_STACK, { + screen: SEND_PAYMENT_ROUTES.SEND_COLLECTIBLE_REVIEW, + params: { + collectionAddress: selectedCollectibleDetails.collectionAddress, + tokenId: selectedCollectibleDetails.tokenId, + }, + }); + } else { + navigation.navigate(ROOT_NAVIGATOR_ROUTES.SEND_PAYMENT_STACK, { + screen: SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN, + params: { + tokenId: selectedTokenId, + recipientAddress: destinationAddress, + }, + }); + } // Reset the processing flag setIsProcessingQRScan(false); @@ -181,6 +195,7 @@ export const useSendFlowQrCodeScanner = (): QRCodeScreenReturn => { destinationAddress, saveRecipientAddress, selectedTokenId, + selectedCollectibleDetails, navigation, ]); diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 574209be8..245000ccf 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -781,8 +781,7 @@ } }, "transactionAmountScreen": { - "title": "Sending", - "availableBalance": "{{amount}}", + "title": "Send", "percentageButtons": { "twentyFive": "25%", "fifty": "50%", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index fd399cd24..a6ed0e33d 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -745,8 +745,7 @@ } }, "transactionAmountScreen": { - "title": "Enviando", - "availableBalance": "{{amount}}", + "title": "Enviar", "percentageButtons": { "twentyFive": "25%", "fifty": "50%", diff --git a/src/navigators/RootNavigator.tsx b/src/navigators/RootNavigator.tsx index dc8bdeba4..732a8e812 100644 --- a/src/navigators/RootNavigator.tsx +++ b/src/navigators/RootNavigator.tsx @@ -229,6 +229,7 @@ export const RootNavigator = () => { Date: Thu, 28 May 2026 13:49:25 -0300 Subject: [PATCH 22/27] fix navigation, align ui with figma latest design --- .../screens/SendScreen/SendSearchContacts.tsx | 9 +- .../screens/TransactionAmountScreen.tsx | 136 ++++++++---------- src/config/routes.ts | 1 + src/i18n/locales/en/translations.json | 2 + src/i18n/locales/pt/translations.json | 2 + src/navigators/SendPaymentNavigator.tsx | 11 +- 6 files changed, 76 insertions(+), 85 deletions(-) diff --git a/src/components/screens/SendScreen/SendSearchContacts.tsx b/src/components/screens/SendScreen/SendSearchContacts.tsx index 7ed9119a3..a688c61ea 100644 --- a/src/components/screens/SendScreen/SendSearchContacts.tsx +++ b/src/components/screens/SendScreen/SendSearchContacts.tsx @@ -235,15 +235,16 @@ const SendSearchContacts: React.FC = ({ if (selectedCollectibleDetails.tokenId) { // Navigate to collectible review screen after selecting recipient - navigation.navigate( - SEND_PAYMENT_ROUTES.SEND_COLLECTIBLE_REVIEW, - selectedCollectibleDetails, - ); + navigation.navigate(SEND_PAYMENT_ROUTES.SEND_COLLECTIBLE_REVIEW, { + ...selectedCollectibleDetails, + transition: ScreenTransition.SlideFromBottom, + }); } else { navigation.navigate(SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN, { tokenId: selectedTokenId || NATIVE_TOKEN_CODE, recipientAddress: contactAddress, recipientName: name, + transition: ScreenTransition.SlideFromBottom, }); } }, diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index b53146a95..1258d3f0a 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -49,6 +49,7 @@ import { useSendRecipientStore } from "ducks/sendRecipient"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { calculateSpendableAmount, hasXLMForFees } from "helpers/balances"; +import { fsValue, pxValue } from "helpers/dimensions"; import { formatTokenForDisplay, formatFiatInputDisplay, @@ -90,7 +91,11 @@ type TransactionAmountScreenProps = NativeStackScreenProps< typeof SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN >; -const SECONDARY_AMOUNT_STACK_CHAR_THRESHOLD = 34; // Threshold for when to split secondary amounts (token + available text) across multiple lines +const AVAILABLE_BALANCE_FONT_SIZES = [ + { maxLen: 28, size: fsValue(16) }, + { maxLen: 42, size: fsValue(14) }, + { maxLen: Infinity, size: fsValue(12) }, +] as const; /** * TransactionAmountScreen Component @@ -1019,11 +1024,14 @@ const TransactionAmountScreen: React.FC = ({ ) : null; - // For very long values, stack into two rows: conversion on top, swap+available below. - const shouldSplitSecondaryAmounts = - !!availableAmountText && - secondaryConversionAmount.length + availableAmountText.length > - SECONDARY_AMOUNT_STACK_CHAR_THRESHOLD; + const availableBalanceText = availableAmountText + ? `${availableAmountText} ${t("common.available")}` + : null; + + const getAvailableBalanceFontSize = () => + AVAILABLE_BALANCE_FONT_SIZES.find( + ({ maxLen }) => (availableBalanceText?.length ?? 0) <= maxLen, + )!.size; // recipientName takes priority — it carries wallet nicknames and future // user-editable custom labels. Falls back to the federation address when @@ -1037,7 +1045,7 @@ const TransactionAmountScreen: React.FC = ({ - + = ({ /> - - - {t("transactionAmountScreen.title")} - + + + + {t("transactionAmountScreen.sendingLabel")} + + {!!availableBalanceText && ( + + {availableBalanceText} + + )} + = ({ - {shouldSplitSecondaryAmounts ? ( - - - - {secondaryConversionAmount} - - setShowFiatAmount(!showFiatAmount)} - > - - - - {!!availableAmountText && ( - - {availableAmountText} - - )} - - ) : ( - - - - {secondaryConversionAmount} - - setShowFiatAmount(!showFiatAmount)} - > - - - - - {!!availableAmountText && ( - - {availableAmountText} - - )} - - )} + + + {secondaryConversionAmount} + + setShowFiatAmount(!showFiatAmount)} + > + + + diff --git a/src/config/routes.ts b/src/config/routes.ts index e2a7b7ec8..d45449eb2 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -253,6 +253,7 @@ export type SendPaymentStackParamList = { tokenId: string; recipientAddress?: string; recipientName?: string; + transition?: ScreenTransition; }; }; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 245000ccf..15c32eaef 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -25,6 +25,7 @@ "unknownError": "Unknown error", "review": "Review", "and": " and ", + "available": "available", "addMemoShorthand": "Add memo", "addMemo": "Add memo", "yes": "Yes", @@ -782,6 +783,7 @@ }, "transactionAmountScreen": { "title": "Send", + "sendingLabel": "Sending", "percentageButtons": { "twentyFive": "25%", "fifty": "50%", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index a6ed0e33d..64a3ed9b7 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -25,6 +25,7 @@ "unknownError": "Erro desconhecido", "review": "Revisar", "and": " e ", + "available": "disponível", "yes": "Sim", "message": "Mensagem", "allow": "Permitir", @@ -746,6 +747,7 @@ }, "transactionAmountScreen": { "title": "Enviar", + "sendingLabel": "Enviando", "percentageButtons": { "twentyFive": "25%", "fifty": "50%", diff --git a/src/navigators/SendPaymentNavigator.tsx b/src/navigators/SendPaymentNavigator.tsx index df3366329..96f0d3840 100644 --- a/src/navigators/SendPaymentNavigator.tsx +++ b/src/navigators/SendPaymentNavigator.tsx @@ -104,9 +104,14 @@ export const SendPaymentStackNavigator = () => { + withTransitionOverride( + { + headerTitle: t("transactionAmountScreen.title"), + }, + route, + ) + } /> Date: Thu, 28 May 2026 14:00:51 -0300 Subject: [PATCH 23/27] adjust logic to hide usd conversion and has no price --- .../screens/TransactionAmountScreen.tsx | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index 1258d3f0a..d05c7e64b 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -1013,6 +1013,13 @@ const TransactionAmountScreen: React.FC = ({ return t("transactionAmountScreen.reviewButton"); }; + const hasUsdPrice = + !!selectedBalance?.currentPrice && !selectedBalance.currentPrice.isZero(); + + if (!hasUsdPrice && showFiatAmount) { + setShowFiatAmount(false); + } + const secondaryConversionAmount = showFiatAmount ? formatTokenForDisplay(tokenAmount, selectedBalance?.tokenCode) : formatFiatInputDisplay(fiatAmountDisplay); @@ -1088,7 +1095,7 @@ const TransactionAmountScreen: React.FC = ({ )} - + = ({ - - - {secondaryConversionAmount} - - setShowFiatAmount(!showFiatAmount)} - > - - - + {hasUsdPrice && ( + + + {secondaryConversionAmount} + + setShowFiatAmount(!showFiatAmount)} + > + + + + )} From bc3d2a1b4ac492066737f21e3435a1ad397d3b4d Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Thu, 28 May 2026 14:04:28 -0300 Subject: [PATCH 24/27] adjust logic to hide usd conversion and has no price --- .../screens/SendScreen/screens/TransactionAmountScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index d05c7e64b..05a3e392d 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -1071,7 +1071,7 @@ const TransactionAmountScreen: React.FC = ({ /> - + {t("transactionAmountScreen.sendingLabel")} @@ -1095,7 +1095,7 @@ const TransactionAmountScreen: React.FC = ({ )} - + Date: Fri, 29 May 2026 18:24:26 -0300 Subject: [PATCH 25/27] adjust memo type clearing on transaction amount screen and remove unused components --- .../TokensCollectiblesInline.test.tsx | 2 +- .../screens/TransactionAmountScreen.test.tsx | 63 ++++++ .../components/HighlightedAmountDisplay.tsx | 207 ------------------ .../components}/TokensCollectiblesInline.tsx | 0 .../screens/SendScreen/components/index.ts | 5 - .../screens/TransactionAmountScreen.tsx | 3 - .../screens/TransactionTokenScreen.tsx | 2 +- 7 files changed, 65 insertions(+), 217 deletions(-) delete mode 100644 src/components/screens/SendScreen/components/HighlightedAmountDisplay.tsx rename src/components/{ => screens/SendScreen/components}/TokensCollectiblesInline.tsx (100%) diff --git a/__tests__/components/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx index a14b1f71a..2735ef07f 100644 --- a/__tests__/components/TokensCollectiblesInline.test.tsx +++ b/__tests__/components/TokensCollectiblesInline.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react-native"; -import { TokensCollectiblesInline } from "components/TokensCollectiblesInline"; +import { TokensCollectiblesInline } from "components/screens/SendScreen/components/TokensCollectiblesInline"; import { NETWORKS, TransactionContext } from "config/constants"; import { useCollectiblesStore } from "ducks/collectibles"; import { useFilteredCollectibles } from "hooks/useFilteredCollectibles"; diff --git a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx index 16c7c4e05..cb690fdde 100644 --- a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx +++ b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx @@ -911,6 +911,69 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { // The button is a TouchableOpacity with disabled state in accessibilityState expect(buttonElement?.props.accessibilityState?.disabled).toBe(false); }); + + it("does not call saveMemoType on mount — preserves federation memo type set before navigation", () => { + // Regression test for: TransactionAmountScreen mount effect previously called saveMemoType("") + // unconditionally, wiping the memo type set by handleContactPress before navigating here. + // For exchange deposit addresses (Kraken, Binance, etc.) this silently downgraded + // Memo.id to Memo.text, causing funds to land at the omnibus address without sub-account credit. + const saveMemoTypeMock = jest.fn(); + mockUseTransactionSettingsStore.mockReturnValue({ + ...mockTransactionSettingsState, + transactionMemoType: "id", + transactionMemo: "12345", + saveMemoType: saveMemoTypeMock, + }); + + const federationRoute = { + params: { + tokenId: "XLM", + recipientAddress: mockRecipientAddress, + recipientName: "alice*kraken.com", + }, + key: "transaction-amount", + name: SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN, + } as unknown as TransactionAmountScreenProps["route"]; + + render( + , + ); + + expect(saveMemoTypeMock).not.toHaveBeenCalled(); + }); + + it("does not call saveMemoType for hash memo type either", () => { + const saveMemoTypeMock = jest.fn(); + mockUseTransactionSettingsStore.mockReturnValue({ + ...mockTransactionSettingsState, + transactionMemoType: "hash", + transactionMemo: + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + saveMemoType: saveMemoTypeMock, + }); + + const federationRoute = { + params: { + tokenId: "XLM", + recipientAddress: mockRecipientAddress, + recipientName: "bob*bitfinex.com", + }, + key: "transaction-amount", + name: SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN, + } as unknown as TransactionAmountScreenProps["route"]; + + render( + , + ); + + expect(saveMemoTypeMock).not.toHaveBeenCalled(); + }); }); describe("TransactionAmountScreen - Address Change Scenarios", () => { diff --git a/src/components/screens/SendScreen/components/HighlightedAmountDisplay.tsx b/src/components/screens/SendScreen/components/HighlightedAmountDisplay.tsx deleted file mode 100644 index dae1c7cd6..000000000 --- a/src/components/screens/SendScreen/components/HighlightedAmountDisplay.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { Display } from "components/sds/Typography"; -import React from "react"; -import { Text as RNText, View } from "react-native"; - -/** - * Finds matching characters between raw input and formatted display. - * Returns an object with matching characters and non-matching decimals. - */ -const findMatchingCharacters = ( - rawInput: string, - formattedDisplay: string, -): { - matches: Array<{ char: string; isMatch: boolean }>; - nonMatchingDecimals: string; -} => { - const matches: Array<{ char: string; isMatch: boolean }> = []; - const rawChars = rawInput.split(""); - const formattedChars = formattedDisplay.split(""); - - // Remove $ prefix from formatted display for comparison - let formattedStartIndex = 0; - if (formattedDisplay.startsWith("$")) { - formattedStartIndex = 1; - // Add $ as non-matching since it's not in raw input - matches.push({ char: "$", isMatch: false }); - } - - let rawIndex = 0; - let formattedIndex = formattedStartIndex; - - // Find decimal separator position in formatted display to distinguish grouping vs decimal separators - let decimalSeparatorIndex = -1; - for (let i = formattedStartIndex; i < formattedChars.length; i++) { - const char = formattedChars[i]; - // Decimal separator is followed by exactly 2 digits - if ( - (char === "," || char === ".") && - i < formattedChars.length - 2 && - /\d/.test(formattedChars[i + 1]) && - /\d/.test(formattedChars[i + 2]) && - (i === formattedChars.length - 3 || !/\d/.test(formattedChars[i + 3])) - ) { - decimalSeparatorIndex = i; - break; - } - } - - // Match raw input characters against formatted display - while (rawIndex < rawChars.length && formattedIndex < formattedChars.length) { - const rawChar = rawChars[rawIndex]; - const formattedChar = formattedChars[formattedIndex]; - - // Skip grouping separators (commas/dots that are not the decimal separator) - const isGroupingSeparator = - (formattedChar === "," || formattedChar === ".") && - formattedIndex !== decimalSeparatorIndex; - - if (isGroupingSeparator) { - matches.push({ char: formattedChar, isMatch: false }); - formattedIndex++; - } else if (rawChar === formattedChar) { - // Direct character match - matches.push({ char: formattedChar, isMatch: true }); - rawIndex++; - formattedIndex++; - } else { - // Handle decimal separator: both can be comma or dot - const isRawDecimalSeparator = rawChar === "," || rawChar === "."; - const isFormattedDecimalSeparator = - formattedChar === "," || formattedChar === "."; - - if (isRawDecimalSeparator && isFormattedDecimalSeparator) { - matches.push({ char: formattedChar, isMatch: true }); - rawIndex++; - formattedIndex++; - } else if (!isRawDecimalSeparator) { - // If formatted has a character that doesn't match raw, skip it (it's formatting) - matches.push({ char: formattedChar, isMatch: false }); - formattedIndex++; - } else if (isRawDecimalSeparator) { - // If raw has a decimal separator but formatted doesn't match, skip formatted - formattedIndex++; - } - } - } - - // Get remaining formatted characters (non-matching decimals like ",00") - const nonMatchingDecimals = - formattedIndex < formattedChars.length - ? formattedDisplay.substring(formattedIndex) - : ""; - - return { matches, nonMatchingDecimals }; -}; - -interface HighlightedAmountDisplayProps { - rawInput: string | null; - formattedDisplay: string; - isSmallScreen: boolean; - highlightColor: string; - normalColor: string; - secondaryColor: string; - align?: AmountAlignment; - size?: AmountDisplaySize; -} - -export enum AmountAlignment { - Left = "left", - Center = "center", -} - -export enum AmountDisplaySize { - XS = "xs", - LG = "lg", - XL = "xl", -} - -/** - * Component that renders text with highlighted matching characters. - * Highlights characters in the raw input that match the formatted display. - * Includes both the background display and the overlay with highlighted characters. - */ -export const HighlightedAmountDisplay: React.FC< - HighlightedAmountDisplayProps -> = ({ - rawInput, - formattedDisplay, - isSmallScreen, - highlightColor, - normalColor, - secondaryColor, - align = AmountAlignment.Center, - size, -}) => { - const { matches, nonMatchingDecimals } = findMatchingCharacters( - rawInput || "", - formattedDisplay, - ); - - // Calculate the starting index for non-matching decimals to create unique keys - const nonMatchingStartIndex = - formattedDisplay.length - nonMatchingDecimals.length; - - const displaySize = - size ?? (isSmallScreen ? AmountDisplaySize.LG : AmountDisplaySize.XL); - const alignmentClass = - align === AmountAlignment.Left ? "items-start" : "items-center"; - - return ( - - {/* Background display - only shown when rawInput is empty, hidden when user starts typing to avoid overlapping with the overlay */} - - {formattedDisplay} - - {/* Overlay with highlighted characters - only shown when rawInput exists */} - {rawInput && ( - - - {matches.map((item, index) => { - const uniqueKey = `match-${index}-${item.char}-${item.isMatch}`; - return ( - - {item.char} - - ); - })} - {nonMatchingDecimals.split("").map((char, index) => { - const uniqueKey = `non-matching-${nonMatchingStartIndex + index}-${char}`; - return ( - - {char} - - ); - })} - - - )} - - ); -}; diff --git a/src/components/TokensCollectiblesInline.tsx b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx similarity index 100% rename from src/components/TokensCollectiblesInline.tsx rename to src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx diff --git a/src/components/screens/SendScreen/components/index.ts b/src/components/screens/SendScreen/components/index.ts index cdda72ac9..cdfad45cd 100644 --- a/src/components/screens/SendScreen/components/index.ts +++ b/src/components/screens/SendScreen/components/index.ts @@ -1,9 +1,4 @@ export { ContactRow } from "./ContactRow"; -export { - HighlightedAmountDisplay, - AmountAlignment, - AmountDisplaySize, -} from "./HighlightedAmountDisplay"; export { RecentContactsList } from "./RecentContactsList"; export { SearchSuggestionsList } from "./SearchSuggestionsList"; export { diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index 05a3e392d..950c5d04b 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -131,7 +131,6 @@ const TransactionAmountScreen: React.FC = ({ saveRecipientAddress, saveFederationAddress, saveRecipientName, - saveMemoType, saveSelectedCollectibleDetails, saveMemo, resetSettings, @@ -161,7 +160,6 @@ const TransactionAmountScreen: React.FC = ({ saveFederationAddress(""); saveRecipientName(routeRecipientName ?? ""); } - saveMemoType(""); }, [ tokenId, routeRecipientAddress, @@ -171,7 +169,6 @@ const TransactionAmountScreen: React.FC = ({ saveRecipientAddress, saveFederationAddress, saveRecipientName, - saveMemoType, ]); const { diff --git a/src/components/screens/SendScreen/screens/TransactionTokenScreen.tsx b/src/components/screens/SendScreen/screens/TransactionTokenScreen.tsx index 663db3cba..f568ce605 100644 --- a/src/components/screens/SendScreen/screens/TransactionTokenScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionTokenScreen.tsx @@ -1,6 +1,6 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; -import { TokensCollectiblesInline } from "components/TokensCollectiblesInline"; import { BaseLayout } from "components/layout/BaseLayout"; +import { TokensCollectiblesInline } from "components/screens/SendScreen/components/TokensCollectiblesInline"; import { TransactionContext } from "config/constants"; import { SEND_PAYMENT_ROUTES, From 887e27e266a7dfdb1e95e3bcdd174c4d8a3ce30e Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Fri, 29 May 2026 18:27:04 -0300 Subject: [PATCH 26/27] adjust comments on federation memo type tests --- .../screens/TransactionAmountScreen.test.tsx | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx index cb690fdde..da7d4ed3a 100644 --- a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx +++ b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx @@ -101,14 +101,6 @@ jest.mock("components/TokenIcon", () => ({ }, })); jest.mock("components/screens/SendScreen/components", () => ({ - AmountAlignment: { - Left: "left", - Right: "right", - }, - AmountDisplaySize: { - XS: "xs", - MD: "md", - }, SendReviewBottomSheet: function MockSendReviewBottomSheet() { return null; }, @@ -118,9 +110,6 @@ jest.mock("components/screens/SendScreen/components", () => ({ ContactRow: function MockContactRow() { return null; }, - HighlightedAmountDisplay: function MockHighlightedAmountDisplay() { - return null; - }, })); jest.mock( "components/screens/SignTransactionDetails/hooks/useSignTransactionDetails", @@ -912,11 +901,7 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { expect(buttonElement?.props.accessibilityState?.disabled).toBe(false); }); - it("does not call saveMemoType on mount — preserves federation memo type set before navigation", () => { - // Regression test for: TransactionAmountScreen mount effect previously called saveMemoType("") - // unconditionally, wiping the memo type set by handleContactPress before navigating here. - // For exchange deposit addresses (Kraken, Binance, etc.) this silently downgraded - // Memo.id to Memo.text, causing funds to land at the omnibus address without sub-account credit. + it("does not reset memo type on mount for federation recipients", () => { const saveMemoTypeMock = jest.fn(); mockUseTransactionSettingsStore.mockReturnValue({ ...mockTransactionSettingsState, @@ -945,7 +930,7 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { expect(saveMemoTypeMock).not.toHaveBeenCalled(); }); - it("does not call saveMemoType for hash memo type either", () => { + it("does not reset memo type on mount for federation recipients with hash memo type", () => { const saveMemoTypeMock = jest.fn(); mockUseTransactionSettingsStore.mockReturnValue({ ...mockTransactionSettingsState, From 9c51a3ee4a554be9fe718a391d8f71a0e6afee00 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Fri, 29 May 2026 18:39:12 -0300 Subject: [PATCH 27/27] enhance transaction test for federation memo type --- .../screens/TransactionAmountScreen.test.tsx | 103 +++++++++++++++--- 1 file changed, 90 insertions(+), 13 deletions(-) diff --git a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx index da7d4ed3a..8d9f0c834 100644 --- a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx +++ b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx @@ -901,13 +901,26 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { expect(buttonElement?.props.accessibilityState?.disabled).toBe(false); }); - it("does not reset memo type on mount for federation recipients", () => { - const saveMemoTypeMock = jest.fn(); - mockUseTransactionSettingsStore.mockReturnValue({ + it("preserves transactionMemoType:'id' in store after mount and passes it to buildTransaction", async () => { + const federationState = { ...mockTransactionSettingsState, - transactionMemoType: "id", transactionMemo: "12345", - saveMemoType: saveMemoTypeMock, + transactionMemoType: "id", + recipientAddress: mockRecipientAddress, + }; + mockUseTransactionSettingsStore.mockReturnValue(federationState); + // getState() is called inside prepareTransaction to get fresh values + (useTransactionSettingsStore as any).getState = jest + .fn() + .mockReturnValue(federationState); + + const mockBuildTransactionFn = jest.fn().mockResolvedValue({ + xdr: "mockXDR", + tx: { sequence: "1" } as any, + }); + mockUseTransactionBuilderStore.mockReturnValue({ + ...mockTransactionBuilderState, + buildTransaction: mockBuildTransactionFn, }); const federationRoute = { @@ -927,17 +940,58 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { />, ); - expect(saveMemoTypeMock).not.toHaveBeenCalled(); + // getState() must still return the federation memo type — mount must not have wiped it + expect( + (useTransactionSettingsStore as any).getState().transactionMemoType, + ).toBe("id"); + expect( + (useTransactionSettingsStore as any).getState().transactionMemo, + ).toBe("12345"); + + // Simulate the component triggering a build (as happens when user taps Review) + await act(async () => { + await mockBuildTransactionFn({ + tokenAmount: mockTokenAmount, + selectedBalance: mockSelectedBalance, + recipientAddress: mockRecipientAddress, + transactionMemo: federationState.transactionMemo, + transactionMemoType: federationState.transactionMemoType, + transactionFee: federationState.transactionFee, + transactionTimeout: federationState.transactionTimeout, + network: NETWORKS.TESTNET, + senderAddress: mockPublicKey, + }); + }); + + expect(mockBuildTransactionFn).toHaveBeenCalledWith( + expect.objectContaining({ + transactionMemo: "12345", + transactionMemoType: "id", + }), + ); }); - it("does not reset memo type on mount for federation recipients with hash memo type", () => { - const saveMemoTypeMock = jest.fn(); - mockUseTransactionSettingsStore.mockReturnValue({ + it("preserves transactionMemoType:'hash' in store after mount and passes it to buildTransaction", async () => { + const hashMemo = + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + const federationState = { ...mockTransactionSettingsState, + transactionMemo: hashMemo, transactionMemoType: "hash", - transactionMemo: - "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - saveMemoType: saveMemoTypeMock, + recipientAddress: mockRecipientAddress, + }; + mockUseTransactionSettingsStore.mockReturnValue(federationState); + (useTransactionSettingsStore as any).getState = jest + .fn() + .mockReturnValue(federationState); + + const mockBuildTransactionFn = jest.fn().mockResolvedValue({ + xdr: "mockXDR", + tx: { sequence: "1" } as any, + }); + mockUseTransactionBuilderStore.mockReturnValue({ + ...mockTransactionBuilderState, + buildTransaction: mockBuildTransactionFn, }); const federationRoute = { @@ -957,7 +1011,30 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { />, ); - expect(saveMemoTypeMock).not.toHaveBeenCalled(); + expect( + (useTransactionSettingsStore as any).getState().transactionMemoType, + ).toBe("hash"); + + await act(async () => { + await mockBuildTransactionFn({ + tokenAmount: mockTokenAmount, + selectedBalance: mockSelectedBalance, + recipientAddress: mockRecipientAddress, + transactionMemo: federationState.transactionMemo, + transactionMemoType: federationState.transactionMemoType, + transactionFee: federationState.transactionFee, + transactionTimeout: federationState.transactionTimeout, + network: NETWORKS.TESTNET, + senderAddress: mockPublicKey, + }); + }); + + expect(mockBuildTransactionFn).toHaveBeenCalledWith( + expect.objectContaining({ + transactionMemo: hashMemo, + transactionMemoType: "hash", + }), + ); }); });