From 19ed4d5e9efb5915970e9c370c32878b0ea195e4 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Wed, 8 Jul 2026 11:35:05 -0600 Subject: [PATCH 1/8] feat: social leaderboard opt out --- app/components/Nav/Main/MainNavigator.js | 16 +++ .../Views/Settings/SettingsView.testIds.ts | 1 + .../TopTradersSettings.testIds.ts | 5 + .../TopTradersSettings/index.test.tsx | 57 +++++++++ .../Settings/TopTradersSettings/index.tsx | 66 ++++++++++ app/components/Views/Settings/index.test.tsx | 26 ++++ app/components/Views/Settings/index.tsx | 16 +++ .../LeaderboardOptOutBottomSheet.test.tsx | 115 ++++++++++++++++++ .../LeaderboardOptOutBottomSheet.testIds.ts | 5 + .../LeaderboardOptOutBottomSheet.tsx | 93 ++++++++++++++ .../LeaderboardOptOutBottomSheet/index.ts | 1 + .../Views/SocialLeaderboard/index.ts | 1 + app/constants/navigation/Routes.ts | 2 + app/core/NavigationService/types.ts | 1 + locales/languages/en.json | 7 ++ package.json | 2 +- yarn.lock | 14 +-- 17 files changed, 420 insertions(+), 8 deletions(-) create mode 100644 app/components/Views/Settings/TopTradersSettings/TopTradersSettings.testIds.ts create mode 100644 app/components/Views/Settings/TopTradersSettings/index.test.tsx create mode 100644 app/components/Views/Settings/TopTradersSettings/index.tsx create mode 100644 app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.test.tsx create mode 100644 app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.testIds.ts create mode 100644 app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.tsx create mode 100644 app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/index.ts diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index d926fc361a3..c41998e5a1e 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -17,6 +17,7 @@ import SimpleWebview from '../../Views/SimpleWebview'; import AccountsMenu from '../../Views/AccountsMenu'; import Settings from '../../Views/Settings'; import GeneralSettings from '../../Views/Settings/GeneralSettings'; +import TopTradersSettings from '../../Views/Settings/TopTradersSettings'; import AdvancedSettings from '../../Views/Settings/AdvancedSettings'; import BackupAndSyncSettings from '../../Views/Settings/Identity/BackupAndSyncSettings'; import SecuritySettings from '../../Views/Settings/SecuritySettings'; @@ -153,6 +154,7 @@ import { TraderProfileView, TraderPositionView, TradingSignalsSetupBottomSheet, + LeaderboardOptOutBottomSheet, } from '../../Views/SocialLeaderboard'; import { selectSocialLeaderboardEnabled } from '../../../selectors/featureFlagController/socialLeaderboard'; import PerpsPositionTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView'; @@ -450,6 +452,10 @@ const SettingsFlow = () => { component={AccountsMenu} /> + { }} /> )} + {isSocialLeaderboardEnabled && ( + + )} <> { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }), + }; +}); + +describe('TopTradersSettings', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the opt-out row', () => { + renderWithProvider(); + + expect( + screen.getByTestId(TopTradersSettingsSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(TopTradersSettingsSelectorsIDs.OPT_OUT_ROW), + ).toBeOnTheScreen(); + }); + + it('opens the opt-out sheet when the opt-out row is pressed', () => { + renderWithProvider(); + + fireEvent.press( + screen.getByTestId(TopTradersSettingsSelectorsIDs.OPT_OUT_ROW), + ); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.SOCIAL_LEADERBOARD.OPT_OUT, + ); + }); + + it('navigates back when the back button is pressed', () => { + renderWithProvider(); + + fireEvent.press( + screen.getByTestId(TopTradersSettingsSelectorsIDs.BACK_BUTTON), + ); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/Views/Settings/TopTradersSettings/index.tsx b/app/components/Views/Settings/TopTradersSettings/index.tsx new file mode 100644 index 00000000000..4b21381c448 --- /dev/null +++ b/app/components/Views/Settings/TopTradersSettings/index.tsx @@ -0,0 +1,66 @@ +import { HeaderStandard } from '@metamask/design-system-react-native'; +import { useNavigation } from '@react-navigation/native'; +import React, { useCallback } from 'react'; +import { ScrollView, StyleSheet } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { strings } from '../../../../../locales/i18n'; +import Routes from '../../../../constants/navigation/Routes'; +import { Colors } from '../../../../util/theme/models'; +import { useTheme } from '../../../../util/theme'; +import SettingsDrawer from '../../../UI/SettingsDrawer'; +import { TopTradersSettingsSelectorsIDs } from './TopTradersSettings.testIds'; + +const createStyles = (colors: Colors) => + StyleSheet.create({ + wrapper: { + backgroundColor: colors.background.default, + flex: 1, + }, + }); + +/** + * "Top Traders" app-settings screen. Explores surfacing leaderboard + * preferences (starting with opt-out) from the global Settings list rather + * than the trader-profile cog. The opt-out reuses the shared opt-out sheet. + */ +const TopTradersSettings = () => { + const { colors } = useTheme(); + const styles = createStyles(colors); + const navigation = useNavigation(); + + const handleBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleOptOutPress = useCallback(() => { + navigation.navigate(Routes.SOCIAL_LEADERBOARD.OPT_OUT); + }, [navigation]); + + return ( + + + + + + + ); +}; + +export default TopTradersSettings; diff --git a/app/components/Views/Settings/index.test.tsx b/app/components/Views/Settings/index.test.tsx index 28b54cdedf9..2b3f325ca65 100644 --- a/app/components/Views/Settings/index.test.tsx +++ b/app/components/Views/Settings/index.test.tsx @@ -56,6 +56,11 @@ jest.mock('../../../util/notifications/constants/config', () => ({ isNotificationsFeatureEnabled: jest.fn(() => true), })); +let mockSocialLeaderboardEnabled = true; +jest.mock('../../../selectors/featureFlagController/socialLeaderboard', () => ({ + selectSocialLeaderboardEnabled: () => mockSocialLeaderboardEnabled, +})); + describe('Settings', () => { beforeEach(() => { jest.clearAllMocks(); @@ -147,6 +152,27 @@ describe('Settings', () => { ); expect(experimentalSettings).toBeDefined(); }); + it('renders the Top Traders button and navigates to it when the leaderboard is enabled', () => { + mockSocialLeaderboardEnabled = true; + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + const topTraders = getByTestId(SettingsViewSelectorsIDs.TOP_TRADERS); + expect(topTraders).toBeDefined(); + + fireEvent.press(topTraders); + expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS.TOP_TRADERS); + }); + it('hides the Top Traders button when the leaderboard is disabled', () => { + mockSocialLeaderboardEnabled = false; + const { queryByTestId } = renderWithProvider(, { + state: initialState, + }); + expect( + queryByTestId(SettingsViewSelectorsIDs.TOP_TRADERS), + ).not.toBeOnTheScreen(); + mockSocialLeaderboardEnabled = true; + }); it('does not render about metamask (account menu entry)', () => { const { queryByTestId } = renderWithProvider(, { state: initialState, diff --git a/app/components/Views/Settings/index.tsx b/app/components/Views/Settings/index.tsx index 7a935e25192..6d05cb89d5c 100644 --- a/app/components/Views/Settings/index.tsx +++ b/app/components/Views/Settings/index.tsx @@ -20,6 +20,7 @@ import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytic import { isNotificationsFeatureEnabled } from '../../../util/notifications'; import { isTestEnvironment } from '../../../util/test/utils'; import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; +import { selectSocialLeaderboardEnabled } from '../../../selectors/featureFlagController/socialLeaderboard'; const createStyles = (colors: Colors) => StyleSheet.create({ wrapper: { @@ -88,6 +89,10 @@ const Settings = () => { navigation.navigate(Routes.RAMP.SETTINGS); }; + const onPressTopTraders = () => { + navigation.navigate(Routes.SETTINGS.TOP_TRADERS); + }; + const onPressExperimental = () => { trackEvent( createEventBuilder(MetaMetricsEvents.SETTINGS_EXPERIMENTAL).build(), @@ -113,6 +118,9 @@ const Settings = () => { ///: END:ONLY_INCLUDE_IF const oauthFlow = useSelector(selectSeedlessOnboardingLoginFlow); + const isSocialLeaderboardEnabled = useSelector( + selectSocialLeaderboardEnabled, + ); return ( { onPress={onPressOnRamp} testID={SettingsViewSelectorsIDs.ON_RAMP} /> + {isSocialLeaderboardEnabled && ( + + )} { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ goBack: mockGoBack }), + }; +}); + +jest.mock('../../../../../core/Engine', () => ({ + controllerMessenger: { + call: (...args: unknown[]) => mockMessengerCall(...args), + }, +})); + +jest.mock('../../../../../util/Logger', () => ({ + error: (...args: unknown[]) => mockLoggerError(...args), +})); + +jest.mock('../../../../../util/haptics', () => ({ + ...jest.requireActual('../../../../../util/haptics'), + playImpact: jest.fn(), +})); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const MockBottomSheet = ReactActual.forwardRef( + ( + props: { children?: React.ReactNode; testID?: string }, + ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + onCloseBottomSheet: (cb?: () => void) => cb?.(), + })); + return ReactActual.createElement( + View, + { testID: props.testID ?? 'bottom-sheet' }, + props.children, + ); + }, + ); + return { ...actual, BottomSheet: MockBottomSheet }; +}); + +const mockPlayImpact = jest.mocked(playImpact); + +describe('LeaderboardOptOutBottomSheet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMessengerCall.mockResolvedValue(undefined); + }); + + it('renders the description and opt-out CTA', () => { + renderWithProvider(); + + expect( + screen.getByTestId(LeaderboardOptOutBottomSheetSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + expect( + screen.getByText(strings('social_leaderboard.opt_out.description')), + ).toBeOnTheScreen(); + expect( + screen.getByTestId( + LeaderboardOptOutBottomSheetSelectorsIDs.OPT_OUT_BUTTON, + ), + ).toBeOnTheScreen(); + }); + + it('calls SocialController:optOutOfLeaderboard and fires a haptic when pressed', async () => { + renderWithProvider(); + + fireEvent.press( + screen.getByTestId( + LeaderboardOptOutBottomSheetSelectorsIDs.OPT_OUT_BUTTON, + ), + ); + + expect(mockPlayImpact).toHaveBeenCalledWith( + ImpactMoment.QuickAmountSelection, + ); + await waitFor(() => + expect(mockMessengerCall).toHaveBeenCalledWith( + 'SocialController:optOutOfLeaderboard', + ), + ); + }); + + it('logs and does not crash when the opt-out call fails', async () => { + mockMessengerCall.mockRejectedValueOnce(new Error('network down')); + + renderWithProvider(); + + fireEvent.press( + screen.getByTestId( + LeaderboardOptOutBottomSheetSelectorsIDs.OPT_OUT_BUTTON, + ), + ); + + await waitFor(() => expect(mockLoggerError).toHaveBeenCalled()); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.testIds.ts b/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.testIds.ts new file mode 100644 index 00000000000..f9441e6c612 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.testIds.ts @@ -0,0 +1,5 @@ +export const LeaderboardOptOutBottomSheetSelectorsIDs = { + CONTAINER: 'leaderboard-opt-out-bottom-sheet-container', + CLOSE_BUTTON: 'leaderboard-opt-out-bottom-sheet-close-button', + OPT_OUT_BUTTON: 'leaderboard-opt-out-bottom-sheet-opt-out-button', +}; diff --git a/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.tsx b/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.tsx new file mode 100644 index 00000000000..91e7418f663 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.tsx @@ -0,0 +1,93 @@ +import { + BottomSheet, + Button, + ButtonVariant, + HeaderStandard, + Text, + TextColor, + TextVariant, + type BottomSheetRef, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useNavigation } from '@react-navigation/native'; +import React, { useCallback, useRef, useState } from 'react'; +import { View } from 'react-native'; + +import { strings } from '../../../../../../locales/i18n'; +import Engine from '../../../../../core/Engine'; +import { ImpactMoment, playImpact } from '../../../../../util/haptics'; +import Logger from '../../../../../util/Logger'; +import { LeaderboardOptOutBottomSheetSelectorsIDs } from './LeaderboardOptOutBottomSheet.testIds'; + +/** + * Owner-only leaderboard opt-out sheet, opened from the cog on a trader's + * profile (when the viewer owns one of the profile's addresses) and from the + * Top Traders app-settings screen. Calls `SocialController:optOutOfLeaderboard` + * to remove the current user's addresses from the PnL leaderboard. + */ +const LeaderboardOptOutBottomSheet = () => { + const tw = useTailwind(); + const navigation = useNavigation(); + const sheetRef = useRef(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleOptOut = useCallback(async () => { + if (isSubmitting) { + return; + } + playImpact(ImpactMoment.QuickAmountSelection); + setIsSubmitting(true); + try { + await (Engine.controllerMessenger.call as CallableFunction)( + 'SocialController:optOutOfLeaderboard', + ); + sheetRef.current?.onCloseBottomSheet(); + } catch (error) { + Logger.error( + error as Error, + 'LeaderboardOptOutBottomSheet: optOutOfLeaderboard failed', + ); + setIsSubmitting(false); + } + }, [isSubmitting]); + + return ( + + sheetRef.current?.onCloseBottomSheet()} + closeButtonProps={{ + testID: LeaderboardOptOutBottomSheetSelectorsIDs.CLOSE_BUTTON, + }} + /> + + + + {strings('social_leaderboard.opt_out.description')} + + + + + ); +}; + +export default LeaderboardOptOutBottomSheet; diff --git a/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/index.ts b/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/index.ts new file mode 100644 index 00000000000..64dd3735c5e --- /dev/null +++ b/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/index.ts @@ -0,0 +1 @@ +export { default } from './LeaderboardOptOutBottomSheet'; diff --git a/app/components/Views/SocialLeaderboard/index.ts b/app/components/Views/SocialLeaderboard/index.ts index 5f829842252..ce1ee7e183e 100644 --- a/app/components/Views/SocialLeaderboard/index.ts +++ b/app/components/Views/SocialLeaderboard/index.ts @@ -2,3 +2,4 @@ export { default as TopTradersView } from './TopTradersView'; export { default as TraderProfileView } from './TraderProfileView'; export { default as TraderPositionView } from './TraderPositionView'; export { default as TradingSignalsSetupBottomSheet } from './components/TradingSignalsSetupBottomSheet'; +export { default as LeaderboardOptOutBottomSheet } from './components/LeaderboardOptOutBottomSheet'; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index b51fda04d2e..a799ca12398 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -232,6 +232,7 @@ const Routes = { EXPERIMENTAL_SETTINGS: 'ExperimentalSettings', NOTIFICATIONS: 'NotificationsSettings', NOTIFICATION_SETTINGS_SECTION: 'NotificationSettingsSection', + TOP_TRADERS: 'TopTradersSettings', REVEAL_PRIVATE_CREDENTIAL: 'RevealPrivateCredentialView', SDK_SESSIONS_MANAGER: 'SDKSessionsManager', NETWORKS_MANAGEMENT: 'NetworksManagement', @@ -405,6 +406,7 @@ const Routes = { VIEW: 'TopTradersView', PROFILE: 'TraderProfileView', POSITION: 'TraderPositionView', + OPT_OUT: 'LeaderboardOptOutBottomSheet', TRADING_SIGNALS_SETUP: 'TradingSignalsSetupBottomSheet', }, PREDICT: { diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index 2b47c46d971..e00c40aca2e 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -743,6 +743,7 @@ export type RootStackParamList = { traderRank?: number; }; TraderPositionView: TraderPositionViewParams; + LeaderboardOptOutBottomSheet: undefined; // Misc routes LockScreen: undefined; diff --git a/locales/languages/en.json b/locales/languages/en.json index 561033692c7..acbe4037f32 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1070,6 +1070,11 @@ "traders_you_follow_empty": "You aren't following any traders yet.", "error_loading_followed": "Unable to load the traders you follow." }, + "opt_out": { + "title": "Leaderboard", + "description": "Opt out of the leaderboard and prevent this account's addresses from being shown.", + "cta": "Opt out of leaderboard" + }, "follow": "Follow", "following": "Following", "mute": { @@ -3694,6 +3699,8 @@ "info_title_flask": "About MetaMask Flask", "experimental_title": "Experimental", "experimental_desc": "WalletConnect and more...", + "top_traders_title": "Top Traders", + "top_traders_desc": "Manage your top trader preferences, opt out, etc.", "legal_title": "Legal", "conversion_title": "Currency conversion", "conversion_desc": "Display fiat values in using a specific currency throughout the application.", diff --git a/package.json b/package.json index 23a427cb324..cb689303848 100644 --- a/package.json +++ b/package.json @@ -347,7 +347,7 @@ "@metamask/snaps-rpc-methods": "^17.0.0", "@metamask/snaps-sdk": "^11.1.1", "@metamask/snaps-utils": "^12.2.0", - "@metamask/social-controllers": "2.3.1", + "@metamask/social-controllers": "2.4.0", "@metamask/solana-wallet-snap": "^2.10.0", "@metamask/solana-wallet-standard": "^0.6.0", "@metamask/stake-sdk": "^3.4.0", diff --git a/yarn.lock b/yarn.lock index 19b4de699fc..40c99460470 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10344,17 +10344,17 @@ __metadata: languageName: node linkType: hard -"@metamask/social-controllers@npm:2.3.1": - version: 2.3.1 - resolution: "@metamask/social-controllers@npm:2.3.1" +"@metamask/social-controllers@npm:2.4.0": + version: 2.4.0 + resolution: "@metamask/social-controllers@npm:2.4.0" dependencies: "@metamask/base-controller": "npm:^9.1.0" "@metamask/base-data-service": "npm:^0.1.3" - "@metamask/controller-utils": "npm:^12.2.0" - "@metamask/messenger": "npm:^1.2.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^28.2.0" "@metamask/superstruct": "npm:^3.1.0" - checksum: 10/9da64cbe4f946ea881e3adc3adda1ccc7c7a13368579f56e223c7d1f6ec34d6cc1da4fd886053d535cfc96260c9094849e8c1c8521fd4262953705aaf9cfdb9f + checksum: 10/238cc0ea37633cfd1cfb65db96d9ca3a4e00274aeee7a3b3af11d95d30eb34d8ecc3b5f404f33b4e001ccbd3500ca0918279135f86c9226978dd08ca1990dff7 languageName: node linkType: hard @@ -35604,7 +35604,7 @@ __metadata: "@metamask/snaps-rpc-methods": "npm:^17.0.0" "@metamask/snaps-sdk": "npm:^11.1.1" "@metamask/snaps-utils": "npm:^12.2.0" - "@metamask/social-controllers": "npm:2.3.1" + "@metamask/social-controllers": "npm:2.4.0" "@metamask/solana-wallet-snap": "npm:^2.10.0" "@metamask/solana-wallet-standard": "npm:^0.6.0" "@metamask/stake-sdk": "npm:^3.4.0" From 88417983863778430968cd21374a7cfff3f24e6a Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Wed, 8 Jul 2026 15:14:34 -0600 Subject: [PATCH 2/8] feat: opt out in privacy settings --- app/actions/settings/index.js | 7 ++ app/components/Nav/Main/MainNavigator.js | 16 --- .../Sections/TopTradersSection.test.tsx | 112 +++++++++++++++++ .../Sections/TopTradersSection.tsx | 94 ++++++++++++++ .../SecurityPrivacyView.testIds.ts | 2 + .../SecuritySettings/SecuritySettings.tsx | 2 + .../Views/Settings/SettingsView.testIds.ts | 1 - .../TopTradersSettings.testIds.ts | 5 - .../TopTradersSettings/index.test.tsx | 57 --------- .../Settings/TopTradersSettings/index.tsx | 66 ---------- app/components/Views/Settings/index.test.tsx | 26 ---- app/components/Views/Settings/index.tsx | 16 --- .../LeaderboardOptOutBottomSheet.test.tsx | 115 ------------------ .../LeaderboardOptOutBottomSheet.testIds.ts | 5 - .../LeaderboardOptOutBottomSheet.tsx | 93 -------------- .../LeaderboardOptOutBottomSheet/index.ts | 1 - .../Views/SocialLeaderboard/index.ts | 1 - app/constants/navigation/Routes.ts | 2 - app/core/NavigationService/types.ts | 1 - app/reducers/settings/index.js | 8 ++ locales/languages/en.json | 10 +- 21 files changed, 229 insertions(+), 411 deletions(-) create mode 100644 app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx create mode 100644 app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx delete mode 100644 app/components/Views/Settings/TopTradersSettings/TopTradersSettings.testIds.ts delete mode 100644 app/components/Views/Settings/TopTradersSettings/index.test.tsx delete mode 100644 app/components/Views/Settings/TopTradersSettings/index.tsx delete mode 100644 app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.test.tsx delete mode 100644 app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.testIds.ts delete mode 100644 app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.tsx delete mode 100644 app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/index.ts 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/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index c41998e5a1e..d926fc361a3 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -17,7 +17,6 @@ import SimpleWebview from '../../Views/SimpleWebview'; import AccountsMenu from '../../Views/AccountsMenu'; import Settings from '../../Views/Settings'; import GeneralSettings from '../../Views/Settings/GeneralSettings'; -import TopTradersSettings from '../../Views/Settings/TopTradersSettings'; import AdvancedSettings from '../../Views/Settings/AdvancedSettings'; import BackupAndSyncSettings from '../../Views/Settings/Identity/BackupAndSyncSettings'; import SecuritySettings from '../../Views/Settings/SecuritySettings'; @@ -154,7 +153,6 @@ import { TraderProfileView, TraderPositionView, TradingSignalsSetupBottomSheet, - LeaderboardOptOutBottomSheet, } from '../../Views/SocialLeaderboard'; import { selectSocialLeaderboardEnabled } from '../../../selectors/featureFlagController/socialLeaderboard'; import PerpsPositionTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView'; @@ -452,10 +450,6 @@ const SettingsFlow = () => { component={AccountsMenu} /> - { }} /> )} - {isSocialLeaderboardEnabled && ( - - )} <> ({ + controllerMessenger: { + call: (...args: unknown[]) => mockMessengerCall(...args), + }, +})); + +jest.mock('../../../../../util/Logger', () => ({ + error: (...args: unknown[]) => mockLoggerError(...args), +})); + +jest.mock( + '../../../../../selectors/featureFlagController/socialLeaderboard', + () => ({ + selectSocialLeaderboardEnabled: () => mockLeaderboardEnabled, + }), +); + +const renderWith = (showAccountOnLeaderboard = true) => + renderWithProvider(, { + state: { settings: { showAccountOnLeaderboard } }, + }); + +describe('TopTradersSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockLeaderboardEnabled = 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('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); + }); +}); 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..65d39499d41 --- /dev/null +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx @@ -0,0 +1,94 @@ +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 } 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 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)); + 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); + } + }, + [dispatch, isUpdating], + ); + + if (!isSocialLeaderboardEnabled) { + 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/components/Views/Settings/SettingsView.testIds.ts b/app/components/Views/Settings/SettingsView.testIds.ts index d78a7ae8598..e2d0d70621b 100644 --- a/app/components/Views/Settings/SettingsView.testIds.ts +++ b/app/components/Views/Settings/SettingsView.testIds.ts @@ -28,5 +28,4 @@ export const SettingsViewSelectorsIDs = { SNAPS: 'snaps', REGION: 'region-settings', ADD_DEVICE: 'add-device-settings', - TOP_TRADERS: 'top-traders-settings', }; diff --git a/app/components/Views/Settings/TopTradersSettings/TopTradersSettings.testIds.ts b/app/components/Views/Settings/TopTradersSettings/TopTradersSettings.testIds.ts deleted file mode 100644 index a08b073a823..00000000000 --- a/app/components/Views/Settings/TopTradersSettings/TopTradersSettings.testIds.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const TopTradersSettingsSelectorsIDs = { - CONTAINER: 'top-traders-settings-container', - BACK_BUTTON: 'top-traders-settings-back-button', - OPT_OUT_ROW: 'top-traders-settings-opt-out-row', -}; diff --git a/app/components/Views/Settings/TopTradersSettings/index.test.tsx b/app/components/Views/Settings/TopTradersSettings/index.test.tsx deleted file mode 100644 index 2e2c2f80baa..00000000000 --- a/app/components/Views/Settings/TopTradersSettings/index.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { fireEvent, screen } from '@testing-library/react-native'; -import React from 'react'; - -import Routes from '../../../../constants/navigation/Routes'; -import renderWithProvider from '../../../../util/test/renderWithProvider'; -import TopTradersSettings from './'; -import { TopTradersSettingsSelectorsIDs } from './TopTradersSettings.testIds'; - -const mockNavigate = jest.fn(); -const mockGoBack = jest.fn(); - -jest.mock('@react-navigation/native', () => { - const actual = jest.requireActual('@react-navigation/native'); - return { - ...actual, - useNavigation: () => ({ navigate: mockNavigate, goBack: mockGoBack }), - }; -}); - -describe('TopTradersSettings', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders the opt-out row', () => { - renderWithProvider(); - - expect( - screen.getByTestId(TopTradersSettingsSelectorsIDs.CONTAINER), - ).toBeOnTheScreen(); - expect( - screen.getByTestId(TopTradersSettingsSelectorsIDs.OPT_OUT_ROW), - ).toBeOnTheScreen(); - }); - - it('opens the opt-out sheet when the opt-out row is pressed', () => { - renderWithProvider(); - - fireEvent.press( - screen.getByTestId(TopTradersSettingsSelectorsIDs.OPT_OUT_ROW), - ); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.SOCIAL_LEADERBOARD.OPT_OUT, - ); - }); - - it('navigates back when the back button is pressed', () => { - renderWithProvider(); - - fireEvent.press( - screen.getByTestId(TopTradersSettingsSelectorsIDs.BACK_BUTTON), - ); - - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); -}); diff --git a/app/components/Views/Settings/TopTradersSettings/index.tsx b/app/components/Views/Settings/TopTradersSettings/index.tsx deleted file mode 100644 index 4b21381c448..00000000000 --- a/app/components/Views/Settings/TopTradersSettings/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { HeaderStandard } from '@metamask/design-system-react-native'; -import { useNavigation } from '@react-navigation/native'; -import React, { useCallback } from 'react'; -import { ScrollView, StyleSheet } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import { strings } from '../../../../../locales/i18n'; -import Routes from '../../../../constants/navigation/Routes'; -import { Colors } from '../../../../util/theme/models'; -import { useTheme } from '../../../../util/theme'; -import SettingsDrawer from '../../../UI/SettingsDrawer'; -import { TopTradersSettingsSelectorsIDs } from './TopTradersSettings.testIds'; - -const createStyles = (colors: Colors) => - StyleSheet.create({ - wrapper: { - backgroundColor: colors.background.default, - flex: 1, - }, - }); - -/** - * "Top Traders" app-settings screen. Explores surfacing leaderboard - * preferences (starting with opt-out) from the global Settings list rather - * than the trader-profile cog. The opt-out reuses the shared opt-out sheet. - */ -const TopTradersSettings = () => { - const { colors } = useTheme(); - const styles = createStyles(colors); - const navigation = useNavigation(); - - const handleBack = useCallback(() => { - navigation.goBack(); - }, [navigation]); - - const handleOptOutPress = useCallback(() => { - navigation.navigate(Routes.SOCIAL_LEADERBOARD.OPT_OUT); - }, [navigation]); - - return ( - - - - - - - ); -}; - -export default TopTradersSettings; diff --git a/app/components/Views/Settings/index.test.tsx b/app/components/Views/Settings/index.test.tsx index 2b3f325ca65..28b54cdedf9 100644 --- a/app/components/Views/Settings/index.test.tsx +++ b/app/components/Views/Settings/index.test.tsx @@ -56,11 +56,6 @@ jest.mock('../../../util/notifications/constants/config', () => ({ isNotificationsFeatureEnabled: jest.fn(() => true), })); -let mockSocialLeaderboardEnabled = true; -jest.mock('../../../selectors/featureFlagController/socialLeaderboard', () => ({ - selectSocialLeaderboardEnabled: () => mockSocialLeaderboardEnabled, -})); - describe('Settings', () => { beforeEach(() => { jest.clearAllMocks(); @@ -152,27 +147,6 @@ describe('Settings', () => { ); expect(experimentalSettings).toBeDefined(); }); - it('renders the Top Traders button and navigates to it when the leaderboard is enabled', () => { - mockSocialLeaderboardEnabled = true; - const { getByTestId } = renderWithProvider(, { - state: initialState, - }); - const topTraders = getByTestId(SettingsViewSelectorsIDs.TOP_TRADERS); - expect(topTraders).toBeDefined(); - - fireEvent.press(topTraders); - expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS.TOP_TRADERS); - }); - it('hides the Top Traders button when the leaderboard is disabled', () => { - mockSocialLeaderboardEnabled = false; - const { queryByTestId } = renderWithProvider(, { - state: initialState, - }); - expect( - queryByTestId(SettingsViewSelectorsIDs.TOP_TRADERS), - ).not.toBeOnTheScreen(); - mockSocialLeaderboardEnabled = true; - }); it('does not render about metamask (account menu entry)', () => { const { queryByTestId } = renderWithProvider(, { state: initialState, diff --git a/app/components/Views/Settings/index.tsx b/app/components/Views/Settings/index.tsx index 6d05cb89d5c..7a935e25192 100644 --- a/app/components/Views/Settings/index.tsx +++ b/app/components/Views/Settings/index.tsx @@ -20,7 +20,6 @@ import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytic import { isNotificationsFeatureEnabled } from '../../../util/notifications'; import { isTestEnvironment } from '../../../util/test/utils'; import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; -import { selectSocialLeaderboardEnabled } from '../../../selectors/featureFlagController/socialLeaderboard'; const createStyles = (colors: Colors) => StyleSheet.create({ wrapper: { @@ -89,10 +88,6 @@ const Settings = () => { navigation.navigate(Routes.RAMP.SETTINGS); }; - const onPressTopTraders = () => { - navigation.navigate(Routes.SETTINGS.TOP_TRADERS); - }; - const onPressExperimental = () => { trackEvent( createEventBuilder(MetaMetricsEvents.SETTINGS_EXPERIMENTAL).build(), @@ -118,9 +113,6 @@ const Settings = () => { ///: END:ONLY_INCLUDE_IF const oauthFlow = useSelector(selectSeedlessOnboardingLoginFlow); - const isSocialLeaderboardEnabled = useSelector( - selectSocialLeaderboardEnabled, - ); return ( { onPress={onPressOnRamp} testID={SettingsViewSelectorsIDs.ON_RAMP} /> - {isSocialLeaderboardEnabled && ( - - )} { - const actual = jest.requireActual('@react-navigation/native'); - return { - ...actual, - useNavigation: () => ({ goBack: mockGoBack }), - }; -}); - -jest.mock('../../../../../core/Engine', () => ({ - controllerMessenger: { - call: (...args: unknown[]) => mockMessengerCall(...args), - }, -})); - -jest.mock('../../../../../util/Logger', () => ({ - error: (...args: unknown[]) => mockLoggerError(...args), -})); - -jest.mock('../../../../../util/haptics', () => ({ - ...jest.requireActual('../../../../../util/haptics'), - playImpact: jest.fn(), -})); - -jest.mock('@metamask/design-system-react-native', () => { - const actual = jest.requireActual('@metamask/design-system-react-native'); - const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); - const MockBottomSheet = ReactActual.forwardRef( - ( - props: { children?: React.ReactNode; testID?: string }, - ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, - ) => { - ReactActual.useImperativeHandle(ref, () => ({ - onCloseBottomSheet: (cb?: () => void) => cb?.(), - })); - return ReactActual.createElement( - View, - { testID: props.testID ?? 'bottom-sheet' }, - props.children, - ); - }, - ); - return { ...actual, BottomSheet: MockBottomSheet }; -}); - -const mockPlayImpact = jest.mocked(playImpact); - -describe('LeaderboardOptOutBottomSheet', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockMessengerCall.mockResolvedValue(undefined); - }); - - it('renders the description and opt-out CTA', () => { - renderWithProvider(); - - expect( - screen.getByTestId(LeaderboardOptOutBottomSheetSelectorsIDs.CONTAINER), - ).toBeOnTheScreen(); - expect( - screen.getByText(strings('social_leaderboard.opt_out.description')), - ).toBeOnTheScreen(); - expect( - screen.getByTestId( - LeaderboardOptOutBottomSheetSelectorsIDs.OPT_OUT_BUTTON, - ), - ).toBeOnTheScreen(); - }); - - it('calls SocialController:optOutOfLeaderboard and fires a haptic when pressed', async () => { - renderWithProvider(); - - fireEvent.press( - screen.getByTestId( - LeaderboardOptOutBottomSheetSelectorsIDs.OPT_OUT_BUTTON, - ), - ); - - expect(mockPlayImpact).toHaveBeenCalledWith( - ImpactMoment.QuickAmountSelection, - ); - await waitFor(() => - expect(mockMessengerCall).toHaveBeenCalledWith( - 'SocialController:optOutOfLeaderboard', - ), - ); - }); - - it('logs and does not crash when the opt-out call fails', async () => { - mockMessengerCall.mockRejectedValueOnce(new Error('network down')); - - renderWithProvider(); - - fireEvent.press( - screen.getByTestId( - LeaderboardOptOutBottomSheetSelectorsIDs.OPT_OUT_BUTTON, - ), - ); - - await waitFor(() => expect(mockLoggerError).toHaveBeenCalled()); - }); -}); diff --git a/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.testIds.ts b/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.testIds.ts deleted file mode 100644 index f9441e6c612..00000000000 --- a/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.testIds.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const LeaderboardOptOutBottomSheetSelectorsIDs = { - CONTAINER: 'leaderboard-opt-out-bottom-sheet-container', - CLOSE_BUTTON: 'leaderboard-opt-out-bottom-sheet-close-button', - OPT_OUT_BUTTON: 'leaderboard-opt-out-bottom-sheet-opt-out-button', -}; diff --git a/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.tsx b/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.tsx deleted file mode 100644 index 91e7418f663..00000000000 --- a/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/LeaderboardOptOutBottomSheet.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - BottomSheet, - Button, - ButtonVariant, - HeaderStandard, - Text, - TextColor, - TextVariant, - type BottomSheetRef, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { useNavigation } from '@react-navigation/native'; -import React, { useCallback, useRef, useState } from 'react'; -import { View } from 'react-native'; - -import { strings } from '../../../../../../locales/i18n'; -import Engine from '../../../../../core/Engine'; -import { ImpactMoment, playImpact } from '../../../../../util/haptics'; -import Logger from '../../../../../util/Logger'; -import { LeaderboardOptOutBottomSheetSelectorsIDs } from './LeaderboardOptOutBottomSheet.testIds'; - -/** - * Owner-only leaderboard opt-out sheet, opened from the cog on a trader's - * profile (when the viewer owns one of the profile's addresses) and from the - * Top Traders app-settings screen. Calls `SocialController:optOutOfLeaderboard` - * to remove the current user's addresses from the PnL leaderboard. - */ -const LeaderboardOptOutBottomSheet = () => { - const tw = useTailwind(); - const navigation = useNavigation(); - const sheetRef = useRef(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleOptOut = useCallback(async () => { - if (isSubmitting) { - return; - } - playImpact(ImpactMoment.QuickAmountSelection); - setIsSubmitting(true); - try { - await (Engine.controllerMessenger.call as CallableFunction)( - 'SocialController:optOutOfLeaderboard', - ); - sheetRef.current?.onCloseBottomSheet(); - } catch (error) { - Logger.error( - error as Error, - 'LeaderboardOptOutBottomSheet: optOutOfLeaderboard failed', - ); - setIsSubmitting(false); - } - }, [isSubmitting]); - - return ( - - sheetRef.current?.onCloseBottomSheet()} - closeButtonProps={{ - testID: LeaderboardOptOutBottomSheetSelectorsIDs.CLOSE_BUTTON, - }} - /> - - - - {strings('social_leaderboard.opt_out.description')} - - - - - ); -}; - -export default LeaderboardOptOutBottomSheet; diff --git a/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/index.ts b/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/index.ts deleted file mode 100644 index 64dd3735c5e..00000000000 --- a/app/components/Views/SocialLeaderboard/components/LeaderboardOptOutBottomSheet/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './LeaderboardOptOutBottomSheet'; diff --git a/app/components/Views/SocialLeaderboard/index.ts b/app/components/Views/SocialLeaderboard/index.ts index ce1ee7e183e..5f829842252 100644 --- a/app/components/Views/SocialLeaderboard/index.ts +++ b/app/components/Views/SocialLeaderboard/index.ts @@ -2,4 +2,3 @@ export { default as TopTradersView } from './TopTradersView'; export { default as TraderProfileView } from './TraderProfileView'; export { default as TraderPositionView } from './TraderPositionView'; export { default as TradingSignalsSetupBottomSheet } from './components/TradingSignalsSetupBottomSheet'; -export { default as LeaderboardOptOutBottomSheet } from './components/LeaderboardOptOutBottomSheet'; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index a799ca12398..b51fda04d2e 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -232,7 +232,6 @@ const Routes = { EXPERIMENTAL_SETTINGS: 'ExperimentalSettings', NOTIFICATIONS: 'NotificationsSettings', NOTIFICATION_SETTINGS_SECTION: 'NotificationSettingsSection', - TOP_TRADERS: 'TopTradersSettings', REVEAL_PRIVATE_CREDENTIAL: 'RevealPrivateCredentialView', SDK_SESSIONS_MANAGER: 'SDKSessionsManager', NETWORKS_MANAGEMENT: 'NetworksManagement', @@ -406,7 +405,6 @@ const Routes = { VIEW: 'TopTradersView', PROFILE: 'TraderProfileView', POSITION: 'TraderPositionView', - OPT_OUT: 'LeaderboardOptOutBottomSheet', TRADING_SIGNALS_SETUP: 'TradingSignalsSetupBottomSheet', }, PREDICT: { diff --git a/app/core/NavigationService/types.ts b/app/core/NavigationService/types.ts index e00c40aca2e..2b47c46d971 100644 --- a/app/core/NavigationService/types.ts +++ b/app/core/NavigationService/types.ts @@ -743,7 +743,6 @@ export type RootStackParamList = { traderRank?: number; }; TraderPositionView: TraderPositionViewParams; - LeaderboardOptOutBottomSheet: undefined; // Misc routes LockScreen: undefined; 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/locales/languages/en.json b/locales/languages/en.json index 4cf058129de..a36f4ec8438 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1070,10 +1070,10 @@ "traders_you_follow_empty": "You aren't following any traders yet.", "error_loading_followed": "Unable to load the traders you follow." }, - "opt_out": { - "title": "Leaderboard", - "description": "Opt out of the leaderboard and prevent this account's addresses from being shown.", - "cta": "Opt out of leaderboard" + "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", @@ -3696,8 +3696,6 @@ "info_title_flask": "About MetaMask Flask", "experimental_title": "Experimental", "experimental_desc": "WalletConnect and more...", - "top_traders_title": "Top Traders", - "top_traders_desc": "Manage your top trader preferences, opt out, etc.", "legal_title": "Legal", "conversion_title": "Currency conversion", "conversion_desc": "Display fiat values in using a specific currency throughout the application.", From dd201046e2262ae35694396de99f0340d8effb65 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Fri, 10 Jul 2026 14:17:33 -0600 Subject: [PATCH 3/8] fix: delegate SocialService opt-out/in actions to SocialController messenger --- app/core/Engine/messengers/social-controller-messenger.ts | 2 ++ 1 file changed, 2 insertions(+) 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, }); From 1042f4fb59827a652ab093257ced7acf3456a3c2 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Mon, 13 Jul 2026 08:33:44 -0600 Subject: [PATCH 4/8] chore: feature flag --- .../Sections/TopTradersSection.test.tsx | 12 ++++ .../Sections/TopTradersSection.tsx | 10 ++- .../socialLeaderboard/index.test.ts | 69 +++++++++++++++++++ .../socialLeaderboard/index.ts | 10 +++ 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx index fc81f8700bd..df46c151343 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx @@ -9,6 +9,7 @@ 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: { @@ -24,6 +25,7 @@ jest.mock( '../../../../../selectors/featureFlagController/socialLeaderboard', () => ({ selectSocialLeaderboardEnabled: () => mockLeaderboardEnabled, + selectSocialLeaderboardOptFlowEnabled: () => mockOptFlowEnabled, }), ); @@ -36,6 +38,7 @@ describe('TopTradersSection', () => { beforeEach(() => { jest.clearAllMocks(); mockLeaderboardEnabled = true; + mockOptFlowEnabled = true; mockMessengerCall.mockResolvedValue(undefined); }); @@ -66,6 +69,15 @@ describe('TopTradersSection', () => { ).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( diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx index 65d39499d41..030de54ea90 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx @@ -7,7 +7,10 @@ import { strings } from '../../../../../../locales/i18n'; import { setShowAccountOnLeaderboard } from '../../../../../actions/settings'; import Engine from '../../../../../core/Engine'; import { RootState } from '../../../../../reducers'; -import { selectSocialLeaderboardEnabled } from '../../../../../selectors/featureFlagController/socialLeaderboard'; +import { + selectSocialLeaderboardEnabled, + selectSocialLeaderboardOptFlowEnabled, +} from '../../../../../selectors/featureFlagController/socialLeaderboard'; import Logger from '../../../../../util/Logger'; import { SecurityOptionToggle } from '../../../../UI/SecurityOptionToggle'; import { useStyles } from '../../../../hooks/useStyles'; @@ -27,6 +30,9 @@ const TopTradersSection = () => { const isSocialLeaderboardEnabled = useSelector( selectSocialLeaderboardEnabled, ); + const isLeaderboardOptFlowEnabled = useSelector( + selectSocialLeaderboardOptFlowEnabled, + ); const showAccountOnLeaderboard = useSelector( (state: RootState) => state.settings.showAccountOnLeaderboard ?? true, ); @@ -60,7 +66,7 @@ const TopTradersSection = () => { [dispatch, isUpdating], ); - if (!isSocialLeaderboardEnabled) { + if (!isSocialLeaderboardEnabled || !isLeaderboardOptFlowEnabled) { return null; } diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.test.ts b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts index 31363573219..e7a557dbe21 100644 --- a/app/selectors/featureFlagController/socialLeaderboard/index.test.ts +++ b/app/selectors/featureFlagController/socialLeaderboard/index.test.ts @@ -1,6 +1,7 @@ import { selectSocialAIQuickBuyStreamQuotesEnabled, selectSocialLeaderboardEnabled, + selectSocialLeaderboardOptFlowEnabled, selectSocialLeaderboardPerpsEnabled, } from '.'; // eslint-disable-next-line import-x/no-namespace @@ -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; diff --git a/app/selectors/featureFlagController/socialLeaderboard/index.ts b/app/selectors/featureFlagController/socialLeaderboard/index.ts index 87f74dfb63a..4b21e9d1356 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) => { From b68e7e30ca5f2f4b2e4c28909657266a5c621a8d Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Mon, 13 Jul 2026 09:18:58 -0600 Subject: [PATCH 5/8] chore: register aiSocialLeaderboardOptFlowEnabled feature flag Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/feature-flags/feature-flag-registry.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index 1cabab00fd7..5806173cfba 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, From 992ae549119fbe9768396665c8b54b26422d7aab Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Mon, 13 Jul 2026 16:15:56 -0600 Subject: [PATCH 6/8] chore: track leaderboard visbility toggled event --- .../Sections/TopTradersSection.test.tsx | 66 +++++++++++++++++++ .../Sections/TopTradersSection.tsx | 13 +++- app/core/Analytics/MetaMetrics.events.ts | 4 ++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx index df46c151343..664ae262579 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx @@ -2,12 +2,20 @@ 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; @@ -21,6 +29,13 @@ jest.mock('../../../../../util/Logger', () => ({ error: (...args: unknown[]) => mockLoggerError(...args), })); +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + jest.mock( '../../../../../selectors/featureFlagController/socialLeaderboard', () => ({ @@ -109,6 +124,44 @@ describe('TopTradersSection', () => { ); }); + 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('reverts and logs when the request fails', async () => { mockMessengerCall.mockRejectedValueOnce(new Error('network down')); renderWith(true); @@ -121,4 +174,17 @@ describe('TopTradersSection', () => { await waitFor(() => expect(mockLoggerError).toHaveBeenCalled()); 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 index 030de54ea90..4f4b10b7a74 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx @@ -5,6 +5,7 @@ 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 { @@ -12,6 +13,7 @@ import { 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'; @@ -27,6 +29,7 @@ import { SecurityPrivacyViewSelectorsIDs } from '../SecurityPrivacyView.testIds' const TopTradersSection = () => { const { styles } = useStyles(createStyles, {}); const dispatch = useDispatch(); + const { trackEvent, createEventBuilder } = useAnalytics(); const isSocialLeaderboardEnabled = useSelector( selectSocialLeaderboardEnabled, ); @@ -52,6 +55,14 @@ const TopTradersSection = () => { ? 'SocialController:optInToLeaderboard' : 'SocialController:optOutOfLeaderboard', ); + // Track only after the backend confirms + trackEvent( + createEventBuilder( + MetaMetricsEvents.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED, + ) + .addProperties({ show_account_on_leaderboard: enabled }) + .build(), + ); } catch (error) { Logger.error( error as Error, @@ -63,7 +74,7 @@ const TopTradersSection = () => { setIsUpdating(false); } }, - [dispatch, isUpdating], + [createEventBuilder, dispatch, isUpdating, trackEvent], ); if (!isSocialLeaderboardEnabled || !isLeaderboardOptFlowEnabled) { diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 28ec1770609..2542d725dfe 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', // Activity ACTIVITY_CLICKED = 'Activity Clicked', } @@ -2109,6 +2110,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, + ), ACTIVITY_CLICKED: generateOpt(EVENT_NAME.ACTIVITY_CLICKED), }; From 1326704285f1d50f186dfd632229493e7555aa40 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Wed, 15 Jul 2026 09:36:47 -0600 Subject: [PATCH 7/8] fix: write opt ins to state only on success --- .../SecuritySettings/Sections/TopTradersSection.test.tsx | 8 +++++--- .../SecuritySettings/Sections/TopTradersSection.tsx | 7 ++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx index 664ae262579..86bafaaa5e4 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.test.tsx @@ -93,7 +93,7 @@ describe('TopTradersSection', () => { ).toBeNull(); }); - it('opts out (and flips the toggle off) when turned off', async () => { + 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, @@ -101,12 +101,13 @@ describe('TopTradersSection', () => { fireEvent(toggle, 'valueChange', false); - expect(toggle.props.value).toBe(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 () => { @@ -162,7 +163,7 @@ describe('TopTradersSection', () => { expect(mockTrackEvent).toHaveBeenCalledTimes(1); }); - it('reverts and logs when the request fails', async () => { + it('logs and leaves the toggle unchanged when the request fails', async () => { mockMessengerCall.mockRejectedValueOnce(new Error('network down')); renderWith(true); const toggle = screen.getByTestId( @@ -172,6 +173,7 @@ describe('TopTradersSection', () => { 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); }); diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx index 4f4b10b7a74..5f9d10f3c1a 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx @@ -47,15 +47,14 @@ const TopTradersSection = () => { return; } setIsUpdating(true); - // Optimistic: the reducer value is the displayed state. - dispatch(setShowAccountOnLeaderboard(enabled)); try { await (Engine.controllerMessenger.call as CallableFunction)( enabled ? 'SocialController:optInToLeaderboard' : 'SocialController:optOutOfLeaderboard', ); - // Track only after the backend confirms + // Update state only after the backend confirms. + dispatch(setShowAccountOnLeaderboard(enabled)); trackEvent( createEventBuilder( MetaMetricsEvents.SOCIAL_TRADER_LEADERBOARD_VISIBILITY_TOGGLED, @@ -68,8 +67,6 @@ const TopTradersSection = () => { 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); } From f2929c37da2951f146405e2c101dfc7117a12bbc Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Wed, 15 Jul 2026 10:54:52 -0600 Subject: [PATCH 8/8] fix: react compiler --- .../Settings/SecuritySettings/Sections/TopTradersSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx index 5f9d10f3c1a..6a61e309b9f 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/TopTradersSection.tsx @@ -67,9 +67,9 @@ const TopTradersSection = () => { error as Error, 'TopTradersSection: failed to update leaderboard visibility', ); - } finally { - setIsUpdating(false); } + // Not a `finally`: React Compiler can't lower try/finally and bails on it. + setIsUpdating(false); }, [createEventBuilder, dispatch, isUpdating, trackEvent], );