Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const mockCreateEventBuilder = jest.fn(
const mockUseSwapBridgeNavigation = jest.fn((_options: unknown) => ({
goToSwaps: mockGoToSwaps,
}));
const mockPerpsTrack = jest.fn();
let mockIsEligible = true;

let mockRouteParams: {
assetSymbol: string;
Expand Down Expand Up @@ -228,6 +230,27 @@ jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
}),
}));

jest.mock('../../../Perps/selectors/perpsController', () => ({
selectPerpsEligibility: jest.fn(() => mockIsEligible),
}));

jest.mock('../../../../../selectors/accountsController', () => ({
...jest.requireActual('../../../../../selectors/accountsController'),
selectSelectedInternalAccountAddress: jest.fn(() => '0xMockAddress'),
}));

jest.mock('../../../Perps/components/PerpsBottomSheetTooltip', () => {
const { View: MockView } = jest.requireActual('react-native');
const Tooltip = (props: { testID?: string; onClose?: () => void }) => (
<MockView testID={props.testID ?? 'geo-block-tooltip'} />
);
return { __esModule: true, default: Tooltip };
});

jest.mock('../../../Perps/hooks/usePerpsEventTracking', () => ({
usePerpsEventTracking: () => ({ track: mockPerpsTrack }),
}));

jest.mock('@metamask/design-system-react-native', () => {
const actual = jest.requireActual('@metamask/design-system-react-native');
const { View } = jest.requireActual('react-native');
Expand Down Expand Up @@ -259,6 +282,7 @@ describe('MarketInsightsView', () => {
jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined);
jest.clearAllMocks();
resetFeedbackCache();
mockIsEligible = true;
mockRouteParams = {
assetSymbol: 'ETH',
assetIdentifier: 'eip155:1/erc20:0x123',
Expand Down Expand Up @@ -729,7 +753,7 @@ describe('MarketInsightsView', () => {
expect(queryByTestId(MarketInsightsSelectorsIDs.BUY_BUTTON)).toBeNull();
});

it('navigates to PerpsOrderRedirect with long direction when Long button is pressed', () => {
it('navigates to PerpsOrderRedirect with long direction when Long button is pressed', async () => {
mockRouteParams = {
assetSymbol: 'ETH',
assetIdentifier: 'ETH',
Expand All @@ -751,7 +775,9 @@ describe('MarketInsightsView', () => {

const { getByTestId } = renderWithProvider(<MarketInsightsView />);

fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.LONG_BUTTON));
await act(async () => {
fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.LONG_BUTTON));
});

expect(mockNavigate).toHaveBeenCalledWith(
Routes.PERPS.ROOT,
Expand All @@ -763,7 +789,7 @@ describe('MarketInsightsView', () => {
expect(mockGoToSwaps).not.toHaveBeenCalled();
});

it('navigates to PerpsOrderRedirect with short direction when Short button is pressed', () => {
it('navigates to PerpsOrderRedirect with short direction when Short button is pressed', async () => {
mockRouteParams = {
assetSymbol: 'ETH',
assetIdentifier: 'ETH',
Expand All @@ -785,7 +811,9 @@ describe('MarketInsightsView', () => {

const { getByTestId } = renderWithProvider(<MarketInsightsView />);

fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.SHORT_BUTTON));
await act(async () => {
fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.SHORT_BUTTON));
});

expect(mockNavigate).toHaveBeenCalledWith(
Routes.PERPS.ROOT,
Expand All @@ -797,6 +825,47 @@ describe('MarketInsightsView', () => {
expect(mockGoToSwaps).not.toHaveBeenCalled();
});

it('shows geo-block modal instead of navigating when user is not eligible', async () => {
mockIsEligible = false;
mockRouteParams = {
assetSymbol: 'ETH',
assetIdentifier: 'ETH',
isPerps: true,
};
mockUseMarketInsights.mockReturnValue({
report: {
asset: 'eth',
generatedAt: '2026-02-17T11:55:00.000Z',
headline: 'ETH perps insight',
summary: 'Open interest rises',
trends: [],
sources: [],
},
isLoading: false,
error: null,
timeAgo: '1m ago',
});

const { getByTestId, queryByTestId } = renderWithProvider(
<MarketInsightsView />,
);

expect(
queryByTestId('market-insights-geo-block-tooltip'),
).not.toBeOnTheScreen();

await act(async () => {
fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.LONG_BUTTON));
});

expect(getByTestId('market-insights-geo-block-tooltip')).toBeOnTheScreen();
expect(mockNavigate).not.toHaveBeenCalledWith(
Routes.PERPS.ROOT,
expect.anything(),
);
expect(mockPerpsTrack).toHaveBeenCalled();
});

it('navigates to swaps when swap button is pressed in token context', () => {
const { getByTestId, queryByTestId } = renderWithProvider(
<MarketInsightsView />,
Expand All @@ -814,7 +883,7 @@ describe('MarketInsightsView', () => {
);
});

it('sends perps_market analytics property (not caip19) in perps context', () => {
it('sends perps_market analytics property (not caip19) in perps context', async () => {
mockRouteParams = {
assetSymbol: 'ETH',
assetIdentifier: 'ETH',
Expand Down Expand Up @@ -864,7 +933,9 @@ describe('MarketInsightsView', () => {
);

// Long button carries perps_market, digest_id, and interaction_type 'long'
fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.LONG_BUTTON));
await act(async () => {
fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.LONG_BUTTON));
});
expect(mockTrackEvent).toHaveBeenCalledWith(
expect.objectContaining({
category: MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION,
Expand All @@ -887,7 +958,9 @@ describe('MarketInsightsView', () => {
);

// Short button carries perps_market, digest_id, and interaction_type 'short'
fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.SHORT_BUTTON));
await act(async () => {
fireEvent.press(getByTestId(MarketInsightsSelectorsIDs.SHORT_BUTTON));
});
expect(mockTrackEvent).toHaveBeenCalledWith(
expect.objectContaining({
category: MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
Pressable,
Animated,
Image,
Modal,
View,
useColorScheme,
} from 'react-native';
import Video from 'react-native-video';
Expand Down Expand Up @@ -83,6 +85,13 @@ import MarketInsightsFeedbackBottomSheet, {
import { useRampNavigation } from '../../../Ramp/hooks/useRampNavigation';
import parseRampIntent from '../../../Ramp/utils/parseRampIntent';
import { getDecimalChainId } from '../../../../../util/networks';
import { selectPerpsEligibility } from '../../../Perps/selectors/perpsController';
import PerpsBottomSheetTooltip from '../../../Perps/components/PerpsBottomSheetTooltip';
import {
PERPS_EVENT_PROPERTY,
PERPS_EVENT_VALUE,
} from '@metamask/perps-controller';
import { usePerpsEventTracking } from '../../../Perps/hooks/usePerpsEventTracking';

const feedbackByDigest = new Map<string, 'up' | 'down'>();

Expand Down Expand Up @@ -212,6 +221,11 @@ const MarketInsightsView: React.FC = () => {
[isDarkMode],
);

const isEligible = useSelector(selectPerpsEligibility);
const [isEligibilityModalVisible, setIsEligibilityModalVisible] =
useState(false);
const { track } = usePerpsEventTracking();

const { trackEvent, createEventBuilder } = useAnalytics();
const { toastRef } = useContext(ToastContext);
const theme = useAppThemeFromContext();
Expand Down Expand Up @@ -328,8 +342,23 @@ const MarketInsightsView: React.FC = () => {
assetSymbolProperty,
]);

const closeEligibilityModal = useCallback(() => {
setIsEligibilityModalVisible(false);
}, []);

const handlePerpsDirectionPress = useCallback(
(direction: 'long' | 'short') => {
async (direction: 'long' | 'short') => {
if (!isEligible) {
track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, {
[PERPS_EVENT_PROPERTY.SCREEN_TYPE]:
PERPS_EVENT_VALUE.SCREEN_TYPE.GEO_BLOCK_NOTIF,
[PERPS_EVENT_PROPERTY.SOURCE]:
PERPS_EVENT_VALUE.SOURCE.MARKET_INSIGHTS,
});
setIsEligibilityModalVisible(true);
return;
}

const event = createEventBuilder(
MetaMetricsEvents.MARKET_INSIGHTS_INTERACTION,
)
Expand All @@ -347,6 +376,8 @@ const MarketInsightsView: React.FC = () => {
});
},
[
isEligible,
track,
navigation,
trackEvent,
createEventBuilder,
Expand Down Expand Up @@ -844,6 +875,20 @@ const MarketInsightsView: React.FC = () => {
onSubmit={handleFeedbackSubmit}
/>
) : null}

{isEligibilityModalVisible && (
// Android Compatibility: Wrap the <Modal> in a plain <View> component to prevent rendering issues and freezing.
<View>
<Modal visible transparent animationType="none" statusBarTranslucent>
<PerpsBottomSheetTooltip
isVisible
onClose={closeEligibilityModal}
contentKey="geo_block"
testID="market-insights-geo-block-tooltip"
/>
</Modal>
</View>
)}
</Box>
);
};
Expand Down
1 change: 1 addition & 0 deletions app/controllers/perps/constants/eventNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export const PERPS_EVENT_VALUE = {
ADD_FUNDS_ACTION: 'add_funds_action',
CANCEL_ORDER: 'cancel_order',
ASSET_DETAIL_SCREEN: 'asset_detail_screen',
MARKET_INSIGHTS: 'market_insights',
// TAT-2449: Geo-block sources for close/modify actions
CLOSE_POSITION_ACTION: 'close_position_action',
MODIFY_POSITION_ACTION: 'modify_position_action',
Expand Down
58 changes: 50 additions & 8 deletions tests/component-view/renderers/perpsViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PerpsStreamProvider,
type PerpsStreamManager,
} from '../../../app/components/UI/Perps/providers/PerpsStreamManager';
import { AccessRestrictedProvider } from '../../../app/components/UI/Compliance';
import PerpsMarketDetailsView from '../../../app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView';
import PerpsMarketListView from '../../../app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView';
import PerpsSelectModifyActionView from '../../../app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView';
Expand Down Expand Up @@ -149,6 +150,9 @@ function createTestStreamManager(
export interface PerpsExtraRoute {
name: string;
Component?: React.ComponentType<unknown>;
/** 'root' registers in the root stack (e.g. cross-feature screens like MarketInsightsView).
* Omitting or using 'perps-root' nests the route under Routes.PERPS.ROOT (default — backward-compatible). */
mount?: 'root' | 'perps-root';
}

interface RenderPerpsViewOptions {
Expand All @@ -161,7 +165,7 @@ interface RenderPerpsViewOptions {
}

const DefaultRouteProbe =
(routeName: string): React.FC =>
(routeName: string): React.ComponentType<unknown> =>
() => <Text testID={`route-${routeName}`}>{routeName}</Text>;

/**
Expand Down Expand Up @@ -192,19 +196,44 @@ export function renderPerpsView(
</PerpsConnectionContext.Provider>
);

const wrapRouteWithPerpsProviders = (
RouteComponent: React.ComponentType<unknown>,
) => {
const WrappedRoute = (props: Record<string, unknown>) => (
<AccessRestrictedProvider>
<PerpsConnectionContext.Provider value={testConnectionValue}>
<PerpsStreamProvider testStreamManager={testStreamManager}>
<RouteComponent {...props} />
</PerpsStreamProvider>
</PerpsConnectionContext.Provider>
</AccessRestrictedProvider>
);
return WrappedRoute as unknown as React.ComponentType;
};

if (extraRoutes?.length) {
const Stack = createStackNavigator();
const InnerStack = createStackNavigator();
// Routes with mount: 'root' go directly in the root stack (e.g. MarketInsightsView).
// All others (mount: 'perps-root' or unset) nest under Routes.PERPS.ROOT — the
// default preserves backward compatibility with tests that omit mount entirely.
const nestedPerpsRoutes = extraRoutes.filter(
({ mount }) => mount !== 'root',
);
const rootRoutes = extraRoutes.filter(({ mount }) => mount === 'root');
// PerpsTabView navigates via navigation.navigate(PERPS.ROOT, { screen: MARKET_LIST }).
// So we register PERPS.ROOT as a nested stack containing the extra routes; then
// navigating to ROOT with screen: MARKET_LIST shows the route probe.
const nestedScreens = (
<>
{extraRoutes.map(({ name, Component: Extra }) => (
{nestedPerpsRoutes.map(({ name, Component: Extra }) => (
// Extra routes can render real views (not only probes), so keep provider parity.
<InnerStack.Screen
key={name}
name={name}
component={Extra ?? DefaultRouteProbe(name)}
component={wrapRouteWithPerpsProviders(
Extra ?? DefaultRouteProbe(name),
)}
/>
))}
</>
Expand All @@ -219,10 +248,21 @@ export function renderPerpsView(
component={WrappedComponent as unknown as React.ComponentType}
initialParams={initialParams}
/>
<Stack.Screen
name={Routes.PERPS.ROOT}
component={NestedPerpsStack as unknown as React.ComponentType}
/>
{rootRoutes.map(({ name, Component: Extra }) => (
<Stack.Screen
key={`root-${name}`}
name={name}
component={wrapRouteWithPerpsProviders(
Extra ?? DefaultRouteProbe(name),
)}
/>
))}
{nestedPerpsRoutes.length ? (
<Stack.Screen
name={Routes.PERPS.ROOT}
component={NestedPerpsStack as unknown as React.ComponentType}
/>
) : null}
</Stack.Navigator>
);
return renderWithProvider(stackTree, { state });
Expand Down Expand Up @@ -318,17 +358,19 @@ export function renderPerpsMarketDetailsView(
overrides?: DeepPartial<RootState>;
initialParams?: Record<string, unknown>;
streamOverrides?: PerpsStreamOverrides;
extraRoutes?: PerpsExtraRoute[];
} = {},
) {
const {
overrides = defaultGeoRestrictionOverrides,
initialParams = { market: defaultMarketDetailsMarket },
streamOverrides = { positions: [defaultSelectModifyActionPosition] },
extraRoutes,
} = options;
return renderPerpsView(
PerpsMarketDetailsView as unknown as React.ComponentType,
'PerpsMarketDetails',
{ overrides, initialParams, streamOverrides },
{ overrides, initialParams, streamOverrides, extraRoutes },
);
}

Expand Down
Loading