Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
7 changes: 7 additions & 0 deletions app/actions/settings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,10 @@ export function setPerpsChartPreferredCandlePeriod(preferredCandlePeriod) {
preferredCandlePeriod,
};
}

export function setShowAccountOnLeaderboard(showAccountOnLeaderboard) {
return {
type: 'SET_SHOW_ACCOUNT_ON_LEADERBOARD',
showAccountOnLeaderboard,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { fireEvent, screen, waitFor } from '@testing-library/react-native';
import React from 'react';

import { strings } from '../../../../../../locales/i18n';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
import { SecurityPrivacyViewSelectorsIDs } from '../SecurityPrivacyView.testIds';
import TopTradersSection from './TopTradersSection';

const mockMessengerCall = jest.fn().mockResolvedValue(undefined);
const mockLoggerError = jest.fn();
let mockLeaderboardEnabled = true;
let mockOptFlowEnabled = true;

jest.mock('../../../../../core/Engine', () => ({
controllerMessenger: {
call: (...args: unknown[]) => mockMessengerCall(...args),
},
}));

jest.mock('../../../../../util/Logger', () => ({
error: (...args: unknown[]) => mockLoggerError(...args),
}));

jest.mock(
'../../../../../selectors/featureFlagController/socialLeaderboard',
() => ({
selectSocialLeaderboardEnabled: () => mockLeaderboardEnabled,
selectSocialLeaderboardOptFlowEnabled: () => mockOptFlowEnabled,
}),
);

const renderWith = (showAccountOnLeaderboard = true) =>
renderWithProvider(<TopTradersSection />, {
state: { settings: { showAccountOnLeaderboard } },
});

describe('TopTradersSection', () => {
beforeEach(() => {
jest.clearAllMocks();
mockLeaderboardEnabled = true;
mockOptFlowEnabled = true;
mockMessengerCall.mockResolvedValue(undefined);
});

it('renders the section and toggle when the leaderboard is enabled', () => {
renderWith(true);

expect(
screen.getByTestId(SecurityPrivacyViewSelectorsIDs.TOP_TRADERS_SECTION),
).toBeOnTheScreen();
expect(
screen.getByText(
strings('social_leaderboard.settings.show_account_on_leaderboard'),
),
).toBeOnTheScreen();
expect(
screen.getByTestId(
SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
).props.value,
).toBe(true);
});

it('renders nothing when the leaderboard feature is disabled', () => {
mockLeaderboardEnabled = false;
renderWith(true);

expect(
screen.queryByTestId(SecurityPrivacyViewSelectorsIDs.TOP_TRADERS_SECTION),
).toBeNull();
});

it('renders nothing when the leaderboard opt-flow flag is disabled', () => {
mockOptFlowEnabled = false;
renderWith(true);

expect(
screen.queryByTestId(SecurityPrivacyViewSelectorsIDs.TOP_TRADERS_SECTION),
).toBeNull();
});

it('opts out (and flips the toggle off) when turned off', async () => {
renderWith(true);
const toggle = screen.getByTestId(
SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
);

fireEvent(toggle, 'valueChange', false);

expect(toggle.props.value).toBe(false);
await waitFor(() =>
expect(mockMessengerCall).toHaveBeenCalledWith(
'SocialController:optOutOfLeaderboard',
),
);
});

it('opts in when turned on', async () => {
renderWith(false);
const toggle = screen.getByTestId(
SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
);

fireEvent(toggle, 'valueChange', true);

await waitFor(() =>
expect(mockMessengerCall).toHaveBeenCalledWith(
'SocialController:optInToLeaderboard',
),
);
});

it('reverts and logs when the request fails', async () => {
mockMessengerCall.mockRejectedValueOnce(new Error('network down'));
renderWith(true);
const toggle = screen.getByTestId(
SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE,
);

fireEvent(toggle, 'valueChange', false);

await waitFor(() => expect(mockLoggerError).toHaveBeenCalled());
expect(toggle.props.value).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Text, TextVariant } from '@metamask/design-system-react-native';
import React, { useCallback, useState } from 'react';
import { View } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';

import { strings } from '../../../../../../locales/i18n';
import { setShowAccountOnLeaderboard } from '../../../../../actions/settings';
import Engine from '../../../../../core/Engine';
import { RootState } from '../../../../../reducers';
import {
selectSocialLeaderboardEnabled,
selectSocialLeaderboardOptFlowEnabled,
} from '../../../../../selectors/featureFlagController/socialLeaderboard';
import Logger from '../../../../../util/Logger';
import { SecurityOptionToggle } from '../../../../UI/SecurityOptionToggle';
import { useStyles } from '../../../../hooks/useStyles';
import createStyles from '../SecuritySettings.styles';
import { SecurityPrivacyViewSelectorsIDs } from '../SecurityPrivacyView.testIds';

/**
* "Top Traders" section for the Security & privacy screen. Lets the user toggle
* whether their account is shown on the Top Traders leaderboard, calling
* `SocialController:optInToLeaderboard` / `optOutOfLeaderboard`. The displayed
* value is a local (non-AUS) mirror kept in the `settings` reducer, defaulting
* to shown; it is updated optimistically and reverted if the request fails.
*/
const TopTradersSection = () => {
const { styles } = useStyles(createStyles, {});
const dispatch = useDispatch();
const isSocialLeaderboardEnabled = useSelector(
selectSocialLeaderboardEnabled,
);
const isLeaderboardOptFlowEnabled = useSelector(
selectSocialLeaderboardOptFlowEnabled,
);
const showAccountOnLeaderboard = useSelector(
(state: RootState) => state.settings.showAccountOnLeaderboard ?? true,
);
const [isUpdating, setIsUpdating] = useState(false);

const handleToggle = useCallback(
async (enabled: boolean) => {
if (isUpdating) {
return;
}
setIsUpdating(true);
// Optimistic: the reducer value is the displayed state.
dispatch(setShowAccountOnLeaderboard(enabled));
Comment thread
Bigshmow marked this conversation as resolved.
Outdated
try {
await (Engine.controllerMessenger.call as CallableFunction)(
enabled
? 'SocialController:optInToLeaderboard'
: 'SocialController:optOutOfLeaderboard',
);
} catch (error) {
Logger.error(
error as Error,
'TopTradersSection: failed to update leaderboard visibility',
);
// Revert on failure so the toggle reflects the unchanged backend state.
dispatch(setShowAccountOnLeaderboard(!enabled));
} finally {
setIsUpdating(false);
}
Comment thread
Bigshmow marked this conversation as resolved.
},
[dispatch, isUpdating],
);

if (!isSocialLeaderboardEnabled || !isLeaderboardOptFlowEnabled) {
return null;
}

return (
<View testID={SecurityPrivacyViewSelectorsIDs.TOP_TRADERS_SECTION}>
<Text variant={TextVariant.HeadingMd} style={styles.subHeading}>
{strings('social_leaderboard.settings.section_title')}
</Text>
{/* Wrap the toggle in `styles.setting` (marginTop) so the gap below the
section heading matches the other sections (e.g. Analytics). */}
<View style={styles.setting}>
<SecurityOptionToggle
title={strings(
'social_leaderboard.settings.show_account_on_leaderboard',
)}
description={strings(
'social_leaderboard.settings.show_account_on_leaderboard_description',
)}
value={showAccountOnLeaderboard}
onOptionUpdated={handleToggle}
testId={
SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE
}
disabled={isUpdating}
/>
</View>
</View>
);
};

export default TopTradersSection;
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const SecurityPrivacyViewSelectorsIDs = {
CLEAR_PRIVACY_DATA_BUTTON: 'clear-privacy-data-button',
PROTECT_YOUR_WALLET: 'protect-your-wallet',
DELETE_WALLET_BUTTON: 'security-settings-delete-wallet-buttons',
TOP_TRADERS_SECTION: 'top-traders-section',
SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE: 'show-account-on-leaderboard-toggle',
};

export const SecurityPrivacyViewSelectorsText = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { TextVariant as LibraryTextVariant } from '../../../../component-library
import BasicFunctionalityComponent from '../../../UI/BasicFunctionality/BasicFunctionality';
import Routes from '../../../../constants/navigation/Routes';
import MetaMetricsAndDataCollectionSection from './Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection';
import TopTradersSection from './Sections/TopTradersSection';
import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications';
import SwitchLoadingModal from '../../../../components/UI/Notification/SwitchLoadingModal';
import { RootState } from '../../../../reducers';
Expand Down Expand Up @@ -411,6 +412,7 @@ const Settings: React.FC = () => {
<MetaMetricsAndDataCollectionSection />
<DeleteMetaMetricsData metricsOptin={analyticsEnabled} />
<DeleteWalletData />
<TopTradersSection />
{renderHint()}
</View>
</ScrollView>
Expand Down
2 changes: 2 additions & 0 deletions app/core/Engine/messengers/social-controller-messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export function getSocialControllerMessenger(
'SocialService:follow',
'SocialService:unfollow',
'SocialService:fetchFollowing',
'SocialService:optOutOfLeaderboard',
'SocialService:optInToLeaderboard',
],
messenger,
});
Expand Down
8 changes: 8 additions & 0 deletions app/reducers/settings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const initialState = {
basicFunctionalityEnabled: true,
deepLinkModalDisabled: false,
hapticsEnabled: true,
// Whether this account is shown on the Top Traders leaderboard. Local mirror
// of the backend opt-in/out state; defaults to shown. Not persisted to AUS.
showAccountOnLeaderboard: true,
// Perps chart preferences
perpsChartPreferences: {
preferredCandlePeriod: '15m', // Default to 15 minutes
Expand Down Expand Up @@ -73,6 +76,11 @@ const settingsReducer = (state = initialState, action) => {
...state,
hapticsEnabled: action.hapticsEnabled,
};
case 'SET_SHOW_ACCOUNT_ON_LEADERBOARD':
return {
...state,
showAccountOnLeaderboard: action.showAccountOnLeaderboard,
};
case 'SET_PERPS_CHART_PREFERRED_CANDLE_PERIOD':
return {
...state,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
selectSocialAIQuickBuyStreamQuotesEnabled,
selectSocialLeaderboardEnabled,
selectSocialLeaderboardOptFlowEnabled,
selectSocialLeaderboardPerpsEnabled,
} from '.';
// eslint-disable-next-line import-x/no-namespace
Expand Down Expand Up @@ -66,6 +67,74 @@ describe('selectSocialLeaderboardEnabled', () => {
});
});

describe('selectSocialLeaderboardOptFlowEnabled', () => {
let mockHasMinimumRequiredVersion: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
mockHasMinimumRequiredVersion = jest.spyOn(
remoteFeatureFlagModule,
'hasMinimumRequiredVersion',
);
mockHasMinimumRequiredVersion.mockReturnValue(true);
});

afterEach(() => {
mockHasMinimumRequiredVersion?.mockRestore();
});

it('returns true when remote flag is enabled and version requirement is met', () => {
const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({
aiSocialLeaderboardOptFlowEnabled: {
enabled: true,
minimumVersion: '7.72.0',
},
});

expect(result).toBe(true);
});

it('returns false when remote flag is disabled', () => {
const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({
aiSocialLeaderboardOptFlowEnabled: {
enabled: false,
minimumVersion: '7.72.0',
},
});

expect(result).toBe(false);
});

it('returns false when version requirement is not met', () => {
mockHasMinimumRequiredVersion.mockReturnValue(false);
const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({
aiSocialLeaderboardOptFlowEnabled: {
enabled: true,
minimumVersion: '99.0.0',
},
});

expect(result).toBe(false);
});

it('returns false when remote flag is absent', () => {
const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({});

expect(result).toBe(false);
});

it('returns false when remote flag has an invalid shape', () => {
const result = selectSocialLeaderboardOptFlowEnabled.resultFunc({
aiSocialLeaderboardOptFlowEnabled: {
enabled: 'invalid',
minimumVersion: 123,
},
});

expect(result).toBe(false);
});
});

describe('selectSocialLeaderboardPerpsEnabled', () => {
let mockHasMinimumRequiredVersion: jest.SpyInstance;

Expand Down
10 changes: 10 additions & 0 deletions app/selectors/featureFlagController/socialLeaderboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ export const selectSocialLeaderboardEnabled = createSelector(
},
);

export const selectSocialLeaderboardOptFlowEnabled = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
const remoteFlag =
remoteFeatureFlags?.aiSocialLeaderboardOptFlowEnabled as unknown as VersionGatedFeatureFlag;

return validatedVersionGatedFeatureFlag(remoteFlag) ?? false;
},
);

export const selectSocialLeaderboardPerpsEnabled = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
Expand Down
Loading
Loading