diff --git a/app/actions/settings/index.js b/app/actions/settings/index.js index f68a9dd0837..21140d14c63 100644 --- a/app/actions/settings/index.js +++ b/app/actions/settings/index.js @@ -107,3 +107,10 @@ export function setPerpsChartPreferredCandlePeriod(preferredCandlePeriod) { preferredCandlePeriod, }; } + +export function setShowAccountOnLeaderboard(showAccountOnLeaderboard) { + return { + type: 'SET_SHOW_ACCOUNT_ON_LEADERBOARD', + showAccountOnLeaderboard, + }; +} diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx new file mode 100644 index 00000000000..86bafaaa5e4 --- /dev/null +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +import { strings } from '../../../../../../locales/i18n'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +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(); +const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn().mockReturnThis(); +const mockBuild = jest.fn().mockReturnValue({}); +const mockCreateEventBuilder = jest.fn().mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, +}); +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('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +jest.mock( + '../../../../../selectors/featureFlagController/socialLeaderboard', + () => ({ + selectSocialLeaderboardEnabled: () => mockLeaderboardEnabled, + selectSocialLeaderboardOptFlowEnabled: () => mockOptFlowEnabled, + }), +); + +const renderWith = (showAccountOnLeaderboard = true) => + renderWithProvider(, { + 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 reflects the toggle off after the backend confirms', async () => { + renderWith(true); + const toggle = screen.getByTestId( + SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE, + ); + + fireEvent(toggle, 'valueChange', false); + + await waitFor(() => + expect(mockMessengerCall).toHaveBeenCalledWith( + 'SocialController:optOutOfLeaderboard', + ), + ); + // Persisted only after the backend confirms, not optimistically. + await waitFor(() => expect(toggle.props.value).toBe(false)); + }); + + 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('tracks the visibility-toggled event on a successful opt-out', async () => { + renderWith(true); + const toggle = screen.getByTestId( + SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE, + ); + + fireEvent(toggle, 'valueChange', false); + + await waitFor(() => + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED, + ), + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + show_account_on_leaderboard: false, + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('tracks the visibility-toggled event on a successful opt-in', async () => { + renderWith(false); + const toggle = screen.getByTestId( + SecurityPrivacyViewSelectorsIDs.SHOW_ACCOUNT_ON_LEADERBOARD_TOGGLE, + ); + + fireEvent(toggle, 'valueChange', true); + + await waitFor(() => + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED, + ), + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + show_account_on_leaderboard: true, + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('logs and leaves the toggle unchanged 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()); + // Never persisted optimistically, so nothing to revert — stays as it was. + expect(toggle.props.value).toBe(true); + }); + + it('does not track the event 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(mockTrackEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx new file mode 100644 index 00000000000..6a61e309b9f --- /dev/null +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx @@ -0,0 +1,108 @@ +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 { MetaMetricsEvents } from '../../../../../core/Analytics'; +import Engine from '../../../../../core/Engine'; +import { RootState } from '../../../../../reducers'; +import { + selectSocialLeaderboardEnabled, + selectSocialLeaderboardOptFlowEnabled, +} from '../../../../../selectors/featureFlagController/socialLeaderboard'; +import Logger from '../../../../../util/Logger'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +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 { trackEvent, createEventBuilder } = useAnalytics(); + 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); + try { + await (Engine.controllerMessenger.call as CallableFunction)( + enabled + ? 'SocialController:optInToLeaderboard' + : 'SocialController:optOutOfLeaderboard', + ); + // Update state only after the backend confirms. + dispatch(setShowAccountOnLeaderboard(enabled)); + trackEvent( + createEventBuilder( + MetaMetricsEvents.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED, + ) + .addProperties({ show_account_on_leaderboard: enabled }) + .build(), + ); + } catch (error) { + Logger.error( + error as Error, + 'TopTradersSection: failed to update leaderboard visibility', + ); + } + // Not a `finally`: React Compiler can't lower try/finally and bails on it. + setIsUpdating(false); + }, + [createEventBuilder, dispatch, isUpdating, trackEvent], + ); + + if (!isSocialLeaderboardEnabled || !isLeaderboardOptFlowEnabled) { + return null; + } + + return ( + + + {strings('social_leaderboard.settings.section_title')} + + {/* Wrap the toggle in `styles.setting` (marginTop) so the gap below the + section heading matches the other sections (e.g. Analytics). */} + + + + + ); +}; + +export default TopTradersSection; diff --git a/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts b/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts index 50fd8b639a2..4d7f3f813c4 100644 --- a/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts +++ b/app/components/Views/Settings/SecuritySettings/SecurityPrivacyView.testIds.ts @@ -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 = { diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx index e077aca68c7..4e24106761b 100644 --- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx +++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx @@ -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'; @@ -411,6 +412,7 @@ const Settings: React.FC = () => { + {renderHint()} diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 20c992ad58f..9802a86849b 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -789,6 +789,7 @@ enum EVENT_NAME { SOCIAL_QUICK_BUY_INTERACTED = 'Quick Buy Interacted', SOCIAL_QUICK_TRADE_MODE_TOGGLED = 'Quick Trade Mode Toggled', SOCIAL_FOLLOW_TRADING_NOTIFICATION_CLICKED = 'Follow Trading Notification Clicked', + SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED = 'Trader Leaderboard Visibility Toggled', SOCIAL_FOLLOW_TRADING_ONBOARDING_VIEWED = 'Follow Trading Onboarding Viewed', SOCIAL_FOLLOW_TRADING_ONBOARDING_COMPLETED = 'Follow Trading Onboarding Completed', SOCIAL_FOLLOW_TRADING_ONBOARDING_INTERACTION = 'Follow Trading Onboarding Interaction', @@ -2117,6 +2118,9 @@ const events = { SOCIAL_FOLLOW_TRADING_NOTIFICATION_CLICKED: generateOpt( EVENT_NAME.SOCIAL_FOLLOW_TRADING_NOTIFICATION_CLICKED, ), + SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED: generateOpt( + EVENT_NAME.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED, + ), SOCIAL_FOLLOW_TRADING_ONBOARDING_VIEWED: generateOpt( EVENT_NAME.SOCIAL_FOLLOW_TRADING_ONBOARDING_VIEWED, ), diff --git a/app/core/Engine/messengers/social-controller-messenger.ts b/app/core/Engine/messengers/social-controller-messenger.ts index 8e34e4fb842..053fd64015d 100644 --- a/app/core/Engine/messengers/social-controller-messenger.ts +++ b/app/core/Engine/messengers/social-controller-messenger.ts @@ -33,6 +33,8 @@ export function getSocialControllerMessenger( 'SocialService:follow', 'SocialService:unfollow', 'SocialService:fetchFollowing', + 'SocialService:optOutOfLeaderboard', + 'SocialService:optInToLeaderboard', ], messenger, }); diff --git a/app/reducers/settings/index.js b/app/reducers/settings/index.js index 6cb0af34a81..eb863d381c3 100644 --- a/app/reducers/settings/index.js +++ b/app/reducers/settings/index.js @@ -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 @@ -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, diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.test.ts b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts index f603411a424..259d2d45287 100644 --- a/app/selectors/featureFlagController/socialLeaderboard/index.test.ts +++ b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts @@ -2,6 +2,7 @@ import { selectAiSocialLeaderboardOnboardingEnabled, selectSocialAIQuickBuyStreamQuotesEnabled, selectSocialLeaderboardEnabled, + selectSocialLeaderboardOptFlowEnabled, selectSocialLeaderboardPerpsEnabled, } from '.'; // eslint-disable-next-line import-x/no-namespace @@ -67,6 +68,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; diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.ts b/app/selectors/featureFlagController/socialLeaderboard/index.ts index 70b77520804..481fac529ad 100644 --- a/app/selectors/featureFlagController/socialLeaderboard/index.ts +++ b/app/selectors/featureFlagController/socialLeaderboard/index.ts @@ -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) => { diff --git a/locales/languages/en.json b/locales/languages/en.json index 8e37d9f34cc..95f89838c0e 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1181,6 +1181,11 @@ "traders_you_follow_empty": "You aren't following any traders yet.", "error_loading_followed": "Unable to load the traders you follow." }, + "settings": { + "section_title": "Top Traders", + "show_account_on_leaderboard": "Show account on leaderboard", + "show_account_on_leaderboard_description": "Appear on top traders leaderboard when your weekly earnings rank." + }, "follow": "Follow", "following": "Following", "mute": { diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 6641f83982b..e461e081e03 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -93,6 +93,17 @@ export const FEATURE_FLAG_REGISTRY: Record = { status: FeatureFlagStatus.Active, }, + aiSocialLeaderboardOptFlowEnabled: { + name: 'aiSocialLeaderboardOptFlowEnabled', + type: FeatureFlagType.Remote, + inProd: true, + productionDefault: { + enabled: false, + minimumVersion: '8.3.0', + }, + status: FeatureFlagStatus.Active, + }, + aiSocialLeaderboardPerpsEnabled: { name: 'aiSocialLeaderboardPerpsEnabled', type: FeatureFlagType.Remote,