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
10 changes: 6 additions & 4 deletions .js.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,13 @@ export MM_POOLED_STAKING_ENABLED="true"
export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true"
# mUSD
export MM_MUSD_CONVERSION_FLOW_ENABLED="false"
# Allowlist of convertible tokens by chain
# IMPORTANT: Must use SINGLE QUOTES to preserve JSON format
# Example: MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}'
export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST=''
export MM_MUSD_CTA_ENABLED="false"
# See app/components/UI/Earn/docs/wildcard-token-list.md for more information.
export MM_MUSD_CONVERTIBLE_TOKENS_BLOCKLIST=''
export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST=''
# Example: MM_MUSD_CTA_TOKENS='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}'
export MM_MUSD_CTA_TOKENS=''

# Activates remote feature flag override mode.
# Remote feature flag values won't be updated,
# and selectors should return their fallback values
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { IconName } from '@metamask/design-system-react-native';
import BasicFunctionalityEmptyState from './BasicFunctionalityEmptyState';
import Routes from '../../../../constants/navigation/Routes';

const mockNavigate = jest.fn();

jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
navigate: mockNavigate,
}),
}));

describe('BasicFunctionalityEmptyState', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders empty state with default title', () => {
const { getByText, queryByTestId } = render(
<BasicFunctionalityEmptyState />,
);

expect(getByText('Explore is not available')).toBeOnTheScreen();
expect(
getByText(
"We can't fetch the required metadata when basic functionality is disabled.",
),
).toBeOnTheScreen();
expect(getByText('Enable basic functionality')).toBeOnTheScreen();
expect(
queryByTestId('basic-functionality-empty-state-icon-container'),
).toBeNull();
});

it('renders custom title when title prop is provided', () => {
const customTitle = 'Custom Title';
const { getByText } = render(
<BasicFunctionalityEmptyState title={customTitle} />,
);

expect(getByText(customTitle)).toBeOnTheScreen();
});

it('renders icon when iconName prop is provided', () => {
const { getByTestId } = render(
<BasicFunctionalityEmptyState iconName={IconName.Warning} />,
);

expect(
getByTestId('basic-functionality-empty-state-icon-container'),
).toBeOnTheScreen();
});

