From a4f9955b6cdedfb8d30644bcc074c96e1c20c873 Mon Sep 17 00:00:00 2001 From: Philipp Trentmann Date: Tue, 23 Jun 2026 10:10:06 +0200 Subject: [PATCH] fix(LIVE-29443): add mobile swap status drawer infrastructure --- .changeset/mobile-swap-status-infra.md | 5 + CODEOWNERS | 1 + .../QueuedDrawer/QueuedDrawerBottomSheet.tsx | 4 +- .../useQueuedDrawerBottomSheet.test.ts | 31 ++++++ .../useQueuedDrawerBottomSheet.ts | 15 +++ .../swapTransactionStatusDrawer.test.ts | 101 ++++++++++++++++++ apps/ledger-live-mobile/src/reducers/index.ts | 2 + .../reducers/swapTransactionStatusDrawer.ts | 43 ++++++++ apps/ledger-live-mobile/src/reducers/types.ts | 2 + 9 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 .changeset/mobile-swap-status-infra.md create mode 100644 apps/ledger-live-mobile/src/reducers/__tests__/swapTransactionStatusDrawer.test.ts create mode 100644 apps/ledger-live-mobile/src/reducers/swapTransactionStatusDrawer.ts diff --git a/.changeset/mobile-swap-status-infra.md b/.changeset/mobile-swap-status-infra.md new file mode 100644 index 000000000000..af12c752099c --- /dev/null +++ b/.changeset/mobile-swap-status-infra.md @@ -0,0 +1,5 @@ +--- +"live-mobile": minor +--- + +Add mobile swap transaction status drawer infrastructure. diff --git a/CODEOWNERS b/CODEOWNERS index 4b4e5122133c..0056b4a9171a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -202,6 +202,7 @@ apps/ledger-live-mobile/src/components/Stake/ @ledge apps/ledger-live-mobile/src/components/WebPTXPlayer/ @ledgerhq/ptx apps/ledger-live-mobile/src/reducers/earn.ts @ledgerhq/ptx apps/ledger-live-mobile/src/reducers/swap.ts @ledgerhq/ptx +apps/ledger-live-mobile/src/reducers/swapTransactionStatusDrawer.ts @ledgerhq/ptx apps/ledger-live-mobile/src/screens/Exchange/ @ledgerhq/ptx apps/ledger-live-mobile/src/screens/PTX/ @ledgerhq/ptx apps/ledger-live-mobile/src/screens/Swap/ @ledgerhq/ptx diff --git a/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/QueuedDrawerBottomSheet.tsx b/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/QueuedDrawerBottomSheet.tsx index e98e5db6a23c..b07a833c40ef 100644 --- a/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/QueuedDrawerBottomSheet.tsx +++ b/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/QueuedDrawerBottomSheet.tsx @@ -80,6 +80,7 @@ const QueuedDrawerBottomSheet = ({ bottomSheetRef, areDrawersLocked, handleBackdropPress, + handleHeaderClosePressed, handleDismiss, handleCloseAnimationStart, onBack: hookOnBack, @@ -91,6 +92,7 @@ const QueuedDrawerBottomSheet = ({ isForcingToBeOpened, onClose, onBack, + onHeaderClosePressed, onBackdropPress, onModalHide, preventBackdropClick, @@ -109,9 +111,9 @@ const QueuedDrawerBottomSheet = ({ hideCloseButton={noCloseButton || areDrawersLocked} hideHandle={hideHandle} onBack={hasBackButton ? hookOnBack : undefined} + onHeaderClosePressed={handleHeaderClosePressed} onAnimate={handleCloseAnimationStart} onDismiss={handleDismiss} - onHeaderClosePressed={onHeaderClosePressed} backdropPressBehavior={preventBackdropClick || areDrawersLocked ? "none" : "close"} onBackdropPress={handleBackdropPress} backgroundComponent={backgroundComponent} diff --git a/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/__tests__/useQueuedDrawerBottomSheet.test.ts b/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/__tests__/useQueuedDrawerBottomSheet.test.ts index 82139d614ffa..3be3e896cea5 100644 --- a/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/__tests__/useQueuedDrawerBottomSheet.test.ts +++ b/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/__tests__/useQueuedDrawerBottomSheet.test.ts @@ -404,6 +404,37 @@ describe("useQueuedDrawerBottomSheet", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("calls header close callbacks once and ignores later close animation and dismiss callbacks", () => { + const onClose = jest.fn(); + const onHeaderClosePressed = jest.fn(); + const { signal } = setupDrawerStateCapture(); + + const { result } = renderHook(() => + useQueuedDrawerBottomSheet({ + isRequestingToBeOpened: true, + onClose, + onHeaderClosePressed, + }), + ); + + signal(true); + + act(() => { + result.current.handleHeaderClosePressed(); + }); + + expect(onHeaderClosePressed).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + + act(() => { + result.current.handleCloseAnimationStart(0, -1); + result.current.handleDismiss(); + }); + + expect(onHeaderClosePressed).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it("does not clear consumer state on open or snap-point animations", () => { const onClose = jest.fn(); const { signal } = setupDrawerStateCapture(); diff --git a/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/useQueuedDrawerBottomSheet.ts b/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/useQueuedDrawerBottomSheet.ts index a5d95d0bec39..454122ac4078 100644 --- a/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/useQueuedDrawerBottomSheet.ts +++ b/apps/ledger-live-mobile/src/mvvm/components/QueuedDrawer/useQueuedDrawerBottomSheet.ts @@ -14,6 +14,7 @@ interface UseQueuedDrawerBottomSheetProps { isForcingToBeOpened?: boolean; onClose?: () => void; onBack?: () => void; + onHeaderClosePressed?: () => void; onBackdropPress?: () => void; onModalHide?: () => void; preventBackdropClick?: boolean; @@ -26,6 +27,7 @@ const useQueuedDrawerBottomSheet = ({ isForcingToBeOpened = false, onClose, onBack, + onHeaderClosePressed, onBackdropPress, onModalHide, preventBackdropClick, @@ -43,6 +45,9 @@ const useQueuedDrawerBottomSheet = ({ const onCloseRef = useRef(onClose); onCloseRef.current = onClose; + const onHeaderClosePressedRef = useRef(onHeaderClosePressed); + onHeaderClosePressedRef.current = onHeaderClosePressed; + const onBackdropPressRef = useRef(onBackdropPress); onBackdropPressRef.current = onBackdropPress; @@ -118,6 +123,15 @@ const useQueuedDrawerBottomSheet = ({ handleUserClose(); }, [handleUserClose]); + const handleHeaderClosePressed = useCallback(() => { + if (stateRef.current === "dismissing") return; + + logDrawer("Header close pressed"); + stateRef.current = "dismissing"; + onHeaderClosePressedRef.current?.(); + onCloseRef.current?.(); + }, []); + // Fired at the START of an animation. A close animation targets index -1, so this is the // earliest deterministic signal that the sheet is closing — for the X (close) button, the // backdrop and the pan-down gesture alike. We clear consumer state here rather than waiting for @@ -192,6 +206,7 @@ const useQueuedDrawerBottomSheet = ({ areDrawersLocked, handleUserClose, handleBackdropPress, + handleHeaderClosePressed, handleDismiss, handleCloseAnimationStart, onBack, diff --git a/apps/ledger-live-mobile/src/reducers/__tests__/swapTransactionStatusDrawer.test.ts b/apps/ledger-live-mobile/src/reducers/__tests__/swapTransactionStatusDrawer.test.ts new file mode 100644 index 000000000000..dc89cfa79173 --- /dev/null +++ b/apps/ledger-live-mobile/src/reducers/__tests__/swapTransactionStatusDrawer.test.ts @@ -0,0 +1,101 @@ +import type { SwapTransactionStatusParams } from "@ledgerhq/live-common/exchange/swapTransactionStatus/index"; +import reducer, { + INITIAL_STATE, + closeSwapTransactionStatusDrawer, + openSwapTransactionStatusDrawer, + selectIsSwapTransactionStatusDrawerOpen, + selectSwapTransactionStatusDrawerParams, + swapTransactionStatusDrawerSelector, +} from "../swapTransactionStatusDrawer"; +import type { State } from "../types"; + +const swapTransactionStatusParams: SwapTransactionStatusParams = { + swapId: "swap-id", + provider: "lifi", + redirectUrl: "ledgerlive://swap/status", +}; + +const buildState = (swapTransactionStatusDrawer = INITIAL_STATE): State => ({ + ...({} as State), + swapTransactionStatusDrawer, +}); + +describe("swapTransactionStatusDrawer reducer", () => { + it("should expose a closed initial state", () => { + expect(INITIAL_STATE).toEqual({ + isOpen: false, + params: null, + }); + }); + + it("should open the drawer with transaction status params", () => { + const state = reducer( + INITIAL_STATE, + openSwapTransactionStatusDrawer(swapTransactionStatusParams), + ); + + expect(state).toEqual({ + isOpen: true, + params: swapTransactionStatusParams, + }); + }); + + it("should replace params when opening an already open drawer", () => { + const initialParams: SwapTransactionStatusParams = { + swapId: "previous-swap-id", + provider: "changelly", + }; + const openState = reducer(INITIAL_STATE, openSwapTransactionStatusDrawer(initialParams)); + + const state = reducer(openState, openSwapTransactionStatusDrawer(swapTransactionStatusParams)); + + expect(state).toEqual({ + isOpen: true, + params: swapTransactionStatusParams, + }); + }); + + it("should close the drawer and clear params", () => { + const openState = reducer( + INITIAL_STATE, + openSwapTransactionStatusDrawer(swapTransactionStatusParams), + ); + + const state = reducer(openState, closeSwapTransactionStatusDrawer()); + + expect(state).toEqual(INITIAL_STATE); + }); +}); + +describe("swapTransactionStatusDrawer selectors", () => { + it("should return the drawer slice from state", () => { + const drawerState = { + isOpen: true, + params: swapTransactionStatusParams, + }; + + expect(swapTransactionStatusDrawerSelector(buildState(drawerState))).toEqual(drawerState); + }); + + it("should return whether the drawer is open", () => { + expect( + selectIsSwapTransactionStatusDrawerOpen( + buildState({ + isOpen: true, + params: swapTransactionStatusParams, + }), + ), + ).toBe(true); + }); + + it("should return the current drawer params", () => { + expect( + selectSwapTransactionStatusDrawerParams( + buildState({ + isOpen: true, + params: swapTransactionStatusParams, + }), + ), + ).toEqual(swapTransactionStatusParams); + }); +}); diff --git a/apps/ledger-live-mobile/src/reducers/index.ts b/apps/ledger-live-mobile/src/reducers/index.ts index ca7dc98dd1cc..87811a7a7f4c 100644 --- a/apps/ledger-live-mobile/src/reducers/index.ts +++ b/apps/ledger-live-mobile/src/reducers/index.ts @@ -24,6 +24,7 @@ import modularDrawer from "./modularDrawer"; import receiveOptionsDrawer from "./receiveOptionsDrawer"; import rebornBuyDeviceDrawer from "./rebornBuyDeviceDrawer"; import transferDrawer from "./transferDrawer"; +import swapTransactionStatusDrawer from "./swapTransactionStatusDrawer"; import notifications from "./notifications"; import protect from "./protect"; import ratings from "./ratings"; @@ -69,6 +70,7 @@ const appReducer = combineReducers({ receiveOptionsDrawer, rebornBuyDeviceDrawer, transferDrawer, + swapTransactionStatusDrawer, notifications, postOnboarding, postOnboardingHubDrawer, diff --git a/apps/ledger-live-mobile/src/reducers/swapTransactionStatusDrawer.ts b/apps/ledger-live-mobile/src/reducers/swapTransactionStatusDrawer.ts new file mode 100644 index 000000000000..a17e89d7d1fe --- /dev/null +++ b/apps/ledger-live-mobile/src/reducers/swapTransactionStatusDrawer.ts @@ -0,0 +1,43 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import type { SwapTransactionStatusParams } from "@ledgerhq/live-common/exchange/swapTransactionStatus/index"; +import type { State } from "~/reducers/types"; + +export type SwapTransactionStatusDrawerState = { + isOpen: boolean; + params: SwapTransactionStatusParams | null; +}; + +export const INITIAL_STATE: SwapTransactionStatusDrawerState = { + isOpen: false, + params: null, +}; + +export const swapTransactionStatusDrawerSelector = (state: State) => + state.swapTransactionStatusDrawer; +export const selectIsSwapTransactionStatusDrawerOpen = (state: State) => + state.swapTransactionStatusDrawer.isOpen; +export const selectSwapTransactionStatusDrawerParams = (state: State) => + state.swapTransactionStatusDrawer.params; + +const swapTransactionStatusDrawerSlice = createSlice({ + name: "swapTransactionStatusDrawer", + initialState: INITIAL_STATE, + reducers: { + openSwapTransactionStatusDrawer: ( + state, + action: PayloadAction, + ) => { + state.isOpen = true; + state.params = action.payload; + }, + closeSwapTransactionStatusDrawer: state => { + state.isOpen = false; + state.params = null; + }, + }, +}); + +export const { openSwapTransactionStatusDrawer, closeSwapTransactionStatusDrawer } = + swapTransactionStatusDrawerSlice.actions; + +export default swapTransactionStatusDrawerSlice.reducer; diff --git a/apps/ledger-live-mobile/src/reducers/types.ts b/apps/ledger-live-mobile/src/reducers/types.ts index 2525852d9375..726a96ddeda2 100644 --- a/apps/ledger-live-mobile/src/reducers/types.ts +++ b/apps/ledger-live-mobile/src/reducers/types.ts @@ -31,6 +31,7 @@ import type { ModularDrawerState } from "./modularDrawer"; import type { LLMRTKApiState } from "~/context/rtkQueryApi"; import type { ReceiveOptionsDrawerState } from "./receiveOptionsDrawer"; import type { TransferDrawerState } from "./transferDrawer"; +import type { SwapTransactionStatusDrawerState } from "./swapTransactionStatusDrawer"; import type { PostOnboardingHubDrawerState } from "./postOnboardingHubDrawer"; import type { SendFlowState } from "./sendFlow"; import { IdentitiesState } from "@ledgerhq/client-ids/store"; @@ -457,6 +458,7 @@ export type State = LLMRTKApiState & { receiveOptionsDrawer: ReceiveOptionsDrawerState; rebornBuyDeviceDrawer: RebornBuyDeviceDrawerState; transferDrawer: TransferDrawerState; + swapTransactionStatusDrawer: SwapTransactionStatusDrawerState; notifications: NotificationsState; postOnboarding: PostOnboardingState; postOnboardingHubDrawer: PostOnboardingHubDrawerState;