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 WalletActions from '../../Views/WalletActions';
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 @@ const RootModalFlow = (props: RootModalFlowProps) => (
component={ReturnToAppNotification}
initialParams={{ ...props.route.params }}
/>
<Stack.Screen
name={Routes.CARD.NOTIFICATION}
component={CardNotification}
/>
</Stack.Navigator>
);

Expand Down
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();
});
});
});
63 changes: 63 additions & 0 deletions app/core/DeeplinkManager/Handlers/handleEnableCardButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import DevLogger from '../../SDKConnect/utils/DevLogger';
import Logger from '../../../util/Logger';
import { store } from '../../../store';
import { setAlwaysShowCardButton } from '../../../core/redux/slices/card';
import { selectCardExperimentalSwitch } from '../../../selectors/featureFlagController/card';
import NavigationService from '../../NavigationService';
import Routes from '../../../constants/navigation/Routes';

/**
* Card deeplink handler to enable the card button
*
* This handler enables the card button by setting the alwaysShowCardButton flag
* to true, but only if the cardExperimentalSwitch feature flag is enabled.
* It shows a success toast notification after enabling the button.
*
* Supported URL formats:
* - https://link.metamask.io/enable-card-button
* - https://metamask.app.link/enable-card-button
*/
export const handleEnableCardButton = () => {
DevLogger.log(
'[handleEnableCardButton] Starting card button enable deeplink handling',
);

try {
const state = store.getState();
const cardExperimentalSwitchEnabled = selectCardExperimentalSwitch(state);

DevLogger.log(
'[handleEnableCardButton] Card experimental switch enabled:',
cardExperimentalSwitchEnabled,
);

if (cardExperimentalSwitchEnabled) {
store.dispatch(setAlwaysShowCardButton(true));
DevLogger.log(
'[handleEnableCardButton] Successfully enabled card button',
);
Logger.log('[handleEnableCardButton] Card button enabled via deeplink');

// Show success toast via navigation
NavigationService.navigation?.navigate(Routes.MODAL.ROOT_MODAL_FLOW, {
screen: Routes.CARD.NOTIFICATION,
});
} else {
DevLogger.log(
'[handleEnableCardButton] Card experimental switch is disabled, skipping',
);
Logger.log(
'[handleEnableCardButton] Card experimental switch feature flag is disabled',
);
}
} catch (error) {
DevLogger.log(
'[handleEnableCardButton] Failed to enable card button:',
error,
);
Logger.error(
error as Error,
'[handleEnableCardButton] Error enabling card button',
);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('handleMetaMaskProtocol', () => {
const mockHandleSellCrypto = jest.fn();
const mockHandleDepositCash = jest.fn();
const mockHandleBrowserUrl = jest.fn();
const mockHandleEnableCardButton = jest.fn();
const mockConnectToChannel = jest.fn();
const mockGetConnections = jest.fn();
const mockRevalidateChannel = jest.fn();
Expand All @@ -45,6 +46,7 @@ describe('handleMetaMaskProtocol', () => {
_handleSellCrypto: mockHandleSellCrypto,
_handleDepositCash: mockHandleDepositCash,
_handleBrowserUrl: mockHandleBrowserUrl,
_handleEnableCardButton: mockHandleEnableCardButton,
} as unknown as DeeplinkManager;

const handled = jest.fn();
Expand Down Expand Up @@ -553,4 +555,23 @@ describe('handleMetaMaskProtocol', () => {
expect(mockHandleDepositCash).toHaveBeenCalled();
});
});

describe('when url starts with ${PREFIXES.METAMASK}${ACTIONS.ENABLE_CARD_BUTTON}', () => {
beforeEach(() => {
url = `${PREFIXES.METAMASK}${ACTIONS.ENABLE_CARD_BUTTON}`;
});

it('calls _handleEnableCardButton', () => {
handleMetaMaskDeeplink({
instance,
handled,
params,
url,
origin,
wcURL,
});

expect(mockHandleEnableCardButton).toHaveBeenCalled();
});
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recommended implementation is to leverage universal links instead of traditional links. In other words, let's move this logic to handleUniversalLink instead. Since it seems like we wanted to avoid the interstitial, you can add the ENABLE_CARD_BUTTON action to the WHITELISTED_ACTIONS, which will skip the interstitial UI. Teams should access this feature by using a universal link base aka https://link.metamask.io

Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ export function handleMetaMaskDeeplink({
'',
);
instance._handleDepositCash(depositCashPath);
} else if (
url.startsWith(`${PREFIXES.METAMASK}${ACTIONS.ENABLE_CARD_BUTTON}`)
) {
instance._handleEnableCardButton();
}
}

Expand Down
1 change: 1 addition & 0 deletions locales/languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -6100,6 +6100,7 @@
"change_asset": "Change asset",
"enable_card_button_label": "Enable Card",
"enable_assets_button_label": "Enable Assets",
"card_button_enabled_toast": "You can now sign up for MetaMask Card!",
"warnings": {
"close_spending_limit": {
"title": "You're close to your spending limit",
Expand Down
Loading