it('navigates to basic functionality settings when button is pressed', () => {
const { getByText } = render(<BasicFunctionalityEmptyState />);

const enableButton = getByText('Enable basic functionality');

fireEvent.press(enableButton);

expect(mockNavigate).toHaveBeenCalledWith(Routes.MODAL.ROOT_MODAL_FLOW, {
screen: Routes.SHEET.BASIC_FUNCTIONALITY,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,24 @@ import {
TextVariant,
Button,
ButtonVariant,
Icon,
IconName,
IconSize,
IconColor,
} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import { strings } from '../../../../../locales/i18n';
import { useNavigation } from '@react-navigation/native';
import Routes from '../../../../../constants/navigation/Routes';
import Routes from '../../../../constants/navigation/Routes';

const BasicFunctionalityEmptyState = () => {
interface BasicFunctionalityEmptyStateProps {
title?: string;
iconName?: IconName;
}

const BasicFunctionalityEmptyState = ({
title,
iconName,
}: BasicFunctionalityEmptyStateProps) => {
const navigation = useNavigation();

const handleEnableBasicFunctionality = useCallback(() => {
Expand All @@ -22,14 +34,26 @@ const BasicFunctionalityEmptyState = () => {
return (
<Box
testID="basic-functionality-empty-state"
twClassName="flex-col pt-9 pb-24 justify-center items-center gap-3 flex-1"
twClassName="flex-col justify-center items-center gap-3 flex-1"
>
<Box twClassName="flex-col w-[337px] items-stretch">
{iconName && (
<Box
twClassName="items-center mb-4"
testID="basic-functionality-empty-state-icon-container"
>
<Icon
name={iconName}
color={IconColor.IconMuted}
size={IconSize.Xl}
/>
</Box>
)}
<Text
variant={TextVariant.HeadingSm}
twClassName="text-default text-center self-stretch mb-2"
>
{strings('trending.basic_functionality_disabled_title')}
{title || strings('trending.basic_functionality_disabled_title')}
</Text>
<Text
variant={TextVariant.BodyMd}
Expand Down
6 changes: 5 additions & 1 deletion app/components/UI/Card/Views/CardHome/CardHome.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ const mockSelectedInternalAccount = {
// Mock hooks
const mockFetchPriorityToken = jest.fn().mockResolvedValue(mockPriorityToken);
const mockFetchCardDetails = jest.fn();
const mockFetchAllData = jest.fn();
const mockFetchAllData = jest.fn().mockResolvedValue(undefined);
const mockRefetchAllData = jest.fn().mockResolvedValue(undefined);
const mockPollCardStatusUntilProvisioned = jest.fn().mockResolvedValue(true);
const mockNavigateToCardPage = jest.fn();
const mockGoToSwaps = jest.fn();
Expand Down Expand Up @@ -529,6 +530,7 @@ function setupLoadCardDataMock(
fetchPriorityToken: mockFetchPriorityToken,
fetchCardDetails: mockFetchCardDetails,
fetchAllData: mockFetchAllData,
refetchAllData: mockRefetchAllData,
pollCardStatusUntilProvisioned: mockPollCardStatusUntilProvisioned,
isLoadingPollCardStatusUntilProvisioned: false,
});
Expand Down Expand Up @@ -614,6 +616,7 @@ describe('CardHome Component', () => {
fetchPriorityToken: mockFetchPriorityToken,
fetchCardDetails: mockFetchCardDetails,
fetchAllData: mockFetchAllData,
refetchAllData: mockRefetchAllData,
pollCardStatusUntilProvisioned: mockPollCardStatusUntilProvisioned,
isLoadingPollCardStatusUntilProvisioned: false,
});
Expand Down Expand Up @@ -1955,6 +1958,7 @@ describe('CardHome Component', () => {
fetchPriorityToken: mockFetchPriorityToken,
fetchCardDetails: mockFetchCardDetails,
fetchAllData: mockFetchAllData,
refetchAllData: mockRefetchAllData,
pollCardStatusUntilProvisioned: mockPollCardStatusUntilProvisioned,
isLoadingPollCardStatusUntilProvisioned: true,
});
Expand Down
53 changes: 45 additions & 8 deletions app/components/UI/Card/Views/CardHome/CardHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
useNavigation,
useRoute,
RouteProp,
useFocusEffect,
} from '@react-navigation/native';
import { useDispatch, useSelector } from 'react-redux';
import SensitiveText, {
Expand Down Expand Up @@ -119,6 +120,7 @@ const CardHome = () => {
const { logoutFromProvider, isLoading: isSDKLoading } = useCardSDK();
const hasTrackedCardHomeView = useRef(false);
const hasLoadedCardHomeView = useRef(false);
const hasCompletedInitialFetchRef = useRef(false);
const hasHandledAuthErrorRef = useRef(false);
const isComponentUnmountedRef = useRef(false);
const hasShownKYCAlertRef = useRef(false);
Expand Down Expand Up @@ -151,6 +153,7 @@ const CardHome = () => {
isBaanxLoginEnabled,
fetchPriorityToken,
fetchAllData,
refetchAllData,
pollCardStatusUntilProvisioned,
isLoadingPollCardStatusUntilProvisioned,
allTokens,
Expand Down Expand Up @@ -641,17 +644,51 @@ const CardHome = () => {
handleAuthenticationError();
}, [cardError, dispatch, isAuthenticated, navigation]);

// Load Card Data once CardHome opens
// Load Card Data once when CardHome opens
// This is the single orchestrator for data fetching - individual hooks don't auto-fetch
// to prevent duplicate API calls
// Wait for SDK to be ready before fetching to ensure all API calls can succeed
useEffect(() => {
const loadCardData = async () => {
await fetchAllData();
hasLoadedCardHomeView.current = true;
};

// Wait for SDK to be ready before fetching data
if (isSDKLoading) {
return;
}
if (!hasLoadedCardHomeView.current && isAuthenticated) {
loadCardData();
hasLoadedCardHomeView.current = true;
fetchAllData().then(() => {
hasCompletedInitialFetchRef.current = true;
});
}
}, [fetchAllData, isAuthenticated]);
}, [fetchAllData, isAuthenticated, isSDKLoading]);

// Refetch data when screen comes back into focus and cache was cleared
// This handles the case when user updates priority token or delegation in another screen
useFocusEffect(
useCallback(() => {
// Skip if initial fetch hasn't completed yet
// This prevents duplicate calls on first mount
if (!hasCompletedInitialFetchRef.current) {
return;
}

// Skip if not authenticated or SDK not ready
if (isSDKLoading || !isAuthenticated) {
return;
}

// Check if cache was cleared and needs refresh
// When cache is cleared, externalWalletDetailsData becomes null
if (!externalWalletDetailsData && !isLoading) {
refetchAllData();
}
}, [
isSDKLoading,
isAuthenticated,
externalWalletDetailsData,
isLoading,
refetchAllData,
]),
);

// Show KYC status alert if needed
useEffect(() => {
Expand Down
3 changes: 2 additions & 1 deletion app/components/UI/Card/hooks/useCardDetails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,8 @@ describe('useCardDetails', () => {
// Then: Returns true, calls fetchCardDetails, and updates loading state
expect(pollResult).toBe(true);
expect(mockGetCardDetails).toHaveBeenCalledTimes(1);
expect(mockFetchData).toHaveBeenCalledTimes(2);
// pollCardStatusUntilProvisioned refreshes card details once after provisioning
expect(mockFetchData).toHaveBeenCalledTimes(1);
expect(result.current.isLoadingPollCardStatusUntilProvisioned).toBe(
false,
);
Expand Down
10 changes: 1 addition & 9 deletions app/components/UI/Card/hooks/useCardDetails.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { useCardSDK } from '../sdk';
import {
CardDetailsResponse,
Expand Down Expand Up @@ -79,14 +79,6 @@ const useCardDetails = () => {
fetchData: fetchCardDetails,
} = cacheResult;

useEffect(() => {
if (sdk && isAuthenticated && !isLoading && !error && !cardDetailsData) {
fetchCardDetails();
}
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sdk, isAuthenticated, isLoading, error, cardDetailsData]);

// Poll logic to check if card is provisioned
// max polling attempts is 10, polling interval is 2 seconds
const pollCardStatusUntilProvisioned = useCallback(
Expand Down
Loading
Loading