Skip to content
5 changes: 5 additions & 0 deletions app/components/Nav/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
import FundActionMenu from '../../UI/FundActionMenu';
import NetworkSelector from '../../../components/Views/NetworkSelector';
import ReturnToAppNotification from '../../Views/ReturnToAppNotification';
import CardNotification from '../../Views/CardNotification';
import EditAccountName from '../../Views/EditAccountName/EditAccountName';
import LegacyEditMultichainAccountName from '../../Views/MultichainAccounts/sheets/EditAccountName';
import { EditMultichainAccountName } from '../../Views/MultichainAccounts/sheets/EditMultichainAccountName';
Expand Down Expand Up @@ -568,6 +569,10 @@
component={ReturnToAppNotification}
initialParams={{ ...props.route.params }}
/>
<Stack.Screen
name={Routes.CARD.NOTIFICATION}
component={CardNotification}
/>
</Stack.Navigator>
);

Expand Down Expand Up @@ -1216,7 +1221,7 @@
Logger.error(error, 'Error starting app');
});
// existingUser is not present in the dependency array because it is not needed to re-run the effect when it changes and it will cause a bug.
// eslint-disable-next-line react-hooks/exhaustive-deps

Check warning on line 1224 in app/components/Nav/App/App.tsx

View workflow job for this annotation

GitHub Actions / scripts (lint)

React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior
}, []);

return (
Expand Down
118 changes: 118 additions & 0 deletions app/components/Views/CardNotification/CardNotification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react-native';
import CardNotification from './CardNotification';
import { ToastContext } from '../../../component-library/components/Toast';
import { ToastVariants } from '../../../component-library/components/Toast/Toast.types';
import { IconName } from '../../../component-library/components/Icons/Icon';

jest.mock('../../../../locales/i18n', () => ({
strings: jest.fn((key: string) => key),
}));

const mockGoBack = jest.fn();
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
return {
...actual,
useNavigation: () => ({
goBack: mockGoBack,
}),
};
});

describe('CardNotification', () => {
const createToastRef = () => ({
current: { showToast: jest.fn(), closeToast: jest.fn() },
});

const renderWithProviders = (toastRef = createToastRef()) => {
const ui = (
<ToastContext.Provider value={{ toastRef }}>
<CardNotification />
</ToastContext.Provider>
);

const utils = render(ui);
return { ...utils, toastRef };
};

beforeEach(() => {
jest.clearAllMocks();
mockGoBack.mockClear();
});

it('renders without crashing', () => {
const { toastRef } = renderWithProviders();

expect(toastRef.current).toBeDefined();
});

it('displays toast with correct configuration when toastRef is available', async () => {
const { toastRef } = renderWithProviders();

await waitFor(() => {
expect(toastRef.current.showToast).toHaveBeenCalledWith({
variant: ToastVariants.Icon,
labelOptions: [{ label: 'card.card_home.card_button_enabled_toast' }],
hasNoTimeout: false,
iconName: IconName.Info,
});
});
});

it('calls navigation goBack after showing toast', async () => {
renderWithProviders();

await waitFor(() => {
expect(mockGoBack).toHaveBeenCalledTimes(1);
});
});

it('does not show toast when toastRef is null', () => {
const nullToastRef = {
current: null,
} as unknown as {
current: { showToast: jest.Mock; closeToast: jest.Mock };
};
const { toastRef } = renderWithProviders(nullToastRef);

expect(toastRef.current).toBeNull();
expect(mockGoBack).not.toHaveBeenCalled();
});

it('shows toast only once on multiple renders', async () => {
const { toastRef, rerender } = renderWithProviders();

await waitFor(() => {
expect(toastRef.current.showToast).toHaveBeenCalledTimes(1);
});

rerender(
<ToastContext.Provider value={{ toastRef }}>
<CardNotification />
</ToastContext.Provider>,
);

await waitFor(() => {
expect(toastRef.current.showToast).toHaveBeenCalledTimes(1);
});
});

it('translates toast label using i18n strings', async () => {
const mockStrings = jest.requireMock('../../../../locales/i18n').strings;
const { toastRef } = renderWithProviders();

await waitFor(() => {
expect(mockStrings).toHaveBeenCalledWith(
'card.card_home.card_button_enabled_toast',
);
expect(toastRef.current.showToast).toHaveBeenCalled();
});
});

it('renders empty fragment as component output', () => {
const { toJSON } = renderWithProviders();

expect(toJSON()).toBeNull();
});
});
41 changes: 41 additions & 0 deletions app/components/Views/CardNotification/CardNotification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React, { useContext, useEffect, useRef } from 'react';
import { ToastContext } from '../../../component-library/components/Toast';
import { ToastVariants } from '../../../component-library/components/Toast/Toast.types';
import { useNavigation } from '@react-navigation/native';
import { IconName } from '../../../component-library/components/Icons/Icon';
import { strings } from '../../../../locales/i18n';

/**
* Fake modal that displays a toast for card-related deeplinks.
* Similar to ReturnToAppNotification but for card feature.
*
* This component is used to trigger toasts from non-React contexts (like deeplink handlers)
* by navigating to this route with toast parameters, then immediately going back.
*/
const CardNotification = () => {
const navigation = useNavigation();
const { toastRef } = useContext(ToastContext);
const hasExecuted = useRef<boolean>(false);

useEffect(() => {
if (toastRef && toastRef.current !== null && !hasExecuted.current) {
hasExecuted.current = true;

toastRef.current.showToast({
variant: ToastVariants.Icon,
labelOptions: [
{ label: strings('card.card_home.card_button_enabled_toast') },
],
hasNoTimeout: false,
iconName: IconName.Info,
});

// Hide the fake modal
navigation?.goBack();
}
}, [toastRef, navigation]);

return <></>;
};

