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"