diff --git a/app/actions/notification/helpers/index.test.tsx b/app/actions/notification/helpers/index.test.tsx
index ef143a04384..16ed2137ed8 100644
--- a/app/actions/notification/helpers/index.test.tsx
+++ b/app/actions/notification/helpers/index.test.tsx
@@ -5,12 +5,12 @@ import {
fetchAccountNotificationSettings,
disableAccounts,
enableAccounts,
- resetNotifications,
toggleFeatureAnnouncements,
fetchNotifications,
markNotificationsAsRead,
enablePushNotifications,
disablePushNotifications,
+ hasNotificationPreferences,
type setContentPreviewToken as setContentPreviewTokenFn,
type getContentPreviewToken as getContentPreviewTokenFn,
type subscribeToContentPreviewToken as subscribeToContentPreviewTokenFn,
@@ -38,8 +38,15 @@ jest.mock('../../../core/Engine', () => ({
disablePushNotifications: jest.fn(),
},
},
+ controllerMessenger: {
+ call: jest.fn(),
+ },
}));
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
describe('helpers - enableNotificationServices()', () => {
it('invoke notification services method', async () => {
await enableNotifications();
@@ -47,6 +54,39 @@ describe('helpers - enableNotificationServices()', () => {
Engine.context.NotificationServicesController.enableMetamaskNotifications,
).toHaveBeenCalled();
});
+
+ it('passes marketing consent to notification services method', async () => {
+ const options = { hasMarketingConsent: true };
+
+ await enableNotifications(options);
+
+ expect(
+ Engine.context.NotificationServicesController.enableMetamaskNotifications,
+ ).toHaveBeenCalledWith(options);
+ });
+});
+
+describe('helpers - hasNotificationPreferences()', () => {
+ it('returns true when AUS preferences exist', async () => {
+ jest.mocked(Engine.controllerMessenger.call).mockResolvedValue({
+ walletActivity: {
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
+ accounts: [],
+ },
+ });
+
+ await expect(hasNotificationPreferences()).resolves.toBe(true);
+ expect(Engine.controllerMessenger.call).toHaveBeenCalledWith(
+ 'AuthenticatedUserStorageService:getNotificationPreferences',
+ );
+ });
+
+ it('returns false when AUS preferences are missing', async () => {
+ jest.mocked(Engine.controllerMessenger.call).mockResolvedValue(null);
+
+ await expect(hasNotificationPreferences()).resolves.toBe(false);
+ });
});
describe('helpers - disableNotificationServices()', () => {
@@ -88,15 +128,6 @@ describe('helpers - enableAccounts()', () => {
});
});
-describe('helpers - createOnChainTriggersByAccount()', () => {
- it('invoke notification services method', async () => {
- await resetNotifications();
- expect(
- Engine.context.NotificationServicesController.createOnChainTriggers,
- ).toHaveBeenCalled();
- });
-});
-
describe('helpers - setFeatureAnnouncementsEnabled()', () => {
it('invoke notification services method', async () => {
await toggleFeatureAnnouncements(true);
diff --git a/app/actions/notification/helpers/index.ts b/app/actions/notification/helpers/index.ts
index 8a7653f64a6..e9bf8678741 100644
--- a/app/actions/notification/helpers/index.ts
+++ b/app/actions/notification/helpers/index.ts
@@ -1,5 +1,8 @@
import EventEmitter2 from 'eventemitter2';
-import type { MarkAsReadNotificationsParam } from '@metamask/notification-services-controller/notification-services';
+import type {
+ MarkAsReadNotificationsParam,
+ NotificationServicesControllerEnableNotificationsOptions,
+} from '@metamask/notification-services-controller/notification-services';
import Engine from '../../../core/Engine';
import { isNotificationsFeatureEnabled } from '../../../util/notifications';
@@ -38,9 +41,25 @@ export const assertIsFeatureEnabled = () => {
* - This is used during onboarding and for the notifications settings toggle
* - Enables wallet notifications, feature announcements, and push notifications
*/
-export const enableNotifications = async () => {
+export const enableNotifications = async (
+ options?: NotificationServicesControllerEnableNotificationsOptions,
+) => {
assertIsFeatureEnabled();
- await Engine.context.NotificationServicesController.enableMetamaskNotifications();
+ await Engine.context.NotificationServicesController.enableMetamaskNotifications(
+ options,
+ );
+};
+
+/**
+ * Checks whether the authenticated user storage already has notification preferences.
+ * A missing AUS row is returned as `null` by the service.
+ */
+export const hasNotificationPreferences = async () => {
+ assertIsFeatureEnabled();
+ const preferences = await Engine.controllerMessenger.call(
+ 'AuthenticatedUserStorageService:getNotificationPreferences',
+ );
+ return preferences != null;
};
/**
@@ -147,15 +166,3 @@ export const markNotificationsAsRead = async (
notifications,
);
};
-
-/**
- * Developer options/User toggle to reset notifications
- * (in case their UserStorage or notifications become corrupt)
- * @throws if there is an error resetting notifications
- */
-export const resetNotifications = async () => {
- assertIsFeatureEnabled();
- await Engine.context.NotificationServicesController.createOnChainTriggers({
- resetNotifications: true,
- });
-};
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index ef3b1b36448..44ee83fbaf0 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -17,6 +17,7 @@ import NetworksManagementView from '../../Views/NetworksManagement/NetworksManag
import NetworkDetailsView from '../../Views/NetworksManagement/NetworkDetailsView';
import ExperimentalSettings from '../../Views/Settings/ExperimentalSettings';
import NotificationsSettings from '../../Views/Settings/NotificationsSettings';
+import NotificationSettingsSection from '../../Views/Settings/NotificationsSettings/NotificationSettingsSection';
import RegionSelector from '../../UI/Ramp/Views/Settings/RegionSelector/RegionSelector';
import NotificationsView from '../../Views/Notifications';
import NotificationsDetails from '../../Views/Notifications/Details';
@@ -127,7 +128,6 @@ import {
TopTradersView,
TraderProfileView,
TraderPositionView,
- NotificationPreferencesView,
} from '../../Views/SocialLeaderboard';
import { selectSocialLeaderboardEnabled } from '../../../selectors/featureFlagController/socialLeaderboard';
import PerpsPositionTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView';
@@ -441,7 +441,12 @@ const NotificationsOptInStack = () => (
+
);
@@ -607,7 +612,12 @@ const SettingsFlow = () => {
+
(
/* eslint-disable react/prop-types */
const NotificationsModeView = (props) => (
-
+
+
{
options={{ headerShown: false, ...slideFromRightAnimation }}
/>
)}
- {isSocialLeaderboardEnabled && (
-
- )}
<>
- StyleSheet.create({
- menuItemWarning: {
- flex: 1,
- alignSelf: 'center',
- justifyContent: 'flex-end',
- flexDirection: 'row',
- marginRight: 24,
- },
- wrapper: {
- padding: 12,
- borderRadius: 10,
- display: 'flex',
- flexDirection: 'row',
- justifyContent: 'center',
- alignItems: 'center',
- width: '100%',
- marginTop: 10,
- },
- icon: {
- marginRight: 4,
- },
- red: {
- backgroundColor: colors.error.muted,
- },
- normal: {
- backgroundColor: colors.background.alternative,
- },
- check: {
- color: colors.success.default,
- },
- });
-
-const WarningIcon = () => {
- const { colors } = useTheme();
- const styles = createStyles(colors);
-
- return (
-
- );
-};
-const CheckIcon = () => {
- const { colors } = useTheme();
- const styles = createStyles(colors);
-
- return (
-
- );
-};
-
-const propTypes = {
- style: PropTypes.object,
- isWarning: PropTypes.bool,
- isNotification: PropTypes.bool,
- children: PropTypes.oneOfType([
- PropTypes.arrayOf(PropTypes.node),
- PropTypes.node,
- ]),
-};
-
-const DEFAULT_STYLE = {};
-
-/**
- * @param {object} props
- * @param {object} [props.style]
- * @param {boolean} [props.isWarning]
- * @param {boolean} [props.isNotification]
- * @param {React.ReactNode} [props.children]
- */
-const SettingsNotification = ({
- style = DEFAULT_STYLE,
- isWarning = false,
- isNotification = false,
- children,
-}) => {
- const { colors } = useTheme();
- const styles = createStyles(colors);
-
- return (
-
- {isWarning ? : }
- {children}
-
- );
-};
-
-SettingsNotification.propTypes = propTypes;
-
-export default SettingsNotification;
diff --git a/app/components/UI/SettingsNotification/index.test.tsx b/app/components/UI/SettingsNotification/index.test.tsx
deleted file mode 100644
index acce7596736..00000000000
--- a/app/components/UI/SettingsNotification/index.test.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-import { Text } from 'react-native';
-import { render } from '@testing-library/react-native';
-import SettingsNotification from './';
-
-describe('SettingsNotification', () => {
- it('renders children in warning variant', () => {
- const { getByTestId } = render(
-
- this is a warning
- ,
- );
-
- expect(getByTestId('settings-notification-label').props.children).toBe(
- 'this is a warning',
- );
- });
-
- it('renders children in notification variant', () => {
- const { getByTestId } = render(
-
- this is a notification
- ,
- );
-
- expect(getByTestId('settings-notification-label').props.children).toBe(
- 'this is a notification',
- );
- });
-});
diff --git a/app/components/Views/Notifications/NotificationMenuView.testIds.ts b/app/components/Views/Notifications/NotificationMenuView.testIds.ts
index 37cec4388b5..67eb8b3442f 100644
--- a/app/components/Views/Notifications/NotificationMenuView.testIds.ts
+++ b/app/components/Views/Notifications/NotificationMenuView.testIds.ts
@@ -9,6 +9,7 @@ export const NotificationMenuViewSelectorsText = {
export const NotificationMenuViewSelectorsIDs = {
TITLE: 'notification-menu-view-title',
COG_WHEEL: 'notification-menu-view-cog-wheel',
+ CLOSE_BUTTON: 'notification-menu-view-close-button',
ITEM: (id: string) => `notification-menu-view-item-${id}`,
ITEM_LIST_SCROLLVIEW: 'notification-menu-scroll-view',
};
diff --git a/app/components/Views/Notifications/index.test.tsx b/app/components/Views/Notifications/index.test.tsx
index 88ce72c043b..1d3d8647ec9 100644
--- a/app/components/Views/Notifications/index.test.tsx
+++ b/app/components/Views/Notifications/index.test.tsx
@@ -1,10 +1,5 @@
import React from 'react';
-import {
- renderHook,
- act,
- render,
- fireEvent,
-} from '@testing-library/react-native';
+import { renderHook, act, fireEvent } from '@testing-library/react-native';
import { processNotification } from '@metamask/notification-services-controller/notification-services';
import {
createMockNotificationEthSent,
@@ -28,6 +23,7 @@ import NotificationsService from '../../../util/notifications/services/Notificat
import Routes from '../../../constants/navigation/Routes';
import { strings } from '../../../../locales/i18n';
import { NotificationsViewSelectorsIDs } from './NotificationsView.testIds';
+import { NotificationMenuViewSelectorsIDs } from './NotificationMenuView.testIds';
const navigationMock = {
navigate: jest.fn(),
@@ -68,38 +64,51 @@ describe('NotificationsView - header', () => {
});
const arrange = () => {
- const headerPieces = NotificationsView.navigationOptions({
- navigation: navigationMock,
- });
- return headerPieces;
+ const renderResult = renderWithProvider(
+ ,
+ { state: mockInitialState },
+ );
+ return renderResult;
};
it('finds header title', async () => {
- const headerPieces = arrange();
- const headerTitleTestUtils = render(headerPieces.headerTitle());
+ const { getByTestId } = arrange();
expect(
- headerTitleTestUtils.getByText(
- strings('app_settings.notifications_title'),
- ),
- ).toBeTruthy();
+ getByTestId(NotificationMenuViewSelectorsIDs.TITLE).props.children,
+ ).toBe(strings('app_settings.notifications_title'));
});
- it('finds back button and invoke navigation when pressed', async () => {
- const headerPieces = arrange();
- const closeButtonTestUtils = render(headerPieces.headerLeft());
+ it('finds close button and invokes navigation when pressed', async () => {
+ const { getByTestId } = arrange();
- expect(closeButtonTestUtils.root).toBeTruthy();
- await act(() => fireEvent(closeButtonTestUtils.root, 'onPress'));
+ await act(() =>
+ fireEvent.press(
+ getByTestId(NotificationMenuViewSelectorsIDs.CLOSE_BUTTON),
+ ),
+ );
expect(navigationMock.goBack).toHaveBeenCalled();
});
- it('finds settings button and invoke navigation when pressed', async () => {
- const headerPieces = arrange();
- const cogWheelTestUtils = render(headerPieces.headerRight());
+ it('navigates home when close button is pressed without back stack', async () => {
+ (navigationMock.canGoBack as jest.Mock).mockReturnValueOnce(false);
+ const { getByTestId } = arrange();
- expect(cogWheelTestUtils.root).toBeTruthy();
- await act(() => fireEvent(cogWheelTestUtils.root, 'onPress'));
+ await act(() =>
+ fireEvent.press(
+ getByTestId(NotificationMenuViewSelectorsIDs.CLOSE_BUTTON),
+ ),
+ );
+
+ expect(navigationMock.navigate).toHaveBeenCalledWith(Routes.WALLET.HOME);
+ });
+
+ it('finds settings button and invokes navigation when pressed', async () => {
+ const { getByTestId } = arrange();
+
+ await act(() =>
+ fireEvent.press(getByTestId(NotificationMenuViewSelectorsIDs.COG_WHEEL)),
+ );
expect(navigationMock.navigate).toHaveBeenCalledWith(
Routes.SETTINGS.NOTIFICATIONS,
);
diff --git a/app/components/Views/Notifications/index.tsx b/app/components/Views/Notifications/index.tsx
index 7b868319456..fd20b77ab03 100644
--- a/app/components/Views/Notifications/index.tsx
+++ b/app/components/Views/Notifications/index.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
import { useSelector } from 'react-redux';
import { INotification } from '@metamask/notification-services-controller/notification-services';
@@ -8,17 +9,16 @@ import { NotificationsViewSelectorsIDs } from './NotificationsView.testIds';
import styles from './styles';
import Notifications from '../../UI/Notification/List';
import { sortNotifications } from '../../../util/notifications';
-import { IconName } from '../../../component-library/components/Icons/Icon';
+import HeaderCompactStandard from '../../../component-library/components-temp/HeaderCompactStandard';
+import { useTheme } from '../../../util/theme';
import {
Button,
ButtonVariant,
ButtonSize,
+ IconName,
} from '@metamask/design-system-react-native';
-import Text, {
- TextVariant,
-} from '../../../component-library/components/Texts/Text';
import Empty from '../../UI/Notification/Empty';
import { strings } from '../../../../locales/i18n';
import Routes from '../../../constants/navigation/Routes';
@@ -32,9 +32,6 @@ import {
} from '../../../util/notifications/hooks/useNotifications';
import { NavigationProp, ParamListBase } from '@react-navigation/native';
import NotificationsService from '../../../util/notifications/services/NotificationService';
-import ButtonIcon, {
- ButtonIconSizes,
-} from '../../../component-library/components/Buttons/ButtonIcon';
import { MetaMetricsEvents } from '../../../core/Analytics';
import { NotificationMenuViewSelectorsIDs } from './NotificationMenuView.testIds';
@@ -88,6 +85,7 @@ const NotificationsView = ({
}: {
navigation: NavigationProp;
}) => {
+ const { colors } = useTheme();
const { isLoading } = useListNotifications();
const isNotificationEnabled = useSelector(
selectIsMetamaskNotificationsEnabled,
@@ -105,73 +103,71 @@ const NotificationsView = ({
[allNotifications],
);
+ const handleClose = useCallback(() => {
+ if (navigation.canGoBack()) {
+ navigation.goBack();
+ return;
+ }
+
+ navigation.navigate(Routes.WALLET.HOME);
+ }, [navigation]);
+
+ const handleOpenSettings = useCallback(() => {
+ navigation.navigate(Routes.SETTINGS.NOTIFICATIONS);
+ }, [navigation]);
+
return (
-
- {isNotificationEnabled ? (
- <>
-
+
+ {isNotificationEnabled ? (
+ <>
+
+ {!isLoading && unreadCount > 0 && (
+
+ )}
+ >
+ ) : (
+
- {!isLoading && unreadCount > 0 && (
-
- )}
- >
- ) : (
-
- )}
-
+ )}
+
+
);
};
export default NotificationsView;
-
-NotificationsView.navigationOptions = ({
- navigation,
-}: {
- navigation: NavigationProp;
-}) => ({
- headerRight: () => (
- navigation.navigate(Routes.SETTINGS.NOTIFICATIONS)}
- style={styles.icon}
- />
- ),
- headerLeft: () => (
-
- navigation.canGoBack()
- ? navigation.goBack()
- : navigation.navigate(Routes.WALLET.HOME)
- }
- style={styles.icon}
- />
- ),
- headerTitle: () => (
-
- {strings('app_settings.notifications_title')}
-
- ),
-});
diff --git a/app/components/Views/Notifications/styles.ts b/app/components/Views/Notifications/styles.ts
index a803a435637..7ccaa84d2c1 100644
--- a/app/components/Views/Notifications/styles.ts
+++ b/app/components/Views/Notifications/styles.ts
@@ -11,8 +11,6 @@ const styles = StyleSheet.create({
bottom: 40,
position: 'absolute',
},
- icon: { marginHorizontal: 16 },
- title: { alignSelf: 'center' },
});
export default styles;
diff --git a/app/components/Views/Settings/NotificationsSettings/AccountsList.hooks.test.tsx b/app/components/Views/Settings/NotificationsSettings/AccountsList.hooks.test.tsx
index 350445818b6..a40dccebabc 100644
--- a/app/components/Views/Settings/NotificationsSettings/AccountsList.hooks.test.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/AccountsList.hooks.test.tsx
@@ -10,12 +10,14 @@ import {
useAccountProps,
useNotificationAccountListProps,
useNotificationWalletAccountGroups,
+ useWalletActivityAccountSelection,
} from './AccountsList.hooks';
import { selectInternalAccountsById } from '../../../../selectors/accountsController';
import { InternalAccount } from '@metamask/keyring-internal-api';
import { selectAccountGroupsByWallet } from '../../../../selectors/multichainAccounts/accountTreeController';
import { AccountGroupType, AccountWalletType } from '@metamask/account-api';
import { KeyringTypes } from '@metamask/keyring-controller';
+import { toFormattedAddress } from '../../../../util/address';
jest.mock('../../../../selectors/notifications', () => ({
getValidNotificationAccounts: jest.fn(),
@@ -35,6 +37,15 @@ jest.mock(
);
const MOCK_KEYRING_TYPE = 'HD Key Tree' as KeyringTypes;
+const EVM_ADDRESSES = [
+ '0xb2B92547A92C1aC55EAe3F6632Fa1aF87dc05a29',
+ '0x700CcD8172BC3807D893883a730A1E0E6630F8EC',
+ '0xb2B92547A92C1aC55EAe3F6632Fa1aF87dc05a20',
+ '0x700CcD8172BC3807D893883a730A1E0E6630F8E0',
+];
+const FORMATTED_EVM_ADDRESSES = EVM_ADDRESSES.map((address) =>
+ toFormattedAddress(address),
+);
const createNotificationAccountsMap = () =>
({
@@ -431,3 +442,77 @@ describe('useAccountProps', () => {
);
});
});
+
+describe('useWalletActivityAccountSelection', () => {
+ const arrangeMocks = (notificationData: Record) => {
+ arrangeMockUseAccounts();
+ jest
+ .mocked(selectInternalAccountsById)
+ .mockReturnValue(createNotificationAccountsMap());
+ jest.mocked(getValidNotificationAccounts).mockReturnValue(EVM_ADDRESSES);
+
+ const mockUpdate = jest.fn();
+ jest
+ .spyOn(UseSwitchNotificationsModule, 'useFetchAccountNotifications')
+ .mockReturnValue({
+ accountsBeingUpdated: [],
+ data: notificationData,
+ error: null,
+ initialLoading: false,
+ update: mockUpdate,
+ });
+
+ const mockOnToggle = jest.fn();
+ jest
+ .spyOn(UseSwitchNotificationsModule, 'useAccountNotificationsToggle')
+ .mockReturnValue({
+ onToggle: mockOnToggle,
+ error: null,
+ loading: false,
+ });
+
+ return {
+ mockOnToggle,
+ mockUpdate,
+ };
+ };
+
+ beforeEach(() => jest.clearAllMocks());
+
+ it('deselects all visible EVM accounts when any account is enabled', async () => {
+ const mocks = arrangeMocks({
+ [FORMATTED_EVM_ADDRESSES[0]]: true,
+ });
+
+ const { result } = renderHookWithProvider(() =>
+ useWalletActivityAccountSelection(),
+ );
+
+ expect(result.current.hasEnabledAccount).toBe(true);
+
+ await act(async () => result.current.toggleAllAccounts());
+
+ expect(mocks.mockOnToggle).toHaveBeenCalledWith(
+ FORMATTED_EVM_ADDRESSES,
+ false,
+ );
+ expect(mocks.mockUpdate).toHaveBeenCalledWith(EVM_ADDRESSES);
+ });
+
+ it('selects all visible EVM accounts when every account is disabled', async () => {
+ const mocks = arrangeMocks({});
+
+ const { result } = renderHookWithProvider(() =>
+ useWalletActivityAccountSelection(),
+ );
+
+ expect(result.current.hasEnabledAccount).toBe(false);
+
+ await act(async () => result.current.toggleAllAccounts());
+
+ expect(mocks.mockOnToggle).toHaveBeenCalledWith(
+ FORMATTED_EVM_ADDRESSES,
+ true,
+ );
+ });
+});
diff --git a/app/components/Views/Settings/NotificationsSettings/AccountsList.hooks.tsx b/app/components/Views/Settings/NotificationsSettings/AccountsList.hooks.tsx
index 634760724d4..3e30fe71e6a 100644
--- a/app/components/Views/Settings/NotificationsSettings/AccountsList.hooks.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/AccountsList.hooks.tsx
@@ -1,6 +1,9 @@
import { useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
-import { useFetchAccountNotifications } from '../../../../util/notifications/hooks/useSwitchNotifications';
+import {
+ useAccountNotificationsToggle,
+ useFetchAccountNotifications,
+} from '../../../../util/notifications/hooks/useSwitchNotifications';
import { getValidNotificationAccounts } from '../../../../selectors/notifications';
import { toFormattedAddress } from '../../../../util/address';
import { selectAvatarAccountType } from '../../../../selectors/settings';
@@ -120,3 +123,55 @@ export function useAccountProps() {
accountAvatarType,
};
}
+
+export function useWalletActivityAccountSelection() {
+ const accountProps = useAccountProps();
+ const notificationAccountListProps = useNotificationAccountListProps();
+ const { onToggle, loading } = useAccountNotificationsToggle();
+
+ const accountAddresses = useMemo(
+ () =>
+ accountProps.accountWalletGroups.flatMap((walletGroup) =>
+ walletGroup.data
+ .map((accountGroup) =>
+ notificationAccountListProps.getEvmAddress(accountGroup.accounts),
+ )
+ .filter((address): address is string => Boolean(address)),
+ ),
+ [accountProps.accountWalletGroups, notificationAccountListProps],
+ );
+
+ const hasEnabledAccount = useMemo(
+ () =>
+ accountProps.accountWalletGroups.some((walletGroup) =>
+ walletGroup.data.some((accountGroup) =>
+ notificationAccountListProps.isAccountEnabled(accountGroup.accounts),
+ ),
+ ),
+ [accountProps.accountWalletGroups, notificationAccountListProps],
+ );
+
+ const toggleAllAccounts = useCallback(async () => {
+ if (accountAddresses.length === 0) {
+ return;
+ }
+
+ await onToggle(accountAddresses, !hasEnabledAccount);
+ await notificationAccountListProps.refetchAccountSettings();
+ }, [
+ accountAddresses,
+ hasEnabledAccount,
+ notificationAccountListProps,
+ onToggle,
+ ]);
+
+ return {
+ accountProps,
+ notificationAccountListProps,
+ hasEnabledAccount,
+ hasNotificationAccounts: accountAddresses.length > 0,
+ isUpdatingAllAccounts:
+ loading || notificationAccountListProps.shouldDisableSwitches,
+ toggleAllAccounts,
+ };
+}
diff --git a/app/components/Views/Settings/NotificationsSettings/AccountsList.test.tsx b/app/components/Views/Settings/NotificationsSettings/AccountsList.test.tsx
index 9e7f030cd70..e921362e0eb 100644
--- a/app/components/Views/Settings/NotificationsSettings/AccountsList.test.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/AccountsList.test.tsx
@@ -1,8 +1,10 @@
import React from 'react';
import { fireEvent, waitFor } from '@testing-library/react-native';
-import { AccountsList } from './AccountsList';
-// eslint-disable-next-line import-x/no-namespace
-import * as AccountListHooksModule from './AccountsList.hooks';
+import {
+ AccountsList,
+ type AccountProps,
+ type NotificationAccountListProps,
+} from './AccountsList';
import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar';
// eslint-disable-next-line import-x/no-namespace
import * as useSwitchNotificationsModule from '../../../../util/notifications/hooks/useSwitchNotifications';
@@ -84,6 +86,16 @@ const ACCOUNT_3_TEST_ID = {
),
};
+type RenderAccountsListOverrides = Partial<{
+ accountProps: AccountProps;
+ notificationAccountListProps: NotificationAccountListProps;
+}>;
+
+interface RenderAccountsListMocks {
+ accountProps: AccountProps;
+ notificationAccountListProps: NotificationAccountListProps;
+}
+
describe('AccountList', () => {
const arrangeSelectors = () => {
jest
@@ -94,12 +106,6 @@ describe('AccountList', () => {
const arrangeMocks = () => {
arrangeSelectors();
- const createMockAccounts = (addresses: string[]) =>
- addresses.map((address, idx) => ({
- address,
- name: `My Account ${idx}`,
- }));
-
const createMockMultichainAccountGroup = (
idx: number,
accounts: [string, ...string[]],
@@ -150,53 +156,51 @@ describe('AccountList', () => {
`MOCK-ID-FOR-${CHECKSUMMED_ADDRESS_3}`,
);
- const mockUseAccountProps = jest
- .spyOn(AccountListHooksModule, 'useAccountProps')
- .mockReturnValue({
- accountAvatarType: AvatarAccountType.JazzIcon,
- accountWalletGroups: [
- {
- title: 'Wallet 1',
- wallet: {
- id: 'entropy:wallet-1',
- type: AccountWalletType.Entropy,
- metadata: {
- entropy: {
- id: '',
- },
- name: 'Wallet 1',
- },
- status: 'ready',
- groups: {
- [group1.id]: group1,
- [group2.id]: group2,
+ const accountProps: AccountProps = {
+ accountAvatarType: AvatarAccountType.JazzIcon,
+ accountWalletGroups: [
+ {
+ title: 'Wallet 1',
+ wallet: {
+ id: 'entropy:wallet-1',
+ type: AccountWalletType.Entropy,
+ metadata: {
+ entropy: {
+ id: '',
},
+ name: 'Wallet 1',
+ },
+ status: 'ready',
+ groups: {
+ [group1.id]: group1,
+ [group2.id]: group2,
},
- data: [group1, group2],
},
- {
- title: 'Imported wallet',
- wallet: {
- id: 'keyring:wallet-2',
- type: AccountWalletType.Keyring,
- metadata: {
- name: 'Imported wallet',
- keyring: {
- type: MOCK_KEYRING_TYPE,
- },
- },
- status: 'ready',
- groups: {
- [importedGroup.id]: importedGroup,
+ data: [group1, group2],
+ },
+ {
+ title: 'Imported wallet',
+ wallet: {
+ id: 'keyring:wallet-2',
+ type: AccountWalletType.Keyring,
+ metadata: {
+ name: 'Imported wallet',
+ keyring: {
+ type: MOCK_KEYRING_TYPE,
},
},
- data: [importedGroup],
+ status: 'ready',
+ groups: {
+ [importedGroup.id]: importedGroup,
+ },
},
- ],
- });
+ data: [importedGroup],
+ },
+ ],
+ };
const mockRefetchAccountSettings = jest.fn();
- const createUseNotificationAccountListProps = () => ({
+ const notificationAccountListProps: NotificationAccountListProps = {
shouldDisableSwitches: false,
refetchAccountSettings: mockRefetchAccountSettings,
isAccountLoading: jest
@@ -211,16 +215,13 @@ describe('AccountList', () => {
),
getEvmAddress: jest
.fn()
- .mockImplementation((accountIds: string) =>
+ .mockImplementation((accountIds: string[]) =>
accountIds.at(0)?.replace('MOCK-ID-FOR-', ''),
),
- });
- const mockUseNotificationAccountListProps = jest
- .spyOn(AccountListHooksModule, 'useNotificationAccountListProps')
- .mockReturnValue(createUseNotificationAccountListProps());
+ };
const mockOnToggle = jest.fn();
- const mockUseUpdateAccountSettings = jest
+ jest
.spyOn(useSwitchNotificationsModule, 'useAccountNotificationsToggle')
.mockReturnValue({
onToggle: mockOnToggle,
@@ -229,23 +230,32 @@ describe('AccountList', () => {
});
return {
- createMockAccounts,
- mockUseAccountProps,
+ accountProps,
mockRefetchAccountSettings,
- createUseNotificationAccountListProps,
- mockUseNotificationAccountListProps,
+ notificationAccountListProps,
mockOnToggle,
- mockUseUpdateAccountSettings,
};
};
- it('renders correctly', () => {
- arrangeMocks();
- const { getByTestId, queryByTestId } = renderWithProvider(
- ,
+ const renderAccountsList = (
+ mocks: RenderAccountsListMocks,
+ overrides: RenderAccountsListOverrides = {},
+ ) =>
+ renderWithProvider(
+ ,
{ state: initialRootState },
);
+ it('renders correctly', () => {
+ const mocks = arrangeMocks();
+ const { getByTestId, queryByTestId } = renderAccountsList(mocks);
+
// Assert - Items exist
expect(getByTestId(ACCOUNT_1_TEST_ID.item)).toBeOnTheScreen();
expect(getByTestId(ACCOUNT_2_TEST_ID.item)).toBeOnTheScreen();
@@ -263,14 +273,13 @@ describe('AccountList', () => {
it('disables switches during initial data loading', () => {
const mocks = arrangeMocks();
- mocks.mockUseNotificationAccountListProps.mockReturnValue({
- ...mocks.createUseNotificationAccountListProps(),
- shouldDisableSwitches: true,
- isAccountLoading: () => false,
- });
- const { getByTestId } = renderWithProvider(, {
- state: initialRootState,
+ const { getByTestId } = renderAccountsList(mocks, {
+ notificationAccountListProps: {
+ ...mocks.notificationAccountListProps,
+ shouldDisableSwitches: true,
+ isAccountLoading: jest.fn().mockReturnValue(false),
+ },
});
// Assert switches are disabled since we are loading
@@ -286,14 +295,13 @@ describe('AccountList', () => {
it('invokes switch toggle logic when clicked', async () => {
const mocks = arrangeMocks();
- mocks.mockUseNotificationAccountListProps.mockReturnValue({
- ...mocks.createUseNotificationAccountListProps(),
- shouldDisableSwitches: false,
- isAccountLoading: () => false,
- });
- const { getByTestId } = renderWithProvider(, {
- state: initialRootState,
+ const { getByTestId } = renderAccountsList(mocks, {
+ notificationAccountListProps: {
+ ...mocks.notificationAccountListProps,
+ shouldDisableSwitches: false,
+ isAccountLoading: jest.fn().mockReturnValue(false),
+ },
});
// Act
@@ -309,13 +317,12 @@ describe('AccountList', () => {
it('renders nothing when there are no notification wallet groups', () => {
const mocks = arrangeMocks();
- mocks.mockUseAccountProps.mockReturnValue({
- accountAvatarType: AvatarAccountType.JazzIcon,
- accountWalletGroups: [],
- });
- const { queryByTestId } = renderWithProvider(, {
- state: initialRootState,
+ const { queryByTestId } = renderAccountsList(mocks, {
+ accountProps: {
+ accountAvatarType: AvatarAccountType.JazzIcon,
+ accountWalletGroups: [],
+ },
});
expect(queryByTestId(ACCOUNT_1_TEST_ID.item)).not.toBeOnTheScreen();
@@ -323,13 +330,12 @@ describe('AccountList', () => {
it('skips account groups without an EVM address', () => {
const mocks = arrangeMocks();
- mocks.mockUseNotificationAccountListProps.mockReturnValue({
- ...mocks.createUseNotificationAccountListProps(),
- getEvmAddress: jest.fn().mockReturnValue(undefined),
- });
- const { queryByTestId } = renderWithProvider(, {
- state: initialRootState,
+ const { queryByTestId } = renderAccountsList(mocks, {
+ notificationAccountListProps: {
+ ...mocks.notificationAccountListProps,
+ getEvmAddress: jest.fn().mockReturnValue(undefined),
+ },
});
expect(queryByTestId(ACCOUNT_1_TEST_ID.item)).not.toBeOnTheScreen();
diff --git a/app/components/Views/Settings/NotificationsSettings/AccountsList.tsx b/app/components/Views/Settings/NotificationsSettings/AccountsList.tsx
index e383f327a60..84117b2dc7a 100644
--- a/app/components/Views/Settings/NotificationsSettings/AccountsList.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/AccountsList.tsx
@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { SectionList, View } from 'react-native';
-import {
+import type {
useAccountProps,
useNotificationAccountListProps,
} from './AccountsList.hooks';
@@ -11,18 +11,31 @@ import { useTheme } from '../../../../util/theme';
import { useStyles } from '../../../../component-library/hooks';
import styleSheet from './NotificationsSettings.styles';
-export const AccountsList = () => {
+export type AccountProps = ReturnType;
+export type NotificationAccountListProps = ReturnType<
+ typeof useNotificationAccountListProps
+>;
+
+interface AccountsListProps {
+ accountProps: AccountProps;
+ notificationAccountListProps: NotificationAccountListProps;
+}
+
+export const AccountsList = ({
+ accountProps,
+ notificationAccountListProps,
+}: AccountsListProps) => {
const theme = useTheme();
const { styles } = useStyles(styleSheet, { theme });
- const { accountAvatarType, accountWalletGroups } = useAccountProps();
+ const { accountAvatarType, accountWalletGroups } = accountProps;
const {
shouldDisableSwitches,
isAccountLoading,
isAccountEnabled,
refetchAccountSettings,
getEvmAddress,
- } = useNotificationAccountListProps();
+ } = notificationAccountListProps;
const sections = useMemo(
() =>
diff --git a/app/components/Views/Settings/NotificationsSettings/MainNotificationToggle.test.tsx b/app/components/Views/Settings/NotificationsSettings/MainNotificationToggle.test.tsx
index 5aa9a79d6fe..f09e2784246 100644
--- a/app/components/Views/Settings/NotificationsSettings/MainNotificationToggle.test.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/MainNotificationToggle.test.tsx
@@ -1,9 +1,7 @@
import { fireEvent, render, waitFor } from '@testing-library/react-native';
import React from 'react';
import { Linking } from 'react-native';
-import AppConstants from '../../../../core/AppConstants';
import {
- MAIN_NOTIFICATION_TOGGLE_LEARN_MORE_TEST_ID,
MAIN_NOTIFICATION_TOGGLE_TEST_ID,
MainNotificationToggle,
} from './MainNotificationToggle';
@@ -57,20 +55,4 @@ describe('MainNotificationToggle', () => {
expect(mocks.mockOnToggle).toHaveBeenCalled();
});
});
-
- it('opens learn more link', async () => {
- const mocks = arrangeMocks();
- const { getByTestId } = render();
- const learnMoreText = getByTestId(
- MAIN_NOTIFICATION_TOGGLE_LEARN_MORE_TEST_ID,
- );
-
- fireEvent.press(learnMoreText);
-
- await waitFor(() => {
- expect(mocks.mockOpenURL).toHaveBeenCalledWith(
- AppConstants.URLS.PROFILE_SYNC,
- );
- });
- });
});
diff --git a/app/components/Views/Settings/NotificationsSettings/MainNotificationToggle.tsx b/app/components/Views/Settings/NotificationsSettings/MainNotificationToggle.tsx
index de454a329e6..f7409dac1a1 100644
--- a/app/components/Views/Settings/NotificationsSettings/MainNotificationToggle.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/MainNotificationToggle.tsx
@@ -3,19 +3,18 @@ import { useTheme } from '../../../../util/theme';
import { Linking, Switch, View } from 'react-native';
import { strings } from '../../../../../locales/i18n';
-import Text, {
+import {
+ FontWeight,
+ Text,
TextColor,
TextVariant,
-} from '../../../../component-library/components/Texts/Text';
+} from '@metamask/design-system-react-native';
import { useStyles } from '../../../../component-library/hooks';
-import AppConstants from '../../../../core/AppConstants';
import { useMainNotificationToggle } from './MainNotificationToggle.hooks';
import styleSheet from './NotificationsSettings.styles';
import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds';
export const MAIN_NOTIFICATION_TOGGLE_TEST_ID = 'main-notification-toggle';
-export const MAIN_NOTIFICATION_TOGGLE_LEARN_MORE_TEST_ID =
- 'main-notification-toggle--learn-more-button';
export const MainNotificationToggle = () => {
const theme = useTheme();
@@ -23,17 +22,20 @@ export const MainNotificationToggle = () => {
const { onToggle, value } = useMainNotificationToggle();
- const goToLearnMore = useCallback(() => {
- Linking.openURL(AppConstants.URLS.PROFILE_SYNC);
- }, []);
-
return (
<>
+
+ {strings('app_settings.allow_notifications_desc')}
+
-
+
{strings('app_settings.allow_notifications')}
{
testID={NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE}
/>
-
-
- {strings('app_settings.allow_notifications_desc')}{' '}
-
- {strings('notifications.activation_card.learn_more')}
-
-
-
>
);
};
diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationSettingsSection.test.tsx b/app/components/Views/Settings/NotificationsSettings/NotificationSettingsSection.test.tsx
new file mode 100644
index 00000000000..fc17b1698e6
--- /dev/null
+++ b/app/components/Views/Settings/NotificationsSettings/NotificationSettingsSection.test.tsx
@@ -0,0 +1,163 @@
+import React from 'react';
+import { StackActions } from '@react-navigation/native';
+import { fireEvent, screen, waitFor } from '@testing-library/react-native';
+import renderWithProvider from '../../../../util/test/renderWithProvider';
+import Routes from '../../../../constants/navigation/Routes';
+import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds';
+import NotificationSettingsSection, {
+ type NotificationSettingsSectionProps,
+} from './NotificationSettingsSection';
+
+const mockDispatch = jest.fn();
+const mockGoBack = jest.fn();
+const mockUpdatePreference = jest.fn();
+const mockToggleAllAccounts = jest.fn();
+let mockIsMetamaskNotificationsEnabled = true;
+let mockHasEnabledAccount = true;
+let mockHasNotificationAccounts = true;
+let mockIsUpdatingAllAccounts = false;
+
+const mockPreferences = {
+ walletActivity: {
+ pushNotificationsEnabled: true,
+ inAppNotificationsEnabled: true,
+ accounts: [],
+ },
+ perps: {
+ pushNotificationsEnabled: true,
+ inAppNotificationsEnabled: true,
+ },
+ socialAI: {
+ pushNotificationsEnabled: true,
+ inAppNotificationsEnabled: true,
+ txAmountLimit: 500,
+ mutedTraderProfileIds: [],
+ },
+ marketing: {
+ pushNotificationsEnabled: false,
+ inAppNotificationsEnabled: false,
+ },
+};
+
+jest.mock('../../../../selectors/notifications', () => ({
+ selectIsMetamaskNotificationsEnabled: () =>
+ mockIsMetamaskNotificationsEnabled,
+}));
+
+jest.mock('./hooks/useNotificationStoragePreferences', () => ({
+ useNotificationStoragePreferences: () => ({
+ preferences: mockPreferences,
+ updatePreference: mockUpdatePreference,
+ }),
+}));
+
+jest.mock('./SocialAINotificationPreferencesContent', () => () => null);
+jest.mock('./AccountsList', () => ({
+ AccountsList: () => null,
+}));
+jest.mock('./AccountsList.hooks', () => ({
+ useWalletActivityAccountSelection: () => ({
+ accountProps: {},
+ notificationAccountListProps: {},
+ hasEnabledAccount: mockHasEnabledAccount,
+ hasNotificationAccounts: mockHasNotificationAccounts,
+ isUpdatingAllAccounts: mockIsUpdatingAllAccounts,
+ toggleAllAccounts: mockToggleAllAccounts,
+ }),
+}));
+
+const marketingDisclaimer =
+ 'By turning this on, you agree to receive product news and marketing updates from MetaMask.';
+
+const renderSection = (
+ params: NotificationSettingsSectionProps['route']['params'] = {
+ type: 'socialAI',
+ title: 'Trading Signals',
+ description: 'SocialAI notification preferences',
+ },
+) =>
+ renderWithProvider(
+ ,
+ );
+
+describe('NotificationSettingsSection', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsMetamaskNotificationsEnabled = true;
+ mockHasEnabledAccount = true;
+ mockHasNotificationAccounts = true;
+ mockIsUpdatingAllAccounts = false;
+ });
+
+ it('renders section preferences when global notifications are enabled', () => {
+ renderSection();
+
+ expect(screen.getByText('Notifications')).toBeOnTheScreen();
+ expect(screen.getByText('Trading Signals')).toBeOnTheScreen();
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it('renders the marketing disclaimer for marketing preferences', () => {
+ renderSection({
+ type: 'marketing',
+ title: 'Updates and Rewards',
+ description: 'Product updates, feature announcements, and new releases',
+ });
+
+ expect(screen.getByText(marketingDisclaimer)).toBeOnTheScreen();
+ });
+
+ it('renders a wallet activity deselect all button when any account is enabled', () => {
+ renderSection({
+ type: 'walletActivity',
+ title: 'Wallet Activity',
+ description: 'Buy, sells, transfers, swaps and rewards',
+ });
+
+ const button = screen.getByTestId(
+ NotificationSettingsViewSelectorsIDs.ACCOUNT_NOTIFICATIONS_SELECT_ALL,
+ );
+ expect(screen.getByText('Deselect all')).toBeOnTheScreen();
+
+ fireEvent.press(button);
+
+ expect(mockToggleAllAccounts).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders a wallet activity select all button when every account is disabled', () => {
+ mockHasEnabledAccount = false;
+
+ renderSection({
+ type: 'walletActivity',
+ title: 'Wallet Activity',
+ description: 'Buy, sells, transfers, swaps and rewards',
+ });
+
+ expect(screen.getByText('Select all')).toBeOnTheScreen();
+ });
+
+ it('redirects to notification settings when global notifications are disabled', async () => {
+ mockIsMetamaskNotificationsEnabled = false;
+
+ renderSection();
+
+ await waitFor(() => {
+ expect(mockDispatch).toHaveBeenCalledWith(
+ StackActions.replace(Routes.SETTINGS.NOTIFICATIONS),
+ );
+ });
+ expect(screen.queryByText('Trading Signals')).toBeNull();
+ });
+});
diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationSettingsSection.tsx b/app/components/Views/Settings/NotificationsSettings/NotificationSettingsSection.tsx
new file mode 100644
index 00000000000..c8804ea5718
--- /dev/null
+++ b/app/components/Views/Settings/NotificationsSettings/NotificationSettingsSection.tsx
@@ -0,0 +1,253 @@
+import {
+ NavigationProp,
+ ParamListBase,
+ RouteProp,
+ StackActions,
+} from '@react-navigation/native';
+import React, { useEffect } from 'react';
+import { ScrollView, Switch, TouchableOpacity, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useSelector } from 'react-redux';
+import { useTheme } from '../../../../util/theme';
+import { useStyles } from '../../../../component-library/hooks';
+import styleSheet from './NotificationsSettings.styles';
+import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard';
+import {
+ Text,
+ TextColor,
+ TextVariant,
+ FontWeight,
+} from '@metamask/design-system-react-native';
+import {
+ useNotificationStoragePreferences,
+ type NotificationStoragePreferenceSection,
+} from './hooks/useNotificationStoragePreferences';
+import { AccountsList } from './AccountsList';
+import { strings } from '../../../../../locales/i18n';
+import SocialAINotificationPreferencesContent from './SocialAINotificationPreferencesContent';
+import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications';
+import Routes from '../../../../constants/navigation/Routes';
+import { useWalletActivityAccountSelection } from './AccountsList.hooks';
+import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds';
+
+type NotificationSettingsStyles = ReturnType;
+
+interface SectionContentProps {
+ styles: NotificationSettingsStyles;
+}
+
+const WalletActivitySectionContent = ({ styles }: SectionContentProps) => {
+ const {
+ accountProps,
+ notificationAccountListProps,
+ hasEnabledAccount,
+ hasNotificationAccounts,
+ isUpdatingAllAccounts,
+ toggleAllAccounts,
+ } = useWalletActivityAccountSelection();
+
+ return (
+ <>
+
+
+
+
+ {strings('app_settings.notifications_opts.select_accounts_title')}
+
+ {hasNotificationAccounts ? (
+
+
+ {strings(
+ hasEnabledAccount
+ ? 'app_settings.notifications_opts.deselect_all'
+ : 'app_settings.notifications_opts.select_all',
+ )}
+
+
+ ) : null}
+
+
+ {strings('app_settings.notifications_opts.select_accounts_desc')}
+
+
+
+ >
+ );
+};
+
+const SocialAISectionContent = ({ styles }: SectionContentProps) => (
+ <>
+
+
+ >
+);
+
+const MarketingSectionContent = ({ styles }: SectionContentProps) => (
+
+
+ {strings('app_settings.notifications_opts.marketing_disclaimer')}
+
+
+);
+
+const SECTION_CONTENT_BY_TYPE: Partial<
+ Record<
+ NotificationStoragePreferenceSection,
+ React.ComponentType
+ >
+> = {
+ walletActivity: WalletActivitySectionContent,
+ socialAI: SocialAISectionContent,
+ marketing: MarketingSectionContent,
+};
+
+export interface NotificationSettingsSectionProps {
+ navigation: NavigationProp;
+ route: RouteProp<
+ {
+ params: {
+ type: NotificationStoragePreferenceSection;
+ title: string;
+ description: string;
+ };
+ },
+ 'params'
+ >;
+}
+
+const NotificationSettingsSection = ({
+ navigation,
+ route,
+}: NotificationSettingsSectionProps) => {
+ const theme = useTheme();
+ const { styles } = useStyles(styleSheet, { theme });
+ const { type, title, description } = route.params;
+
+ const isMetamaskNotificationsEnabled = useSelector(
+ selectIsMetamaskNotificationsEnabled,
+ );
+ const { preferences, updatePreference } = useNotificationStoragePreferences();
+
+ useEffect(() => {
+ if (!isMetamaskNotificationsEnabled) {
+ navigation.dispatch(StackActions.replace(Routes.SETTINGS.NOTIFICATIONS));
+ }
+ }, [isMetamaskNotificationsEnabled, navigation]);
+
+ if (!isMetamaskNotificationsEnabled || !preferences) {
+ return null;
+ }
+
+ const sectionPrefs = preferences[type];
+ const SectionContent = SECTION_CONTENT_BY_TYPE[type];
+
+ return (
+
+ navigation.goBack()}
+ />
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+
+ {strings('app_settings.notifications_opts.push_recommended')}
+
+
+ updatePreference(
+ type,
+ 'pushNotificationsEnabled',
+ !sectionPrefs.pushNotificationsEnabled,
+ )
+ }
+ trackColor={{
+ true: theme.colors.primary.default,
+ false: theme.colors.border.muted,
+ }}
+ thumbColor={theme.brandColors.white}
+ style={styles.switch}
+ ios_backgroundColor={theme.colors.border.muted}
+ />
+
+
+
+
+ {strings('app_settings.notifications_opts.in_app')}
+
+
+ updatePreference(
+ type,
+ 'inAppNotificationsEnabled',
+ !sectionPrefs.inAppNotificationsEnabled,
+ )
+ }
+ trackColor={{
+ true: theme.colors.primary.default,
+ false: theme.colors.border.muted,
+ }}
+ thumbColor={theme.brandColors.white}
+ style={styles.switch}
+ ios_backgroundColor={theme.colors.border.muted}
+ />
+
+
+ {SectionContent ? : null}
+
+
+ );
+};
+
+export default NotificationSettingsSection;
diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationSettingsView.testIds.ts b/app/components/Views/Settings/NotificationsSettings/NotificationSettingsView.testIds.ts
index 6c067138888..536cf95263e 100644
--- a/app/components/Views/Settings/NotificationsSettings/NotificationSettingsView.testIds.ts
+++ b/app/components/Views/Settings/NotificationsSettings/NotificationSettingsView.testIds.ts
@@ -14,6 +14,8 @@ export const NotificationSettingsViewSelectorsIDs = {
'notification-settings-feature-announcements-separator',
PERPS_NOTIFICATIONS_TOGGLE:
'notification-settings-perps-notifications-toggle',
+ ACCOUNT_NOTIFICATIONS_SELECT_ALL:
+ 'notification-settings-account-notifications-select-all',
ACCOUNT_NOTIFICATION_TOGGLE: (address: string) =>
`notification-settings-account-notifications-${address}`,
};
diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.styles.ts b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.styles.ts
index e5de22cc426..24ae16e3efd 100644
--- a/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.styles.ts
+++ b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.styles.ts
@@ -4,6 +4,10 @@ import { Theme } from '../../../../util/theme/models';
const styleSheet = (params: { theme: Theme }) =>
StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: params.theme.colors.background.default,
+ },
container: {
flex: 1,
paddingLeft: 16,
@@ -11,10 +15,13 @@ const styleSheet = (params: { theme: Theme }) =>
paddingBottom: 48,
backgroundColor: params.theme.colors.background.default,
},
+ contentContainer: {
+ flexGrow: 1,
+ },
line: {
borderTopWidth: 1,
borderTopColor: params.theme.colors.border.muted,
- marginVertical: 16,
+ marginTop: 16,
marginHorizontal: -16,
},
heading: {
@@ -29,6 +36,22 @@ const styleSheet = (params: { theme: Theme }) =>
productAnnouncementContainer: {
marginTop: 16,
},
+ walletActivityHeader: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ selectAllButton: {
+ marginLeft: 16,
+ },
+ marketingDisclaimer: {
+ marginTop: 'auto',
+ paddingTop: 16,
+ paddingBottom: 48,
+ },
+ marketingDisclaimerText: {
+ textAlign: 'center',
+ },
accountHeader: {
marginTop: 16,
marginLeft: -16,
diff --git a/app/components/Views/Settings/NotificationsSettings/SocialAINotificationPreferencesContent.tsx b/app/components/Views/Settings/NotificationsSettings/SocialAINotificationPreferencesContent.tsx
new file mode 100644
index 00000000000..c0b835dd7fd
--- /dev/null
+++ b/app/components/Views/Settings/NotificationsSettings/SocialAINotificationPreferencesContent.tsx
@@ -0,0 +1,348 @@
+import React, { useCallback } from 'react';
+import { Image, Switch, TouchableOpacity, View } from 'react-native';
+import { useNavigation, type NavigationProp } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import {
+ AvatarBase,
+ AvatarBaseSize,
+ Box,
+ BoxAlignItems,
+ BoxFlexDirection,
+ BoxJustifyContent,
+ FontWeight,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+import Routes from '../../../../constants/navigation/Routes';
+import type { RootStackParamList } from '../../../../core/NavigationService/types';
+import { selectCurrentCurrency } from '../../../../selectors/currencyRateController';
+import { selectSocialLeaderboardEnabled } from '../../../../selectors/featureFlagController/socialLeaderboard';
+import {
+ fireSwitchHaptic,
+ ImpactFeedbackStyle,
+ ImpactMoment,
+ playImpact,
+} from '../../../../util/haptics';
+import { useTheme } from '../../../../util/theme';
+import { strings } from '../../../../../locales/i18n';
+import ErrorState from '../../Homepage/components/ErrorState/ErrorState';
+import {
+ DEFAULT_TX_AMOUNT_LIMIT,
+ useFollowedTraders,
+ useNotificationPreferences,
+ type TxAmountThreshold,
+} from '../../SocialLeaderboard/NotificationPreferences/hooks';
+import {
+ PreferencesSkeleton,
+ TradersFollowedSkeleton,
+} from '../../SocialLeaderboard/NotificationPreferences/components/Skeletons';
+import ThresholdRadioList from '../../SocialLeaderboard/NotificationPreferences/components/ThresholdRadioList';
+import { NotificationPreferencesSelectorsIDs } from '../../SocialLeaderboard/NotificationPreferences/NotificationPreferences.testIds';
+
+const AVATAR_SIZE = 40;
+
+interface TraderNotificationRowProps {
+ traderId: string;
+ username: string;
+ avatarUri?: string;
+ isEnabled: boolean;
+ isDisabled: boolean;
+ withHorizontalPadding: boolean;
+ onToggle: (traderId: string) => void;
+ onPress: (traderId: string, username: string) => void;
+}
+
+const TraderNotificationRow: React.FC = ({
+ traderId,
+ username,
+ avatarUri,
+ isEnabled,
+ isDisabled,
+ withHorizontalPadding,
+ onToggle,
+ onPress,
+}) => {
+ const tw = useTailwind();
+ const { colors, brandColors } = useTheme();
+
+ return (
+
+ onPress(traderId, username)}
+ accessibilityRole="button"
+ style={tw.style('flex-row items-center gap-3 flex-1 min-w-0 mr-3')}
+ testID={NotificationPreferencesSelectorsIDs.TRADER_PRESS(traderId)}
+ >
+ {avatarUri ? (
+
+ ) : (
+
+ )}
+
+
+ {username}
+
+
+
+ onToggle(traderId)}
+ disabled={isDisabled}
+ trackColor={{
+ true: colors.primary.default,
+ false: colors.border.muted,
+ }}
+ thumbColor={brandColors.white}
+ ios_backgroundColor={colors.border.muted}
+ testID={NotificationPreferencesSelectorsIDs.TRADER_TOGGLE(traderId)}
+ />
+
+ );
+};
+
+export interface SocialAINotificationPreferencesContentProps {
+ showPushToggle?: boolean;
+ withHorizontalPadding?: boolean;
+}
+
+const SocialAINotificationPreferencesContent = ({
+ showPushToggle = true,
+ withHorizontalPadding = true,
+}: SocialAINotificationPreferencesContentProps) => {
+ const navigation = useNavigation>();
+ const tw = useTailwind();
+ const { colors, brandColors } = useTheme();
+ const isEnabled = useSelector(selectSocialLeaderboardEnabled);
+ const currentCurrency = useSelector(selectCurrentCurrency);
+
+ const {
+ traders: followedTraders,
+ isLoading: isLoadingFollowed,
+ error: followedError,
+ refresh: refreshFollowed,
+ } = useFollowedTraders({ enabled: isEnabled });
+
+ const {
+ preferences,
+ isLoading: isLoadingPreferences,
+ setPushNotificationsEnabled,
+ setTxAmountLimit,
+ toggleTraderNotification,
+ isTraderNotificationEnabled,
+ } = useNotificationPreferences();
+
+ const handleTraderPress = useCallback(
+ (traderId: string, traderName: string) => {
+ navigation.navigate(Routes.SOCIAL_LEADERBOARD.PROFILE, {
+ traderId,
+ traderName,
+ });
+ },
+ [navigation],
+ );
+
+ const showPreferencesSkeleton = isLoadingPreferences;
+ const pushNotificationsOff =
+ isLoadingPreferences || !preferences.pushNotificationsEnabled;
+
+ const handleSetEnabled = useCallback(
+ (value: boolean) => {
+ if (isLoadingPreferences) {
+ return Promise.resolve();
+ }
+ fireSwitchHaptic(ImpactFeedbackStyle.Medium, { override: true });
+ return setPushNotificationsEnabled(value);
+ },
+ [isLoadingPreferences, setPushNotificationsEnabled],
+ );
+
+ const handleToggleTrader = useCallback(
+ (traderId: string) => {
+ if (pushNotificationsOff) {
+ return Promise.resolve();
+ }
+ fireSwitchHaptic(ImpactFeedbackStyle.Light);
+ return toggleTraderNotification(traderId);
+ },
+ [pushNotificationsOff, toggleTraderNotification],
+ );
+
+ const handleSetTxAmountLimit = useCallback(
+ (value: TxAmountThreshold) => {
+ if (pushNotificationsOff || value === preferences.txAmountLimit) {
+ return Promise.resolve();
+ }
+ playImpact(ImpactMoment.QuickAmountSelection);
+ return setTxAmountLimit(value);
+ },
+ [pushNotificationsOff, preferences.txAmountLimit, setTxAmountLimit],
+ );
+
+ const showFollowedError =
+ Boolean(followedError) && followedTraders.length === 0;
+ const showFollowedLoading =
+ !showFollowedError && isLoadingFollowed && followedTraders.length === 0;
+ const showFollowedEmpty =
+ !showFollowedError && !isLoadingFollowed && followedTraders.length === 0;
+ const horizontalPaddingClassName = withHorizontalPadding ? 'px-4' : '';
+ const separatorStyle = tw.style(
+ `h-px bg-muted${withHorizontalPadding ? ' mx-4' : ''}`,
+ );
+
+ return (
+ <>
+ {showPreferencesSkeleton ? (
+
+ ) : (
+ <>
+ {showPushToggle ? (
+ <>
+
+
+ {strings(
+ 'social_leaderboard.notification_preferences.allow_push_notifications',
+ )}
+
+
+
+
+
+ >
+ ) : null}
+
+
+ >
+ )}
+
+
+
+ {strings(
+ 'social_leaderboard.notification_preferences.traders_you_follow',
+ )}
+
+
+ {strings(
+ 'social_leaderboard.notification_preferences.traders_you_follow_desc',
+ )}
+
+
+
+ {showFollowedError ? (
+
+
+
+ ) : showFollowedLoading ? (
+
+
+
+ ) : showFollowedEmpty ? (
+
+
+ {strings(
+ 'social_leaderboard.notification_preferences.traders_you_follow_empty',
+ )}
+
+
+ ) : (
+ followedTraders.map((trader) => (
+
+ ))
+ )}
+ >
+ );
+};
+
+export default SocialAINotificationPreferencesContent;
diff --git a/app/components/Views/Settings/NotificationsSettings/hooks/useNotificationStoragePreferences.test.ts b/app/components/Views/Settings/NotificationsSettings/hooks/useNotificationStoragePreferences.test.ts
new file mode 100644
index 00000000000..ecf283a92ec
--- /dev/null
+++ b/app/components/Views/Settings/NotificationsSettings/hooks/useNotificationStoragePreferences.test.ts
@@ -0,0 +1,212 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { useQuery } from '@metamask/react-data-query';
+import { useQueryClient } from '@tanstack/react-query';
+import { useSelector } from 'react-redux';
+import type { NotificationPreferences } from '@metamask/authenticated-user-storage';
+import Engine from '../../../../../core/Engine';
+import Logger from '../../../../../util/Logger';
+import { useNotificationStoragePreferences } from './useNotificationStoragePreferences';
+
+jest.mock('@metamask/react-data-query');
+
+jest.mock('@tanstack/react-query', () => ({
+ useQueryClient: jest.fn(),
+}));
+
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(),
+}));
+
+jest.mock('../../../../../core/Engine', () => ({
+ controllerMessenger: {
+ call: jest.fn(),
+ },
+}));
+
+jest.mock('../../../../../util/Logger', () => ({
+ error: jest.fn(),
+}));
+
+jest.mock('../../../../../selectors/accountsController', () => ({
+ selectSelectedInternalAccountId: jest.fn(),
+}));
+
+const MOCK_ACCOUNT_ID = 'account-1';
+const GET_ACTION = 'AuthenticatedUserStorageService:getNotificationPreferences';
+const PUT_ACTION = 'AuthenticatedUserStorageService:putNotificationPreferences';
+const CLIENT_TYPE = 'mobile';
+
+const mockUseQuery = useQuery as jest.MockedFunction;
+const mockUseQueryClient = useQueryClient as jest.MockedFunction<
+ typeof useQueryClient
+>;
+const mockUseSelector = useSelector as jest.MockedFunction;
+const mockSetQueryData = jest.fn();
+const mockRefetch = jest.fn();
+const mockCall = Engine.controllerMessenger.call as jest.Mock;
+
+type QueryDataUpdater = (
+ previousPreferences: NotificationPreferences | null | undefined,
+) => NotificationPreferences;
+
+const buildPreferences = (
+ overrides: Partial = {},
+): NotificationPreferences => ({
+ walletActivity: {
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
+ accounts: [],
+ },
+ marketing: {
+ inAppNotificationsEnabled: false,
+ pushNotificationsEnabled: false,
+ },
+ perps: {
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
+ },
+ socialAI: {
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
+ txAmountLimit: 500,
+ mutedTraderProfileIds: [],
+ },
+ ...overrides,
+});
+
+type QueryResult = ReturnType;
+
+const makeQueryResult = (
+ overrides: Partial<
+ Omit & { isLoading: boolean }
+ > = {},
+): QueryResult =>
+ ({
+ data: undefined,
+ isLoading: false,
+ error: null,
+ refetch: mockRefetch,
+ ...overrides,
+ }) as ReturnType;
+
+describe('useNotificationStoragePreferences', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseSelector.mockReturnValue(MOCK_ACCOUNT_ID);
+ mockUseQuery.mockReturnValue(makeQueryResult());
+ mockUseQueryClient.mockReturnValue({
+ setQueryData: mockSetQueryData,
+ } as unknown as ReturnType);
+ mockCall.mockResolvedValue(undefined);
+ mockRefetch.mockResolvedValue(undefined);
+ });
+
+ it('scopes the query to the active account and exposes query state', () => {
+ const preferences = buildPreferences();
+ const error = new Error('fetch failed');
+ mockUseQuery.mockReturnValue(
+ makeQueryResult({ data: preferences, isLoading: true, error }),
+ );
+
+ const { result } = renderHook(() => useNotificationStoragePreferences());
+
+ expect(mockUseQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: [GET_ACTION, MOCK_ACCOUNT_ID],
+ }),
+ );
+ expect(result.current.preferences).toBe(preferences);
+ expect(result.current.hasNotificationPreferences).toBe(true);
+ expect(result.current.isLoading).toBe(true);
+ expect(result.current.error).toBe(error);
+ });
+
+ it('persists an updated channel key with a read-merge-write payload', async () => {
+ const cachedPreferences = buildPreferences();
+ const latestPreferences = buildPreferences({
+ walletActivity: {
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
+ accounts: [{ address: '0xabc', enabled: true }],
+ },
+ marketing: {
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
+ },
+ });
+ mockUseQuery.mockReturnValue(makeQueryResult({ data: cachedPreferences }));
+ mockCall.mockImplementation(async (action: string) => {
+ if (action === GET_ACTION) {
+ return latestPreferences;
+ }
+ return undefined;
+ });
+
+ const { result } = renderHook(() => useNotificationStoragePreferences());
+
+ await act(async () => {
+ await result.current.updatePreference(
+ 'perps',
+ 'pushNotificationsEnabled',
+ false,
+ );
+ });
+
+ const [queryKey, updater] = mockSetQueryData.mock.calls[0];
+ expect(queryKey).toEqual([GET_ACTION, MOCK_ACCOUNT_ID]);
+ expect((updater as QueryDataUpdater)(latestPreferences)).toEqual({
+ ...latestPreferences,
+ perps: {
+ ...cachedPreferences.perps,
+ pushNotificationsEnabled: false,
+ },
+ });
+ expect(mockCall).toHaveBeenCalledWith(GET_ACTION);
+ expect(mockCall).toHaveBeenCalledWith(
+ PUT_ACTION,
+ {
+ ...latestPreferences,
+ perps: {
+ ...cachedPreferences.perps,
+ pushNotificationsEnabled: false,
+ },
+ },
+ CLIENT_TYPE,
+ );
+ });
+
+ it('refetches and rethrows when persistence fails', async () => {
+ const persistError = new Error('network down');
+ mockUseQuery.mockReturnValue(makeQueryResult({ data: buildPreferences() }));
+ mockCall.mockImplementation(async (action: string) => {
+ if (action === GET_ACTION) {
+ return buildPreferences();
+ }
+ if (action === PUT_ACTION) {
+ throw persistError;
+ }
+ return undefined;
+ });
+ const { result } = renderHook(() => useNotificationStoragePreferences());
+ let thrownError: unknown;
+
+ await act(async () => {
+ try {
+ await result.current.updatePreference(
+ 'marketing',
+ 'inAppNotificationsEnabled',
+ true,
+ );
+ } catch (error) {
+ thrownError = error;
+ }
+ });
+
+ expect(thrownError).toBe(persistError);
+ expect(mockRefetch).toHaveBeenCalledTimes(1);
+ expect(Logger.error).toHaveBeenCalledWith(
+ persistError,
+ 'Failed to persist notification preferences',
+ );
+ });
+});
diff --git a/app/components/Views/Settings/NotificationsSettings/hooks/useNotificationStoragePreferences.ts b/app/components/Views/Settings/NotificationsSettings/hooks/useNotificationStoragePreferences.ts
new file mode 100644
index 00000000000..ab45e1b2561
--- /dev/null
+++ b/app/components/Views/Settings/NotificationsSettings/hooks/useNotificationStoragePreferences.ts
@@ -0,0 +1,141 @@
+import { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { useQuery } from '@metamask/react-data-query';
+import { useQueryClient } from '@tanstack/react-query';
+import type { AuthenticatedUserStorageServiceGetNotificationPreferencesAction } from '@metamask/authenticated-user-storage';
+import Engine from '../../../../../core/Engine';
+import Logger from '../../../../../util/Logger';
+import { selectSelectedInternalAccountId } from '../../../../../selectors/accountsController';
+
+const CLIENT_TYPE = 'mobile' as const;
+const GET_ACTION =
+ 'AuthenticatedUserStorageService:getNotificationPreferences' as const;
+const PUT_ACTION =
+ 'AuthenticatedUserStorageService:putNotificationPreferences' as const;
+
+type NotificationStoragePreferencesResult = Awaited<
+ ReturnType<
+ AuthenticatedUserStorageServiceGetNotificationPreferencesAction['handler']
+ >
+>;
+export type NotificationStoragePreferences =
+ NonNullable;
+export type NotificationStoragePreferenceSection =
+ keyof NotificationStoragePreferences;
+export type NotificationStoragePreferenceChannelKey =
+ | 'pushNotificationsEnabled'
+ | 'inAppNotificationsEnabled';
+
+export const useNotificationStoragePreferences = () => {
+ const selectedAccountId =
+ useSelector(selectSelectedInternalAccountId) ?? 'anonymous';
+
+ const { data, isLoading, error, refetch } =
+ useQuery({
+ queryKey: [GET_ACTION, selectedAccountId],
+ });
+ const queryClient = useQueryClient();
+
+ const enqueuePersist = useCallback(
+ async <
+ PreferenceType extends
+ NotificationStoragePreferenceSection = NotificationStoragePreferenceSection,
+ >(
+ nextPreferences: NotificationStoragePreferences,
+ updatedType?: PreferenceType,
+ ) => {
+ try {
+ const latest = await Engine.controllerMessenger.call(GET_ACTION);
+ const preferencesToPersist: NotificationStoragePreferences = {
+ ...(latest ?? nextPreferences),
+ ...(updatedType
+ ? { [updatedType]: nextPreferences[updatedType] }
+ : nextPreferences),
+ };
+
+ await Engine.controllerMessenger.call(
+ PUT_ACTION,
+ preferencesToPersist,
+ CLIENT_TYPE,
+ );
+ } catch (err) {
+ Logger.error(
+ err as Error,
+ 'Failed to persist notification preferences',
+ );
+ throw err;
+ }
+ },
+ [],
+ );
+
+ const updatePreferencesSection = useCallback(
+ async (
+ type: PreferenceType,
+ nextSectionPreferences: NotificationStoragePreferences[PreferenceType],
+ ) => {
+ if (!data) {
+ Logger.error(
+ new Error(
+ `No notification preferences found when updating ${type} section, enable notifications first`,
+ ),
+ );
+ return;
+ }
+
+ const nextPreferences = {
+ ...data,
+ [type]: nextSectionPreferences,
+ } as NotificationStoragePreferences;
+
+ queryClient.setQueryData(
+ [GET_ACTION, selectedAccountId],
+ (previousPreferences) =>
+ ({
+ ...(previousPreferences ?? nextPreferences),
+ [type]: nextSectionPreferences,
+ }) as NotificationStoragePreferences,
+ );
+
+ try {
+ await enqueuePersist(nextPreferences, type);
+ } catch (err) {
+ refetch();
+ throw err;
+ }
+ },
+ [data, enqueuePersist, queryClient, selectedAccountId, refetch],
+ );
+
+ const updatePreference = useCallback(
+ async (
+ type: NotificationStoragePreferenceSection,
+ key: NotificationStoragePreferenceChannelKey,
+ value: boolean,
+ ) => {
+ if (!data) {
+ Logger.error(
+ new Error(
+ 'No notification preferences found when updating preference, enable notifications first',
+ ),
+ );
+ return;
+ }
+
+ await updatePreferencesSection(type, {
+ ...data[type],
+ [key]: value,
+ });
+ },
+ [data, updatePreferencesSection],
+ );
+
+ return {
+ preferences: data,
+ hasNotificationPreferences: data !== null && data !== undefined,
+ isLoading,
+ error,
+ updatePreference,
+ updatePreferencesSection,
+ };
+};
diff --git a/app/components/Views/Settings/NotificationsSettings/index.test.tsx b/app/components/Views/Settings/NotificationsSettings/index.test.tsx
index 6a528b3712d..b22c37426c6 100644
--- a/app/components/Views/Settings/NotificationsSettings/index.test.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/index.test.tsx
@@ -42,6 +42,40 @@ jest.mock(
jest.mock('../../../UI/Notification/SwitchLoadingModal', () => () => null);
+jest.mock('./hooks/useNotificationStoragePreferences', () => ({
+ useNotificationStoragePreferences: () => ({
+ preferences: {
+ walletActivity: {
+ pushNotificationsEnabled: false,
+ inAppNotificationsEnabled: false,
+ },
+ perps: {
+ pushNotificationsEnabled: false,
+ inAppNotificationsEnabled: false,
+ },
+ socialAI: {
+ pushNotificationsEnabled: false,
+ inAppNotificationsEnabled: false,
+ },
+ marketing: {
+ pushNotificationsEnabled: false,
+ inAppNotificationsEnabled: false,
+ },
+ card: {
+ pushNotificationsEnabled: false,
+ inAppNotificationsEnabled: false,
+ },
+ securityAlerts: {
+ pushNotificationsEnabled: false,
+ inAppNotificationsEnabled: false,
+ },
+ },
+ isLoading: false,
+ error: null,
+ updatePreference: jest.fn(),
+ }),
+}));
+
const setOptions = jest.fn();
describe('NotificationsSettings', () => {
diff --git a/app/components/Views/Settings/NotificationsSettings/index.tsx b/app/components/Views/Settings/NotificationsSettings/index.tsx
index d50e679962e..8939294577b 100644
--- a/app/components/Views/Settings/NotificationsSettings/index.tsx
+++ b/app/components/Views/Settings/NotificationsSettings/index.tsx
@@ -1,145 +1,192 @@
-/* eslint-disable react/display-name */
-import { NavigationProp, ParamListBase } from '@react-navigation/native';
-import React, { useEffect } from 'react';
-import { ScrollView, View } from 'react-native';
+import React from 'react';
+import { ScrollView, TouchableOpacity } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
import { useSelector } from 'react-redux';
import { strings } from '../../../../../locales/i18n';
import { useTheme } from '../../../../util/theme';
import { useStyles } from '../../../../component-library/hooks';
-import { getNavigationOptionsTitle } from '../../../UI/Navbar';
+import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard';
import SwitchLoadingModal from '../../../UI/Notification/SwitchLoadingModal';
-import { AccountsList } from './AccountsList';
import { Props } from './NotificationsSettings.types';
import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications';
import Routes from '../../../../constants/navigation/Routes';
-import ButtonIcon, {
- ButtonIconSizes,
-} from '../../../../component-library/components/Buttons/ButtonIcon';
-
-import { IconName } from '../../../../component-library/components/Icons/Icon';
import { useSwitchNotificationLoadingText } from '../../../../util/notifications/hooks/useSwitchNotifications';
-import { FeatureAnnouncementToggle } from './FeatureAnnouncementToggle';
import { MainNotificationToggle } from './MainNotificationToggle';
-import styleSheet, {
- styles as navigationOptionsStyles,
-} from './NotificationsSettings.styles';
-import SessionHeader from './sectionHeader';
-import { PushNotificationToggle } from './PushNotificationToggle';
-import { useNotificationWalletAccountGroups } from './AccountsList.hooks';
-import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds';
-
-const NotificationsSettings = ({ navigation, route }: Props) => {
+import styleSheet from './NotificationsSettings.styles';
+import {
+ useNotificationStoragePreferences,
+ type NotificationStoragePreferences,
+ type NotificationStoragePreferenceSection,
+} from './hooks/useNotificationStoragePreferences';
+
+import {
+ Box,
+ Text,
+ Icon,
+ IconName,
+ IconColor,
+ TextVariant,
+ FontWeight,
+ BoxFlexDirection,
+ BoxAlignItems,
+} from '@metamask/design-system-react-native';
+
+interface NotificationRowProps {
+ title: string;
+ status: string;
+ iconName: IconName;
+ onPress: () => void;
+}
+
+const NotificationRow = ({
+ title,
+ status,
+ iconName,
+ onPress,
+}: NotificationRowProps) => {
const theme = useTheme();
+ const { styles } = useStyles(styleSheet, { theme });
- const isMetamaskNotificationsEnabled = useSelector(
- selectIsMetamaskNotificationsEnabled,
+ return (
+
+
+
+
+
+ {title}
+
+
+ {status}
+
+
+
+
+
);
- const notificationWalletAccountGroups = useNotificationWalletAccountGroups();
- const hasNotificationAccounts = notificationWalletAccountGroups.length > 0;
+};
- const loadingText = useSwitchNotificationLoadingText();
+type NotificationPreferenceStatus =
+ NotificationStoragePreferences[NotificationStoragePreferenceSection];
+
+const getStatusText = (prefs?: NotificationPreferenceStatus | null) => {
+ const active = [];
+ if (prefs?.pushNotificationsEnabled) {
+ active.push('Push');
+ }
+ if (prefs?.inAppNotificationsEnabled) {
+ active.push('In app');
+ }
+ return active.length > 0 ? active.join(', ') : 'Off';
+};
- // Params
- const isFullScreenModal = route?.params?.isFullScreenModal;
- // Style
- const { colors } = theme;
+const NotificationsSettings = ({ navigation }: Props) => {
+ const theme = useTheme();
const { styles } = useStyles(styleSheet, { theme });
- useEffect(() => {
- navigation.setOptions(
- getNavigationOptionsTitle(
- strings('app_settings.notifications_title'),
- navigation,
- isFullScreenModal,
- colors,
- null,
- ),
- );
- }, [colors, isFullScreenModal, navigation]);
+ const isMetamaskNotificationsEnabled = useSelector(
+ selectIsMetamaskNotificationsEnabled,
+ );
+
+ const loadingText = useSwitchNotificationLoadingText();
+ const { preferences } = useNotificationStoragePreferences();
+
+ const navigateToSection = (
+ type: NotificationStoragePreferenceSection,
+ title: string,
+ description: string,
+ ) => {
+ navigation.navigate(Routes.SETTINGS.NOTIFICATION_SETTINGS_SECTION, {
+ type,
+ title,
+ description,
+ });
+ };
return (
-
- {/* Main Toggle */}
-
-
- {/* Additional Toggles only visible if main toggle is enabled */}
- {isMetamaskNotificationsEnabled && (
- <>
- {/* Push Notifications Toggle */}
-
-
-
-
- {/* Feature Announcement Toggle */}
-
-
-
-
-
-
-
- {/* Account Notification Toggles */}
- {hasNotificationAccounts && (
- <>
-
-
- >
- )}
- >
- )}
-
-
+
+
+
+
+ {strings('app_settings.notifications_title')}
+
+
+
+ {isMetamaskNotificationsEnabled && (
+ <>
+
+ navigateToSection(
+ 'walletActivity',
+ strings(
+ 'app_settings.notifications_opts.wallet_activity_title',
+ ),
+ strings(
+ 'app_settings.notifications_opts.wallet_activity_desc',
+ ),
+ )
+ }
+ />
+
+
+ navigateToSection(
+ 'perps',
+ strings('app_settings.notifications_opts.perps_title'),
+ strings('app_settings.notifications_opts.perps_desc'),
+ )
+ }
+ />
+
+
+ navigateToSection(
+ 'socialAI',
+ strings('app_settings.notifications_opts.social_ai_title'),
+ strings('app_settings.notifications_opts.social_ai_desc'),
+ )
+ }
+ />
+
+
+ navigateToSection(
+ 'marketing',
+ strings('app_settings.notifications_opts.marketing_title'),
+ strings('app_settings.notifications_opts.marketing_desc'),
+ )
+ }
+ />
+ >
+ )}
+
+
+
);
};
export default NotificationsSettings;
-
-NotificationsSettings.navigationOptions = ({
- navigation,
- isNotificationEnabled,
-}: {
- navigation: NavigationProp;
- isNotificationEnabled: boolean;
-}) => ({
- headerLeft: () => (
-
- !isNotificationEnabled
- ? navigation.navigate(Routes.WALLET.HOME)
- : navigation.goBack()
- }
- style={navigationOptionsStyles.headerLeft}
- />
- ),
-});
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/NotificationPreferencesView.testIds.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/NotificationPreferences.testIds.ts
similarity index 94%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/NotificationPreferencesView.testIds.ts
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/NotificationPreferences.testIds.ts
index 25e366db7ca..efeccf3eca5 100644
--- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/NotificationPreferencesView.testIds.ts
+++ b/app/components/Views/SocialLeaderboard/NotificationPreferences/NotificationPreferences.testIds.ts
@@ -1,4 +1,4 @@
-export const NotificationPreferencesViewSelectorsIDs = {
+export const NotificationPreferencesSelectorsIDs = {
CONTAINER: 'notification-preferences-view-container',
BACK_BUTTON: 'notification-preferences-view-back-button',
GLOBAL_TOGGLE: 'notification-preferences-view-global-toggle',
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/AllowPushNotificationsRow/AllowPushNotificationsRow.tsx b/app/components/Views/SocialLeaderboard/NotificationPreferences/components/AllowPushNotificationsRow/AllowPushNotificationsRow.tsx
similarity index 100%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/AllowPushNotificationsRow/AllowPushNotificationsRow.tsx
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/components/AllowPushNotificationsRow/AllowPushNotificationsRow.tsx
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/AllowPushNotificationsRow/index.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/components/AllowPushNotificationsRow/index.ts
similarity index 100%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/AllowPushNotificationsRow/index.ts
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/components/AllowPushNotificationsRow/index.ts
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/Skeletons.tsx b/app/components/Views/SocialLeaderboard/NotificationPreferences/components/Skeletons.tsx
similarity index 89%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/Skeletons.tsx
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/components/Skeletons.tsx
index 417d32cdac6..9f053466806 100644
--- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/Skeletons.tsx
+++ b/app/components/Views/SocialLeaderboard/NotificationPreferences/components/Skeletons.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { View } from 'react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { SkeletonShell } from '../../TraderProfileView/components/Skeletons';
-import { NotificationPreferencesViewSelectorsIDs } from '../NotificationPreferencesView.testIds';
+import { NotificationPreferencesSelectorsIDs } from '../NotificationPreferences.testIds';
const SKELETON_ROW_COUNT = 3;
@@ -76,13 +76,13 @@ const THRESHOLD_PLACEHOLDER_COUNT = 4;
* Shimmer placeholder shown while the initial notification-preferences GET
* is in flight. Mirrors the layout of the global toggle row plus the four
* threshold rows so the UI doesn't shift — and, critically, doesn't render
- * `DEFAULT_SOCIAL_AI` (enabled: false) as if it were real data, which would
- * make the toggle visibly flash OFF on every (re)entry to the screen.
+ * local Social AI defaults as if they were real data, which could flash the
+ * toggle in the wrong state on every (re)entry to the screen.
*/
export const PreferencesSkeleton: React.FC = () => {
const tw = useTailwind();
return (
-
+
{Array.from({ length: THRESHOLD_PLACEHOLDER_COUNT }, (_, i) => (
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/ThresholdRadioList/ThresholdRadioList.tsx b/app/components/Views/SocialLeaderboard/NotificationPreferences/components/ThresholdRadioList/ThresholdRadioList.tsx
similarity index 90%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/ThresholdRadioList/ThresholdRadioList.tsx
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/components/ThresholdRadioList/ThresholdRadioList.tsx
index 6a7caa543de..90f217efdeb 100644
--- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/ThresholdRadioList/ThresholdRadioList.tsx
+++ b/app/components/Views/SocialLeaderboard/NotificationPreferences/components/ThresholdRadioList/ThresholdRadioList.tsx
@@ -55,6 +55,7 @@ interface ThresholdRowProps {
label: string;
isChecked: boolean;
isDisabled: boolean;
+ withHorizontalPadding: boolean;
onPress: () => void;
testID?: string;
}
@@ -63,6 +64,7 @@ const ThresholdRow: React.FC = ({
label,
isChecked,
isDisabled,
+ withHorizontalPadding,
onPress,
testID,
}) => {
@@ -79,7 +81,9 @@ const ThresholdRow: React.FC = ({
disabled={isDisabled}
testID={testID}
style={tw.style(
- 'flex-row items-center justify-between px-4 py-4',
+ `flex-row items-center justify-between ${
+ withHorizontalPadding ? 'px-4' : ''
+ } py-4`,
isDisabled && 'opacity-50',
)}
accessibilityRole="radio"
@@ -117,6 +121,7 @@ export interface ThresholdRadioListProps {
isDisabled: boolean;
currency: string | undefined;
labelText: string;
+ withHorizontalPadding?: boolean;
testIDForAmount?: (amount: number) => string;
}
@@ -126,10 +131,11 @@ const ThresholdRadioList: React.FC = ({
isDisabled,
currency,
labelText,
+ withHorizontalPadding = true,
testIDForAmount,
}) => (
-
+
= ({
label={formatThreshold(amount, currency)}
isChecked={selected === amount}
isDisabled={isDisabled}
+ withHorizontalPadding={withHorizontalPadding}
onPress={() => onChange(amount)}
testID={testIDForAmount?.(amount)}
/>
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/ThresholdRadioList/index.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/components/ThresholdRadioList/index.ts
similarity index 100%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/components/ThresholdRadioList/index.ts
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/components/ThresholdRadioList/index.ts
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/index.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/index.ts
similarity index 80%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/index.ts
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/index.ts
index 774ba30c287..60fd033c133 100644
--- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/index.ts
+++ b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/index.ts
@@ -1,10 +1,13 @@
export { default as useNotificationPreferences } from './useNotificationPreferences';
export type {
- SocialAIPreference,
UseNotificationPreferencesResult,
TxAmountThreshold,
+ SocialAIPreference,
+} from './useNotificationPreferences';
+export {
+ TX_AMOUNT_THRESHOLDS,
+ DEFAULT_TX_AMOUNT_LIMIT,
} from './useNotificationPreferences';
-export { TX_AMOUNT_THRESHOLDS } from './useNotificationPreferences';
export { default as useFollowedTraders } from './useFollowedTraders';
export type {
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useFollowedTraders.test.ts
similarity index 100%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.test.ts
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useFollowedTraders.test.ts
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useFollowedTraders.ts
similarity index 100%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useFollowedTraders.ts
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useFollowedTraders.ts
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useNotificationPreferences.test.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.test.ts
similarity index 79%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useNotificationPreferences.test.ts
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.test.ts
index e3167e2ab3d..f78f1031cda 100644
--- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useNotificationPreferences.test.ts
+++ b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.test.ts
@@ -2,7 +2,8 @@ import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useSelector } from 'react-redux';
import { useQuery } from '@metamask/react-data-query';
import { useQueryClient } from '@tanstack/react-query';
-import type { NotificationPreferences as StoredNotificationPreferences } from '@metamask/authenticated-user-storage';
+import type { NotificationPreferences } from '@metamask/authenticated-user-storage';
+import { DEFAULT_SOCIAL_AI_PREFERENCES } from '@metamask/notification-services-controller/notification-services';
import Engine from '../../../../../core/Engine';
import Logger from '../../../../../util/Logger';
import {
@@ -50,15 +51,26 @@ const PUT_ACTION = 'AuthenticatedUserStorageService:putNotificationPreferences';
const CLIENT_TYPE = 'mobile';
const buildRemote = (
- overrides: Partial = {},
-): StoredNotificationPreferences => ({
- walletActivity: { enabled: true, accounts: [] },
- marketing: { enabled: false },
- perps: { enabled: true },
+ overrides: Partial = {},
+): NotificationPreferences => ({
+ walletActivity: {
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
+ accounts: [],
+ },
+ marketing: {
+ inAppNotificationsEnabled: false,
+ pushNotificationsEnabled: false,
+ },
+ perps: {
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
+ },
socialAI: {
- enabled: true,
- txAmountLimit: 500,
- mutedTraderProfileIds: [],
+ ...DEFAULT_SOCIAL_AI_PREFERENCES,
+ mutedTraderProfileIds: [
+ ...DEFAULT_SOCIAL_AI_PREFERENCES.mutedTraderProfileIds,
+ ],
},
...overrides,
});
@@ -110,9 +122,11 @@ describe('useNotificationPreferences', () => {
it('seeds defaults when the query returns no data yet', () => {
const { result } = renderHook(() => useNotificationPreferences());
- expect(result.current.preferences.enabled).toBe(false);
+ expect(result.current.preferences.pushNotificationsEnabled).toBe(true);
+ expect(result.current.preferences.inAppNotificationsEnabled).toBe(true);
expect(result.current.preferences.txAmountLimit).toBe(500);
expect(result.current.preferences.mutedTraderProfileIds).toEqual([]);
+ expect(result.current.hasNotificationPreferences).toBe(false);
});
it('reflects the remote socialAI slice when the query resolves', () => {
@@ -120,7 +134,8 @@ describe('useNotificationPreferences', () => {
makeQueryResult({
data: buildRemote({
socialAI: {
- enabled: false,
+ pushNotificationsEnabled: false,
+ inAppNotificationsEnabled: true,
txAmountLimit: 100,
mutedTraderProfileIds: ['trader-muted'],
},
@@ -131,10 +146,12 @@ describe('useNotificationPreferences', () => {
const { result } = renderHook(() => useNotificationPreferences());
expect(result.current.preferences).toEqual({
- enabled: false,
+ pushNotificationsEnabled: false,
+ inAppNotificationsEnabled: true,
txAmountLimit: 100,
mutedTraderProfileIds: ['trader-muted'],
});
+ expect(result.current.hasNotificationPreferences).toBe(true);
});
it('forwards the useQuery loading state', () => {
@@ -163,7 +180,8 @@ describe('useNotificationPreferences', () => {
makeQueryResult({
data: buildRemote({
socialAI: {
- enabled: true,
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
txAmountLimit: 500,
mutedTraderProfileIds: ['muted-1'],
},
@@ -201,7 +219,8 @@ describe('useNotificationPreferences', () => {
makeQueryResult({
data: buildRemote({
socialAI: {
- enabled: true,
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
txAmountLimit: 500,
mutedTraderProfileIds: ['trader-1'],
},
@@ -235,17 +254,17 @@ describe('useNotificationPreferences', () => {
});
});
- describe('setEnabled', () => {
- it('flips enabled locally before the server catches up', async () => {
+ describe('setPushNotificationsEnabled', () => {
+ it('flips pushNotificationsEnabled locally before the server catches up', async () => {
mockUseQuery.mockReturnValue(makeQueryResult({ data: buildRemote() }));
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- await result.current.setEnabled(false);
+ await result.current.setPushNotificationsEnabled(false);
});
- expect(result.current.preferences.enabled).toBe(false);
+ expect(result.current.preferences.pushNotificationsEnabled).toBe(false);
});
});
@@ -274,7 +293,8 @@ describe('useNotificationPreferences', () => {
// Concurrent writer updated the walletActivity slice on the server.
const latest = buildRemote({
walletActivity: {
- enabled: false,
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
accounts: [{ address: '0xabc', enabled: true }],
},
});
@@ -286,7 +306,7 @@ describe('useNotificationPreferences', () => {
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- await result.current.setEnabled(false);
+ await result.current.setPushNotificationsEnabled(false);
});
const calls = mockCall.mock.calls;
@@ -302,10 +322,14 @@ describe('useNotificationPreferences', () => {
mockUseQuery.mockReturnValue(makeQueryResult({ data: buildRemote() }));
const latest = buildRemote({
walletActivity: {
- enabled: false,
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
accounts: [{ address: '0xabc', enabled: true }],
},
- marketing: { enabled: true },
+ marketing: {
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
+ },
});
mockCall.mockImplementation(async (action: string) => {
if (action === GET_ACTION) return latest;
@@ -332,7 +356,7 @@ describe('useNotificationPreferences', () => {
);
});
- it('seeds sensible defaults for other slices when the server has nothing stored', async () => {
+ it('does not initialize preferences when the server has nothing stored', async () => {
mockUseQuery.mockReturnValue(makeQueryResult({ data: null }));
mockCall.mockImplementation(async (action: string) => {
if (action === GET_ACTION) return null;
@@ -342,18 +366,16 @@ describe('useNotificationPreferences', () => {
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- await result.current.setEnabled(false);
+ await result.current.setPushNotificationsEnabled(false);
});
- expect(mockCall).toHaveBeenCalledWith(
- PUT_ACTION,
- expect.objectContaining({
- walletActivity: expect.any(Object),
- marketing: expect.any(Object),
- perps: expect.any(Object),
- socialAI: expect.objectContaining({ enabled: false }),
- }),
- CLIENT_TYPE,
+ expect(mockCall).not.toHaveBeenCalled();
+ expect(result.current.error).toBe(
+ 'No notification preferences found when updating social AI preferences, enable notifications first',
+ );
+ expect(Logger.error).toHaveBeenCalledWith(
+ expect.any(Error),
+ 'useNotificationPreferences: persist skipped',
);
});
@@ -368,17 +390,17 @@ describe('useNotificationPreferences', () => {
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- await result.current.setEnabled(false);
+ await result.current.setPushNotificationsEnabled(false);
});
await waitFor(() => {
- expect(result.current.preferences.enabled).toBe(true);
+ expect(result.current.preferences.pushNotificationsEnabled).toBe(true);
});
expect(result.current.error).toBe('network down');
expect(Logger.error).toHaveBeenCalled();
});
- it('refetches the query after a successful PUT so the overlay clears', async () => {
+ it('does not refetch the query after a successful PUT', async () => {
mockUseQuery.mockReturnValue(makeQueryResult({ data: buildRemote() }));
mockCall.mockImplementation(async (action: string) => {
if (action === GET_ACTION) return buildRemote();
@@ -388,43 +410,35 @@ describe('useNotificationPreferences', () => {
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- await result.current.setEnabled(false);
+ await result.current.setPushNotificationsEnabled(false);
});
- expect(mockRefetch).toHaveBeenCalledTimes(1);
+ expect(mockRefetch).not.toHaveBeenCalled();
});
- it('does NOT roll back or set an error when persist succeeds but the background refetch throws', async () => {
+ it('does not roll back or set an error when persist succeeds', async () => {
mockUseQuery.mockReturnValue(makeQueryResult({ data: buildRemote() }));
mockCall.mockImplementation(async (action: string) => {
if (action === GET_ACTION) return buildRemote();
return undefined;
});
- mockRefetch.mockRejectedValueOnce(new Error('network blip'));
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- await result.current.setEnabled(false);
+ await result.current.setPushNotificationsEnabled(false);
});
- // The mutation was saved — optimistic overlay must remain (enabled: false).
- expect(result.current.preferences.enabled).toBe(false);
- // No error should be surfaced to the UI for a cache-refresh failure.
+ // The mutation was saved — optimistic overlay must remain (push disabled).
+ expect(result.current.preferences.pushNotificationsEnabled).toBe(false);
expect(result.current.error).toBeNull();
- // The refetch runs in the background; its failure is reported via Logger.
- await waitFor(() => {
- expect(Logger.error).toHaveBeenCalledWith(
- expect.any(Error),
- 'useNotificationPreferences: background refetch after persist failed',
- );
- });
});
- it('primes the TanStack cache with the merged payload after a successful PUT', async () => {
+ it('primes the TanStack cache with the updated socialAI slice', async () => {
const latest = buildRemote({
walletActivity: {
- enabled: false,
+ inAppNotificationsEnabled: true,
+ pushNotificationsEnabled: true,
accounts: [{ address: '0xabc', enabled: true }],
},
});
@@ -437,12 +451,9 @@ describe('useNotificationPreferences', () => {
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- await result.current.setEnabled(false);
+ await result.current.setPushNotificationsEnabled(false);
});
- // Cache must be primed synchronously after PUT so the next mount
- // hydrates with the post-PUT value even if the user navigates away
- // before the background refetch returns.
expect(mockSetQueryData).toHaveBeenCalledTimes(1);
const [keyArg, updaterArg] = mockSetQueryData.mock.calls[0];
expect(keyArg).toEqual([GET_ACTION, MOCK_ACCOUNT_ID]);
@@ -451,12 +462,14 @@ describe('useNotificationPreferences', () => {
expect(merged).toEqual(
expect.objectContaining({
walletActivity: latest.walletActivity,
- socialAI: expect.objectContaining({ enabled: false }),
+ socialAI: expect.objectContaining({
+ pushNotificationsEnabled: false,
+ }),
}),
);
});
- it('does NOT prime the cache when the PUT fails', async () => {
+ it('rolls back from the optimistic cache update when the PUT fails', async () => {
mockUseQuery.mockReturnValue(makeQueryResult({ data: buildRemote() }));
mockCall.mockImplementation(async (action: string) => {
if (action === GET_ACTION) return buildRemote();
@@ -467,10 +480,11 @@ describe('useNotificationPreferences', () => {
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- await result.current.setEnabled(false);
+ await result.current.setPushNotificationsEnabled(false);
});
- expect(mockSetQueryData).not.toHaveBeenCalled();
+ expect(mockSetQueryData).toHaveBeenCalledTimes(1);
+ expect(mockRefetch).toHaveBeenCalledTimes(1);
});
it('does not corrupt state when a first rapid mutation fails but a second succeeds', async () => {
@@ -482,7 +496,8 @@ describe('useNotificationPreferences', () => {
if (action === GET_ACTION) return buildRemote();
if (action === PUT_ACTION) {
putCount += 1;
- // First PUT (from setEnabled) fails; second (from setTxAmountLimit) succeeds.
+ // First PUT (from setPushNotificationsEnabled) fails; second
+ // (from setTxAmountLimit) succeeds.
if (putCount === 1) throw new Error('first PUT failed');
return undefined;
}
@@ -492,15 +507,16 @@ describe('useNotificationPreferences', () => {
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- const first = result.current.setEnabled(false);
+ const first = result.current.setPushNotificationsEnabled(false);
const second = result.current.setTxAmountLimit(100);
await Promise.all([first, second]);
});
// The second mutation's overlay must survive the first mutation's rollback.
- // B built its nextSocialAI on top of A's pending state (enabled:false,
- // txAmountLimit:100), and its PUT succeeded, so both changes are on the server.
- expect(result.current.preferences.enabled).toBe(false);
+ // B built its nextSocialAI on top of A's pending state
+ // (pushNotificationsEnabled:false, txAmountLimit:100), and its PUT
+ // succeeded, so both changes are on the server.
+ expect(result.current.preferences.pushNotificationsEnabled).toBe(false);
expect(result.current.preferences.txAmountLimit).toBe(100);
// A's failure was swallowed because a newer mutation was in flight.
expect(result.current.error).toBeNull();
@@ -538,7 +554,7 @@ describe('useNotificationPreferences', () => {
const { result } = renderHook(() => useNotificationPreferences());
await act(async () => {
- const first = result.current.setEnabled(false);
+ const first = result.current.setPushNotificationsEnabled(false);
const second = result.current.setTxAmountLimit(100);
// Release the first PUT only after both calls have been initiated.
// If writes weren't serialized, the second GET would fire before the
@@ -570,7 +586,7 @@ describe('useNotificationPreferences', () => {
await act(async () => {
// Fire both mutations without awaiting — simulates rapid user interaction
// before React can re-render with the new overlay.
- const first = result.current.setEnabled(false);
+ const first = result.current.setPushNotificationsEnabled(false);
const second = result.current.setTxAmountLimit(100);
await Promise.all([first, second]);
});
@@ -580,9 +596,9 @@ describe('useNotificationPreferences', () => {
expect(putCalls).toHaveLength(2);
// The second PUT must carry BOTH mutations — not the stale socialAI base
- // from the first render (which would have re-applied enabled: true).
+ // from the first render (which would have re-applied push: true).
expect(putCalls[1][1].socialAI).toMatchObject({
- enabled: false,
+ pushNotificationsEnabled: false,
txAmountLimit: 100,
});
});
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useNotificationPreferences.ts b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.ts
similarity index 57%
rename from app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useNotificationPreferences.ts
rename to app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.ts
index c0aa6098660..b8dba4ddf46 100644
--- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/hooks/useNotificationPreferences.ts
+++ b/app/components/Views/SocialLeaderboard/NotificationPreferences/hooks/useNotificationPreferences.ts
@@ -1,41 +1,33 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { useSelector } from 'react-redux';
-import { useQuery } from '@metamask/react-data-query';
-import { useQueryClient } from '@tanstack/react-query';
-import type {
- NotificationPreferences as StoredNotificationPreferences,
- SocialAIPreference,
-} from '@metamask/authenticated-user-storage';
-import Engine from '../../../../../core/Engine';
+import type { SocialAIPreference } from '@metamask/authenticated-user-storage';
import Logger from '../../../../../util/Logger';
-import { selectSelectedInternalAccountId } from '../../../../../selectors/accountsController';
+import { DEFAULT_SOCIAL_AI_PREFERENCES } from '@metamask/notification-services-controller';
+import { useNotificationStoragePreferences } from '../../../Settings/NotificationsSettings/hooks/useNotificationStoragePreferences';
+
+export type { SocialAIPreference } from '@metamask/authenticated-user-storage';
export const TX_AMOUNT_THRESHOLDS = [10, 100, 500, 1000] as const;
export type TxAmountThreshold = (typeof TX_AMOUNT_THRESHOLDS)[number];
-const DEFAULT_ENABLED = false;
-const DEFAULT_TX_AMOUNT_LIMIT: TxAmountThreshold = 500;
+export const DEFAULT_TX_AMOUNT_LIMIT =
+ DEFAULT_SOCIAL_AI_PREFERENCES.txAmountLimit as TxAmountThreshold;
-const CLIENT_TYPE = 'mobile' as const;
-const GET_ACTION =
- 'AuthenticatedUserStorageService:getNotificationPreferences' as const;
-const PUT_ACTION =
- 'AuthenticatedUserStorageService:putNotificationPreferences' as const;
+const getDefaultSocialAIPreferences = (): SocialAIPreference => ({
+ ...DEFAULT_SOCIAL_AI_PREFERENCES,
+ mutedTraderProfileIds: [
+ ...DEFAULT_SOCIAL_AI_PREFERENCES.mutedTraderProfileIds,
+ ],
+});
/** Used while the initial GET is in-flight or when the server has no row yet. */
-const DEFAULT_SOCIAL_AI: SocialAIPreference = {
- enabled: DEFAULT_ENABLED,
- txAmountLimit: DEFAULT_TX_AMOUNT_LIMIT,
- mutedTraderProfileIds: [],
-};
-
-export type { SocialAIPreference } from '@metamask/authenticated-user-storage';
+const DEFAULT_SOCIAL_AI: SocialAIPreference = getDefaultSocialAIPreferences();
export interface UseNotificationPreferencesResult {
preferences: SocialAIPreference;
+ hasNotificationPreferences: boolean;
isLoading: boolean;
error: string | null;
- setEnabled: (value: boolean) => Promise;
+ setPushNotificationsEnabled: (value: boolean) => Promise;
setTxAmountLimit: (value: TxAmountThreshold) => Promise;
toggleTraderNotification: (traderId: string) => Promise;
/** Derived selector: is the given trader currently receiving notifications? */
@@ -57,7 +49,12 @@ const hasRemoteCaughtUp = (
remote: SocialAIPreference,
): boolean => {
if (overlay === remote) return true;
- if (overlay.enabled !== remote.enabled) return false;
+ if (
+ overlay.pushNotificationsEnabled !== remote.pushNotificationsEnabled ||
+ overlay.inAppNotificationsEnabled !== remote.inAppNotificationsEnabled
+ ) {
+ return false;
+ }
if (overlay.txAmountLimit !== remote.txAmountLimit) return false;
const overlaySet = new Set(overlay.mutedTraderProfileIds);
const remoteSet = new Set(remote.mutedTraderProfileIds);
@@ -68,51 +65,26 @@ const hasRemoteCaughtUp = (
return true;
};
-/**
- * Merge the supplied `socialAI` slice on top of the remote payload so a PUT
- * doesn't stomp concurrent writes to the other slices (`walletActivity`,
- * `marketing`, `perps`) coming from extension/portfolio.
- */
-const mergeForPut = (
- remote: StoredNotificationPreferences | null,
- socialAI: SocialAIPreference,
-): StoredNotificationPreferences => {
- const base: StoredNotificationPreferences = remote ?? {
- walletActivity: { enabled: true, accounts: [] },
- marketing: { enabled: false },
- perps: { enabled: true },
- socialAI: DEFAULT_SOCIAL_AI,
- };
- return { ...base, socialAI };
-};
-
/**
* Notification preferences for the Top Traders feature, backed by
+ * the shared notification preferences stored in
* `AuthenticatedUserStorageService`.
*
* The UI renders `overlay ?? remote`: an optimistic overlay flips the toggles
* instantly on tap, while a read-merge-write PUT runs in the background and
- * the overlay is dropped once `useQuery` reflects the new value. Failed PUTs
- * roll the overlay back — unless a newer mutation already owns it (tracked
- * via `generationRef`).
+ * the overlay is dropped once the shared preferences query reflects the new
+ * value. Failed PUTs roll the overlay back — unless a newer mutation already
+ * owns it (tracked via `generationRef`).
*/
export const useNotificationPreferences =
(): UseNotificationPreferencesResult => {
- // Scope the cache by account so a stale entry from a previous user is
- // never served after an account switch. Fallback keeps the hook usable
- // in tests and pre-auth screens.
- const selectedAccountId =
- useSelector(selectSelectedInternalAccountId) ?? 'anonymous';
-
const {
- data,
+ preferences: storagePreferences,
+ hasNotificationPreferences,
isLoading,
error: queryError,
- refetch,
- } = useQuery({
- queryKey: [GET_ACTION, selectedAccountId],
- });
- const queryClient = useQueryClient();
+ updatePreferencesSection,
+ } = useNotificationStoragePreferences();
const [overlay, setOverlay] = useState(
undefined,
@@ -120,7 +92,7 @@ export const useNotificationPreferences =
const [persistError, setPersistError] = useState(null);
const remoteSocialAI: SocialAIPreference =
- data?.socialAI ?? DEFAULT_SOCIAL_AI;
+ storagePreferences?.socialAI ?? DEFAULT_SOCIAL_AI;
const socialAI: SocialAIPreference = overlay ?? remoteSocialAI;
// Refs mirrored during render so async code never reads stale closures.
@@ -141,29 +113,30 @@ export const useNotificationPreferences =
// Each mutation captures its generation; only rolls back if still current.
const generationRef = useRef(0);
- const enqueuePersist = useCallback((nextSocialAI: SocialAIPreference) => {
- const next = writeChainRef.current.then(async () => {
- // These actions are registered at runtime by
- // AuthenticatedUserStorageService and aren't in the default messenger's
- // action map, hence the `CallableFunction` cast.
- const latest = (await (
- Engine.controllerMessenger.call as CallableFunction
- )(GET_ACTION)) as StoredNotificationPreferences | null;
-
- await (Engine.controllerMessenger.call as CallableFunction)(
- PUT_ACTION,
- mergeForPut(latest, nextSocialAI),
- CLIENT_TYPE,
- );
- });
- // Swallow the chain's error so one failure doesn't jam subsequent
- // writes. The returned promise still rejects for the caller.
- writeChainRef.current = next.catch(() => undefined);
- return next;
- }, []);
+ const enqueuePersist = useCallback(
+ (nextSocialAI: SocialAIPreference) => {
+ const next = writeChainRef.current.then(async () => {
+ await updatePreferencesSection('socialAI', nextSocialAI);
+ });
+ // Swallow the chain's error so one failure doesn't jam subsequent
+ // writes. The returned promise still rejects for the caller.
+ writeChainRef.current = next.catch(() => undefined);
+ return next;
+ },
+ [updatePreferencesSection],
+ );
const applyChange = useCallback(
async (updater: (prev: SocialAIPreference) => SocialAIPreference) => {
+ if (!hasNotificationPreferences) {
+ const err = new Error(
+ 'No notification preferences found when updating social AI preferences, enable notifications first',
+ );
+ Logger.error(err, 'useNotificationPreferences: persist skipped');
+ setPersistError(toErrorMessage(err));
+ return;
+ }
+
generationRef.current += 1;
const myGeneration = generationRef.current;
@@ -187,25 +160,8 @@ export const useNotificationPreferences =
}
return;
}
-
- // Prime the cache so (a) the overlay can auto-drop on the next render
- // and (b) reopening the view hydrates instantly — even if the user
- // navigates away before the background refetch returns.
- queryClient.setQueryData(
- [GET_ACTION, selectedAccountId],
- (prev) => mergeForPut(prev ?? null, nextSocialAI),
- );
-
- // Background reconcile with the server (picks up concurrent writes
- // to other slices). Not awaited — the UI is already correct.
- refetch().catch((err) => {
- Logger.error(
- err as Error,
- 'useNotificationPreferences: background refetch after persist failed',
- );
- });
},
- [enqueuePersist, queryClient, refetch, selectedAccountId],
+ [enqueuePersist, hasNotificationPreferences],
);
useEffect(() => {
@@ -214,8 +170,12 @@ export const useNotificationPreferences =
}
}, [overlay, remoteSocialAI]);
- const setEnabled = useCallback(
- (value: boolean) => applyChange((prev) => ({ ...prev, enabled: value })),
+ const setPushNotificationsEnabled = useCallback(
+ (value: boolean) =>
+ applyChange((prev) => ({
+ ...prev,
+ pushNotificationsEnabled: value,
+ })),
[applyChange],
);
@@ -255,9 +215,10 @@ export const useNotificationPreferences =
return {
preferences: socialAI,
+ hasNotificationPreferences,
isLoading,
error,
- setEnabled,
+ setPushNotificationsEnabled,
setTxAmountLimit,
toggleTraderNotification,
isTraderNotificationEnabled,
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/NotificationPreferencesView.test.tsx b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/NotificationPreferencesView.test.tsx
deleted file mode 100644
index 6aaab9cf341..00000000000
--- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/NotificationPreferencesView.test.tsx
+++ /dev/null
@@ -1,754 +0,0 @@
-import React from 'react';
-import { Platform } from 'react-native';
-import { fireEvent, screen } from '@testing-library/react-native';
-import {
- ImpactFeedbackStyle,
- ImpactMoment,
- playImpact,
-} from '../../../../util/haptics';
-import renderWithProvider from '../../../../util/test/renderWithProvider';
-import { mockTheme } from '../../../../util/theme';
-import { strings } from '../../../../../locales/i18n';
-import { useFollowedTraders, useNotificationPreferences } from './hooks';
-import { selectCurrentCurrency } from '../../../../selectors/currencyRateController';
-import NotificationPreferencesView from './NotificationPreferencesView';
-import { NotificationPreferencesViewSelectorsIDs } from './NotificationPreferencesView.testIds';
-import type {
- FollowedTrader,
- UseFollowedTradersResult,
-} from './hooks/useFollowedTraders';
-import type {
- UseNotificationPreferencesResult,
- SocialAIPreference,
-} from './hooks/useNotificationPreferences';
-
-// ---------------------------------------------------------------------------
-// Mocks
-// ---------------------------------------------------------------------------
-
-const mockGoBack = jest.fn();
-const mockNavigate = jest.fn();
-
-jest.mock('@react-navigation/native', () => ({
- ...jest.requireActual('@react-navigation/native'),
- useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }),
-}));
-
-jest.mock('@metamask/design-system-twrnc-preset', () => ({
- useTailwind: () => ({
- style: jest.fn(() => ({})),
- }),
-}));
-
-jest.mock('../../../../util/theme', () => ({
- mockTheme: jest.requireActual('../../../../util/theme').mockTheme,
- useTheme: () => mockTheme,
- useAssetFromTheme: jest.fn((light: unknown) => light),
-}));
-
-jest.mock('./hooks', () => ({
- ...jest.requireActual('./hooks'),
- useFollowedTraders: jest.fn(),
- useNotificationPreferences: jest.fn(),
-}));
-
-jest.mock(
- '../../../../selectors/featureFlagController/socialLeaderboard',
- () => ({
- selectSocialLeaderboardEnabled: jest.fn(),
- }),
-);
-
-jest.mock('../../../../selectors/currencyRateController', () => ({
- selectCurrentCurrency: jest.fn(),
-}));
-
-jest.mock('expo-haptics', () => ({
- impactAsync: jest.fn(),
- ImpactFeedbackStyle: {
- Light: 'light',
- Medium: 'medium',
- Heavy: 'heavy',
- },
-}));
-
-jest.mock('../../../../util/haptics', () => {
- const actual = jest.requireActual(
- '../../../../util/haptics',
- );
- return {
- ...actual,
- playImpact: jest.fn(),
- };
-});
-
-// ---------------------------------------------------------------------------
-// Shared mock primitives
-// ---------------------------------------------------------------------------
-
-const mockUseFollowedTraders = useFollowedTraders as jest.MockedFunction<
- typeof useFollowedTraders
->;
-const mockUseNotificationPreferences =
- useNotificationPreferences as jest.MockedFunction<
- typeof useNotificationPreferences
- >;
-const mockSelectCurrentCurrency = selectCurrentCurrency as jest.MockedFunction<
- typeof selectCurrentCurrency
->;
-const { impactAsync: mockImpactAsync } = jest.requireMock('expo-haptics') as {
- impactAsync: jest.Mock;
-};
-const mockPlayImpact = jest.mocked(playImpact);
-
-const makeTrader = (
- id: string,
- username: string,
- avatarUri?: string,
-): FollowedTrader => ({
- id,
- username,
- avatarUri,
-});
-
-const MOCK_TRADERS: FollowedTrader[] = [
- makeTrader('trader-1', 'dutchiono', 'https://example.com/a1.png'),
- makeTrader('trader-2', 'Kien', 'https://example.com/a2.png'),
- makeTrader('trader-3', 'Raggedandrusty'),
-];
-
-const makeUseFollowedTradersResult = (
- overrides: Partial = {},
-): UseFollowedTradersResult => ({
- traders: MOCK_TRADERS,
- isLoading: false,
- error: null,
- refresh: jest.fn().mockResolvedValue(undefined),
- ...overrides,
-});
-
-const makePreferences = (
- overrides: Partial = {},
-): SocialAIPreference => ({
- enabled: true,
- txAmountLimit: 500,
- mutedTraderProfileIds: [],
- ...overrides,
-});
-
-const makeUseNotificationPreferencesResult = (
- overrides: Partial = {},
-): UseNotificationPreferencesResult => {
- const preferences = overrides.preferences ?? makePreferences();
- const muted = new Set(preferences.mutedTraderProfileIds);
- return {
- preferences,
- isLoading: false,
- error: null,
- setEnabled: jest.fn().mockResolvedValue(undefined),
- setTxAmountLimit: jest.fn().mockResolvedValue(undefined),
- toggleTraderNotification: jest.fn().mockResolvedValue(undefined),
- isTraderNotificationEnabled: (id: string) => !muted.has(id),
- ...overrides,
- };
-};
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-const renderScreen = () =>
- renderWithProvider(, { state: {} });
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-describe('NotificationPreferencesView', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- mockUseFollowedTraders.mockReturnValue(makeUseFollowedTradersResult());
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult(),
- );
- mockSelectCurrentCurrency.mockReturnValue('usd');
- });
-
- describe('rendering', () => {
- it('renders the screen container', () => {
- renderScreen();
-
- expect(
- screen.getByTestId(NotificationPreferencesViewSelectorsIDs.CONTAINER),
- ).toBeOnTheScreen();
- });
-
- it('renders the header back button', () => {
- renderScreen();
-
- expect(
- screen.getByTestId(NotificationPreferencesViewSelectorsIDs.BACK_BUTTON),
- ).toBeOnTheScreen();
- });
-
- it('renders the title', () => {
- renderScreen();
-
- expect(
- screen.getByText(
- strings('social_leaderboard.notification_preferences.title'),
- ),
- ).toBeOnTheScreen();
- });
-
- it('renders the global toggle reflecting the hook enabled value', () => {
- renderScreen();
-
- const toggle = screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.GLOBAL_TOGGLE,
- );
- expect(toggle).toBeOnTheScreen();
- expect(toggle.props.value).toBe(true);
- });
-
- it('renders all four dollar threshold options', () => {
- renderScreen();
-
- [10, 100, 500, 1000].forEach((amount) => {
- expect(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.THRESHOLD_OPTION(amount),
- ),
- ).toBeOnTheScreen();
- });
- });
-
- it('marks the threshold option matching preferences.txAmountLimit as selected', () => {
- renderScreen();
-
- const option500 = screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.THRESHOLD_OPTION(500),
- );
- expect(option500.props.accessibilityState.checked).toBe(true);
- });
-
- it('marks non-matching thresholds as not selected', () => {
- renderScreen();
-
- [10, 100, 1000].forEach((amount) => {
- const option = screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.THRESHOLD_OPTION(amount),
- );
- expect(option.props.accessibilityState.checked).toBe(false);
- });
- });
-
- it('renders the traders section', () => {
- renderScreen();
-
- expect(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADERS_SECTION,
- ),
- ).toBeOnTheScreen();
- });
-
- it('renders a row for each followed trader', () => {
- renderScreen();
-
- MOCK_TRADERS.forEach((trader) => {
- expect(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_ROW(trader.id),
- ),
- ).toBeOnTheScreen();
- });
- });
-
- it('renders each trader toggle as enabled by default (no muted ids)', () => {
- renderScreen();
-
- MOCK_TRADERS.forEach((trader) => {
- const toggle = screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_TOGGLE(trader.id),
- );
- expect(toggle.props.value).toBe(true);
- });
- });
-
- it('renders a muted trader toggle as off', () => {
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({
- preferences: makePreferences({
- mutedTraderProfileIds: ['trader-1'],
- }),
- }),
- );
-
- renderScreen();
-
- const mutedToggle = screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_TOGGLE('trader-1'),
- );
- expect(mutedToggle.props.value).toBe(false);
- });
-
- it('renders the traders section header text', () => {
- renderScreen();
-
- expect(
- screen.getByText(
- strings(
- 'social_leaderboard.notification_preferences.traders_you_follow',
- ),
- ),
- ).toBeOnTheScreen();
- });
-
- it('renders the traders section subtitle', () => {
- renderScreen();
-
- expect(
- screen.getByText(
- strings(
- 'social_leaderboard.notification_preferences.traders_you_follow_desc',
- ),
- ),
- ).toBeOnTheScreen();
- });
- });
-
- describe('followed-traders async states', () => {
- it('renders the loading indicator while followed traders are loading', () => {
- mockUseFollowedTraders.mockReturnValue(
- makeUseFollowedTradersResult({ traders: [], isLoading: true }),
- );
-
- renderScreen();
-
- expect(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADERS_LOADING,
- ),
- ).toBeOnTheScreen();
- });
-
- it('renders the empty state when the user follows nobody', () => {
- mockUseFollowedTraders.mockReturnValue(
- makeUseFollowedTradersResult({ traders: [], isLoading: false }),
- );
-
- renderScreen();
-
- expect(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADERS_EMPTY,
- ),
- ).toBeOnTheScreen();
- expect(
- screen.getByText(
- strings(
- 'social_leaderboard.notification_preferences.traders_you_follow_empty',
- ),
- ),
- ).toBeOnTheScreen();
- });
-
- it('renders the error banner when the fetch fails', () => {
- mockUseFollowedTraders.mockReturnValue(
- makeUseFollowedTradersResult({
- traders: [],
- isLoading: false,
- error: 'boom',
- refresh: jest.fn().mockResolvedValue(undefined),
- }),
- );
-
- renderScreen();
-
- expect(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADERS_ERROR,
- ),
- ).toBeOnTheScreen();
- });
- });
-
- describe('preferences loading state', () => {
- // Prevents the "toggle flashes OFF on reopen" regression. While the GET
- // is in flight we must show a skeleton — binding `preferences.enabled`
- // (which falls back to the `enabled: false` default) directly into the
- // Switch would render a visibly OFF toggle for users whose stored value
- // is ON.
- it('renders the skeleton and hides the real controls while preferences are loading', () => {
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({
- isLoading: true,
- preferences: makePreferences({ enabled: false }),
- }),
- );
-
- renderScreen();
-
- expect(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.PREFERENCES_LOADING,
- ),
- ).toBeOnTheScreen();
- expect(
- screen.queryByTestId(
- NotificationPreferencesViewSelectorsIDs.GLOBAL_TOGGLE,
- ),
- ).toBeNull();
- [10, 100, 500, 1000].forEach((amount) => {
- expect(
- screen.queryByTestId(
- NotificationPreferencesViewSelectorsIDs.THRESHOLD_OPTION(amount),
- ),
- ).toBeNull();
- });
- });
-
- it('renders the real controls once preferences have loaded', () => {
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({
- isLoading: false,
- preferences: makePreferences({ enabled: true }),
- }),
- );
-
- renderScreen();
-
- expect(
- screen.queryByTestId(
- NotificationPreferencesViewSelectorsIDs.PREFERENCES_LOADING,
- ),
- ).toBeNull();
- expect(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.GLOBAL_TOGGLE,
- ),
- ).toBeOnTheScreen();
- });
- });
-
- describe('disabled state when global toggle is off', () => {
- beforeEach(() => {
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({
- preferences: makePreferences({ enabled: false }),
- }),
- );
- });
-
- it('disables all threshold options when preferences.enabled is false', () => {
- renderScreen();
-
- [10, 100, 500, 1000].forEach((amount) => {
- const option = screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.THRESHOLD_OPTION(amount),
- );
- expect(option.props.accessibilityState.disabled).toBe(true);
- });
- });
-
- it('disables all trader toggles when preferences.enabled is false', () => {
- renderScreen();
-
- MOCK_TRADERS.forEach((trader) => {
- const toggle = screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_TOGGLE(trader.id),
- );
- expect(
- toggle.props.accessibilityState?.disabled ?? toggle.props.disabled,
- ).toBe(true);
- });
- });
- });
-
- describe('interactions', () => {
- it('calls navigation.goBack when back button is pressed', () => {
- renderScreen();
-
- fireEvent.press(
- screen.getByTestId(NotificationPreferencesViewSelectorsIDs.BACK_BUTTON),
- );
-
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- });
-
- it('calls setTxAmountLimit when a threshold option is pressed', () => {
- const setTxAmountLimit = jest.fn().mockResolvedValue(undefined);
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({ setTxAmountLimit }),
- );
-
- renderScreen();
-
- fireEvent.press(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.THRESHOLD_OPTION(100),
- ),
- );
-
- expect(setTxAmountLimit).toHaveBeenCalledWith(100);
- });
-
- it('calls toggleTraderNotification when a trader switch is pressed', () => {
- const toggleTraderNotification = jest.fn().mockResolvedValue(undefined);
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({ toggleTraderNotification }),
- );
-
- renderScreen();
-
- fireEvent(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_TOGGLE('trader-1'),
- ),
- 'valueChange',
- false,
- );
-
- expect(toggleTraderNotification).toHaveBeenCalledWith('trader-1');
- });
-
- it('navigates to the trader profile when a trader username is pressed', () => {
- renderScreen();
-
- fireEvent.press(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_PRESS('trader-1'),
- ),
- );
-
- expect(mockNavigate).toHaveBeenCalledWith('TraderProfileView', {
- traderId: 'trader-1',
- traderName: 'dutchiono',
- });
- });
-
- it('calls setEnabled when the global toggle is pressed', () => {
- const setEnabled = jest.fn().mockResolvedValue(undefined);
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({ setEnabled }),
- );
-
- renderScreen();
-
- fireEvent(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.GLOBAL_TOGGLE,
- ),
- 'valueChange',
- false,
- );
-
- expect(setEnabled).toHaveBeenCalledWith(false);
- });
- });
-
- describe('haptic feedback', () => {
- const originalPlatform = Platform.OS;
-
- afterEach(() => {
- Platform.OS = originalPlatform;
- });
-
- it('fires a medium impact when toggling the master switch on iOS', () => {
- Platform.OS = 'ios';
- renderScreen();
-
- fireEvent(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.GLOBAL_TOGGLE,
- ),
- 'valueChange',
- false,
- );
-
- expect(mockImpactAsync).toHaveBeenCalledTimes(1);
- expect(mockImpactAsync).toHaveBeenCalledWith(ImpactFeedbackStyle.Medium);
- });
-
- it('fires a medium impact when toggling the master switch on Android', () => {
- Platform.OS = 'android';
- renderScreen();
-
- fireEvent(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.GLOBAL_TOGGLE,
- ),
- 'valueChange',
- false,
- );
-
- expect(mockImpactAsync).toHaveBeenCalledTimes(1);
- expect(mockImpactAsync).toHaveBeenCalledWith(ImpactFeedbackStyle.Medium);
- });
-
- it('fires a light impact when toggling a per-trader switch on Android', () => {
- Platform.OS = 'android';
- renderScreen();
-
- fireEvent(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_TOGGLE('trader-1'),
- ),
- 'valueChange',
- false,
- );
-
- expect(mockImpactAsync).toHaveBeenCalledTimes(1);
- expect(mockImpactAsync).toHaveBeenCalledWith(ImpactFeedbackStyle.Light);
- });
-
- it('does not fire a haptic when toggling a per-trader switch on iOS', () => {
- Platform.OS = 'ios';
- renderScreen();
-
- fireEvent(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_TOGGLE('trader-1'),
- ),
- 'valueChange',
- false,
- );
-
- expect(mockImpactAsync).not.toHaveBeenCalled();
- expect(mockPlayImpact).not.toHaveBeenCalled();
- });
-
- it('fires catalog impact when changing the threshold to a new value', () => {
- Platform.OS = 'ios';
- renderScreen();
-
- fireEvent.press(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.THRESHOLD_OPTION(100),
- ),
- );
-
- expect(mockPlayImpact).toHaveBeenCalledTimes(1);
- expect(mockPlayImpact).toHaveBeenCalledWith(
- ImpactMoment.QuickAmountSelection,
- );
- });
-
- it('does not fire a haptic when re-tapping the already-selected threshold', () => {
- const setTxAmountLimit = jest.fn().mockResolvedValue(undefined);
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({
- preferences: makePreferences({ txAmountLimit: 500 }),
- setTxAmountLimit,
- }),
- );
-
- renderScreen();
-
- fireEvent.press(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.THRESHOLD_OPTION(500),
- ),
- );
-
- expect(mockPlayImpact).not.toHaveBeenCalled();
- expect(setTxAmountLimit).not.toHaveBeenCalled();
- });
-
- it('does not fire a haptic when pressing the back button', () => {
- renderScreen();
-
- fireEvent.press(
- screen.getByTestId(NotificationPreferencesViewSelectorsIDs.BACK_BUTTON),
- );
-
- expect(mockImpactAsync).not.toHaveBeenCalled();
- expect(mockPlayImpact).not.toHaveBeenCalled();
- });
-
- it('does not fire a haptic when pressing a trader row to navigate to the profile', () => {
- renderScreen();
-
- fireEvent.press(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_PRESS('trader-1'),
- ),
- );
-
- expect(mockImpactAsync).not.toHaveBeenCalled();
- expect(mockPlayImpact).not.toHaveBeenCalled();
- });
-
- it('does not fire a haptic when toggling a per-trader switch while the master toggle is off', () => {
- const toggleTraderNotification = jest.fn().mockResolvedValue(undefined);
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({
- preferences: makePreferences({ enabled: false }),
- toggleTraderNotification,
- }),
- );
-
- renderScreen();
-
- fireEvent(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.TRADER_TOGGLE('trader-1'),
- ),
- 'valueChange',
- false,
- );
-
- expect(mockImpactAsync).not.toHaveBeenCalled();
- expect(mockPlayImpact).not.toHaveBeenCalled();
- expect(toggleTraderNotification).not.toHaveBeenCalled();
- });
-
- it('does not fire a haptic when tapping a threshold while the master toggle is off', () => {
- const setTxAmountLimit = jest.fn().mockResolvedValue(undefined);
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({
- preferences: makePreferences({ enabled: false }),
- setTxAmountLimit,
- }),
- );
-
- renderScreen();
-
- fireEvent.press(
- screen.getByTestId(
- NotificationPreferencesViewSelectorsIDs.THRESHOLD_OPTION(100),
- ),
- );
-
- expect(mockImpactAsync).not.toHaveBeenCalled();
- expect(mockPlayImpact).not.toHaveBeenCalled();
- expect(setTxAmountLimit).not.toHaveBeenCalled();
- });
-
- it('does not fire a haptic when the master toggle handler is invoked while preferences are loading', () => {
- const setEnabled = jest.fn().mockResolvedValue(undefined);
- mockUseNotificationPreferences.mockReturnValue(
- makeUseNotificationPreferencesResult({
- isLoading: true,
- preferences: makePreferences({ enabled: true }),
- setEnabled,
- }),
- );
-
- renderScreen();
-
- // The Switch is hidden behind a skeleton during loading, so this is a
- // belt-and-suspenders defense: if the handler ever gets invoked from
- // a stale render or accessibility path, the haptic must stay silent.
- expect(
- screen.queryByTestId(
- NotificationPreferencesViewSelectorsIDs.GLOBAL_TOGGLE,
- ),
- ).toBeNull();
- expect(mockImpactAsync).not.toHaveBeenCalled();
- expect(mockPlayImpact).not.toHaveBeenCalled();
- expect(setEnabled).not.toHaveBeenCalled();
- });
- });
-});
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/NotificationPreferencesView.tsx b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/NotificationPreferencesView.tsx
deleted file mode 100644
index 3807064e28c..00000000000
--- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/NotificationPreferencesView.tsx
+++ /dev/null
@@ -1,413 +0,0 @@
-import React, { useCallback } from 'react';
-import {
- Image,
- ScrollView,
- Switch,
- TouchableOpacity,
- View,
-} from 'react-native';
-import { useNavigation, type NavigationProp } from '@react-navigation/native';
-import { useSelector } from 'react-redux';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import {
- Box,
- Text,
- TextVariant,
- ButtonIcon,
- ButtonIconSize,
- IconName,
- BoxFlexDirection,
- BoxAlignItems,
- BoxJustifyContent,
- TextColor,
- FontWeight,
- AvatarBase,
- AvatarBaseSize,
-} from '@metamask/design-system-react-native';
-import { useTheme } from '../../../../util/theme';
-import { strings } from '../../../../../locales/i18n';
-import Routes from '../../../../constants/navigation/Routes';
-import type { RootStackParamList } from '../../../../core/NavigationService/types';
-import {
- fireSwitchHaptic,
- ImpactFeedbackStyle,
- playImpact,
- ImpactMoment,
-} from '../../../../util/haptics';
-import { NotificationPreferencesViewSelectorsIDs } from './NotificationPreferencesView.testIds';
-import {
- useNotificationPreferences,
- useFollowedTraders,
- type TxAmountThreshold,
-} from './hooks';
-import { selectSocialLeaderboardEnabled } from '../../../../selectors/featureFlagController/socialLeaderboard';
-import { selectCurrentCurrency } from '../../../../selectors/currencyRateController';
-import ErrorState from '../../Homepage/components/ErrorState/ErrorState';
-import {
- PreferencesSkeleton,
- TradersFollowedSkeleton,
-} from './components/Skeletons';
-import ThresholdRadioList from './components/ThresholdRadioList';
-
-const AVATAR_SIZE = 40;
-
-// ---------------------------------------------------------------------------
-// Per-trader row
-// ---------------------------------------------------------------------------
-
-interface TraderNotificationRowProps {
- traderId: string;
- username: string;
- avatarUri?: string;
- isEnabled: boolean;
- isDisabled: boolean;
- onToggle: (traderId: string) => void;
- onPress: (traderId: string, username: string) => void;
-}
-
-const TraderNotificationRow: React.FC = ({
- traderId,
- username,
- avatarUri,
- isEnabled,
- isDisabled,
- onToggle,
- onPress,
-}) => {
- const tw = useTailwind();
- const { colors, brandColors } = useTheme();
-
- return (
-
- onPress(traderId, username)}
- accessibilityRole="button"
- style={tw.style('flex-row items-center gap-3 flex-1 min-w-0 mr-3')}
- testID={NotificationPreferencesViewSelectorsIDs.TRADER_PRESS(traderId)}
- >
- {avatarUri ? (
-
- ) : (
-
- )}
-
-
- {username}
-
-
-
- onToggle(traderId)}
- disabled={isDisabled}
- trackColor={{
- true: colors.primary.default,
- false: colors.border.muted,
- }}
- thumbColor={brandColors.white}
- ios_backgroundColor={colors.border.muted}
- testID={NotificationPreferencesViewSelectorsIDs.TRADER_TOGGLE(traderId)}
- />
-
- );
-};
-
-// ---------------------------------------------------------------------------
-// Main screen
-// ---------------------------------------------------------------------------
-
-/**
- * NotificationPreferencesView — notification settings for the Top Traders feature.
- *
- * Lets the user control:
- * - Global push notification toggle (socialAI.enabled)
- * - Minimum trade size threshold (socialAI.txAmountLimit)
- * - Per-trader mute list (socialAI.mutedTraderProfileIds — opt-out semantics: traders absent from this list receive notifications)
- *
- * The "Traders you follow" section is sourced from
- * `SocialService:fetchFollowing` (via `useFollowedTraders`) so it surfaces
- * every trader the user follows — not just the ones that happen to be in
- * the cached top-leaderboard slice. Preferences themselves are persisted
- * through `AuthenticatedUserStorageService` (via `useNotificationPreferences`).
- */
-const NotificationPreferencesView = () => {
- const navigation = useNavigation>();
- const tw = useTailwind();
- const { colors, brandColors } = useTheme();
- const isEnabled = useSelector(selectSocialLeaderboardEnabled);
- const currentCurrency = useSelector(selectCurrentCurrency);
-
- const {
- traders: followedTraders,
- isLoading: isLoadingFollowed,
- error: followedError,
- refresh: refreshFollowed,
- } = useFollowedTraders({ enabled: isEnabled });
-
- const {
- preferences,
- isLoading: isLoadingPreferences,
- setEnabled,
- setTxAmountLimit,
- toggleTraderNotification,
- isTraderNotificationEnabled,
- } = useNotificationPreferences();
-
- const handleBack = useCallback(() => {
- navigation.goBack();
- }, [navigation]);
-
- const handleTraderPress = useCallback(
- (traderId: string, traderName: string) => {
- navigation.navigate(Routes.SOCIAL_LEADERBOARD.PROFILE, {
- traderId,
- traderName,
- });
- },
- [navigation],
- );
-
- // On a cold (re)entry the GET is in flight, `preferences` falls back to
- // defaults (`enabled: false`), and binding that straight into the Switch
- // would render a visibly OFF toggle until the fetch resolves — even when
- // the user previously had it ON. Render a skeleton instead; interaction
- // remains disabled until we have authoritative server state.
- const showPreferencesSkeleton = isLoadingPreferences;
- // Treat "still loading" identically to "disabled" so the traders section
- // never renders in a muted/disabled state based on the false default while
- // the preferences GET is in flight.
- const globalOff = isLoadingPreferences || !preferences.enabled;
-
- // Master switch is the gating control for the whole feature, so it should
- // feel weightier than the native iOS UISwitch tick — `override: true`
- // ensures the Medium impact also fires on iOS. Skipped while preferences
- // are still loading even though the switch is hidden behind the skeleton,
- // to keep the haptic and the persisted call symmetric.
- const handleSetEnabled = useCallback(
- (value: boolean) => {
- if (isLoadingPreferences) {
- return Promise.resolve();
- }
- fireSwitchHaptic(ImpactFeedbackStyle.Medium, { override: true });
- return setEnabled(value);
- },
- [isLoadingPreferences, setEnabled],
- );
-
- // Per-trader switch is a subordinate toggle; rely on iOS UISwitch's native
- // tick on iOS, fire a Light impact only on Android where there is none.
- // The Switch is also rendered with `disabled={globalOff}`, but we guard
- // here too so the haptic never leaks through if the callback fires while
- // the row is disabled (mid-interaction state flips, a11y taps, etc.).
- const handleToggleTrader = useCallback(
- (traderId: string) => {
- if (globalOff) {
- return Promise.resolve();
- }
- fireSwitchHaptic(ImpactFeedbackStyle.Light);
- return toggleTraderNotification(traderId);
- },
- [globalOff, toggleTraderNotification],
- );
-
- // Threshold rows are TouchableOpacity (no native iOS haptic), so fire on
- // both platforms — but only when the value actually changes, to avoid a
- // phantom buzz when the user re-taps the already-selected option. Also
- // guarded against firing while the section is disabled by the master
- // toggle, even though the row's TouchableOpacity is itself `disabled`.
- const handleSetTxAmountLimit = useCallback(
- (value: TxAmountThreshold) => {
- if (globalOff || value === preferences.txAmountLimit) {
- return Promise.resolve();
- }
- playImpact(ImpactMoment.QuickAmountSelection);
- return setTxAmountLimit(value);
- },
- [globalOff, preferences.txAmountLimit, setTxAmountLimit],
- );
-
- const showFollowedError =
- Boolean(followedError) && followedTraders.length === 0;
- const showFollowedLoading =
- !showFollowedError && isLoadingFollowed && followedTraders.length === 0;
- const showFollowedEmpty =
- !showFollowedError && !isLoadingFollowed && followedTraders.length === 0;
-
- return (
-
- {/* Header */}
-
-
-
- {strings('social_leaderboard.notification_preferences.title')}
-
- {/* Spacer to keep title centred */}
-
-
-
-
- {/* ── Global toggle + thresholds ────────────────────────────── */}
- {showPreferencesSkeleton ? (
-
- ) : (
- <>
-
-
- {strings(
- 'social_leaderboard.notification_preferences.allow_push_notifications',
- )}
-
-
-
-
-
-
-
- >
- )}
-
- {/* Separator */}
-
-
-
-
- {strings(
- 'social_leaderboard.notification_preferences.traders_you_follow',
- )}
-
-
- {strings(
- 'social_leaderboard.notification_preferences.traders_you_follow_desc',
- )}
-
-
-
- {showFollowedError ? (
-
-
-
- ) : showFollowedLoading ? (
-
-
-
- ) : showFollowedEmpty ? (
-
-
- {strings(
- 'social_leaderboard.notification_preferences.traders_you_follow_empty',
- )}
-
-
- ) : (
- followedTraders.map((trader) => (
-
- ))
- )}
-
-
- );
-};
-
-export default NotificationPreferencesView;
diff --git a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/index.ts b/app/components/Views/SocialLeaderboard/NotificationPreferencesView/index.ts
deleted file mode 100644
index 302faa1b1bd..00000000000
--- a/app/components/Views/SocialLeaderboard/NotificationPreferencesView/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './NotificationPreferencesView';
diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx
index f2603e7c76c..5f90c191b25 100644
--- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.test.tsx
@@ -1,6 +1,7 @@
import { act, fireEvent, screen } from '@testing-library/react-native';
import React from 'react';
import Logger from '../../../../util/Logger';
+import Routes from '../../../../constants/navigation/Routes';
import renderWithProvider from '../../../../util/test/renderWithProvider';
import type { UseTopTradersResult } from '../../Homepage/Sections/TopTraders/hooks/useTopTraders';
import type { TopTrader } from '../../Homepage/Sections/TopTraders/types';
@@ -15,6 +16,7 @@ const mockGoBack = jest.fn();
const mockNavigate = jest.fn();
const mockToggleFollow = jest.fn();
const mockRefresh = jest.fn();
+const mockHasNotificationPreferences = jest.fn(() => true);
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
@@ -83,11 +85,22 @@ jest.mock('../../Homepage/Sections/TopTraders/hooks', () => ({
useTopTraders: () => mockUseTopTradersHook(),
}));
+jest.mock(
+ '../../Settings/NotificationsSettings/hooks/useNotificationStoragePreferences',
+ () => ({
+ useNotificationStoragePreferences: () => ({
+ hasNotificationPreferences: mockHasNotificationPreferences(),
+ isLoading: false,
+ }),
+ }),
+);
+
describe('TopTradersView', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseTopTradersHook.mockImplementation(() => defaultUseTopTradersResult);
mockSelectSocialLeaderboardEnabled.mockReturnValue(true);
+ mockHasNotificationPreferences.mockReturnValue(true);
});
it('renders the container', () => {
@@ -115,12 +128,33 @@ describe('TopTradersView', () => {
).toBeOnTheScreen();
});
- it('navigates to NotificationPreferencesView when notification button is pressed', () => {
+ it('navigates to the socialAI notification settings section when notification button is pressed and preferences exist', () => {
renderWithProvider();
fireEvent.press(
screen.getByTestId(TopTradersViewSelectorsIDs.NOTIFICATION_BUTTON),
);
- expect(mockNavigate).toHaveBeenCalledWith('NotificationPreferencesView');
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATION_SETTINGS_SECTION,
+ params: {
+ type: 'socialAI',
+ title: 'Trading Signals',
+ description:
+ 'Updates from traders and assets you follow, plus currated market news',
+ },
+ });
+ });
+
+ it('navigates to notification settings when preferences do not exist yet', () => {
+ mockHasNotificationPreferences.mockReturnValue(false);
+
+ renderWithProvider();
+ fireEvent.press(
+ screen.getByTestId(TopTradersViewSelectorsIDs.NOTIFICATION_BUTTON),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATIONS,
+ });
});
it('renders all traders', () => {
diff --git a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx
index 0ad29653b81..56ae12f9df6 100644
--- a/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx
+++ b/app/components/Views/SocialLeaderboard/TopTradersView/TopTradersView.tsx
@@ -35,6 +35,7 @@ import { selectSocialLeaderboardEnabled } from '../../../../selectors/featureFla
import { fontStyles } from '../../../../styles/common';
import Logger from '../../../../util/Logger';
import { useTheme } from '../../../../util/theme';
+import { useNotificationStoragePreferences } from '../../Settings/NotificationsSettings/hooks/useNotificationStoragePreferences';
import {
TraderRow,
TraderRowSkeleton,
@@ -128,6 +129,10 @@ const TopTradersView = () => {
const { colors } = useTheme();
const { height: windowHeight } = useWindowDimensions();
const isEnabled = useSelector(selectSocialLeaderboardEnabled);
+ const {
+ hasNotificationPreferences,
+ isLoading: isLoadingNotificationPreferences,
+ } = useNotificationStoragePreferences();
const [selectedChain, setSelectedChain] = useState('all');
const [refreshing, setRefreshing] = useState(false);
@@ -169,8 +174,30 @@ const TopTradersView = () => {
}, [navigation]);
const handleNotificationPreferencesPress = useCallback(() => {
- navigation.navigate(Routes.SOCIAL_LEADERBOARD.NOTIFICATION_PREFERENCES);
- }, [navigation]);
+ if (isLoadingNotificationPreferences) {
+ return;
+ }
+
+ if (!hasNotificationPreferences) {
+ navigation.navigate(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATIONS,
+ });
+ return;
+ }
+
+ navigation.navigate(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATION_SETTINGS_SECTION,
+ params: {
+ type: 'socialAI',
+ title: strings('app_settings.notifications_opts.social_ai_title'),
+ description: strings('app_settings.notifications_opts.social_ai_desc'),
+ },
+ });
+ }, [
+ hasNotificationPreferences,
+ isLoadingNotificationPreferences,
+ navigation,
+ ]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx
index bcad26a1f59..6f3048d71da 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.test.tsx
@@ -1,3 +1,4 @@
+import { DEFAULT_SOCIAL_AI_PREFERENCES } from '@metamask/notification-services-controller/notification-services';
import type {
Position,
TraderProfileResponse,
@@ -7,6 +8,7 @@ import React from 'react';
import Routes from '../../../../constants/navigation/Routes';
import { ImpactMoment } from '../../../../util/haptics';
import renderWithProvider from '../../../../util/test/renderWithProvider';
+import type { SocialAIPreference } from '../NotificationPreferences/hooks';
import type { UseTraderPositionsResult } from './hooks/useTraderPositions';
import type { UseTraderProfileResult } from './hooks/useTraderProfile';
import TraderProfileView from './TraderProfileView';
@@ -37,23 +39,26 @@ jest.mock(
}),
);
-let mockNotificationPreferences = {
- enabled: false,
- txAmountLimit: 500 as const,
- mutedTraderProfileIds: [] as string[],
+let mockNotificationPreferences: SocialAIPreference = {
+ ...DEFAULT_SOCIAL_AI_PREFERENCES,
+ mutedTraderProfileIds: [
+ ...DEFAULT_SOCIAL_AI_PREFERENCES.mutedTraderProfileIds,
+ ],
};
-const mockSetEnabled = jest.fn();
+const mockSetPushNotificationsEnabled = jest.fn();
const mockSetTxAmountLimit = jest.fn();
const mockToggleTraderNotification = jest.fn();
const mockIsTraderNotificationEnabled = jest.fn().mockReturnValue(true);
+const mockHasNotificationPreferences = jest.fn(() => true);
-jest.mock('../NotificationPreferencesView/hooks', () => ({
- ...jest.requireActual('../NotificationPreferencesView/hooks'),
+jest.mock('../NotificationPreferences/hooks', () => ({
+ ...jest.requireActual('../NotificationPreferences/hooks'),
useNotificationPreferences: () => ({
preferences: mockNotificationPreferences,
+ hasNotificationPreferences: mockHasNotificationPreferences(),
isLoading: false,
error: null,
- setEnabled: mockSetEnabled,
+ setPushNotificationsEnabled: mockSetPushNotificationsEnabled,
setTxAmountLimit: mockSetTxAmountLimit,
toggleTraderNotification: mockToggleTraderNotification,
isTraderNotificationEnabled: mockIsTraderNotificationEnabled,
@@ -290,10 +295,12 @@ describe('TraderProfileView', () => {
mockRefresh.mockResolvedValue(undefined);
mockRefetchPositions.mockResolvedValue(undefined);
mockNotificationPreferences = {
- enabled: false,
- txAmountLimit: 500 as const,
- mutedTraderProfileIds: [],
+ ...DEFAULT_SOCIAL_AI_PREFERENCES,
+ mutedTraderProfileIds: [
+ ...DEFAULT_SOCIAL_AI_PREFERENCES.mutedTraderProfileIds,
+ ],
};
+ mockHasNotificationPreferences.mockReturnValue(true);
mockIsTraderNotificationEnabled.mockReturnValue(true);
});
@@ -424,11 +431,27 @@ describe('TraderProfileView', () => {
});
describe('notification bell routing', () => {
- it('opens the setup sheet when global notifications are off', () => {
+ it('navigates to notification settings when preferences do not exist yet', () => {
+ mockHasNotificationPreferences.mockReturnValue(false);
+
+ renderWithProvider();
+
+ fireEvent.press(
+ screen.getByTestId(TraderProfileViewSelectorsIDs.NOTIFICATION_BUTTON),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATIONS,
+ });
+ });
+
+ it('opens the setup sheet when push notifications are off', () => {
mockNotificationPreferences = {
- enabled: false,
- txAmountLimit: 500 as const,
- mutedTraderProfileIds: [],
+ ...DEFAULT_SOCIAL_AI_PREFERENCES,
+ pushNotificationsEnabled: false,
+ mutedTraderProfileIds: [
+ ...DEFAULT_SOCIAL_AI_PREFERENCES.mutedTraderProfileIds,
+ ],
};
renderWithProvider();
@@ -444,11 +467,13 @@ describe('TraderProfileView', () => {
).toBeOnTheScreen();
});
- it('opens the per-trader sheet when global notifications are on', () => {
+ it('opens the per-trader sheet when push notifications are on', () => {
mockNotificationPreferences = {
- enabled: true,
- txAmountLimit: 500 as const,
- mutedTraderProfileIds: [],
+ ...DEFAULT_SOCIAL_AI_PREFERENCES,
+ pushNotificationsEnabled: true,
+ mutedTraderProfileIds: [
+ ...DEFAULT_SOCIAL_AI_PREFERENCES.mutedTraderProfileIds,
+ ],
};
renderWithProvider();
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx
index 4aa62279161..470619bec70 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/TraderProfileView.tsx
@@ -53,7 +53,7 @@ import {
PositionRowSkeleton,
} from './components/Skeletons';
import ErrorState from '../../Homepage/components/ErrorState/ErrorState';
-import { useNotificationPreferences } from '../NotificationPreferencesView/hooks';
+import { useNotificationPreferences } from '../NotificationPreferences/hooks';
import TraderNotificationsBottomSheet, {
type TraderNotificationsBottomSheetRef,
} from './components/TraderNotificationsBottomSheet';
@@ -129,11 +129,10 @@ const TraderProfileView = () => {
const {
preferences,
+ hasNotificationPreferences,
isLoading: isLoadingPreferences,
- setEnabled,
+ setPushNotificationsEnabled,
setTxAmountLimit,
- toggleTraderNotification,
- isTraderNotificationEnabled,
} = useNotificationPreferences();
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
@@ -163,15 +162,26 @@ const TraderProfileView = () => {
const handleNotificationPress = useCallback(() => {
// Don't open any sheet while preferences are still loading — the enabled
- // default is false, which would incorrectly route to the setup sheet for
- // users who already have notifications enabled.
+ // default may not match the server, which would incorrectly route users
+ // before their saved preferences are available.
if (isLoadingPreferences) return;
- if (preferences.enabled) {
+ if (!hasNotificationPreferences) {
+ navigation.navigate(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATIONS,
+ });
+ return;
+ }
+ if (preferences.pushNotificationsEnabled) {
notificationsSheetRef.current?.onOpenBottomSheet();
} else {
setupSheetRef.current?.onOpenBottomSheet();
}
- }, [isLoadingPreferences, preferences.enabled]);
+ }, [
+ hasNotificationPreferences,
+ isLoadingPreferences,
+ navigation,
+ preferences.pushNotificationsEnabled,
+ ]);
const handlePositionPress = useCallback(
(position: Position) => {
@@ -383,7 +393,7 @@ const TraderProfileView = () => {
@@ -391,9 +401,6 @@ const TraderProfileView = () => {
ref={notificationsSheetRef}
traderId={traderId}
traderName={profile?.profile.name ?? traderName}
- preferences={preferences}
- isTraderNotificationEnabled={isTraderNotificationEnabled}
- toggleTraderNotification={toggleTraderNotification}
/>
);
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TopTradersNotificationsSetupBottomSheet/TopTradersNotificationsSetupBottomSheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TopTradersNotificationsSetupBottomSheet/TopTradersNotificationsSetupBottomSheet.test.tsx
index cd4e2109eb2..79f24d93574 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TopTradersNotificationsSetupBottomSheet/TopTradersNotificationsSetupBottomSheet.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TopTradersNotificationsSetupBottomSheet/TopTradersNotificationsSetupBottomSheet.test.tsx
@@ -1,5 +1,6 @@
import React, { useRef, useEffect } from 'react';
import { screen, fireEvent, act } from '@testing-library/react-native';
+import { DEFAULT_SOCIAL_AI_PREFERENCES } from '@metamask/notification-services-controller/notification-services';
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
import TopTradersNotificationsSetupBottomSheet, {
type TopTradersNotificationsSetupBottomSheetRef,
@@ -9,9 +10,9 @@ import { strings } from '../../../../../../../locales/i18n';
import type {
SocialAIPreference,
TxAmountThreshold,
-} from '../../../NotificationPreferencesView/hooks';
+} from '../../../NotificationPreferences/hooks';
-const mockSetEnabled = jest.fn();
+const mockSetPushNotificationsEnabled = jest.fn();
const mockSetTxAmountLimit = jest.fn();
jest.mock('../../../../../../selectors/currencyRateController', () => ({
@@ -59,9 +60,10 @@ jest.mock(
const makePreferences = (
overrides: Partial = {},
): SocialAIPreference => ({
- enabled: false,
- txAmountLimit: 500 as TxAmountThreshold,
- mutedTraderProfileIds: [],
+ ...DEFAULT_SOCIAL_AI_PREFERENCES,
+ mutedTraderProfileIds: [
+ ...DEFAULT_SOCIAL_AI_PREFERENCES.mutedTraderProfileIds,
+ ],
...overrides,
});
@@ -84,7 +86,7 @@ const OpenedSheet: React.FC = ({
@@ -103,7 +105,7 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
,
);
@@ -167,7 +169,9 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
it('renders the threshold options', () => {
renderWithProvider(
- ,
+ ,
);
expect(
@@ -181,9 +185,11 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
});
describe('toggle', () => {
- it('renders the toggle as off by default when global notifications are disabled', () => {
+ it('renders the toggle as off when push notifications are disabled', () => {
renderWithProvider(
- ,
+ ,
);
const toggle = screen.getByTestId(
@@ -193,9 +199,11 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
expect(toggle.props.value).toBe(false);
});
- it('renders the toggle as on when global notifications are enabled', () => {
+ it('renders the toggle as on when push notifications are enabled', () => {
renderWithProvider(
- ,
+ ,
);
const toggle = screen.getByTestId(
@@ -205,8 +213,12 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
expect(toggle.props.value).toBe(true);
});
- it('calls setEnabled with true when the toggle is turned on', () => {
- renderWithProvider();
+ it('calls setPushNotificationsEnabled with true when the toggle is turned on', () => {
+ renderWithProvider(
+ ,
+ );
fireEvent(
screen.getByTestId(
@@ -216,12 +228,14 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
true,
);
- expect(mockSetEnabled).toHaveBeenCalledWith(true);
+ expect(mockSetPushNotificationsEnabled).toHaveBeenCalledWith(true);
});
- it('calls setEnabled with false when the toggle is turned off', () => {
+ it('calls setPushNotificationsEnabled with false when the toggle is turned off', () => {
renderWithProvider(
- ,
+ ,
);
fireEvent(
@@ -232,14 +246,16 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
false,
);
- expect(mockSetEnabled).toHaveBeenCalledWith(false);
+ expect(mockSetPushNotificationsEnabled).toHaveBeenCalledWith(false);
});
});
describe('threshold selection', () => {
it('calls setTxAmountLimit when a threshold option is pressed', () => {
renderWithProvider(
- ,
+ ,
);
fireEvent.press(
@@ -253,9 +269,11 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
expect(mockSetTxAmountLimit).toHaveBeenCalledWith(100);
});
- it('disables threshold options when global notifications are off', () => {
+ it('disables threshold options when push notifications are off', () => {
renderWithProvider(
- ,
+ ,
);
const option = screen.getByTestId(
@@ -285,7 +303,7 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
expect(mockOnDismiss).toHaveBeenCalledTimes(1);
});
- it('does not call setEnabled when save is pressed', () => {
+ it('does not call setPushNotificationsEnabled when save is pressed', () => {
renderWithProvider();
act(() => {
@@ -296,7 +314,7 @@ describe('TopTradersNotificationsSetupBottomSheet', () => {
);
});
- expect(mockSetEnabled).not.toHaveBeenCalled();
+ expect(mockSetPushNotificationsEnabled).not.toHaveBeenCalled();
});
});
});
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TopTradersNotificationsSetupBottomSheet/TopTradersNotificationsSetupBottomSheet.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TopTradersNotificationsSetupBottomSheet/TopTradersNotificationsSetupBottomSheet.tsx
index a511ac1f50e..3810c90279c 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TopTradersNotificationsSetupBottomSheet/TopTradersNotificationsSetupBottomSheet.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TopTradersNotificationsSetupBottomSheet/TopTradersNotificationsSetupBottomSheet.tsx
@@ -14,24 +14,23 @@ import HeaderCompactStandard from '../../../../../../component-library/component
import { ButtonVariants } from '../../../../../../component-library/components/Buttons/Button/Button.types';
import { strings } from '../../../../../../../locales/i18n';
import { selectCurrentCurrency } from '../../../../../../selectors/currencyRateController';
-import type {
- SocialAIPreference,
- TxAmountThreshold,
-} from '../../../NotificationPreferencesView/hooks';
-import AllowPushNotificationsRow from '../../../NotificationPreferencesView/components/AllowPushNotificationsRow';
-import ThresholdRadioList from '../../../NotificationPreferencesView/components/ThresholdRadioList';
+import { type TxAmountThreshold } from '../../../NotificationPreferences/hooks';
+import AllowPushNotificationsRow from '../../../NotificationPreferences/components/AllowPushNotificationsRow';
+import ThresholdRadioList from '../../../NotificationPreferences/components/ThresholdRadioList';
import { TopTradersNotificationsSetupBottomSheetSelectorsIDs } from './TopTradersNotificationsSetupBottomSheet.testIds';
import {
useControllableBottomSheet,
type ControllableBottomSheetRef,
} from '../hooks/useControllableBottomSheet';
+import { DEFAULT_SOCIAL_AI_PREFERENCES } from '@metamask/notification-services-controller';
+import type { SocialAIPreference } from '@metamask/authenticated-user-storage';
export type TopTradersNotificationsSetupBottomSheetRef =
ControllableBottomSheetRef;
interface TopTradersNotificationsSetupBottomSheetProps {
preferences: SocialAIPreference;
- setEnabled: (value: boolean) => void | Promise;
+ setPushNotificationsEnabled: (value: boolean) => void | Promise;
setTxAmountLimit: (value: TxAmountThreshold) => void | Promise;
onDismiss?: () => void;
}
@@ -39,92 +38,103 @@ interface TopTradersNotificationsSetupBottomSheetProps {
const TopTradersNotificationsSetupBottomSheet = forwardRef<
TopTradersNotificationsSetupBottomSheetRef,
TopTradersNotificationsSetupBottomSheetProps
->(({ preferences, setEnabled, setTxAmountLimit, onDismiss }, ref) => {
- const tw = useTailwind();
- const currentCurrency = useSelector(selectCurrentCurrency);
+>(
+ (
+ { preferences, setPushNotificationsEnabled, setTxAmountLimit, onDismiss },
+ ref,
+ ) => {
+ const tw = useTailwind();
+ const currentCurrency = useSelector(selectCurrentCurrency);
+ const pushNotificationsEnabled = Boolean(
+ preferences.pushNotificationsEnabled,
+ );
- const { sheetRef, isVisible, closeSheet, handleSheetClosed } =
- useControllableBottomSheet({ ref, onDismiss });
+ const { sheetRef, isVisible, closeSheet, handleSheetClosed } =
+ useControllableBottomSheet({ ref, onDismiss });
- const handleSave = useCallback(() => {
- closeSheet();
- }, [closeSheet]);
+ const handleSave = useCallback(() => {
+ closeSheet();
+ }, [closeSheet]);
- if (!isVisible) {
- return null;
- }
+ if (!isVisible) {
+ return null;
+ }
- return (
-
-
-
-
- {strings('social_leaderboard.trader_notifications_setup.description')}
-
+
-
+
+ {strings('social_leaderboard.trader_notifications_setup.description')}
+
-
+
-
+
-
-
- );
-});
+
+
+
+
+ );
+ },
+);
export default TopTradersNotificationsSetupBottomSheet;
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx
index 88e0a683590..58dd99dc3cb 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.test.tsx
@@ -1,6 +1,7 @@
import React, { useRef, useEffect } from 'react';
import { Platform } from 'react-native';
import { screen, fireEvent, act } from '@testing-library/react-native';
+import { DEFAULT_SOCIAL_AI_PREFERENCES } from '@metamask/notification-services-controller/notification-services';
import {
ImpactFeedbackStyle,
ImpactMoment,
@@ -13,11 +14,13 @@ import TraderNotificationsBottomSheet, {
import { TraderNotificationsBottomSheetSelectorsIDs } from './TraderNotificationsBottomSheet.testIds';
import Routes from '../../../../../../constants/navigation/Routes';
import { strings } from '../../../../../../../locales/i18n';
-import type { SocialAIPreference } from '../../../NotificationPreferencesView/hooks';
+import type { SocialAIPreference } from '../../../NotificationPreferences/hooks';
const mockNavigate = jest.fn();
const mockToggleTraderNotification = jest.fn();
const mockIsTraderNotificationEnabled = jest.fn().mockReturnValue(true);
+let mockPreferences: SocialAIPreference;
+const mockHasNotificationPreferences = jest.fn(() => true);
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
@@ -27,6 +30,20 @@ jest.mock('@react-navigation/native', () => {
};
});
+jest.mock('../../../NotificationPreferences/hooks', () => ({
+ ...jest.requireActual('../../../NotificationPreferences/hooks'),
+ useNotificationPreferences: () => ({
+ preferences: mockPreferences,
+ hasNotificationPreferences: mockHasNotificationPreferences(),
+ isLoading: false,
+ error: null,
+ setPushNotificationsEnabled: jest.fn(),
+ setTxAmountLimit: jest.fn(),
+ toggleTraderNotification: mockToggleTraderNotification,
+ isTraderNotificationEnabled: mockIsTraderNotificationEnabled,
+ }),
+}));
+
jest.mock('expo-haptics', () => ({
impactAsync: jest.fn(),
ImpactFeedbackStyle: {
@@ -92,25 +109,22 @@ jest.mock(
const makePreferences = (
overrides: Partial = {},
): SocialAIPreference => ({
- enabled: true,
- txAmountLimit: 500,
- mutedTraderProfileIds: [],
+ ...DEFAULT_SOCIAL_AI_PREFERENCES,
+ mutedTraderProfileIds: [
+ ...DEFAULT_SOCIAL_AI_PREFERENCES.mutedTraderProfileIds,
+ ],
...overrides,
});
interface OpenedSheetProps {
traderId?: string;
traderName?: string;
- preferences?: SocialAIPreference;
- isTraderNotificationEnabled?: (id: string) => boolean;
onDismiss?: () => void;
}
const OpenedSheet: React.FC = ({
traderId = 'trader-1',
traderName = 'dutchiono',
- preferences = makePreferences(),
- isTraderNotificationEnabled = mockIsTraderNotificationEnabled,
onDismiss,
}) => {
const ref = useRef(null);
@@ -124,17 +138,34 @@ const OpenedSheet: React.FC = ({
ref={ref}
traderId={traderId}
traderName={traderName}
- preferences={preferences}
- isTraderNotificationEnabled={isTraderNotificationEnabled}
- toggleTraderNotification={mockToggleTraderNotification}
onDismiss={onDismiss}
/>
);
};
+const renderOpenedSheet = ({
+ preferences = makePreferences(),
+ isTraderNotificationEnabled = mockIsTraderNotificationEnabled,
+ ...props
+}: OpenedSheetProps & {
+ preferences?: SocialAIPreference;
+ isTraderNotificationEnabled?: (id: string) => boolean;
+} = {}) => {
+ mockPreferences = preferences;
+ if (isTraderNotificationEnabled !== mockIsTraderNotificationEnabled) {
+ mockIsTraderNotificationEnabled.mockImplementation(
+ isTraderNotificationEnabled,
+ );
+ }
+
+ return renderWithProvider();
+};
+
describe('TraderNotificationsBottomSheet', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockPreferences = makePreferences();
+ mockHasNotificationPreferences.mockReturnValue(true);
mockIsTraderNotificationEnabled.mockReturnValue(true);
});
@@ -146,9 +177,6 @@ describe('TraderNotificationsBottomSheet', () => {
ref={ref}
traderId="trader-1"
traderName="dutchiono"
- preferences={makePreferences()}
- isTraderNotificationEnabled={mockIsTraderNotificationEnabled}
- toggleTraderNotification={mockToggleTraderNotification}
/>,
);
@@ -160,7 +188,7 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('renders the container when opened', () => {
- renderWithProvider();
+ renderOpenedSheet();
expect(
screen.getByTestId(
@@ -170,7 +198,7 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('renders the title interpolated with trader name', () => {
- renderWithProvider();
+ renderOpenedSheet({ traderName: 'dutchiono' });
expect(
screen.getByText(
@@ -182,7 +210,7 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('renders the notification description with trader name', () => {
- renderWithProvider();
+ renderOpenedSheet({ traderName: 'dutchiono' });
expect(
screen.getByText(
@@ -195,7 +223,7 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('renders the manage traders row', () => {
- renderWithProvider();
+ renderOpenedSheet();
expect(
screen.getByTestId(
@@ -205,7 +233,7 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('renders the save button', () => {
- renderWithProvider();
+ renderOpenedSheet();
expect(
screen.getByTestId(
@@ -217,12 +245,10 @@ describe('TraderNotificationsBottomSheet', () => {
describe('toggle', () => {
it('renders the toggle as on when isTraderNotificationEnabled returns true', () => {
- renderWithProvider(
- true}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => true,
+ });
const toggle = screen.getByTestId(
TraderNotificationsBottomSheetSelectorsIDs.TOGGLE,
@@ -232,12 +258,10 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('renders the toggle as off when isTraderNotificationEnabled returns false', () => {
- renderWithProvider(
- false}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => false,
+ });
const toggle = screen.getByTestId(
TraderNotificationsBottomSheetSelectorsIDs.TOGGLE,
@@ -247,12 +271,10 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('flips the toggle value locally but does NOT call toggleTraderNotification immediately', () => {
- renderWithProvider(
- true}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => true,
+ });
fireEvent(
screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
@@ -267,13 +289,11 @@ describe('TraderNotificationsBottomSheet', () => {
).toBe(false);
});
- it('disables the toggle when global notifications are off', () => {
- renderWithProvider(
- ,
- );
+ it('disables the toggle when push notifications are off', () => {
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ preferences: makePreferences({ pushNotificationsEnabled: false }),
+ });
const toggle = screen.getByTestId(
TraderNotificationsBottomSheetSelectorsIDs.TOGGLE,
@@ -282,13 +302,11 @@ describe('TraderNotificationsBottomSheet', () => {
expect(toggle.props.disabled).toBe(true);
});
- it('does not call toggleTraderNotification when global is off and toggle fires a change event', () => {
- renderWithProvider(
- ,
- );
+ it('does not call toggleTraderNotification when push notifications are off and toggle fires a change event', () => {
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ preferences: makePreferences({ pushNotificationsEnabled: false }),
+ });
fireEvent(
screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
@@ -301,8 +319,8 @@ describe('TraderNotificationsBottomSheet', () => {
});
describe('manage traders row', () => {
- it('navigates to notification preferences when manage traders row is pressed', () => {
- renderWithProvider();
+ it('navigates to the socialAI notification settings section when manage traders row is pressed', () => {
+ renderOpenedSheet();
fireEvent.press(
screen.getByTestId(
@@ -310,20 +328,40 @@ describe('TraderNotificationsBottomSheet', () => {
),
);
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.SOCIAL_LEADERBOARD.NOTIFICATION_PREFERENCES,
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATION_SETTINGS_SECTION,
+ params: {
+ type: 'socialAI',
+ title: strings('app_settings.notifications_opts.social_ai_title'),
+ description: strings(
+ 'app_settings.notifications_opts.social_ai_desc',
+ ),
+ },
+ });
+ });
+
+ it('navigates to notification settings when preferences do not exist yet', () => {
+ mockHasNotificationPreferences.mockReturnValue(false);
+ renderOpenedSheet();
+
+ fireEvent.press(
+ screen.getByTestId(
+ TraderNotificationsBottomSheetSelectorsIDs.MANAGE_TRADERS_ROW,
+ ),
);
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATIONS,
+ });
});
});
describe('save button', () => {
it('calls toggleTraderNotification when the toggle was changed before saving', () => {
- renderWithProvider(
- true}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => true,
+ });
fireEvent(
screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
@@ -343,12 +381,10 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('does not call toggleTraderNotification when the toggle was not changed before saving', () => {
- renderWithProvider(
- true}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => true,
+ });
act(() => {
fireEvent.press(
@@ -362,12 +398,10 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('does not call toggleTraderNotification when the toggle was changed and then reverted before saving', () => {
- renderWithProvider(
- true}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => true,
+ });
fireEvent(
screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
@@ -394,7 +428,7 @@ describe('TraderNotificationsBottomSheet', () => {
it('closes the sheet and calls onDismiss when save is pressed', () => {
const mockOnDismiss = jest.fn();
- renderWithProvider();
+ renderOpenedSheet({ onDismiss: mockOnDismiss });
act(() => {
fireEvent.press(
@@ -418,12 +452,10 @@ describe('TraderNotificationsBottomSheet', () => {
it('fires a medium impact when pressing save', () => {
Platform.OS = 'ios';
- renderWithProvider(
- true}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => true,
+ });
fireEvent(
screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
@@ -447,12 +479,10 @@ describe('TraderNotificationsBottomSheet', () => {
it('fires a medium impact when pressing save even if the value did not change', () => {
Platform.OS = 'ios';
- renderWithProvider(
- true}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => true,
+ });
act(() => {
fireEvent.press(
@@ -469,12 +499,10 @@ describe('TraderNotificationsBottomSheet', () => {
it('fires a light impact when toggling the local switch on Android', () => {
Platform.OS = 'android';
- renderWithProvider(
- true}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => true,
+ });
fireEvent(
screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
@@ -488,12 +516,10 @@ describe('TraderNotificationsBottomSheet', () => {
it('does not fire a haptic when toggling the local switch on iOS', () => {
Platform.OS = 'ios';
- renderWithProvider(
- true}
- />,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ isTraderNotificationEnabled: () => true,
+ });
fireEvent(
screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
@@ -505,14 +531,12 @@ describe('TraderNotificationsBottomSheet', () => {
expect(mockPlayImpact).not.toHaveBeenCalled();
});
- it('does not fire a haptic when toggling the local switch while the global toggle is off', () => {
+ it('does not fire a haptic when toggling the local switch while push notifications are off', () => {
Platform.OS = 'android';
- renderWithProvider(
- ,
- );
+ renderOpenedSheet({
+ traderId: 'trader-1',
+ preferences: makePreferences({ pushNotificationsEnabled: false }),
+ });
fireEvent(
screen.getByTestId(TraderNotificationsBottomSheetSelectorsIDs.TOGGLE),
@@ -525,7 +549,7 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('does not fire a haptic when pressing the close button', () => {
- renderWithProvider();
+ renderOpenedSheet();
fireEvent.press(
screen.getByTestId(
@@ -538,7 +562,7 @@ describe('TraderNotificationsBottomSheet', () => {
});
it('does not fire a haptic when pressing the manage traders row', () => {
- renderWithProvider();
+ renderOpenedSheet();
fireEvent.press(
screen.getByTestId(
diff --git a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx
index 15831c06b67..5150c1339cd 100644
--- a/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderProfileView/components/TraderNotificationsBottomSheet/TraderNotificationsBottomSheet.tsx
@@ -28,8 +28,8 @@ import {
playImpact,
ImpactMoment,
} from '../../../../../../util/haptics';
-import type { SocialAIPreference } from '../../../NotificationPreferencesView/hooks';
-import AllowPushNotificationsRow from '../../../NotificationPreferencesView/components/AllowPushNotificationsRow';
+import { useNotificationPreferences } from '../../../NotificationPreferences/hooks';
+import AllowPushNotificationsRow from '../../../NotificationPreferences/components/AllowPushNotificationsRow';
import { TraderNotificationsBottomSheetSelectorsIDs } from './TraderNotificationsBottomSheet.testIds';
import {
useControllableBottomSheet,
@@ -41,171 +41,179 @@ export type TraderNotificationsBottomSheetRef = ControllableBottomSheetRef;
interface TraderNotificationsBottomSheetProps {
traderId: string;
traderName: string;
- preferences: SocialAIPreference;
- isTraderNotificationEnabled: (traderId: string) => boolean;
- toggleTraderNotification: (traderId: string) => void | Promise;
onDismiss?: () => void;
}
const TraderNotificationsBottomSheet = forwardRef<
TraderNotificationsBottomSheetRef,
TraderNotificationsBottomSheetProps
->(
- (
- {
- traderId,
- traderName,
- preferences,
- isTraderNotificationEnabled,
- toggleTraderNotification,
- onDismiss,
- },
- ref,
- ) => {
- const [localEnabled, setLocalEnabled] = useState(() =>
- isTraderNotificationEnabled(traderId),
- );
- const tw = useTailwind();
- const navigation = useNavigation();
-
- const { sheetRef, isVisible, closeSheet, handleSheetClosed } =
- useControllableBottomSheet({ ref, onDismiss });
-
- const globalOff = !preferences.enabled;
-
- // Snapshot the remote value each time the sheet opens so the toggle
- // always starts from the authoritative server state.
- useEffect(() => {
- if (isVisible) {
- setLocalEnabled(isTraderNotificationEnabled(traderId));
+>(({ traderId, traderName, onDismiss }, ref) => {
+ const {
+ preferences,
+ hasNotificationPreferences,
+ isTraderNotificationEnabled,
+ toggleTraderNotification,
+ } = useNotificationPreferences();
+ const [localEnabled, setLocalEnabled] = useState(() =>
+ isTraderNotificationEnabled(traderId),
+ );
+ const tw = useTailwind();
+ const navigation = useNavigation();
+
+ const { sheetRef, isVisible, closeSheet, handleSheetClosed } =
+ useControllableBottomSheet({ ref, onDismiss });
+
+ const pushNotificationsOff =
+ !hasNotificationPreferences || !preferences.pushNotificationsEnabled;
+
+ // Snapshot the remote value each time the sheet opens so the toggle
+ // always starts from the authoritative server state.
+ useEffect(() => {
+ if (isVisible) {
+ setLocalEnabled(isTraderNotificationEnabled(traderId));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isVisible]);
+
+ const handleManageTradersPress = useCallback(() => {
+ sheetRef.current?.onCloseBottomSheet(() => {
+ if (!hasNotificationPreferences) {
+ navigation.navigate(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATIONS,
+ });
+ return;
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isVisible]);
- const handleManageTradersPress = useCallback(() => {
- sheetRef.current?.onCloseBottomSheet(() => {
- navigation.navigate(Routes.SOCIAL_LEADERBOARD.NOTIFICATION_PREFERENCES);
+ navigation.navigate(Routes.SETTINGS_VIEW, {
+ screen: Routes.SETTINGS.NOTIFICATION_SETTINGS_SECTION,
+ params: {
+ type: 'socialAI',
+ title: strings('app_settings.notifications_opts.social_ai_title'),
+ description: strings(
+ 'app_settings.notifications_opts.social_ai_desc',
+ ),
+ },
});
- }, [navigation, sheetRef]);
-
- // Only persist when the user explicitly confirms with Save.
- // If the local draft differs from the remote value, issue one toggle call.
- // Save is a deliberate primary-action commit, so always fire the haptic
- // — including when the value didn't change — to acknowledge the press.
- const handleSave = useCallback(() => {
- playImpact(ImpactMoment.PrimaryCTA);
- if (localEnabled !== isTraderNotificationEnabled(traderId)) {
- toggleTraderNotification(traderId);
- }
- closeSheet();
- }, [
- closeSheet,
- isTraderNotificationEnabled,
- localEnabled,
- toggleTraderNotification,
- traderId,
- ]);
-
- if (!isVisible) {
- return null;
+ });
+ }, [hasNotificationPreferences, navigation, sheetRef]);
+
+ // Only persist when the user explicitly confirms with Save.
+ // If the local draft differs from the remote value, issue one toggle call.
+ // Save is a deliberate primary-action commit, so always fire the haptic
+ // — including when the value didn't change — to acknowledge the press.
+ const handleSave = useCallback(() => {
+ playImpact(ImpactMoment.PrimaryCTA);
+ if (localEnabled !== isTraderNotificationEnabled(traderId)) {
+ toggleTraderNotification(traderId);
}
+ closeSheet();
+ }, [
+ closeSheet,
+ isTraderNotificationEnabled,
+ localEnabled,
+ toggleTraderNotification,
+ traderId,
+ ]);
+
+ if (!isVisible) {
+ return null;
+ }
+
+ return (
+
+
- return (
- {
+ if (pushNotificationsOff) {
+ return;
+ }
+ // Subordinate switch: rely on iOS UISwitch's native tick on iOS,
+ // fire a Light impact only on Android where there is none.
+ fireSwitchHaptic(ImpactFeedbackStyle.Light);
+ setLocalEnabled(next);
+ }}
+ disabled={pushNotificationsOff}
+ toggleTestID={TraderNotificationsBottomSheetSelectorsIDs.TOGGLE}
+ />
+
+
+
+ {/* Manage traders row */}
+
-
-
- {
- if (globalOff) {
- return;
- }
- // Subordinate switch: rely on iOS UISwitch's native tick on iOS,
- // fire a Light impact only on Android where there is none.
- fireSwitchHaptic(ImpactFeedbackStyle.Light);
- setLocalEnabled(next);
- }}
- disabled={globalOff}
- toggleTestID={TraderNotificationsBottomSheetSelectorsIDs.TOGGLE}
- />
-
-
-
- {/* Manage traders row */}
-
-
-
-
- {strings(
- 'social_leaderboard.trader_notifications.manage_traders',
- )}
-
-
+
+ {strings(
+ 'social_leaderboard.trader_notifications.manage_traders',
+ )}
+
-
-
-
-
- );
- },
-);
+
+
+
+
+
+
+ );
+});
export default TraderNotificationsBottomSheet;
diff --git a/app/components/Views/SocialLeaderboard/index.ts b/app/components/Views/SocialLeaderboard/index.ts
index c6b1025d15f..60fd1eff3d7 100644
--- a/app/components/Views/SocialLeaderboard/index.ts
+++ b/app/components/Views/SocialLeaderboard/index.ts
@@ -1,4 +1,3 @@
export { default as TopTradersView } from './TopTradersView';
export { default as TraderProfileView } from './TraderProfileView';
export { default as TraderPositionView } from './TraderPositionView';
-export { default as NotificationPreferencesView } from './NotificationPreferencesView';
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 522163ab78b..d3318530811 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -216,6 +216,7 @@ const Routes = {
DEVELOPER_OPTIONS: 'DeveloperOptions',
EXPERIMENTAL_SETTINGS: 'ExperimentalSettings',
NOTIFICATIONS: 'NotificationsSettings',
+ NOTIFICATION_SETTINGS_SECTION: 'NotificationSettingsSection',
REVEAL_PRIVATE_CREDENTIAL: 'RevealPrivateCredentialView',
SDK_SESSIONS_MANAGER: 'SDKSessionsManager',
NETWORKS_MANAGEMENT: 'NetworksManagement',
@@ -372,7 +373,6 @@ const Routes = {
VIEW: 'TopTradersView',
PROFILE: 'TraderProfileView',
POSITION: 'TraderPositionView',
- NOTIFICATION_PREFERENCES: 'NotificationPreferencesView',
},
PREDICT: {
ROOT: 'Predict',
diff --git a/app/core/Engine/messengers/notifications/notification-services-controller-messenger.test.ts b/app/core/Engine/messengers/notifications/notification-services-controller-messenger.test.ts
index 4056cd6b56d..892c0a9e338 100644
--- a/app/core/Engine/messengers/notifications/notification-services-controller-messenger.test.ts
+++ b/app/core/Engine/messengers/notifications/notification-services-controller-messenger.test.ts
@@ -33,6 +33,9 @@ describe('getNotificationServicesControllerMessenger', () => {
'NotificationServicesPushController:disablePushNotifications',
'NotificationServicesPushController:deletePushNotificationLinks',
'NotificationServicesPushController:subscribeToPushNotifications',
+ // Authenticated user storage (notification preferences, etc.)
+ 'AuthenticatedUserStorageService:getNotificationPreferences',
+ 'AuthenticatedUserStorageService:putNotificationPreferences',
],
events: [
// Keyring Events
diff --git a/app/core/Engine/messengers/notifications/notification-services-controller-messenger.ts b/app/core/Engine/messengers/notifications/notification-services-controller-messenger.ts
index 1cb267d8dfb..231e389bc3d 100644
--- a/app/core/Engine/messengers/notifications/notification-services-controller-messenger.ts
+++ b/app/core/Engine/messengers/notifications/notification-services-controller-messenger.ts
@@ -32,6 +32,9 @@ export function getNotificationServicesControllerMessenger(
'NotificationServicesPushController:disablePushNotifications',
'NotificationServicesPushController:deletePushNotificationLinks',
'NotificationServicesPushController:subscribeToPushNotifications',
+ // Authenticated user storage (notification preferences, etc.)
+ 'AuthenticatedUserStorageService:getNotificationPreferences',
+ 'AuthenticatedUserStorageService:putNotificationPreferences',
],
events: [
// Keyring Events
diff --git a/app/util/notifications/hooks/useNotifications.test.tsx b/app/util/notifications/hooks/useNotifications.test.tsx
index 5c41bbf12bc..eb61cf8f8cc 100644
--- a/app/util/notifications/hooks/useNotifications.test.tsx
+++ b/app/util/notifications/hooks/useNotifications.test.tsx
@@ -16,7 +16,6 @@ import {
useEnableNotifications,
useListNotifications,
useMarkNotificationAsRead,
- useResetNotifications,
} from './useNotifications';
// eslint-disable-next-line import-x/no-namespace
import * as UsePushNotifications from './usePushNotifications';
@@ -131,6 +130,27 @@ describe('useNotifications - useEnableNotifications()', () => {
expect(mocks.mockTogglePushNotification).toHaveBeenCalled();
expect(mocks.mockSelectLoading).toHaveBeenCalled();
expect(mocks.mockSelectData).toHaveBeenCalled();
+ expect(mocks.mockEnableNotifications).toHaveBeenCalledWith({
+ hasMarketingConsent: false,
+ productAnnouncementEnabled: true,
+ });
+ });
+
+ it('passes the current marketing consent when enabling notifications', async () => {
+ const mocks = arrangeMocks();
+
+ const hook = renderHookWithProvider(() => useEnableNotifications(), {
+ state: { security: { dataCollectionForMarketing: true } },
+ });
+ await act(() => hook.result.current.enableNotifications());
+ await waitFor(() =>
+ expect(mocks.mockEnableNotifications).toHaveBeenCalled(),
+ );
+
+ expect(mocks.mockEnableNotifications).toHaveBeenCalledWith({
+ hasMarketingConsent: true,
+ productAnnouncementEnabled: true,
+ });
});
it('creates an error when fails', async () => {
@@ -237,48 +257,6 @@ describe('useNotifications - useMarkNotificationAsRead()', () => {
});
});
-describe('useNotifications - useResetNotifications()', () => {
- const arrangeMocks = () => {
- const mockSelectLoading = jest
- .spyOn(Selectors, 'selectIsUpdatingMetamaskNotifications')
- .mockReturnValue(false);
- const mockResetNotifications = jest.spyOn(Actions, 'resetNotifications');
- return {
- mockSelectLoading,
- mockResetNotifications,
- };
- };
-
- type Mocks = ReturnType;
- const arrangeAct = async (mutateMocks?: (mocks: Mocks) => void) => {
- // Arrange
- const mocks = arrangeMocks();
- mutateMocks?.(mocks);
-
- // Act
- const hook = renderHookWithProvider(() => useResetNotifications());
- await act(() => hook.result.current.resetNotifications());
- await waitFor(() =>
- expect(mocks.mockResetNotifications).toHaveBeenCalled(),
- );
-
- return { mocks, hook };
- };
-
- it('successfully invokes action', async () => {
- const { mocks } = await arrangeAct();
- expect(mocks.mockSelectLoading).toHaveBeenCalled();
- });
-
- it('creates an error when fails', async () => {
- const { hook } = await arrangeAct((m) => {
- m.mockResetNotifications.mockRejectedValue(new Error('Test Error'));
- });
-
- expect(hook.result.current.error).toBeDefined();
- });
-});
-
describe('useNotifications - useContiguousLoading()', () => {
beforeEach(() => {
jest.useFakeTimers();
diff --git a/app/util/notifications/hooks/useNotifications.ts b/app/util/notifications/hooks/useNotifications.ts
index 46cf6902edc..ebc64bf193b 100644
--- a/app/util/notifications/hooks/useNotifications.ts
+++ b/app/util/notifications/hooks/useNotifications.ts
@@ -1,18 +1,18 @@
import { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
-import { MarkAsReadNotificationsParam } from '@metamask/notification-services-controller/notification-services';
+import type { MarkAsReadNotificationsParam } from '@metamask/notification-services-controller/notification-services';
import {
assertIsFeatureEnabled,
disableNotifications as disableNotificationsHelper,
enableNotifications as enableNotificationsHelper,
fetchNotifications,
markNotificationsAsRead as markNotificationsAsReadHelper,
- resetNotifications as resetNotificationsHelper,
} from '../../../actions/notification/helpers';
import {
getNotificationsList,
selectIsFetchingMetamaskNotifications,
+ selectIsFeatureAnnouncementsEnabled,
selectIsMetamaskNotificationsEnabled,
selectIsUpdatingMetamaskNotifications,
} from '../../../selectors/notifications';
@@ -22,6 +22,10 @@ import {
setUserHasTurnedOffNotificationsOnce,
updateNotificationSubscriptionExpiration,
} from '../constants/notification-storage-keys';
+import type { RootState } from '../../../reducers';
+
+const selectHasMarketingConsent = (state: RootState) =>
+ Boolean(state.security.dataCollectionForMarketing);
/**
* Custom hook to fetch and update the list of notifications.
@@ -103,18 +107,29 @@ export function useContiguousLoading(
export function useEnableNotifications(props = { nudgeEnablePush: true }) {
const { togglePushNotification, loading: pushLoading } =
usePushNotificationsToggle(props);
- const data = useSelector(selectIsMetamaskNotificationsEnabled);
+ const isMetamaskNotificationsEnabled = useSelector(
+ selectIsMetamaskNotificationsEnabled,
+ );
const loading = useSelector(selectIsUpdatingMetamaskNotifications);
const [error, setError] = useState(null);
+ const hasMarketingConsent = useSelector(selectHasMarketingConsent);
+ const isFeatureAnnouncementsEnabled = useSelector(
+ selectIsFeatureAnnouncementsEnabled,
+ );
+ const productAnnouncementEnabled =
+ isFeatureAnnouncementsEnabled || !isMetamaskNotificationsEnabled;
const enableNotifications = useCallback(async () => {
assertIsFeatureEnabled();
setError(null);
- await enableNotificationsHelper().catch((e) => setError(e));
+ await enableNotificationsHelper({
+ hasMarketingConsent,
+ productAnnouncementEnabled,
+ }).catch((e) => setError(e));
await togglePushNotification(true).catch(() => {
/* Do Nothing */
});
await updateNotificationSubscriptionExpiration();
- }, [togglePushNotification]);
+ }, [hasMarketingConsent, productAnnouncementEnabled, togglePushNotification]);
const contiguousLoading = useContiguousLoading(loading, pushLoading);
@@ -124,7 +139,7 @@ export function useEnableNotifications(props = { nudgeEnablePush: true }) {
isEnablingPushNotifications: pushLoading,
loading: loading || pushLoading || contiguousLoading,
error,
- data,
+ data: isMetamaskNotificationsEnabled,
};
}
@@ -186,25 +201,3 @@ export function useMarkNotificationAsRead() {
loading,
};
}
-
-/**
- * Custom hook to delete notifications storage key.
- * It manages loading and error states internally.
- *
- * @returns An object containing the `deleteNotificationsStorageKey` function, loading state, and error state.
- */
-export function useResetNotifications() {
- const loading = useSelector(selectIsUpdatingMetamaskNotifications);
- const [error, setError] = useState(null);
- const resetNotifications = useCallback(async () => {
- assertIsFeatureEnabled();
- setError(null);
- await resetNotificationsHelper().catch((e) => setError(e));
- }, []);
-
- return {
- resetNotifications,
- loading,
- error,
- };
-}
diff --git a/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts b/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts
index 4c7d584b697..0e291f12128 100644
--- a/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts
+++ b/app/util/notifications/hooks/useStartupNotificationsEffect.test.ts
@@ -22,6 +22,8 @@ import * as Constants from '../constants/config';
import * as NotificationHooks from './useNotifications';
// eslint-disable-next-line import-x/no-namespace
import * as StorageHooks from '../../../store/storage-wrapper-hooks';
+// eslint-disable-next-line import-x/no-namespace
+import * as NotificationHelpers from '../../../actions/notification/helpers';
import {
useRegisterAndFetchNotifications,
useEnableNotificationsByDefaultEffect,
@@ -103,6 +105,9 @@ describe('useRegisterAndFetchNotifications', () => {
const mockIsFlagEnabled = jest
.spyOn(Constants, 'isNotificationsFeatureEnabled')
.mockReturnValue(true);
+ const mockHasNotificationPreferences = jest
+ .spyOn(NotificationHelpers, 'hasNotificationPreferences')
+ .mockResolvedValue(true);
return {
hooks: arrangeHooks(),
@@ -111,6 +116,7 @@ describe('useRegisterAndFetchNotifications', () => {
mockGetStorageItem,
mockSetStorageItem,
mockIsFlagEnabled,
+ mockHasNotificationPreferences,
},
};
};
@@ -157,6 +163,24 @@ describe('useRegisterAndFetchNotifications', () => {
});
});
+ it('enables notifications if AUS notification preferences are missing even when resubscription has not expired', async () => {
+ const mocks = arrange();
+ mocks.selectors.mockIsNotifsEnabled.mockReturnValue(true);
+ mocks.selectors.mockSelectBasicFunctionalityEnabled.mockReturnValue(true);
+ mocks.selectors.mockSelectIsUnlocked.mockReturnValue(true);
+ mocks.selectors.mockSelectIsSignedIn.mockReturnValue(true);
+
+ mocks.helpers.mockGetStorageItem.mockResolvedValue(Date.now() + 1000);
+ mocks.helpers.mockHasNotificationPreferences.mockResolvedValue(false);
+
+ renderHookWithProvider(() => useRegisterAndFetchNotifications(), {});
+
+ await waitFor(() => {
+ expect(mocks.hooks.enableNotifications).toHaveBeenCalled();
+ expect(mocks.hooks.listNotifications).toHaveBeenCalled();
+ });
+ });
+
it('deos not fetch notifications when basic functionality is disabled', async () => {
const mocks = arrange();
mocks.selectors.mockIsNotifsEnabled.mockReturnValue(true);
diff --git a/app/util/notifications/hooks/useStartupNotificationsEffect.ts b/app/util/notifications/hooks/useStartupNotificationsEffect.ts
index 350234d52be..1ba01343ff3 100644
--- a/app/util/notifications/hooks/useStartupNotificationsEffect.ts
+++ b/app/util/notifications/hooks/useStartupNotificationsEffect.ts
@@ -22,6 +22,7 @@ import {
hasNotificationSubscriptionExpired,
hasUserTurnedOffNotificationsOnce,
} from '../constants/notification-storage-keys';
+import { hasNotificationPreferences } from '../../../actions/notification/helpers';
const showPushNush = { nudgeEnablePush: true };
@@ -37,6 +38,22 @@ const useEnableAndRefresh = () => {
);
};
+const shouldEnableNotificationsOnStartup = async () => {
+ if (await hasNotificationSubscriptionExpired()) {
+ return true;
+ }
+
+ try {
+ return !(await hasNotificationPreferences());
+ } catch (error) {
+ Logger.error(
+ error instanceof Error ? error : new Error(String(error)),
+ 'Failed to check notification preferences initialization',
+ );
+ return false;
+ }
+};
+
const useNotificationStartupSelectors = () => {
// Base requirements
const isUnlocked = Boolean(useSelector(selectIsUnlocked));
@@ -73,7 +90,7 @@ export function useRegisterAndFetchNotifications() {
const run = async () => {
try {
if (isUnlocked && isBasicFunctionalityEnabled && notificationsEnabled) {
- await enableAndRefresh(await hasNotificationSubscriptionExpired());
+ await enableAndRefresh(await shouldEnableNotificationsOnStartup());
}
} catch (error) {
const errorMessage =
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 4d727a050f0..20e68c61f66 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -3276,8 +3276,24 @@
"notifications_desc": "Manage your notifications",
"allow_notifications": "Allow notifications",
"enable_push_notifications": "Enable push notifications",
- "allow_notifications_desc": "Stay in the loop on what’s happening in your wallet with notifications. To use notifications, we use a profile to sync some settings across your devices.",
+ "allow_notifications_desc": "Choose what you're notified about and how.",
"notifications_opts": {
+ "preferences_title": "Preferences",
+ "push_recommended": "Push",
+ "in_app": "In-app",
+ "select_all": "Select all",
+ "deselect_all": "Deselect all",
+ "select_accounts_title": "Accounts",
+ "select_accounts_desc": "Choose which accounts you'd like to get notifications for.",
+ "wallet_activity_title": "Wallet Activity",
+ "wallet_activity_desc": "Buy, sells, transfers, swaps and rewards",
+ "perps_title": "Trading Activity",
+ "perps_desc": "Perps position changes, liquidations, funding rates, and margin updates",
+ "social_ai_title": "Trading Signals",
+ "social_ai_desc": "Updates from traders and assets you follow, plus currated market news",
+ "marketing_title": "Updates and Rewards",
+ "marketing_desc": "Product updates, feature announcements, and new releases",
+ "marketing_disclaimer": "By turning this on, you agree to receive product news and marketing updates from MetaMask.",
"customize_session_title": "Customize your notifications",
"customize_session_desc": "Turn on the types of notifications you want to receive:",
"account_session_title": "Account activity",
@@ -3291,8 +3307,7 @@
"snaps_title": "Snaps",
"snaps_desc": "New features and updates",
"products_announcements_title": "Product announcements",
- "products_announcements_desc": "New products and features",
- "perps_title": "Perps trading"
+ "products_announcements_desc": "New products and features"
},
"contacts_title": "Contacts",
"contacts_desc": "Add, edit, remove, and manage your accounts.",
diff --git a/package.json b/package.json
index 55bb09b8b8a..07733b4265b 100644
--- a/package.json
+++ b/package.json
@@ -245,7 +245,7 @@
"@metamask/approval-controller": "^9.0.0",
"@metamask/assets-controller": "^6.2.1",
"@metamask/assets-controllers": "^106.0.0",
- "@metamask/authenticated-user-storage": "^1.0.0",
+ "@metamask/authenticated-user-storage": "^2.0.0",
"@metamask/base-controller": "^9.0.1",
"@metamask/bitcoin-wallet-snap": "^1.10.1",
"@metamask/bridge-controller": "^71.1.0",
@@ -309,7 +309,7 @@
"@metamask/native-utils": "^0.8.0",
"@metamask/network-controller": "^31.0.0",
"@metamask/network-enablement-controller": "^5.1.0",
- "@metamask/notification-services-controller": "^23.1.0",
+ "@metamask/notification-services-controller": "^24.0.0",
"@metamask/permission-controller": "^13.1.1",
"@metamask/phishing-controller": "^17.1.1",
"@metamask/post-message-stream": "^10.0.0",
diff --git a/tests/smoke/notifications/utils/mock-notification-trigger-server.ts b/tests/smoke/notifications/utils/mock-notification-trigger-server.ts
index dffa68fba1e..1db1cd9e583 100644
--- a/tests/smoke/notifications/utils/mock-notification-trigger-server.ts
+++ b/tests/smoke/notifications/utils/mock-notification-trigger-server.ts
@@ -1,13 +1,9 @@
import { CompletedRequest, Mockttp } from 'mockttp';
-import {
- getMockOnChainNotificationsConfig,
- getMockUpdateOnChainNotifications,
-} from '@metamask/notification-services-controller/notification-services/mocks';
+import { getMockOnChainNotificationsConfig } from '@metamask/notification-services-controller/notification-services/mocks';
import { getDecodedProxiedURL } from './helpers';
import { createLogger } from '../../../framework/logger';
const GET_CONFIG_URL = getMockOnChainNotificationsConfig().url;
-const UPDATE_CONFIG_URL = getMockUpdateOnChainNotifications().url;
const logger = createLogger({
name: 'MockttpNotificationTriggerServer',
@@ -45,23 +41,6 @@ export class MockttpNotificationTriggerServer {
};
};
- readonly updateConfig = async (
- request: Pick,
- statusCode: number = 200,
- ) => {
- const requestBody = (await request.body.getJson()) as NotificationConfig[];
-
- // Save the notification configs
- requestBody.forEach(({ address, enabled }) => {
- const normalizedAddress = address.toLowerCase();
- this.notificationConfigs.set(normalizedAddress, enabled);
- });
-
- return {
- statusCode,
- };
- };
-
setupServer = async (server: Mockttp) => {
// Mobile uses a API url proxy, where all subsequent calls need to pulled out from this proxy API
await server
@@ -78,21 +57,6 @@ export class MockttpNotificationTriggerServer {
);
return this.getConfig(request);
});
-
- await server
- .forPost('/proxy')
- .matching((request) =>
- getDecodedProxiedURL(request.url).includes(UPDATE_CONFIG_URL),
- )
- .asPriority(999)
- .thenCallback((request) => {
- logger.debug(
- `Mocking ${request.method} request to: ${getDecodedProxiedURL(
- request.url,
- )}`,
- );
- return this.updateConfig(request);
- });
};
// Helper methods for testing
diff --git a/yarn.lock b/yarn.lock
index df1f9bac546..84f9c253a31 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7870,7 +7870,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/authenticated-user-storage@npm:^1.0.0, @metamask/authenticated-user-storage@npm:^1.0.1":
+"@metamask/authenticated-user-storage@npm:^1.0.1":
version: 1.0.1
resolution: "@metamask/authenticated-user-storage@npm:1.0.1"
dependencies:
@@ -7883,6 +7883,19 @@ __metadata:
languageName: node
linkType: hard
+"@metamask/authenticated-user-storage@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "@metamask/authenticated-user-storage@npm:2.0.0"
+ dependencies:
+ "@metamask/base-data-service": "npm:^0.1.3"
+ "@metamask/controller-utils": "npm:^12.1.0"
+ "@metamask/messenger": "npm:^1.2.0"
+ "@metamask/superstruct": "npm:^3.1.0"
+ "@metamask/utils": "npm:^11.9.0"
+ checksum: 10/ceba77b0947f6eb0607a68ea87d99dc8264b817229be43de2d9b6fbbf519041b47050a9c6d646efe50c0f9246fd3b0f1e1251f7fddbc14719d8bbd5609bcf8f4
+ languageName: node
+ linkType: hard
+
"@metamask/auto-changelog@npm:^5.3.0":
version: 5.3.0
resolution: "@metamask/auto-changelog@npm:5.3.0"
@@ -7911,16 +7924,16 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/base-data-service@npm:^0.1.0, @metamask/base-data-service@npm:^0.1.1, @metamask/base-data-service@npm:^0.1.2":
- version: 0.1.2
- resolution: "@metamask/base-data-service@npm:0.1.2"
+"@metamask/base-data-service@npm:^0.1.0, @metamask/base-data-service@npm:^0.1.1, @metamask/base-data-service@npm:^0.1.2, @metamask/base-data-service@npm:^0.1.3":
+ version: 0.1.3
+ resolution: "@metamask/base-data-service@npm:0.1.3"
dependencies:
- "@metamask/controller-utils": "npm:^12.0.0"
+ "@metamask/controller-utils": "npm:^12.1.0"
"@metamask/messenger": "npm:^1.2.0"
"@metamask/utils": "npm:^11.9.0"
"@tanstack/query-core": "npm:^4.43.0"
fast-deep-equal: "npm:^3.1.3"
- checksum: 10/c05e2334e94f8a6d61764ddfec657cb5fc9cd1b29688bbcc3d83bee0ff032de9490319fedf17deb36916da906efd648197c71af4663d68966bdb78b8f1f8c3e2
+ checksum: 10/f5a714b4d6954cef1ab203c164d2e549937ecd185104a2cc0eef2954a7a150b0c35608671df281fc71574022204823b633c486efac0e98d6018a1a111a2edaf0
languageName: node
linkType: hard
@@ -9404,16 +9417,17 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/notification-services-controller@npm:^23.1.0":
- version: 23.1.0
- resolution: "@metamask/notification-services-controller@npm:23.1.0"
+"@metamask/notification-services-controller@npm:^24.0.0":
+ version: 24.1.0
+ resolution: "@metamask/notification-services-controller@npm:24.1.0"
dependencies:
"@contentful/rich-text-html-renderer": "npm:^16.5.2"
- "@metamask/base-controller": "npm:^9.0.1"
- "@metamask/controller-utils": "npm:^11.20.0"
- "@metamask/keyring-controller": "npm:^25.2.0"
- "@metamask/messenger": "npm:^1.1.1"
- "@metamask/profile-sync-controller": "npm:^28.0.2"
+ "@metamask/authenticated-user-storage": "npm:^2.0.0"
+ "@metamask/base-controller": "npm:^9.1.0"
+ "@metamask/controller-utils": "npm:^12.1.0"
+ "@metamask/keyring-controller": "npm:^25.5.0"
+ "@metamask/messenger": "npm:^1.2.0"
+ "@metamask/profile-sync-controller": "npm:^28.1.0"
"@metamask/utils": "npm:^11.9.0"
bignumber.js: "npm:^9.1.2"
firebase: "npm:^11.2.0"
@@ -9421,7 +9435,7 @@ __metadata:
loglevel: "npm:^1.8.1"
semver: "npm:^7.6.3"
uuid: "npm:^8.3.2"
- checksum: 10/a6381830bd121a5d89478df0f09aa08ad6952d78b76e2ce733ebea39b5c1c0744e347a0f41030fa41cfacf6911bc83c76c458dc3a71a320b486a733cee594f3a
+ checksum: 10/c8bcfcc7e9178eee8789ef4417636ac583e597639596c85eac82e5703c5d59fb8e4762e0d1fe71005320bedb11ea5893431ee540f0d268bcecf10bb226d7af33
languageName: node
linkType: hard
@@ -35326,7 +35340,7 @@ __metadata:
"@metamask/approval-controller": "npm:^9.0.0"
"@metamask/assets-controller": "npm:^6.2.1"
"@metamask/assets-controllers": "npm:^106.0.0"
- "@metamask/authenticated-user-storage": "npm:^1.0.0"
+ "@metamask/authenticated-user-storage": "npm:^2.0.0"
"@metamask/auto-changelog": "npm:^5.3.0"
"@metamask/base-controller": "npm:^9.0.1"
"@metamask/bitcoin-wallet-snap": "npm:^1.10.1"
@@ -35398,7 +35412,7 @@ __metadata:
"@metamask/native-utils": "npm:^0.8.0"
"@metamask/network-controller": "npm:^31.0.0"
"@metamask/network-enablement-controller": "npm:^5.1.0"
- "@metamask/notification-services-controller": "npm:^23.1.0"
+ "@metamask/notification-services-controller": "npm:^24.0.0"
"@metamask/object-multiplex": "npm:^1.1.0"
"@metamask/permission-controller": "npm:^13.1.1"
"@metamask/phishing-controller": "npm:^17.1.1"