diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index b615ca78b..6ead76ed6 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -230,6 +230,7 @@ jobs: # - ImportFundedWallet: Import wallet with funded account # - SwitchToTestnet: Switch network to testnet # - SendClassicTokenMainnet: Send XLM on mainnet (small amount: 0.000001 XLM) + # - SendClassicTokenMainnetFromDetails: Send XLM on mainnet starting from Token Details screen (small amount: 0.000001 XLM) # - SwapClassicTokenMainnet: Swap tokens on mainnet (small amount: 0.000001 XLM) # - ForgotPasswordWarning: Lock screen forgot password confirmation modal # - SendFederatedAddress: Send XLM and USDC to a federated address on mainnet (small amount: 0.000001 each) @@ -240,47 +241,51 @@ jobs: matrix: include: - shard-index: 0 - shard-total: 9 + shard-total: 10 flow-name: LaunchAndInspect requires-mock-server: false - shard-index: 1 - shard-total: 9 + shard-total: 10 flow-name: CreateWallet requires-mock-server: false - shard-index: 2 - shard-total: 9 + shard-total: 10 flow-name: ImportWallet requires-mock-server: false - shard-index: 3 - shard-total: 9 + shard-total: 10 flow-name: ImportFundedWallet requires-mock-server: false - shard-index: 4 - shard-total: 9 + shard-total: 10 flow-name: SwitchToTestnet requires-mock-server: false - shard-index: 5 - shard-total: 9 + shard-total: 10 flow-name: SendClassicTokenMainnet requires-mock-server: false - shard-index: 6 - shard-total: 9 + shard-total: 10 flow-name: SwapClassicTokenMainnet requires-mock-server: false - shard-index: 7 - shard-total: 9 + shard-total: 10 flow-name: ForgotPasswordWarning requires-mock-server: false - shard-index: 8 - shard-total: 9 + shard-total: 10 flow-name: SendFederatedAddress requires-mock-server: false + - shard-index: 9 + shard-total: 10 + flow-name: SendClassicTokenMainnetFromDetails + requires-mock-server: false - shard-index: 0 - shard-total: 9 + shard-total: 10 flow-name: SignMessageMockDapp requires-mock-server: true - shard-index: 0 - shard-total: 9 + shard-total: 10 flow-name: SignAuthEntryMockDapp requires-mock-server: true diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index c3f78307b..8b3905b04 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -227,6 +227,7 @@ jobs: # - ImportFundedWallet: Import wallet with funded account # - SwitchToTestnet: Switch network to testnet # - SendClassicTokenMainnet: Send XLM on mainnet (small amount: 0.000001 XLM) + # - SendClassicTokenMainnetFromDetails: Send XLM on mainnet starting from Token Details screen (small amount: 0.000001 XLM) # - SwapClassicTokenMainnet: Swap tokens on mainnet (small amount: 0.000001 XLM) # - ForgotPasswordWarning: Lock screen forgot password confirmation modal # - SendFederatedAddress: Send XLM and USDC to a federated address on mainnet (small amount: 0.000001 each) @@ -237,47 +238,51 @@ jobs: matrix: include: - shard-index: 0 - shard-total: 9 + shard-total: 10 flow-name: LaunchAndInspect requires-mock-server: false - shard-index: 1 - shard-total: 9 + shard-total: 10 flow-name: CreateWallet requires-mock-server: false - shard-index: 2 - shard-total: 9 + shard-total: 10 flow-name: ImportWallet requires-mock-server: false - shard-index: 3 - shard-total: 9 + shard-total: 10 flow-name: ImportFundedWallet requires-mock-server: false - shard-index: 4 - shard-total: 9 + shard-total: 10 flow-name: SwitchToTestnet requires-mock-server: false - shard-index: 5 - shard-total: 9 + shard-total: 10 flow-name: SendClassicTokenMainnet requires-mock-server: false - shard-index: 6 - shard-total: 9 + shard-total: 10 flow-name: SwapClassicTokenMainnet requires-mock-server: false - shard-index: 7 - shard-total: 9 + shard-total: 10 flow-name: ForgotPasswordWarning requires-mock-server: false - shard-index: 8 - shard-total: 9 + shard-total: 10 flow-name: SendFederatedAddress requires-mock-server: false + - shard-index: 9 + shard-total: 10 + flow-name: SendClassicTokenMainnetFromDetails + requires-mock-server: false - shard-index: 0 - shard-total: 9 + shard-total: 10 flow-name: SignMessageMockDapp requires-mock-server: true - shard-index: 0 - shard-total: 9 + shard-total: 10 flow-name: SignAuthEntryMockDapp requires-mock-server: true diff --git a/.github/workflows/prPreviewIos.yml b/.github/workflows/prPreviewIos.yml index 5bd4735ac..14c9b3b46 100644 --- a/.github/workflows/prPreviewIos.yml +++ b/.github/workflows/prPreviewIos.yml @@ -65,8 +65,8 @@ jobs: runs-on: macos-26-xlarge timeout-minutes: 45 permissions: - contents: write # draft release create/delete + tag operations - pull-requests: write # sticky preview-link comment + contents: write # draft release create/delete + tag operations + pull-requests: write # sticky preview-link comment env: NODE_VERSION: "20" RUBY_VERSION: 3.1.4 @@ -97,13 +97,15 @@ jobs: WALLET_KIT_MT_DESCRIPTION_PROD: ${{ vars.WALLET_KIT_MT_DESCRIPTION_DEV }} WALLET_KIT_MT_URL_PROD: ${{ vars.WALLET_KIT_MT_URL_DEV }} WALLET_KIT_MT_ICON_PROD: ${{ vars.WALLET_KIT_MT_ICON_DEV }} - WALLET_KIT_MT_REDIRECT_NATIVE_PROD: ${{ vars.WALLET_KIT_MT_REDIRECT_NATIVE_DEV }} + WALLET_KIT_MT_REDIRECT_NATIVE_PROD: + ${{ vars.WALLET_KIT_MT_REDIRECT_NATIVE_DEV }} WALLET_KIT_PROJECT_ID_DEV: ${{ secrets.WALLET_KIT_PROJECT_ID_DEV }} WALLET_KIT_MT_NAME_DEV: ${{ vars.WALLET_KIT_MT_NAME_DEV }} WALLET_KIT_MT_DESCRIPTION_DEV: ${{ vars.WALLET_KIT_MT_DESCRIPTION_DEV }} WALLET_KIT_MT_URL_DEV: ${{ vars.WALLET_KIT_MT_URL_DEV }} WALLET_KIT_MT_ICON_DEV: ${{ vars.WALLET_KIT_MT_ICON_DEV }} - WALLET_KIT_MT_REDIRECT_NATIVE_DEV: ${{ vars.WALLET_KIT_MT_REDIRECT_NATIVE_DEV }} + WALLET_KIT_MT_REDIRECT_NATIVE_DEV: + ${{ vars.WALLET_KIT_MT_REDIRECT_NATIVE_DEV }} MP_COLLECTIONS_ADDRESSES: ${{ vars.MP_COLLECTIONS_ADDRESSES }} IS_E2E_TEST: "false" E2E_TEST_RECOVERY_PHRASE: "" @@ -190,7 +192,9 @@ jobs: ios/Pods ~/Library/Caches/CocoaPods ~/.cocoapods - key: ${{ runner.os }}-pods-pr-${{ github.event.pull_request.number }}-${{ hashFiles('ios/Podfile.lock') }} + key: + ${{ runner.os }}-pods-pr-${{ github.event.pull_request.number }}-${{ + hashFiles('ios/Podfile.lock') }} restore-keys: | ${{ runner.os }}-pods-pr-${{ github.event.pull_request.number }}- diff --git a/__tests__/components/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx new file mode 100644 index 000000000..2735ef07f --- /dev/null +++ b/__tests__/components/TokensCollectiblesInline.test.tsx @@ -0,0 +1,179 @@ +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { TokensCollectiblesInline } from "components/screens/SendScreen/components/TokensCollectiblesInline"; +import { NETWORKS, TransactionContext } from "config/constants"; +import { useCollectiblesStore } from "ducks/collectibles"; +import { useFilteredCollectibles } from "hooks/useFilteredCollectibles"; +import React from "react"; +import { + TouchableOpacity as mockTouchableOpacity, + View as mockView, +} from "react-native"; + +const MockTouchableOpacity = mockTouchableOpacity; +const MockView = mockView; + +jest.mock("components/BalancesList", () => ({ + BalancesList: ({ + onTokenPress, + }: { + onTokenPress?: (tokenId: string) => void; + }) => ( + onTokenPress?.("native")} + > + + + ), +})); + +jest.mock("components/CollectibleImage", () => ({ + CollectibleImage: () => , +})); + +jest.mock("components/Spinner", () => ({ + __esModule: true, + default: ({ testID }: { testID?: string }) => , +})); + +jest.mock("hooks/useAppTranslation", () => ({ + __esModule: true, + default: () => ({ + t: (key: string) => + ({ + "balancesList.title": "Tokens", + "collectiblesGrid.title": "Collectibles", + "collectiblesGrid.error": "Error loading collectibles", + "collectiblesGrid.empty": "No collectibles", + })[key] || key, + }), +})); + +jest.mock("hooks/useColors", () => ({ + __esModule: true, + default: () => ({ + themeColors: { + secondary: "#000", + text: { + secondary: "#111", + }, + }, + }), +})); + +jest.mock("ducks/collectibles", () => ({ + useCollectiblesStore: jest.fn(), +})); + +jest.mock("hooks/useFilteredCollectibles", () => ({ + useFilteredCollectibles: jest.fn(), +})); + +const mockUseCollectiblesStore = + useCollectiblesStore as unknown as jest.MockedFunction; +const mockUseFilteredCollectibles = + useFilteredCollectibles as unknown as jest.MockedFunction; + +const setupCollectibleState = ({ + isLoading = false, + error = null, + visibleCollectibles = [], +}: { + isLoading?: boolean; + error?: string | null; + visibleCollectibles?: any[]; +}) => { + mockUseCollectiblesStore.mockImplementation((selector: any) => + selector({ isLoading, error }), + ); + mockUseFilteredCollectibles.mockReturnValue({ visibleCollectibles }); +}; + +describe("TokensCollectiblesInline", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("shows loading spinner while collectibles are loading", () => { + setupCollectibleState({ isLoading: true, visibleCollectibles: [] }); + + render( + , + ); + + expect(screen.getByTestId("collectibles-inline-spinner")).toBeTruthy(); + }); + + it("shows collectibles error state", () => { + setupCollectibleState({ error: "failed", visibleCollectibles: [] }); + + render( + , + ); + + expect(screen.getByText("Error loading collectibles")).toBeTruthy(); + }); + + it("shows collectibles empty state", () => { + setupCollectibleState({ visibleCollectibles: [] }); + + render( + , + ); + + expect(screen.getByText("No collectibles")).toBeTruthy(); + }); + + it("forwards token and collectible press handlers", () => { + const onTokenPress = jest.fn(); + const onCollectiblePress = jest.fn(); + + setupCollectibleState({ + visibleCollectibles: [ + { + collectionAddress: "CABC", + collectionName: "Cool Collection", + items: [ + { + collectionAddress: "CABC", + tokenId: "42", + image: "https://example.com/item.png", + name: "Collectible #42", + }, + ], + }, + ], + }); + + render( + , + ); + + fireEvent.press(screen.getByTestId("balances-list-token-row")); + expect(onTokenPress).toHaveBeenCalledWith("native"); + + fireEvent.press(screen.getByText("Collectible #42")); + expect(onCollectiblePress).toHaveBeenCalledWith({ + collectionAddress: "CABC", + tokenId: "42", + }); + }); +}); diff --git a/__tests__/components/screens/CollectibleDetailsScreen/CollectibleDetailsScreen.test.tsx b/__tests__/components/screens/CollectibleDetailsScreen/CollectibleDetailsScreen.test.tsx index 94e0ad06b..e7a93c552 100644 --- a/__tests__/components/screens/CollectibleDetailsScreen/CollectibleDetailsScreen.test.tsx +++ b/__tests__/components/screens/CollectibleDetailsScreen/CollectibleDetailsScreen.test.tsx @@ -242,6 +242,9 @@ describe("CollectibleDetailsScreen", () => { "SendPaymentStack", { screen: "SendSearchContactsScreen", + params: { + dismissToPreviousScreen: true, + }, }, ); }); diff --git a/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx b/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx index 469d76e87..4236365c4 100644 --- a/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx +++ b/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx @@ -1,14 +1,23 @@ import { NavigationContainer, RouteProp } from "@react-navigation/native"; import { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { screen, userEvent, waitFor } from "@testing-library/react-native"; +import { + act, + fireEvent, + screen, + userEvent, + waitFor, +} from "@testing-library/react-native"; import { SendSearchContacts } from "components/screens/SendScreen"; import { RootStackParamList, 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 { isFederationAddress } from "helpers/stellar"; import { renderWithProviders } from "helpers/testUtils"; import React, { ReactNode } from "react"; import { View } from "react-native"; @@ -49,6 +58,7 @@ const mockLoadRecentAddresses = jest.fn(); const mockSearchAddress = jest.fn(); const mockAddRecentAddress = jest.fn(); const mockSetDestinationAddress = jest.fn(); +const mockPrepareForSearch = jest.fn(); const mockReset = jest.fn(); // Create mock data @@ -70,8 +80,8 @@ const mockSearchResults = [ ]; // Create a function to get the useSendStore implementation -const getSendStoreMock = (overrides = {}) => - jest.fn().mockReturnValue({ +const getSendStoreMock = (overrides = {}) => { + const state = { recentAddresses: [], searchResults: [], searchError: null, @@ -86,10 +96,16 @@ const getSendStoreMock = (overrides = {}) => searchAddress: mockSearchAddress, addRecentAddress: mockAddRecentAddress, setDestinationAddress: mockSetDestinationAddress, - prepareForSearch: jest.fn(), + prepareForSearch: mockPrepareForSearch, resetSendRecipient: mockReset, ...overrides, - }); + }; + const fn = jest.fn().mockReturnValue(state) as jest.Mock & { + getState: jest.Mock; + }; + fn.getState = jest.fn().mockReturnValue(state); + return fn; +}; jest.mock("ducks/sendRecipient", () => ({ useSendRecipientStore: getSendStoreMock(), @@ -98,6 +114,7 @@ jest.mock("ducks/sendRecipient", () => ({ const getTransactionSettingsStoreMock = (overrides = {}) => ({ saveRecipientAddress: jest.fn(), saveFederationAddress: jest.fn(), + saveRecipientName: jest.fn(), saveMemo: jest.fn(), saveMemoType: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), @@ -110,6 +127,7 @@ jest.mock("ducks/transactionSettings", () => ({ useTransactionSettingsStore: jest.fn().mockReturnValue({ saveRecipientAddress: jest.fn(), saveFederationAddress: jest.fn(), + saveRecipientName: jest.fn(), saveMemo: jest.fn(), saveMemoType: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), @@ -122,6 +140,10 @@ jest.mock("ducks/qrData", () => ({ useQRDataStore: () => ({ clearQRData: jest.fn() }), })); +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: jest.fn(), +})); + jest.mock("hooks/useInAppBrowser", () => ({ useInAppBrowser: () => ({ open: jest.fn() }), })); @@ -161,6 +183,7 @@ const mockNavigation = { reset: jest.fn(), replace: jest.fn(), popToTop: jest.fn(), + popTo: jest.fn(), setParams: jest.fn(), } as unknown as SendSearchContactsNavigationProp; @@ -177,6 +200,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": @@ -187,6 +211,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(); @@ -197,6 +240,10 @@ describe("SendSearchContacts", () => { loadRecentAddresses: mockLoadRecentAddresses, }), ); + mockUseAuthenticationStore.mockReturnValue({ + allAccounts: mockAccounts, + account: { publicKey: activePublicKey } as any, + } as any); }); it("renders correctly with the search input", async () => { @@ -247,6 +294,8 @@ describe("SendSearchContacts", () => { }); it("shows search suggestions when text is entered", async () => { + jest.useFakeTimers(); + // Setup the mock to return search results for this specific test jest.spyOn(sendDuck, "useSendRecipientStore").mockImplementation( getSendStoreMock({ @@ -263,11 +312,45 @@ describe("SendSearchContacts", () => { ); const input = await screen.findByPlaceholderText("Enter address"); - await userEvent.type(input, "test"); + fireEvent.changeText(input, "test"); + + expect(mockPrepareForSearch).toHaveBeenCalled(); + expect(mockSearchAddress).not.toHaveBeenCalled(); + + act(() => { + jest.runAllTimers(); + }); await waitFor(() => { expect(mockSearchAddress).toHaveBeenCalledWith("test"); }); + + jest.useRealTimers(); + }); + + it("keeps recents and wallets visible while typing an invalid address", async () => { + jest.spyOn(sendDuck, "useSendRecipientStore").mockImplementation( + getSendStoreMock({ + searchResults: mockSearchResults, + recentAddresses: mockRecentAddresses, + loadRecentAddresses: mockLoadRecentAddresses, + isValidDestination: false, + }), + ); + + renderWithProviders( + + + , + ); + + const input = await screen.findByPlaceholderText("Enter address"); + await userEvent.type(input, "GABC"); + + await waitFor(() => { + expect(screen.getByText("Recent Contact")).toBeTruthy(); + expect(screen.getByText("My Second Wallet")).toBeTruthy(); + }); }); describe("unfunded destination notification", () => { @@ -409,4 +492,204 @@ 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("saves the wallet nickname as recipientName when wallet row is tapped", async () => { + const mockSaveRecipientName = jest.fn(); + const mockSaveFederationAddress = jest.fn(); + + jest + .spyOn(transactionSettingsDuck, "useTransactionSettingsStore") + .mockReturnValue( + getTransactionSettingsStoreMock({ + saveRecipientName: mockSaveRecipientName, + saveFederationAddress: mockSaveFederationAddress, + }), + ); + + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("My Second Wallet")).toBeTruthy(); + }); + + await userEvent.press(screen.getByTestId("my-wallet-row-wallet-1")); + + await waitFor(() => { + // Wallet nicknames go into recipientName, not federationAddress + expect(mockSaveRecipientName).toHaveBeenCalledWith("My Second Wallet"); + expect(mockSaveFederationAddress).toHaveBeenCalledWith(""); + }); + }); + + it("does not save recipientName when contact name is federation address", async () => { + const mockSaveRecipientAddress = jest.fn(); + const mockSaveRecipientName = jest.fn(); + + (isFederationAddress as jest.Mock).mockImplementation((value: string) => + value.includes("*"), + ); + + jest + .spyOn(transactionSettingsDuck, "useTransactionSettingsStore") + .mockReturnValue( + getTransactionSettingsStoreMock({ + saveRecipientAddress: mockSaveRecipientAddress, + saveRecipientName: mockSaveRecipientName, + }), + ); + + const sendStoreMock = getSendStoreMock({ + recentAddresses: [ + { + id: "recent-fed", + address: "GDAS7BS4XKW27H2K5C25V6ZU46FCFGBTFQGFDZURAKVPA6QYQG4GTWBC", + name: "alice*example.com", + }, + ], + // Re-resolution returns the same address; handleContactPress reads + // searchResults via useSendRecipientStore.getState() to pick the + // (possibly remapped) resolved key. + searchResults: [ + { + id: "search-fed", + address: "GDAS7BS4XKW27H2K5C25V6ZU46FCFGBTFQGFDZURAKVPA6QYQG4GTWBC", + name: "alice*example.com", + }, + ], + loadRecentAddresses: mockLoadRecentAddresses, + }); + jest + .spyOn(sendDuck, "useSendRecipientStore") + .mockImplementation(sendStoreMock); + // jest.spyOn replaces the hook function but not the static .getState + // helper attached to it; re-bind it so the source's getState() call + // returns the same overridden state. + ( + sendDuck.useSendRecipientStore as unknown as { getState: jest.Mock } + ).getState = sendStoreMock.getState; + + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("alice*example.com")).toBeTruthy(); + }); + + await userEvent.press(screen.getByText("alice*example.com")); + + await waitFor(() => { + expect(mockSaveRecipientAddress).toHaveBeenCalledWith( + "GDAS7BS4XKW27H2K5C25V6ZU46FCFGBTFQGFDZURAKVPA6QYQG4GTWBC", + ); + expect(mockSaveRecipientName).toHaveBeenCalledWith(""); + }); + }); + + 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/components/SendReviewBottomSheet.test.tsx b/__tests__/components/screens/SendScreen/components/SendReviewBottomSheet.test.tsx index e7d8cad54..abeded6ab 100644 --- a/__tests__/components/screens/SendScreen/components/SendReviewBottomSheet.test.tsx +++ b/__tests__/components/screens/SendScreen/components/SendReviewBottomSheet.test.tsx @@ -32,6 +32,7 @@ jest.mock("ducks/transactionSettings", () => ({ useTransactionSettingsStore: jest.fn(() => ({ recipientAddress: "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF", + federationAddress: "", memo: "", isMemoRequired: false, transactionFee: "0.00001", @@ -83,6 +84,73 @@ describe("SendReviewBottomSheet", () => { expect(getByText("Test Account")).toBeTruthy(); }); + + it("displays federation address above the truncated address when provided", () => { + ( + jest.requireMock("ducks/transactionSettings") + .useTransactionSettingsStore as jest.Mock + ).mockReturnValueOnce({ + recipientAddress: + "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF", + federationAddress: "account2*stellar.org", + recipientName: "", + memo: "", + isMemoRequired: false, + transactionFee: "0.00001", + transactionMemo: "", + }); + const { getByText } = renderWithProviders( + , + ); + + expect(getByText("account2*stellar.org")).toBeTruthy(); + expect(getByText("GA6S...IMFF")).toBeTruthy(); + }); + + it("displays the recipient nickname above the truncated address when provided", () => { + ( + jest.requireMock("ducks/transactionSettings") + .useTransactionSettingsStore as jest.Mock + ).mockReturnValueOnce({ + recipientAddress: + "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF", + federationAddress: "", + recipientName: "Account 2", + memo: "", + isMemoRequired: false, + transactionFee: "0.00001", + transactionMemo: "", + }); + const { getByText } = renderWithProviders( + , + ); + + expect(getByText("Account 2")).toBeTruthy(); + expect(getByText("GA6S...IMFF")).toBeTruthy(); + }); + + it("recipientName takes priority over federationAddress when both are set", () => { + ( + jest.requireMock("ducks/transactionSettings") + .useTransactionSettingsStore as jest.Mock + ).mockReturnValueOnce({ + recipientAddress: + "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF", + federationAddress: "alice*example.com", + recipientName: "Alice's wallet", + memo: "", + isMemoRequired: false, + transactionFee: "0.00001", + transactionMemo: "", + }); + const { getByText, queryByText } = renderWithProviders( + , + ); + + expect(getByText("Alice's wallet")).toBeTruthy(); + // Federation address should not appear when a custom name is set + expect(queryByText("alice*example.com")).toBeNull(); + }); }); describe("Security banner states", () => { diff --git a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx index e7a7bc197..8d9f0c834 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; @@ -104,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", @@ -267,11 +270,15 @@ const mockStellarSdkServer = jest.fn(); const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); const mockReset = jest.fn(); +const mockSetOptions = jest.fn(); +const mockParentGoBack = jest.fn(); const mockNavigation = { goBack: mockGoBack, navigate: mockNavigate, reset: mockReset, + setOptions: mockSetOptions, + getParent: jest.fn(() => ({ goBack: mockParentGoBack })), } as unknown as TransactionAmountScreenProps["navigation"]; const mockRoute = { @@ -339,6 +346,7 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { transactionTimeout: 30, recipientAddress: mockRecipientAddress, federationAddress: "", + recipientName: "", selectedTokenId: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", saveMemo: jest.fn(), @@ -347,6 +355,7 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { saveTransactionTimeout: jest.fn(), saveRecipientAddress: jest.fn(), saveFederationAddress: jest.fn(), + saveRecipientName: jest.fn(), saveSelectedTokenId: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), resetSettings: jest.fn(), @@ -891,6 +900,142 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { // The button is a TouchableOpacity with disabled state in accessibilityState expect(buttonElement?.props.accessibilityState?.disabled).toBe(false); }); + + it("preserves transactionMemoType:'id' in store after mount and passes it to buildTransaction", async () => { + const federationState = { + ...mockTransactionSettingsState, + transactionMemo: "12345", + 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 = { + 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( + , + ); + + // 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("preserves transactionMemoType:'hash' in store after mount and passes it to buildTransaction", async () => { + const hashMemo = + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + const federationState = { + ...mockTransactionSettingsState, + transactionMemo: hashMemo, + transactionMemoType: "hash", + 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 = { + 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( + (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", + }), + ); + }); }); describe("TransactionAmountScreen - Address Change Scenarios", () => { @@ -948,6 +1093,7 @@ describe("TransactionAmountScreen - Address Change Scenarios", () => { transactionTimeout: 30, recipientAddress: mockNonMemoRequiredAddress, federationAddress: "", + recipientName: "", selectedTokenId: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", saveMemo: jest.fn(), @@ -956,6 +1102,7 @@ describe("TransactionAmountScreen - Address Change Scenarios", () => { saveTransactionTimeout: jest.fn(), saveRecipientAddress: jest.fn(), saveFederationAddress: jest.fn(), + saveRecipientName: jest.fn(), saveSelectedTokenId: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), resetSettings: jest.fn(), @@ -1572,3 +1719,241 @@ 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", + recipientName: "", + selectedTokenId: + "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + federationAddress: "", + saveMemo: jest.fn(), + saveTransactionFee: jest.fn(), + saveTransactionTimeout: jest.fn(), + saveRecipientAddress: jest.fn(), + saveRecipientName: 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 once with full pasted text", () => { + const { getByTestId } = render( + , + ); + + fireEvent.changeText(getByTestId("amount-text-input"), "123"); + expect(mockHandleDisplayAmountChange).toHaveBeenCalledTimes(1); + expect(mockHandleDisplayAmountChange).toHaveBeenCalledWith("123"); + }); + + it("sanitizes mixed pasted input before forwarding", () => { + const { getByTestId } = render( + , + ); + + fireEvent.changeText(getByTestId("amount-text-input"), "$12a,3b4"); + expect(mockHandleDisplayAmountChange).toHaveBeenCalledWith("12,34"); + }); + + it("maps comma deletion sequence to backspace calls", () => { + const { getByTestId } = render( + , + ); + + fireEvent.changeText(getByTestId("amount-text-input"), "0,1"); + fireEvent.changeText(getByTestId("amount-text-input"), "0,"); + fireEvent.changeText(getByTestId("amount-text-input"), "0"); + + expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(1, "0,1"); + expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(2, ""); + expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(3, ""); + }); + + it("allows retyping in fiat mode after deleting to partial zero", () => { + mockUseTokenFiatConverter.mockReturnValue({ + tokenAmount: "0", + tokenAmountDisplay: "0", + tokenAmountDisplayRaw: null, + fiatAmount: "0", + fiatAmountDisplay: "0,00", + fiatAmountDisplayRaw: "0,0", + showFiatAmount: true, + setTokenAmount: jest.fn(), + setFiatAmount: jest.fn(), + setShowFiatAmount: jest.fn(), + handleDisplayAmountChange: mockHandleDisplayAmountChange, + updateFiatDisplay: jest.fn(), + }); + + const { getByTestId } = render( + , + ); + + fireEvent.changeText(getByTestId("amount-text-input"), "0,"); + fireEvent.changeText(getByTestId("amount-text-input"), "1"); + + expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(1, ""); + expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(2, "1"); + }); +}); diff --git a/__tests__/ducks/sendRecipient.test.ts b/__tests__/ducks/sendRecipient.test.ts index ce8928148..efbea2dfc 100644 --- a/__tests__/ducks/sendRecipient.test.ts +++ b/__tests__/ducks/sendRecipient.test.ts @@ -327,9 +327,7 @@ describe("sendRecipient Duck", () => { it("should reset all search-related state", () => { act(() => { store.setState({ - searchResults: [ - { id: "1", address: "address" }, - ], + searchResults: [{ id: "1", address: "address" }], destinationAddress: "address", federationAddress: "fed*address", isSearching: true, diff --git a/__tests__/ducks/transactionSettings.test.ts b/__tests__/ducks/transactionSettings.test.ts index 9ab996d37..3a0a7cf56 100644 --- a/__tests__/ducks/transactionSettings.test.ts +++ b/__tests__/ducks/transactionSettings.test.ts @@ -20,6 +20,8 @@ describe("transactionSettings Duck", () => { expect(initialState.transactionFee).toBe(MIN_TRANSACTION_FEE); expect(initialState.transactionTimeout).toBe(DEFAULT_TRANSACTION_TIMEOUT); expect(initialState.recipientAddress).toBe(""); + expect(initialState.federationAddress).toBe(""); + expect(initialState.recipientName).toBe(""); expect(initialState.selectedTokenId).toBe(""); }); @@ -56,6 +58,30 @@ describe("transactionSettings Duck", () => { expect(store.getState().recipientAddress).toBe(newAddress); }); + it("should save federation address", () => { + const newFederationAddress = "alice*example.com"; + + act(() => { + store.getState().saveFederationAddress(newFederationAddress); + }); + + expect(store.getState().federationAddress).toBe(newFederationAddress); + // recipientName must remain untouched + expect(store.getState().recipientName).toBe(""); + }); + + it("should save recipient name", () => { + const newName = "Account 2"; + + act(() => { + store.getState().saveRecipientName(newName); + }); + + expect(store.getState().recipientName).toBe(newName); + // federationAddress must remain untouched + expect(store.getState().federationAddress).toBe(""); + }); + it("should save selected token ID", () => { const newTokenId = "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; @@ -71,6 +97,8 @@ describe("transactionSettings Duck", () => { const newTimeout = 600; const newAddress = "GCVOLU545KR4QKJ5J57Q4AP3ZT6M2PX5FQOOWEVJ6VAMSHMWWUH4Y3QF"; + const newFederationAddress = "alice*example.com"; + const newRecipientName = "Account 2"; const newTokenId = "TEST:TEST"; act(() => { @@ -78,6 +106,8 @@ describe("transactionSettings Duck", () => { store.getState().saveTransactionFee(newFee); store.getState().saveTransactionTimeout(newTimeout); store.getState().saveRecipientAddress(newAddress); + store.getState().saveFederationAddress(newFederationAddress); + store.getState().saveRecipientName(newRecipientName); store.getState().saveSelectedTokenId(newTokenId); }); @@ -85,6 +115,8 @@ describe("transactionSettings Duck", () => { expect(store.getState().transactionFee).toBe(newFee); expect(store.getState().transactionTimeout).toBe(newTimeout); expect(store.getState().recipientAddress).toBe(newAddress); + expect(store.getState().federationAddress).toBe(newFederationAddress); + expect(store.getState().recipientName).toBe(newRecipientName); expect(store.getState().selectedTokenId).toBe(newTokenId); act(() => { @@ -97,6 +129,8 @@ describe("transactionSettings Duck", () => { DEFAULT_TRANSACTION_TIMEOUT, ); expect(store.getState().recipientAddress).toBe(""); + expect(store.getState().federationAddress).toBe(""); + expect(store.getState().recipientName).toBe(""); expect(store.getState().selectedTokenId).toBe(""); }); diff --git a/__tests__/helpers/navigationOptions.test.ts b/__tests__/helpers/navigationOptions.test.ts index 6ea70419a..ed6691a7b 100644 --- a/__tests__/helpers/navigationOptions.test.ts +++ b/__tests__/helpers/navigationOptions.test.ts @@ -1,4 +1,5 @@ import { NativeStackNavigationOptions } from "@react-navigation/native-stack"; +import { ScreenTransition } from "config/routes"; import { withTransitionOverride } from "helpers/navigationOptions"; describe("withTransitionOverride", () => { @@ -22,14 +23,14 @@ describe("withTransitionOverride", () => { it("overrides animation when transition is provided", () => { const result = withTransitionOverride(baseOptions, { - params: { transition: "fade" }, + params: { transition: ScreenTransition.Fade }, }); - expect(result.animation).toBe("fade"); + expect(result.animation).toBe(ScreenTransition.Fade); }); it("preserves other base options when overriding animation", () => { const result = withTransitionOverride(baseOptions, { - params: { transition: "fade" }, + params: { transition: ScreenTransition.Fade }, }); expect(result.animationDuration).toBe(300); expect(result.animationTypeForReplace).toBe("push"); @@ -37,7 +38,9 @@ describe("withTransitionOverride", () => { it("does not mutate the base options object", () => { const snapshot = { ...baseOptions }; - withTransitionOverride(baseOptions, { params: { transition: "fade" } }); + withTransitionOverride(baseOptions, { + params: { transition: ScreenTransition.Fade }, + }); expect(baseOptions).toEqual(snapshot); }); }); diff --git a/__tests__/hooks/useTokenFiatConverter.test.tsx b/__tests__/hooks/useTokenFiatConverter.test.tsx index 3d1e0ceb7..f72380ea3 100644 --- a/__tests__/hooks/useTokenFiatConverter.test.tsx +++ b/__tests__/hooks/useTokenFiatConverter.test.tsx @@ -168,6 +168,22 @@ describe("useTokenFiatConverter", () => { }); describe("Mid-typing scenarios", () => { + it("should handle full pasted token text through reducer path", () => { + const mockBalance = createMockPricedBalance(100, 2.5); + const { result } = renderHook(() => + useTokenFiatConverter({ + selectedBalance: mockBalance, + }), + ); + + act(() => { + result.current.handleDisplayAmountChange("0.000001"); + }); + + expect(result.current.tokenAmount).toBe("0.000001"); + expect(result.current.tokenAmountDisplay).toBe("0.000001"); + }); + it("should preserve typing state when typing '100.' in token amount", () => { const mockBalance = createMockPricedBalance(100, 2.5); const { result } = renderHook(() => diff --git a/docs/best-practices/code-style.md b/docs/best-practices/code-style.md index e022bfda9..e97eebfb9 100644 --- a/docs/best-practices/code-style.md +++ b/docs/best-practices/code-style.md @@ -62,6 +62,29 @@ handler. See `anti-patterns.md` for examples. Note: (`eslint.config.mjs`) — this is a reviewer-enforced convention, not a linter-enforced rule. +**Async event handlers**: When a component prop expects a `void`-returning +callback (e.g. `onPress`, `onChangeText`) but the work is async, wrap the call +in a block-bodied arrow function so the handler returns `undefined`, then attach +a `.catch()` for error handling. Never use the `void` operator to swallow the +promise — it bypasses the floating-promise convention above. + +```tsx +// Wrong — `no-misused-promises` will flag this, the implicit return is the promise + - - - - - - + + + + reviewBottomSheetModalRef.current?.dismiss()} + handleCloseModal={handleCancelReview} analyticsEvent={AnalyticsEvent.VIEW_SEND_CONFIRM} scrollable bottomSheetModalProps={{ accessible: false }} @@ -1086,9 +1275,7 @@ const TransactionAmountScreen: React.FC = ({ /> - transactionSettingsBottomSheetModalRef.current?.dismiss() - } + handleCloseModal={handleCancelTransactionSettings} customContent={ + isSameAccount(account.publicKey, recipientAddress), + ); const [status, setStatus] = useState( TransactionStatus.SENDING, ); @@ -103,7 +107,14 @@ const TransactionProcessingScreen: React.FC< setStatus(TransactionStatus.FAILED); } else if (transactionHash) { setStatus(TransactionStatus.SENT); - addRecentAddress(recipientAddress, federationAddress || undefined); + if (!isSelfOwnedRecipient) { + // Persist the most meaningful label: custom recipient name first, + // then federation address as a fallback. + addRecentAddress( + recipientAddress, + recipientName || federationAddress || undefined, + ); + } } else if (isContractAddress && !isSubmitting) { setStatus(TransactionStatus.UNSUPPORTED); } @@ -117,6 +128,8 @@ const TransactionProcessingScreen: React.FC< network, recipientAddress, federationAddress, + recipientName, + isSelfOwnedRecipient, addRecentAddress, ]); @@ -181,15 +194,15 @@ const TransactionProcessingScreen: React.FC< return ( - - + + {getStatusIcon()} {getStatusText()} - + {type === SendType.Token && selectedBalance && ( @@ -214,7 +227,7 @@ const TransactionProcessingScreen: React.FC< - + {type === SendType.Token && transactionAmount ? formatTokenForDisplay( @@ -230,7 +243,7 @@ const TransactionProcessingScreen: React.FC< {getMessageText()} - {slicedAddress} + {recipientName || federationAddress || slicedAddress} @@ -239,7 +252,7 @@ const TransactionProcessingScreen: React.FC< {status === TransactionStatus.SENT ? ( - + diff --git a/src/config/routes.ts b/src/config/routes.ts index 9f7e97cbc..d45449eb2 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -1,5 +1,4 @@ import { NavigatorScreenParams } from "@react-navigation/native"; -import { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { BiometricsSource, NETWORKS, @@ -7,7 +6,12 @@ import { SWAP_SELECTION_TYPES, } from "config/constants"; -export type ScreenTransition = NativeStackNavigationOptions["animation"]; +export enum ScreenTransition { + Fade = "fade", + SlideFromBottom = "slide_from_bottom", + SlideFromRight = "slide_from_right", + Default = "default", +} /** * ROUTE NAMING CONVENTIONS FOR ANALYTICS @@ -227,15 +231,29 @@ export type AddFundsStackParamList = { }; export type SendPaymentStackParamList = { - [SEND_PAYMENT_ROUTES.SEND_SEARCH_CONTACTS_SCREEN]: undefined; + [SEND_PAYMENT_ROUTES.SEND_SEARCH_CONTACTS_SCREEN]: + | { + dismissToPreviousScreen?: boolean; + transition?: ScreenTransition; + } + | undefined; [SEND_PAYMENT_ROUTES.SEND_COLLECTIBLE_REVIEW]: { tokenId: string; collectionAddress: string; + dismissToPreviousScreen?: boolean; + transition?: ScreenTransition; }; - [SEND_PAYMENT_ROUTES.TRANSACTION_TOKEN_SCREEN]: undefined; + [SEND_PAYMENT_ROUTES.TRANSACTION_TOKEN_SCREEN]: + | { + dismissToPreviousScreen?: boolean; + transition?: ScreenTransition; + } + | undefined; [SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN]: { tokenId: string; recipientAddress?: string; + recipientName?: string; + transition?: ScreenTransition; }; }; diff --git a/src/ducks/sendRecipient.ts b/src/ducks/sendRecipient.ts index 2bc9b2bb8..e35dad212 100644 --- a/src/ducks/sendRecipient.ts +++ b/src/ducks/sendRecipient.ts @@ -38,6 +38,20 @@ interface SendStore { resetSendRecipient: () => void; } +// Shared search state reset fields used by both prepareForSearch and searchAddress. +// Extracted here so both paths stay in sync if fields are added or renamed. +const SEARCH_RESET_STATE = { + searchResults: [] as Contact[], + searchError: null as string | null, + isValidDestination: false, + isDestinationFunded: null as boolean | null, + isSearching: true, + destinationAddress: "", + federationAddress: "", + federationMemo: "", + federationMemoType: "", +}; + const initialState: Omit< SendStore, | "loadRecentAddresses" @@ -87,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 }); @@ -152,17 +168,7 @@ export const useSendRecipientStore = create((set, get) => ({ }, searchAddress: async (searchTerm: string) => { - set({ - isSearching: true, - searchError: null, - isValidDestination: false, - isDestinationFunded: null, - searchResults: [], - destinationAddress: "", - federationAddress: "", - federationMemo: "", - federationMemoType: "", - }); + set(SEARCH_RESET_STATE); try { const { network } = useAuthenticationStore.getState(); @@ -350,17 +356,7 @@ export const useSendRecipientStore = create((set, get) => ({ }, prepareForSearch: () => { - set({ - searchResults: [], - searchError: null, - isValidDestination: false, - isDestinationFunded: null, - isSearching: true, - destinationAddress: "", - federationAddress: "", - federationMemo: "", - federationMemoType: "", - }); + set(SEARCH_RESET_STATE); }, resetSendRecipient: () => { diff --git a/src/ducks/transactionSettings.ts b/src/ducks/transactionSettings.ts index 0a4acffad..6aa1bb513 100644 --- a/src/ducks/transactionSettings.ts +++ b/src/ducks/transactionSettings.ts @@ -11,6 +11,7 @@ const INITIAL_TRANSACTION_SETTINGS_STATE = { transactionTimeout: DEFAULT_TRANSACTION_TIMEOUT, recipientAddress: "", federationAddress: "", + recipientName: "", selectedTokenId: "", selectedCollectibleDetails: { collectionAddress: "", @@ -32,6 +33,7 @@ const INITIAL_TRANSACTION_SETTINGS_STATE = { * @property {number} transactionTimeout - Timeout in seconds for the transaction * @property {string} recipientAddress - Recipient address for the transaction (resolved G... public key) * @property {string} federationAddress - Original federation address (user*domain) if applicable + * @property {string} recipientName - Display name for the recipient contact (e.g. wallet name) * @property {string} selectedTokenId - ID of the token selected for the transaction * @property {string} selectedCollectibleDetails - collection ID and token ID of the collectible selected for the transaction * @property {Function} saveMemo - Function to save the memo value @@ -40,6 +42,7 @@ const INITIAL_TRANSACTION_SETTINGS_STATE = { * @property {Function} saveTransactionTimeout - Function to save the transaction timeout value * @property {Function} saveRecipientAddress - Function to save the recipient address * @property {Function} saveFederationAddress - Function to save the federation address + * @property {Function} saveRecipientName - Function to save the recipient display name * @property {Function} saveSelectedTokenId - Function to save the selected token ID * @property {Function} saveSelectedCollectibleDetails - Function to save the selected collectible details * @property {Function} resetSettings - Function to reset all settings to default values @@ -51,6 +54,7 @@ interface TransactionSettingsState { transactionTimeout: number; recipientAddress: string; federationAddress: string; + recipientName: string; selectedTokenId: string; selectedCollectibleDetails: { collectionAddress: string; @@ -64,6 +68,7 @@ interface TransactionSettingsState { saveTransactionTimeout: (timeout: number) => void; saveRecipientAddress: (address: string) => void; saveFederationAddress: (address: string) => void; + saveRecipientName: (name: string) => void; saveSelectedTokenId: (tokenId: string) => void; saveSelectedCollectibleDetails: (collectibleDetails: { collectionAddress: string; @@ -119,6 +124,12 @@ export const useTransactionSettingsStore = create( */ saveFederationAddress: (address) => set({ federationAddress: address }), + /** + * Saves the recipient display name (e.g. wallet name or contact name) + * @param {string} name - The recipient display name + */ + saveRecipientName: (name) => set({ recipientName: name }), + /** * Saves the selected token ID for the transaction * @param {string} tokenId - The token ID diff --git a/src/helpers/navigationOptions.tsx b/src/helpers/navigationOptions.tsx index 381a5ab21..0dffecec0 100644 --- a/src/helpers/navigationOptions.tsx +++ b/src/helpers/navigationOptions.tsx @@ -2,6 +2,7 @@ import { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { CustomHeaderButton } from "components/layout/CustomHeaderButton"; import CustomNavigationHeader from "components/layout/CustomNavigationHeader"; import Icon from "components/sds/Icon"; +import { ScreenTransition } from "config/routes"; import React from "react"; /** @@ -69,9 +70,14 @@ export const getScreenOptionsNoHeader = (): NativeStackNavigationOptions => ({ export const withTransitionOverride = ( options: NativeStackNavigationOptions, route: { - params?: { transition?: NativeStackNavigationOptions["animation"] }; + params?: { transition?: ScreenTransition }; }, ): NativeStackNavigationOptions => { const transition = route.params?.transition; - return transition ? { ...options, animation: transition } : options; + return transition + ? { + ...options, + animation: transition as NativeStackNavigationOptions["animation"], + } + : options; }; 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/hooks/useTokenFiatConverter/index.ts b/src/hooks/useTokenFiatConverter/index.ts index 6f88fbdf4..5b5676ec0 100644 --- a/src/hooks/useTokenFiatConverter/index.ts +++ b/src/hooks/useTokenFiatConverter/index.ts @@ -182,6 +182,16 @@ export const useTokenFiatConverter = ({ const handleDisplayAmountChange = useCallback( (key: string) => { + if (key.length > 1) { + dispatch({ + type: state.showFiatAmount + ? TokenFiatConverterActionType.SET_FIAT_DISPLAY_FROM_TEXT + : TokenFiatConverterActionType.SET_TOKEN_DISPLAY_FROM_TEXT, + payload: key, + }); + return; + } + if (state.showFiatAmount) { const currentDisplay = state.fiatAmountDisplayRaw !== null diff --git a/src/hooks/useTokenFiatConverter/reducer.ts b/src/hooks/useTokenFiatConverter/reducer.ts index bc82f6f49..2a99d8d10 100644 --- a/src/hooks/useTokenFiatConverter/reducer.ts +++ b/src/hooks/useTokenFiatConverter/reducer.ts @@ -15,6 +15,8 @@ export interface TokenFiatConverterState { export enum TokenFiatConverterActionType { SET_TOKEN_AMOUNT = "SET_TOKEN_AMOUNT", SET_FIAT_AMOUNT = "SET_FIAT_AMOUNT", + SET_TOKEN_DISPLAY_FROM_TEXT = "SET_TOKEN_DISPLAY_FROM_TEXT", + SET_FIAT_DISPLAY_FROM_TEXT = "SET_FIAT_DISPLAY_FROM_TEXT", SET_SHOW_FIAT_AMOUNT = "SET_SHOW_FIAT_AMOUNT", HANDLE_TOKEN_INPUT = "HANDLE_TOKEN_INPUT", HANDLE_FIAT_INPUT = "HANDLE_FIAT_INPUT", @@ -33,6 +35,16 @@ export interface SetFiatAmountAction { payload: string; } +export interface SetTokenDisplayFromTextAction { + type: TokenFiatConverterActionType.SET_TOKEN_DISPLAY_FROM_TEXT; + payload: string; +} + +export interface SetFiatDisplayFromTextAction { + type: TokenFiatConverterActionType.SET_FIAT_DISPLAY_FROM_TEXT; + payload: string; +} + export interface SetShowFiatAmountAction { type: TokenFiatConverterActionType.SET_SHOW_FIAT_AMOUNT; payload: boolean; @@ -66,6 +78,8 @@ export interface ConvertFiatToTokenAction { export type TokenFiatConverterAction = | SetTokenAmountAction | SetFiatAmountAction + | SetTokenDisplayFromTextAction + | SetFiatDisplayFromTextAction | SetShowFiatAmountAction | HandleTokenInputAction | HandleFiatInputAction @@ -460,6 +474,62 @@ export const createTokenFiatConverterReducer = }; } + case TokenFiatConverterActionType.SET_TOKEN_DISPLAY_FROM_TEXT: { + const text = action.payload; + const nextDisplay = text + .split("") + .reduce( + (accumulator, character) => + formatNumericInput(accumulator, character, tokenDecimals), + "0", + ); + const tokenAmount = parseDisplayNumber(nextDisplay, tokenDecimals); + + let { fiatAmount } = state; + if (!state.showFiatAmount && tokenPrice && !tokenPrice.isZero()) { + const bnTokenAmount = new BigNumber(tokenAmount); + fiatAmount = + bnTokenAmount.isFinite() && !bnTokenAmount.isZero() + ? recalculateFiatAmountFromToken(tokenAmount, tokenPrice) + : "0"; + } + + return { + ...state, + tokenAmount, + fiatAmount, + tokenAmountDisplayRaw: nextDisplay, + }; + } + + case TokenFiatConverterActionType.SET_FIAT_DISPLAY_FROM_TEXT: { + const text = action.payload; + const newDisplay = text + .split("") + .reduce( + (accumulator, character) => + formatFiatInputTemplate(accumulator, character), + "0", + ); + const fiatAmount = normalizeInternalAmount(newDisplay); + + let { tokenAmount } = state; + if (state.showFiatAmount && tokenPrice) { + tokenAmount = recalculateTokenAmountFromFiat( + fiatAmount, + tokenPrice, + tokenDecimals, + ); + } + + return { + ...state, + tokenAmount, + fiatAmount, + fiatAmountDisplayRaw: newDisplay, + }; + } + case TokenFiatConverterActionType.SET_SHOW_FIAT_AMOUNT: { const showFiatAmount = action.payload; let { tokenAmount } = state; diff --git a/src/hooks/useWalletConnectQrCodeScanner.tsx b/src/hooks/useWalletConnectQrCodeScanner.tsx index 1d6835d75..9f07beefc 100644 --- a/src/hooks/useWalletConnectQrCodeScanner.tsx +++ b/src/hooks/useWalletConnectQrCodeScanner.tsx @@ -2,7 +2,11 @@ import { useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { WalletConnectManualInputOverlay } from "components/WalletConnectManualInputOverlay"; import { QRCodeSource } from "config/constants"; -import { ROOT_NAVIGATOR_ROUTES, RootStackParamList } from "config/routes"; +import { + ROOT_NAVIGATOR_ROUTES, + RootStackParamList, + ScreenTransition, +} from "config/routes"; import { useQRDataStore } from "ducks/qrData"; import { isValidWalletConnectURI } from "helpers/qrValidation"; import { walletKit } from "helpers/walletKitUtil"; @@ -142,7 +146,7 @@ export const useWalletConnectQrCodeScanner = (): QRCodeScreenReturn => { // which fades from AccountQRCodeScreen back to this screen. navigation.replace(ROOT_NAVIGATOR_ROUTES.ACCOUNT_QR_CODE_SCREEN, { showNavigationAsCloseButton: true, - transition: "fade", + transition: ScreenTransition.Fade, }); }, [navigation]); diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index b8d97a7df..e736f2cac 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", @@ -655,6 +656,7 @@ "scanQRCodeText": "Scan a Freighter QR Code" }, "sendSearchContacts": { + "myWallets": "My Wallets", "unfunded": { "title": "The destination account doesn’t exist", "action": "Send at least 1 XLM to create the account.", @@ -781,6 +783,7 @@ }, "transactionAmountScreen": { "title": "Send", + "sendingLabel": "Sending", "percentageButtons": { "twentyFive": "25%", "fifty": "50%", @@ -788,8 +791,8 @@ "max": "Max" }, "chooseRecipient": "Choose recipient", - "setAmount": "Set amount", - "reviewButton": "Review", + "setAmount": "Enter an amount", + "reviewButton": "Review send", "menu": { "fee": "Fee: {{fee}} XLM", "timeout": "Timeout: {{timeout}}(s)", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 3e8e2fc28..a6fa7e315 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", @@ -619,6 +620,7 @@ "scanQRCodeText": "Escanear um QR Code do Freighter" }, "sendSearchContacts": { + "myWallets": "Minhas carteiras", "unfunded": { "title": "A conta de destino não existe", "action": "Envie pelo menos 1 XLM para criar a conta.", @@ -745,6 +747,7 @@ }, "transactionAmountScreen": { "title": "Enviar", + "sendingLabel": "Enviando", "percentageButtons": { "twentyFive": "25%", "fifty": "50%", @@ -752,8 +755,8 @@ "max": "Máx" }, "chooseRecipient": "Escolher destinatário", - "setAmount": "Definir valor", - "reviewButton": "Revisar", + "setAmount": "Insira um valor", + "reviewButton": "Revisar envio", "menu": { "fee": "Taxa: {{fee}} XLM", "timeout": "Tempo limite: {{timeout}}(s)", diff --git a/src/navigators/RootNavigator.tsx b/src/navigators/RootNavigator.tsx index 419446b2a..732a8e812 100644 --- a/src/navigators/RootNavigator.tsx +++ b/src/navigators/RootNavigator.tsx @@ -224,10 +224,12 @@ export const RootNavigator = () => { (); +const closeSendFlow = ( + navigation: { + goBack: () => void; + getParent: () => { goBack: () => void } | undefined; + }, + dismissToPreviousScreen?: boolean, + transition?: ScreenTransition, +) => { + const isBottomSheetOverlay = + dismissToPreviousScreen && transition === ScreenTransition.SlideFromBottom; + + if (isBottomSheetOverlay) { + navigation.goBack(); + return; + } + + useSendRecipientStore.getState().resetSendRecipient(); + useTransactionSettingsStore.getState().resetSettings(); + useTransactionBuilderStore.getState().resetTransaction(); + navigation.getParent()?.goBack(); +}; + export const SendPaymentStackNavigator = () => { const { t } = useAppTranslation(); @@ -27,28 +58,84 @@ export const SendPaymentStackNavigator = () => { + withTransitionOverride( + route.params?.dismissToPreviousScreen + ? { + headerTitle: t("sendPaymentScreen.title"), + headerLeft: () => ( + navigation.goBack()} + /> + ), + } + : { + headerTitle: t("sendPaymentScreen.title"), + }, + route, + ) + } /> + withTransitionOverride( + { + headerTitle: t("transactionTokenScreen.title"), + headerLeft: () => ( + + closeSendFlow( + navigation, + route.params?.dismissToPreviousScreen, + route.params?.transition, + ) + } + /> + ), + }, + route, + ) + } /> + withTransitionOverride( + { + headerTitle: t("transactionAmountScreen.title"), + }, + route, + ) + } /> + withTransitionOverride( + { + headerTitle: t("transactionAmountScreen.title"), + headerLeft: () => ( + + closeSendFlow( + navigation, + route.params?.dismissToPreviousScreen, + route.params?.transition, + ) + } + /> + ), + }, + route, + ) + } /> );