export default CardNotification;
1 change: 1 addition & 0 deletions app/components/Views/CardNotification/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './CardNotification';
2 changes: 2 additions & 0 deletions app/constants/deeplinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export enum PROTOCOLS {
}

export enum ACTIONS {
ENABLE_CARD_BUTTON = 'enable-card-button',
DAPP = 'dapp',
SEND = 'send',
APPROVE = 'approve',
Expand Down Expand Up @@ -62,5 +63,6 @@ export const PREFIXES = {
[ACTIONS.PERPS_ASSET]: '',
[ACTIONS.REWARDS]: '',
[ACTIONS.ONBOARDING]: '',
[ACTIONS.ENABLE_CARD_BUTTON]: '',
METAMASK: 'metamask://',
};
1 change: 1 addition & 0 deletions app/constants/navigation/Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ const Routes = {
HOME: 'CardHome',
WELCOME: 'CardWelcome',
AUTHENTICATION: 'CardAuthentication',
NOTIFICATION: 'CardNotification',
ONBOARDING: {
ROOT: 'CardOnboarding',
SIGN_UP: 'CardOnboardingSignUp',
Expand Down
5 changes: 5 additions & 0 deletions app/core/DeeplinkManager/DeeplinkManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import SharedDeeplinkManager from './SharedDeeplinkManager';
import FCMService from '../../util/notifications/services/FCMService';
import { handleRewardsUrl } from './Handlers/handleRewardsUrl';
import handleFastOnboarding from './Handlers/handleFastOnboarding';
import { handleEnableCardButton } from './Handlers/handleEnableCardButton';

class DeeplinkManager {
// TODO: Replace "any" with type
Expand Down Expand Up @@ -146,6 +147,10 @@ class DeeplinkManager {
handleFastOnboarding({ onboardingPath });
}

_handleEnableCardButton() {
handleEnableCardButton();
}

async parse(
url: string,
{
Expand Down
133 changes: 133 additions & 0 deletions app/core/DeeplinkManager/Handlers/handleEnableCardButton.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { handleEnableCardButton } from './handleEnableCardButton';
import { store } from '../../../store';
import { setAlwaysShowCardButton } from '../../../core/redux/slices/card';
import { selectCardExperimentalSwitch } from '../../../selectors/featureFlagController/card';
import DevLogger from '../../SDKConnect/utils/DevLogger';
import Logger from '../../../util/Logger';

jest.mock('../../../store');
jest.mock('../../../core/redux/slices/card');
jest.mock('../../../selectors/featureFlagController/card');
jest.mock('../../SDKConnect/utils/DevLogger');
jest.mock('../../../util/Logger');

describe('handleEnableCardButton', () => {
const mockGetState = jest.fn();
const mockDispatch = jest.fn();
const mockDevLogger = DevLogger.log as jest.Mock;
const mockLogger = Logger.log as jest.Mock;
const mockLoggerError = Logger.error as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();

(store.getState as jest.Mock) = mockGetState;
(store.dispatch as jest.Mock) = mockDispatch;
});

afterEach(() => {
jest.resetAllMocks();
});

describe('when card experimental switch is enabled', () => {
beforeEach(() => {
(selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
true,
);
mockGetState.mockReturnValue({});
});

it('dispatches setAlwaysShowCardButton with true', () => {
handleEnableCardButton();

expect(mockDispatch).toHaveBeenCalledWith(setAlwaysShowCardButton(true));
});

it('logs successful enablement', () => {
handleEnableCardButton();

expect(mockDevLogger).toHaveBeenCalledWith(
'[handleEnableCardButton] Successfully enabled card button',
);
expect(mockLogger).toHaveBeenCalledWith(
'[handleEnableCardButton] Card button enabled via deeplink',
);
});

it('logs starting message', () => {
handleEnableCardButton();

expect(mockDevLogger).toHaveBeenCalledWith(
'[handleEnableCardButton] Starting card button enable deeplink handling',
);
});
});

describe('when card experimental switch is disabled', () => {
beforeEach(() => {
(selectCardExperimentalSwitch as unknown as jest.Mock).mockReturnValue(
false,
);
mockGetState.mockReturnValue({});
});

it('does not dispatch setAlwaysShowCardButton', () => {
handleEnableCardButton();

expect(mockDispatch).not.toHaveBeenCalled();
});

it('logs that feature flag is disabled', () => {
handleEnableCardButton();

expect(mockDevLogger).toHaveBeenCalledWith(
'[handleEnableCardButton] Card experimental switch is disabled, skipping',
);
expect(mockLogger).toHaveBeenCalledWith(
'[handleEnableCardButton] Card experimental switch feature flag is disabled',
);
});

it('logs starting message', () => {
handleEnableCardButton();

expect(mockDevLogger).toHaveBeenCalledWith(
'[handleEnableCardButton] Starting card button enable deeplink handling',
);
});
});

describe('when an error occurs', () => {
const mockError = new Error('Test error');

beforeEach(() => {
mockGetState.mockImplementation(() => {
throw mockError;
});
});

it('logs error with DevLogger', () => {
handleEnableCardButton();

expect(mockDevLogger).toHaveBeenCalledWith(
'[handleEnableCardButton] Failed to enable card button:',
mockError,
);
});

it('logs error with Logger', () => {
handleEnableCardButton();

expect(mockLoggerError).toHaveBeenCalledWith(
mockError,
'[handleEnableCardButton] Error enabling card button',
);
});

it('does not dispatch setAlwaysShowCardButton', () => {
handleEnableCardButton();

expect(mockDispatch).not.toHaveBeenCalled();
});
});
});
Loading
Loading