From f84dc78d043ee70132ac744ed25baa126539c93a Mon Sep 17 00:00:00 2001 From: amam-deriv Date: Thu, 23 Oct 2025 18:00:42 +0800 Subject: [PATCH 1/5] feat: add bridgefunctionality for native mobile app --- .../Layout/Header/account-actions.tsx | 17 ++++++++++++++++- .../Layout/Header/toggle-menu-drawer.jsx | 18 +++++++++++++++--- .../Layout/header/brand-short-logo.tsx | 14 ++++++++++++-- types/global.d.ts | 3 +++ 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/core/src/App/Components/Layout/Header/account-actions.tsx b/packages/core/src/App/Components/Layout/Header/account-actions.tsx index f0c257b6e7..c10420734b 100644 --- a/packages/core/src/App/Components/Layout/Header/account-actions.tsx +++ b/packages/core/src/App/Components/Layout/Header/account-actions.tsx @@ -25,7 +25,22 @@ const AccountInfo = React.lazy( const LogoutButton = ({ onClickLogout }: { onClickLogout: () => void }) => { const { localize } = useTranslations(); - return ; + }; + + render(); + + const logout_button = screen.getByText('Test Logout'); + await userEvent.click(logout_button); + + expect(mockDerivAppChannel.postMessage).toHaveBeenCalledWith( + JSON.stringify({ event: 'trading:back' }) + ); + expect(default_props.onClickLogout).not.toHaveBeenCalled(); + }); + + it('should fallback to regular logout on mobile when DerivAppChannel is not available', async () => { + // Mock mobile device + (useDevice as jest.Mock).mockReturnValue({ isDesktop: false }); + + // Ensure DerivAppChannel is not available + delete (window as any).DerivAppChannel; + + const TestLogoutButton = () => { + const { isDesktop } = useDevice(); + const handleLogoutClick = () => { + if (!isDesktop && window.DerivAppChannel) { + window.DerivAppChannel.postMessage(JSON.stringify({ event: 'trading:back' })); + } else { + default_props.onClickLogout(); + } + }; + return ; + }; + + render(); + + const logout_button = screen.getByText('Test Logout'); + await userEvent.click(logout_button); + + expect(default_props.onClickLogout).toHaveBeenCalledTimes(1); + }); + + it('should use regular logout on desktop even when DerivAppChannel is available', async () => { + // Mock desktop device + (useDevice as jest.Mock).mockReturnValue({ isDesktop: true }); + + // Add DerivAppChannel to window + (window as any).DerivAppChannel = mockDerivAppChannel; + + render(); + + const logout_button = screen.getByText('Log out'); + await userEvent.click(logout_button); + + expect(default_props.onClickLogout).toHaveBeenCalledTimes(1); + expect(mockDerivAppChannel.postMessage).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/App/Components/Layout/Header/__tests__/toggle-menu-drawer.spec.jsx b/packages/core/src/App/Components/Layout/Header/__tests__/toggle-menu-drawer.spec.jsx index 3e278e232b..bea9ba4e32 100644 --- a/packages/core/src/App/Components/Layout/Header/__tests__/toggle-menu-drawer.spec.jsx +++ b/packages/core/src/App/Components/Layout/Header/__tests__/toggle-menu-drawer.spec.jsx @@ -1,18 +1,33 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { APIProvider } from '@deriv/api'; import { StoreProvider, mockStore } from '@deriv/stores'; import ToggleMenuDrawer from '../toggle-menu-drawer'; jest.mock('@deriv/components', () => { - const MobileDrawer = jest.fn(() =>
Mobile Drawer
); + const MobileDrawer = jest.fn(({ children, is_open, toggle }) => ( +
+ + {children} +
+ )); MobileDrawer.SubMenu = jest.fn(() =>
SubMenu
); - MobileDrawer.Item = jest.fn(() =>
Item
); + MobileDrawer.Item = jest.fn(({ children, onClick }) => ( +
+ {children} +
+ )); + MobileDrawer.Body = jest.fn(({ children }) =>
{children}
); + MobileDrawer.Footer = jest.fn(({ children }) =>
{children}
); return { ...jest.requireActual('@deriv/components'), MobileDrawer, + ToggleSwitch: jest.fn(() =>
Toggle Switch
), + Div100vhContainer: jest.fn(({ children }) =>
{children}
), }; }); + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useLocation: jest.fn(() => ({ pathname: '/appstore/traders-hub' })), @@ -21,12 +36,28 @@ jest.mock('react-router-dom', () => ({ })), })); +jest.mock('@deriv-com/ui', () => ({ + useDevice: jest.fn(() => ({ isDesktop: false })), +})); + +// Mock DerivAppChannel +const mockDerivAppChannel = { + postMessage: jest.fn(), +}; + describe('', () => { - const mockToggleMenuDrawer = () => { + const mockLogout = jest.fn(); + + const mockToggleMenuDrawer = (storeOverrides = {}) => { return ( ', () => { traders_hub: { show_eu_related_content: false, }, + ...storeOverrides, })} > @@ -45,6 +77,12 @@ describe('', () => { ); }; + beforeEach(() => { + jest.clearAllMocks(); + // Clear DerivAppChannel from window + delete window.DerivAppChannel; + }); + it('should clear timeout after component was unmount', () => { jest.useFakeTimers(); jest.spyOn(global, 'clearTimeout'); @@ -54,4 +92,117 @@ describe('', () => { expect(clearTimeout).toBeCalled(); }); + + it('should use Flutter channel postMessage on mobile when DerivAppChannel is available and logout is clicked', async () => { + // Mock mobile device + const { useDevice } = require('@deriv-com/ui'); + useDevice.mockReturnValue({ isDesktop: false }); + + // Add DerivAppChannel to window + window.DerivAppChannel = mockDerivAppChannel; + + render(mockToggleMenuDrawer()); + + // Find and click the hamburger menu to open drawer + const hamburgerButton = screen.getByRole('link'); + await userEvent.click(hamburgerButton); + + // Find logout menu item and click it + const logoutItems = screen.getAllByTestId('drawer-item'); + const logoutItem = logoutItems.find(item => + item.textContent && item.textContent.includes('Back to app') + ); + + if (logoutItem) { + await userEvent.click(logoutItem); + + expect(mockDerivAppChannel.postMessage).toHaveBeenCalledWith( + JSON.stringify({ event: 'trading:back' }) + ); + expect(mockLogout).not.toHaveBeenCalled(); + } + }); + + it('should fallback to regular logout on mobile when DerivAppChannel is not available', async () => { + // Mock mobile device + const { useDevice } = require('@deriv-com/ui'); + useDevice.mockReturnValue({ isDesktop: false }); + + // Ensure DerivAppChannel is not available + delete window.DerivAppChannel; + + render(mockToggleMenuDrawer()); + + // Find and click the hamburger menu to open drawer + const hamburgerButton = screen.getByRole('link'); + await userEvent.click(hamburgerButton); + + // Find logout menu item and click it + const logoutItems = screen.getAllByTestId('drawer-item'); + const logoutItem = logoutItems.find(item => + item.textContent && item.textContent.includes('Log out') + ); + + if (logoutItem) { + await userEvent.click(logoutItem); + + expect(mockLogout).toHaveBeenCalledTimes(1); + } + }); + + it('should show "Back to app" text on mobile when DerivAppChannel is available', () => { + // Mock mobile device + const { useDevice } = require('@deriv-com/ui'); + useDevice.mockReturnValue({ isDesktop: false }); + + // Add DerivAppChannel to window + window.DerivAppChannel = mockDerivAppChannel; + + render(mockToggleMenuDrawer()); + + // The text should be "Back to app" when DerivAppChannel is available + expect(mockDerivAppChannel).toBeDefined(); + }); + + it('should show "Log out" text on mobile when DerivAppChannel is not available', () => { + // Mock mobile device + const { useDevice } = require('@deriv-com/ui'); + useDevice.mockReturnValue({ isDesktop: false }); + + // Ensure DerivAppChannel is not available + delete window.DerivAppChannel; + + render(mockToggleMenuDrawer()); + + // The text should be "Log out" when DerivAppChannel is not available + expect(window.DerivAppChannel).toBeUndefined(); + }); + + it('should use regular logout on desktop even when DerivAppChannel is available', async () => { + // Mock desktop device + const { useDevice } = require('@deriv-com/ui'); + useDevice.mockReturnValue({ isDesktop: true }); + + // Add DerivAppChannel to window + window.DerivAppChannel = mockDerivAppChannel; + + render(mockToggleMenuDrawer()); + + // Find and click the hamburger menu to open drawer + const hamburgerButton = screen.getByRole('link'); + await userEvent.click(hamburgerButton); + + // Find logout menu item and click it + const logoutItems = screen.getAllByTestId('drawer-item'); + const logoutItem = logoutItems.find(item => + item.textContent && item.textContent.includes('Log out') + ); + + if (logoutItem) { + await userEvent.click(logoutItem); + + expect(mockLogout).toHaveBeenCalledTimes(1); + expect(mockDerivAppChannel.postMessage).not.toHaveBeenCalled(); + } + }); }); diff --git a/packages/core/src/App/Containers/Layout/header/__tests__/brand-short-logo.spec.tsx b/packages/core/src/App/Containers/Layout/header/__tests__/brand-short-logo.spec.tsx index 8f3479ba93..e39fb37847 100644 --- a/packages/core/src/App/Containers/Layout/header/__tests__/brand-short-logo.spec.tsx +++ b/packages/core/src/App/Containers/Layout/header/__tests__/brand-short-logo.spec.tsx @@ -8,6 +8,10 @@ jest.mock('@deriv/shared', () => ({ getBrandHomeUrl: jest.fn(() => 'https://home.deriv.com/dashboard/home'), })); +jest.mock('@deriv-com/ui', () => ({ + useDevice: jest.fn(() => ({ isDesktop: true })), +})); + // Mock window.location.href const mockLocation = { href: '', @@ -17,10 +21,17 @@ Object.defineProperty(window, 'location', { writable: true, }); +// Mock DerivAppChannel +const mockDerivAppChannel = { + postMessage: jest.fn(), +}; + describe('BrandShortLogo', () => { beforeEach(() => { jest.clearAllMocks(); mockLocation.href = ''; + // Clear DerivAppChannel from window + delete (window as any).DerivAppChannel; }); it('should render the Deriv logo', () => { @@ -33,7 +44,7 @@ describe('BrandShortLogo', () => { expect(clickableDiv).toHaveStyle('cursor: pointer'); }); - it('should redirect to brand URL when logo is clicked', async () => { + it('should redirect to brand URL when logo is clicked on desktop', async () => { render(); const clickableDiv = screen.getByTestId('brand-logo-clickable'); @@ -55,4 +66,62 @@ describe('BrandShortLogo', () => { expect(mockLocation.href).toBe('https://staging-home.deriv.com/dashboard/home'); }); + + it('should use Flutter channel postMessage on mobile when DerivAppChannel is available', async () => { + // Mock mobile device + const { useDevice } = require('@deriv-com/ui'); + useDevice.mockReturnValue({ isDesktop: false }); + + // Add DerivAppChannel to window + (window as any).DerivAppChannel = mockDerivAppChannel; + + render(); + + const clickableDiv = screen.getByTestId('brand-logo-clickable'); + + await userEvent.click(clickableDiv); + + expect(mockDerivAppChannel.postMessage).toHaveBeenCalledWith( + JSON.stringify({ event: 'trading:home' }) + ); + expect(getBrandHomeUrl).not.toHaveBeenCalled(); + expect(mockLocation.href).toBe(''); + }); + + it('should fallback to brand URL on mobile when DerivAppChannel is not available', async () => { + // Mock mobile device + const { useDevice } = require('@deriv-com/ui'); + useDevice.mockReturnValue({ isDesktop: false }); + + // Ensure DerivAppChannel is not available + delete (window as any).DerivAppChannel; + + render(); + + const clickableDiv = screen.getByTestId('brand-logo-clickable'); + + await userEvent.click(clickableDiv); + + expect(getBrandHomeUrl).toHaveBeenCalled(); + expect(mockLocation.href).toBe('https://home.deriv.com/dashboard/home'); + }); + + it('should use brand URL on desktop even when DerivAppChannel is available', async () => { + // Mock desktop device + const { useDevice } = require('@deriv-com/ui'); + useDevice.mockReturnValue({ isDesktop: true }); + + // Add DerivAppChannel to window + (window as any).DerivAppChannel = mockDerivAppChannel; + + render(); + + const clickableDiv = screen.getByTestId('brand-logo-clickable'); + + await userEvent.click(clickableDiv); + + expect(getBrandHomeUrl).toHaveBeenCalled(); + expect(mockLocation.href).toBe('https://home.deriv.com/dashboard/home'); + expect(mockDerivAppChannel.postMessage).not.toHaveBeenCalled(); + }); }); From 3ddfa8bb1a9b0df827545b7466aae01f283c2a1a Mon Sep 17 00:00:00 2001 From: amam-deriv Date: Fri, 24 Oct 2025 10:57:43 +0800 Subject: [PATCH 3/5] feat: updated review to centralise usage --- .../Layout/Header/account-actions.tsx | 17 ++--- .../Layout/Header/toggle-menu-drawer.jsx | 17 ++--- .../__tests__/brand-short-logo.spec.tsx | 74 ++++++++++--------- .../Layout/header/brand-short-logo.tsx | 19 ++--- .../core/src/App/Hooks/useMobileBridge.ts | 43 +++++++++++ types/global.d.ts | 12 ++- 6 files changed, 110 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/App/Hooks/useMobileBridge.ts diff --git a/packages/core/src/App/Components/Layout/Header/account-actions.tsx b/packages/core/src/App/Components/Layout/Header/account-actions.tsx index c10420734b..24d66f0ece 100644 --- a/packages/core/src/App/Components/Layout/Header/account-actions.tsx +++ b/packages/core/src/App/Components/Layout/Header/account-actions.tsx @@ -3,9 +3,9 @@ import React from 'react'; import { Button } from '@deriv/components'; import { formatMoney } from '@deriv/shared'; import { useTranslations } from '@deriv-com/translations'; -import { useDevice } from '@deriv-com/ui'; import { LoginButtonV2 } from './login-button-v2'; +import { useMobileBridge } from 'App/Hooks/useMobileBridge'; import 'Sass/app/_common/components/account-switcher.scss'; @@ -25,20 +25,13 @@ const AccountInfo = React.lazy( const LogoutButton = ({ onClickLogout }: { onClickLogout: () => void }) => { const { localize } = useTranslations(); - const { isDesktop } = useDevice(); + const { sendBridgeEvent, isBridgeAvailable } = useMobileBridge(); const handleLogoutClick = () => { - // Check if we're in a mobile environment with Flutter channel available - if (!isDesktop && window.DerivAppChannel) { - // Use Flutter channel postMessage for mobile "Back to app" - window.DerivAppChannel.postMessage(JSON.stringify({ event: 'trading:back' })); - } else { - // Fallback to default logout behavior for desktop or when Flutter channel is not available - onClickLogout(); - } + sendBridgeEvent('trading:back', onClickLogout); }; - const buttonText = !isDesktop && window.DerivAppChannel ? localize('Back to app') : localize('Log out'); + const buttonText = isBridgeAvailable() ? localize('Back to app') : localize('Log out'); return