diff --git a/app/components/UI/Notification/BaseNotification/__snapshots__/index.test.jsx.snap b/app/components/UI/Notification/BaseNotification/__snapshots__/index.test.jsx.snap new file mode 100644 index 00000000000..fc9fc68fc42 --- /dev/null +++ b/app/components/UI/Notification/BaseNotification/__snapshots__/index.test.jsx.snap @@ -0,0 +1,1741 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BaseNotification gets icon correctly for each status 1`] = ` + + + + + + + +  + + + + + + + Testing Title + + + Testing description + + + + + + +`; + +exports[`BaseNotification gets icon correctly for each status 2`] = ` + + + + + + + +  + + + + + + + Testing Title + + + Testing description + + + + + + +`; + +exports[`BaseNotification gets icon correctly for each status 3`] = ` + + + + + + + +  + + + + + + + Testing Title + + + Testing description + + + + + + +`; + +exports[`BaseNotification gets icon correctly for each status 4`] = ` + + + + + +  + + + + + Testing Title + + + Testing description + + + + + + +`; + +exports[`BaseNotification gets icon correctly for each status 5`] = ` + + + + + +  + + + + + Testing Title + + + Testing description + + + + + + +`; + +exports[`BaseNotification gets icon correctly for each status 6`] = ` + + + + + +  + + + + + Testing Title + + + Testing description + + + + + + +`; + +exports[`BaseNotification gets icon correctly for each status 7`] = ` + + + + + +  + + + + + Testing Title + + + Testing description + + + + + + +`; + +exports[`BaseNotification gets icon correctly for each status 8`] = ` + + + + + +  + + + + + Testing Title + + + Testing description + + + + + + +`; + +exports[`BaseNotification gets icon correctly for each status 9`] = ` + + + + + +  + + + + + Testing Title + + + Testing description + + + + + + +`; + +exports[`BaseNotification gets icon correctly for each status 10`] = ` + + + + + +  + + + + + Testing Title + + + Testing description + + + + + + +`; diff --git a/app/components/UI/Notification/BaseNotification/index.js b/app/components/UI/Notification/BaseNotification/index.js index 96a11304d75..79b1a7dfc2e 100644 --- a/app/components/UI/Notification/BaseNotification/index.js +++ b/app/components/UI/Notification/BaseNotification/index.js @@ -72,6 +72,7 @@ export const getIcon = (status, colors, styles) => { case 'success': case 'received': case 'received_payment': + case 'eth_received': return ( { return strings('notifications.cancelled_title'); case 'error': return strings('notifications.error_title'); + case 'eth_received': + return strings('notifications.eth_received_title'); } }; -const getDescription = (status, { amount = null }) => { - if (amount) { - return strings(`notifications.${status}_message`, { amount }); +export const getDescription = (status, { amount = null, type = null }) => { + if (amount && typeof amount !== 'object') { + return strings(`notifications.${type}_${status}_message`, { amount }); } return strings(`notifications.${status}_message`); }; diff --git a/app/components/UI/Notification/BaseNotification/index.test.jsx b/app/components/UI/Notification/BaseNotification/index.test.jsx new file mode 100644 index 00000000000..11cb5aa6a17 --- /dev/null +++ b/app/components/UI/Notification/BaseNotification/index.test.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import BaseNotification, { getDescription } from './'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { strings } from '../../../../../locales/i18n'; + +const defaultProps = [ + { status: 'pending', data: { description: 'Testing description', title: 'Testing Title' } }, + { status: 'pending_withdrawal', data: { description: 'Testing description', title: 'Testing Title' } }, + { status: 'pending_deposit', data: { description: 'Testing description', title: 'Testing Title' } }, + { status: 'success_deposit', data: { description: 'Testing description', title: 'Testing Title' } }, + { status: 'success_withdrawal', data: { description: 'Testing description', title: 'Testing Title' } }, + { status: 'received', data: { description: 'Testing description', title: 'Testing Title' } }, + { status: 'received_payment', data: { description: 'Testing description', title: 'Testing Title' } }, + { status: 'eth_received', data: { description: 'Testing description', title: 'Testing Title' } }, + { status: 'cancelled', data: { description: 'Testing description', title: 'Testing Title' } }, + { status: 'error', data: { description: 'Testing description', title: 'Testing Title' } }, + ]; + +describe('BaseNotification', () => { + it('gets icon correctly for each status', () => { + defaultProps.forEach(({ status, data}) => { + const { toJSON } = renderWithProvider(); + expect(toJSON()).toMatchSnapshot(); + }); + }); + + it('gets titles correctly for each status', () => { + defaultProps.forEach(({ status }) => { + const { getByText } = renderWithProvider(); + expect(getByText(strings(`notifications.${status}_title`))).toBeTruthy(); + }); + }); + + it('gets descriptions correctly for if they are provided', () => { + defaultProps.forEach(({ status, data }) => { + const { getByText } = renderWithProvider(); + expect(getByText(data.description)).toBeTruthy(); + }); + }); + + it('constructs the correct description using getDescription when no description is provided', () => { + defaultProps.forEach(({ status }) => { + const { getByText } = renderWithProvider(); + expect(getByText(getDescription(status, {}))).toBeTruthy(); + }); + }); +}); diff --git a/app/core/NotificationManager.js b/app/core/NotificationManager.js index 568d3e97d6d..22af910e519 100644 --- a/app/core/NotificationManager.js +++ b/app/core/NotificationManager.js @@ -3,18 +3,16 @@ import Engine from './Engine'; import { hexToBN, renderFromWei } from '../util/number'; import Device from '../util/device'; -import notifee from '@notifee/react-native'; -import { STORAGE_IDS } from '../util/notifications/settings/storage/constants'; import { strings } from '../../locales/i18n'; import { AppState } from 'react-native'; +import NotificationsService from '../util/notifications/services/NotificationService'; import { NotificationTransactionTypes, - isNotificationsFeatureEnabled, - + ChannelId, } from '../util/notifications'; -import { safeToChecksumAddress } from '../util/address'; +import { safeToChecksumAddress, formatAddress } from '../util/address'; import ReviewManager from './ReviewManager'; import { selectChainId, selectTicker } from '../selectors/networkController'; import { store } from '../store'; @@ -76,9 +74,17 @@ export const constructTitleAndMessage = (notification) => { amount: notification.transaction.amount, }); break; + case NotificationTransactionTypes.eth_received: + title = strings('notifications.default_message_title'); + message = strings('notifications.eth_received_message', { + amount: notification.transaction.amount.usd, + ticker: 'USD', + address: formatAddress(notification.transaction.from, 'short'), + }); + break; default: - title = notification.data.title || strings('notifications.default_message_title'); - message = notification.data.shortDescription || strings('notifications.default_message_description'); + title = notification?.data?.title || strings('notifications.default_message_title'); + message = notification?.data?.shortDescription || strings('notifications.default_message_description'); break; } return { title, message }; @@ -141,8 +147,7 @@ class NotificationManager { ); }; - // TODO: Refactor this method to use notifee's channels in combination with MM auth - _showNotification(data, channelId = STORAGE_IDS.ANDROID_DEFAULT_CHANNEL_ID) { + _showNotification = async (data) => { if (this._backgroundMode) { const { title, message } = constructTitleAndMessage(data); const id = data?.transaction?.id || ''; @@ -151,26 +156,13 @@ class NotificationManager { } const pushData = { + channelId: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, title, body: message, - android: { - lightUpScreen: true, - channelId, - smallIcon: 'ic_notification_small', - largeIcon: 'ic_notification', - pressAction: { - id: 'default', - launchActivity: 'com.metamask.ui.MainActivity', - }, - }, - ios: { - foregroundPresentationOptions: { - alert: true, - sound: true, - badge: true, - banner: true, - list: true, - }, + data: { + ...data?.transaction, + action: 'tx', + id, }, }; @@ -179,9 +171,9 @@ class NotificationManager { if (Device.isAndroid()) { pushData.tag = JSON.stringify(extraData); } else { - pushData.userInfo = extraData; // check if is still needed + pushData.userInfo = extraData; } - isNotificationsFeatureEnabled() && notifee.displayNotification(pushData); + await NotificationsService.displayNotification(pushData); } else { this._showTransactionNotification({ autodismiss: data.duration, @@ -189,7 +181,7 @@ class NotificationManager { status: data.type, }); } - } + }; _failedCallback = (transactionMeta) => { // If it fails we hide the pending tx notification diff --git a/app/core/NotificationsManager.test.ts b/app/core/NotificationsManager.test.ts index 2af79c085b5..fb8e9a47d28 100644 --- a/app/core/NotificationsManager.test.ts +++ b/app/core/NotificationsManager.test.ts @@ -9,6 +9,10 @@ interface NavigationMock { navigate: jest.Mock; } +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + jest.unmock('./NotificationManager'); const mockNavigate: jest.Mock = jest.fn(); @@ -101,7 +105,7 @@ describe('NotificationManager', () => { 'cancelled', ]; selectedNotificationTypes.forEach((type) => { - it(`should construct title and message for ${type}`, () => { + it(`constructs title and message for ${type}`, () => { const { title, message } = constructTitleAndMessage({ type: NotificationTransactionTypes[type], }); @@ -109,5 +113,14 @@ describe('NotificationManager', () => { expect(title).toBe(strings(`notifications.${type}_title`)); expect(message).toBe(strings(`notifications.${type}_message`)); }); + + it('constructs default title and message for unknown type', () => { + const { title, message } = constructTitleAndMessage({ + type: 'unknown', + }); + + expect(title).toBe(strings('notifications.default_message_title')); + expect(message).toBe(strings('notifications.default_message_description')); + }); }); }); diff --git a/app/util/notifications/hooks/index.test.ts b/app/util/notifications/hooks/index.test.ts deleted file mode 100644 index ff4b9fd77aa..00000000000 --- a/app/util/notifications/hooks/index.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { act, renderHook } from '@testing-library/react-hooks'; -// eslint-disable-next-line import/no-namespace -import * as constants from '../constants'; -import useNotificationHandler from './index'; -import { NavigationProp, ParamListBase } from '@react-navigation/native'; -import Routes from '../../../constants/navigation/Routes'; -import { Notification } from '../../../util/notifications/types'; -import { TRIGGER_TYPES } from '../constants'; -import NotificationsService from '../services/NotificationService'; - -jest.mock('@notifee/react-native', () => ({ - setBadgeCount: jest.fn(), - decrementBadgeCount: jest.fn(), - onForegroundEvent: jest.fn(), - createChannel: jest.fn(), - EventType: { - DISMISSED: 'dismissed', - DELIVERED: 'delivered', - PRESS: 'press', - }, - AndroidImportance: { - HIGH: 'high', - }, -})); - -jest.mock('../constants', () => ({ - ...jest.requireActual('../constants'), - isNotificationsFeatureEnabled: jest.fn(), -})); - -const mockNavigate = jest.fn(); -const mockNavigation = { - navigate: mockNavigate, -} as unknown as NavigationProp; - -const notification = { - id: '123', - type: TRIGGER_TYPES.ERC1155_RECEIVED, - data: { - id: '123', - trigger_id: '1', - chain_id: 1, - block_number: 1, - block_timestamp: '', - tx_hash: '', - unread: false, - created_at: '', - address: '', - type: TRIGGER_TYPES.ERC1155_RECEIVED, - data: {}, - createdAt: '', - isRead: false, - }, -} as unknown as Notification; - -jest.mock('../services/NotificationService', () => ({ - onForegroundEvent: jest.fn(), - onBackgroundEvent: jest.fn(), - handleNotificationEvent: jest.fn(), -})); -describe('useNotificationHandler', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('navigates to NOTIFICATIONS.DETAILS when notification is pressed', async () => { - const { result } = renderHook(() => useNotificationHandler(mockNavigation)); - - await act(async () => { - result.current.handlePressedNotification(notification); - }); - - expect(mockNavigation.navigate).toHaveBeenCalledWith( - Routes.NOTIFICATIONS.DETAILS, - { - notificationId: notification.id, - }, - ); - }); - - it('does not navigates when notification is null', async () => { - - - const { result } = renderHook(() => - useNotificationHandler(mockNavigation), - ); - - await act(async () => { - result.current.handlePressedNotification(); - }); - - expect(mockNavigation.navigate).not.toHaveBeenCalled(); - }); - - it('does nothing if the isNotificationsFeatureEnabled is false', async () => { - jest.spyOn(constants, 'isNotificationsFeatureEnabled').mockReturnValue(false); - - const { result } = renderHook(() => useNotificationHandler(mockNavigation)); - - await act(async () => { - result.current.handlePressedNotification(notification); - }); - - expect(NotificationsService.onForegroundEvent).not.toHaveBeenCalled(); - expect(NotificationsService.onBackgroundEvent).not.toHaveBeenCalled(); - - jest.restoreAllMocks(); - }); -}); diff --git a/app/util/notifications/hooks/index.test.tsx b/app/util/notifications/hooks/index.test.tsx new file mode 100644 index 00000000000..59535338cad --- /dev/null +++ b/app/util/notifications/hooks/index.test.tsx @@ -0,0 +1,100 @@ +/* eslint-disable import/no-namespace */ + +import { renderHook } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import createMockStore from 'redux-mock-store'; +import React from 'react'; +import * as NotificationUtils from '../../../util/notifications'; +import FCMService from '../services/FCMService'; +import useNotificationHandler from './index'; +import initialRootState from '../../../util/test/initial-root-state'; +import * as Selectors from '../../../selectors/notifications'; +import { NavigationContainerRef } from '@react-navigation/native'; + +jest.mock('../../../util/notifications', () => ({ + isNotificationsFeatureEnabled: jest.fn(), +})); + +jest.mock('../services/FCMService', () => ({ + registerAppWithFCM: jest.fn(), + saveFCMToken: jest.fn(), + registerTokenRefreshListener: jest.fn(), + listenForMessagesForeground: jest.fn(), +})); + +function arrangeMocks(isFeatureEnabled: boolean, isMetaMaskEnabled: boolean) { + jest.spyOn(NotificationUtils, 'isNotificationsFeatureEnabled') + .mockReturnValue(isFeatureEnabled); + + jest.spyOn(Selectors, 'selectIsMetamaskNotificationsEnabled') + .mockReturnValue(isMetaMaskEnabled); +} + +function arrangeStore() { + const store = createMockStore()(initialRootState); + + store.dispatch = jest.fn().mockImplementation((action) => { + if (typeof action === 'function') { + return action(store.dispatch, store.getState); + } + return Promise.resolve(); + }); + + return store; +} + +const mockNavigate = jest.fn(); +const mockNavigation = { + navigate: mockNavigate, +} as unknown as NavigationContainerRef; + +function arrangeHook() { + const store = arrangeStore(); + const hook = renderHook(() => useNotificationHandler(mockNavigation), { + wrapper: ({ children }) => {children}, + }); + + return hook; +} + +describe('useNotificationHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not register FCM when notifications are disabled', () => { + arrangeMocks(false, false); + + arrangeHook(); + + expect(FCMService.registerAppWithFCM).not.toHaveBeenCalled(); + expect(FCMService.saveFCMToken).not.toHaveBeenCalled(); + expect(FCMService.listenForMessagesForeground).not.toHaveBeenCalled(); + }); + + it('registers FCM when notifications feature is enabled', () => { + arrangeMocks(true, true); + + arrangeHook(); + + expect(FCMService.registerAppWithFCM).toHaveBeenCalledTimes(1); + expect(FCMService.saveFCMToken).toHaveBeenCalledTimes(1); + }); + + it('registers FCM when MetaMask notifications are enabled', () => { + arrangeMocks(true, true); + + arrangeHook(); + + expect(FCMService.registerAppWithFCM).toHaveBeenCalledTimes(1); + expect(FCMService.saveFCMToken).toHaveBeenCalledTimes(1); + }); + + it('handleNotificationCallback does nothing when notification is undefined', () => { + arrangeMocks(true, true); + + arrangeHook(); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/notifications/hooks/index.ts b/app/util/notifications/hooks/index.ts index 3f6bb98c9a3..4b00e90492f 100644 --- a/app/util/notifications/hooks/index.ts +++ b/app/util/notifications/hooks/index.ts @@ -1,73 +1,75 @@ import { useCallback, useEffect } from 'react'; -import { NavigationProp, ParamListBase } from '@react-navigation/native'; -import NotificationsService from '../../../util/notifications/services/NotificationService'; -import Routes from '../../../constants/navigation/Routes'; +import { NotificationServicesController } from '@metamask/notification-services-controller'; + +import { useSelector } from 'react-redux'; import { isNotificationsFeatureEnabled, + Notification, } from '../../../util/notifications'; -import { Notification } from '../../../util/notifications/types'; -import { - TRIGGER_TYPES, -} from '../../../util/notifications/constants'; + +import FCMService from '../services/FCMService'; +import NotificationsService from '../services/NotificationService'; +import { selectIsMetamaskNotificationsEnabled } from '../../../selectors/notifications'; import { Linking } from 'react-native'; +import { NavigationContainerRef } from '@react-navigation/native'; +import Routes from '../../../constants/navigation/Routes'; + +const { TRIGGER_TYPES } = NotificationServicesController.Constants; + +const useNotificationHandler = (navigation: NavigationContainerRef) => { + /** + * Handles the action based on the type of notification (sent from the backend & following Notification types) that is opened + * @param notification - The notification that is opened + */ -const useNotificationHandler = (navigation: NavigationProp) => { - const performActionBasedOnOpenedNotificationType = useCallback( + const isNotificationEnabled = useSelector( + selectIsMetamaskNotificationsEnabled, + ); + + const handleNotificationCallback = useCallback( async (notification: Notification) => { + if (!notification) { + return; + } if ( notification.type === TRIGGER_TYPES.FEATURES_ANNOUNCEMENT && notification.data.externalLink ) { Linking.openURL(notification.data.externalLink.externalLinkUrl); } else { - navigation.navigate(Routes.NOTIFICATIONS.DETAILS, { - notificationId: notification.id, - }); + navigation.navigate(Routes.NOTIFICATIONS.VIEW); } }, [navigation], ); - const handlePressedNotification = useCallback( - (notification?: Notification) => { - if (!notification) { - return; - } - performActionBasedOnOpenedNotificationType(notification); - }, - [performActionBasedOnOpenedNotificationType], - ); + const notificationEnabled = isNotificationsFeatureEnabled() && isNotificationEnabled; useEffect(() => { - if (!isNotificationsFeatureEnabled()) return; + if (!notificationEnabled) return; - const unsubscribeForegroundEvent = NotificationsService.onForegroundEvent( - async ({ type, detail }) => - await NotificationsService.handleNotificationEvent({ - type, - detail, - callback: handlePressedNotification, - }), - ); + // Firebase Cloud Messaging + FCMService.registerAppWithFCM(); + FCMService.saveFCMToken(); + FCMService.getFCMToken(); + FCMService.listenForMessagesBackground(); + // Notifee NotificationsService.onBackgroundEvent( async ({ type, detail }) => await NotificationsService.handleNotificationEvent({ type, detail, - callback: handlePressedNotification, + callback: handleNotificationCallback, }), ); + const unsubscribeForegroundEvent = FCMService.listenForMessagesForeground(); + return () => { unsubscribeForegroundEvent(); }; - }, [handlePressedNotification]); - - return { - performActionBasedOnOpenedNotificationType, - handlePressedNotification, - }; + }, [handleNotificationCallback, notificationEnabled]); }; export default useNotificationHandler; diff --git a/app/util/notifications/services/NotificationService.test.ts b/app/util/notifications/services/NotificationService.test.ts index 26ce917f539..6b498592f0f 100644 --- a/app/util/notifications/services/NotificationService.test.ts +++ b/app/util/notifications/services/NotificationService.test.ts @@ -7,6 +7,7 @@ import notifee, { import { Linking } from 'react-native'; import { ChannelId } from '../../../util/notifications/androidChannels'; import NotificationsService from './NotificationService'; +import { LAUNCH_ACTIVITY, PressActionId } from '../types'; jest.mock('@notifee/react-native', () => ({ getNotificationSettings: jest.fn(), @@ -22,6 +23,7 @@ jest.mock('@notifee/react-native', () => ({ getBadgeCount: jest.fn(), getInitialNotification: jest.fn(), openNotificationSettings: jest.fn(), + displayNotification: jest.fn(), AndroidImportance: { DEFAULT: 'default', HIGH: 'high', @@ -66,7 +68,7 @@ describe('NotificationsService', () => { jest.clearAllMocks(); }); - it('should get blocked notifications', async () => { + it('gets blocked notifications', async () => { (notifee.getNotificationSettings as jest.Mock).mockResolvedValue({ authorizationStatus: AuthorizationStatus.AUTHORIZED, }); @@ -82,7 +84,7 @@ describe('NotificationsService', () => { ).toBe(true); }); - it('should handle notification press', async () => { + it('handles notification press', async () => { const detail = { notification: { id: 'test-id', @@ -97,13 +99,13 @@ describe('NotificationsService', () => { expect(callback).toHaveBeenCalledWith(detail.notification); }); - it('should open system settings on iOS', () => { + it('opens system settings on iOS', () => { NotificationsService.openSystemSettings(); expect(Linking.openSettings).toHaveBeenCalled(); }); - it('should create notification channels', async () => { + it('creates notification channels', async () => { const channel: AndroidChannel = { id: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, name: 'Test Channel', @@ -115,7 +117,7 @@ describe('NotificationsService', () => { expect(notifee.createChannel).toHaveBeenCalledWith(channel); }); - it('should return authorized from getAllPermissions', async () => { + it('returns authorized from getAllPermissions', async () => { (notifee.requestPermission as jest.Mock).mockResolvedValue({ authorizationStatus: AuthorizationStatus.AUTHORIZED, }); @@ -128,7 +130,7 @@ describe('NotificationsService', () => { expect(result.permission).toBe('authorized'); }); - it('should return authorized from requestPermission', async () => { + it('returns authorized from requestPermission', async () => { (notifee.requestPermission as jest.Mock).mockResolvedValue({ authorizationStatus: AuthorizationStatus.AUTHORIZED, }); @@ -136,7 +138,7 @@ describe('NotificationsService', () => { expect(result).toBe('authorized'); }); - it('should return denied from requestPermission', async () => { + it('returns denied from requestPermission', async () => { (notifee.requestPermission as jest.Mock).mockResolvedValue({ authorizationStatus: AuthorizationStatus.DENIED, }); @@ -144,7 +146,7 @@ describe('NotificationsService', () => { expect(result).toBe('denied'); }); - it('should handle notification event', async () => { + it('handles notification event', async () => { const callback = jest.fn(); await NotificationsService.handleNotificationEvent({ @@ -172,4 +174,41 @@ describe('NotificationsService', () => { expect(notifee.decrementBadgeCount).toHaveBeenCalledWith(1); expect(notifee.cancelTriggerNotification).toHaveBeenCalledWith('123'); }); + + it('displays notification', async () => { + const notification = { + title: 'Test Title', + body: 'Test Body', + data: undefined, + android: { + smallIcon: 'ic_notification_small', + largeIcon: 'ic_notification', + channelId: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, + pressAction: { + id: PressActionId.OPEN_NOTIFICATIONS_VIEW, + launchActivity: LAUNCH_ACTIVITY, + }, + }, + ios: { + foregroundPresentationOptions: { + alert: true, + sound: true, + badge: true, + banner: true, + list: true, + }, + interruptionLevel: 'critical', + launchImageName: 'Default', + sound: 'default', + }, + }; + + await NotificationsService.displayNotification({ + title: 'Test Title', + body: 'Test Body', + channelId: ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, + }); + + expect(notifee.displayNotification).toHaveBeenCalledWith(notification); + }); }); diff --git a/app/util/notifications/services/NotificationService.ts b/app/util/notifications/services/NotificationService.ts index 6c6f104404f..1cf9c9f4d8f 100644 --- a/app/util/notifications/services/NotificationService.ts +++ b/app/util/notifications/services/NotificationService.ts @@ -6,7 +6,7 @@ import notifee, { AndroidChannel, } from '@notifee/react-native'; -import { Notification } from '../types'; +import { HandleNotificationCallback, LAUNCH_ACTIVITY, Notification, PressActionId } from '../types'; import { Linking, Platform, Alert as NativeAlert } from 'react-native'; import { @@ -221,7 +221,14 @@ class NotificationsService { await notifee.cancelTriggerNotification(id); }; - getInitialNotification = async () => notifee.getInitialNotification(); + getInitialNotification = async ( + callback: HandleNotificationCallback + ): Promise => { + const event = await notifee.getInitialNotification() + if (event) { + callback(event.notification.data as Notification['data']) + } + }; cancelAllNotifications = async () => { await notifee.cancelAllNotifications(); @@ -229,6 +236,45 @@ class NotificationsService { createChannel = async (channel: AndroidChannel): Promise => notifee.createChannel(channel); + + displayNotification = async ({ + channelId, + title, + body, + data + }: { + channelId: ChannelId + title: string + body?: string + data?: Notification['data'] + }): Promise => { + await notifee.displayNotification({ + title, + body, + data: data as unknown as Notification['data'], + android: { + smallIcon: 'ic_notification_small', + largeIcon: 'ic_notification', + channelId: channelId ?? ChannelId.DEFAULT_NOTIFICATION_CHANNEL_ID, + pressAction: { + id: PressActionId.OPEN_NOTIFICATIONS_VIEW, + launchActivity: LAUNCH_ACTIVITY, + } + }, + ios: { + launchImageName: 'Default', + sound: 'default', + interruptionLevel: 'critical', + foregroundPresentationOptions: { + alert: true, + sound: true, + badge: true, + banner: true, + list: true, + }, + }, + }); + }; } export default new NotificationsService(); diff --git a/app/util/notifications/types/notification/index.ts b/app/util/notifications/types/notification/index.ts index 6b091239414..8c8488dc230 100644 --- a/app/util/notifications/types/notification/index.ts +++ b/app/util/notifications/types/notification/index.ts @@ -10,6 +10,17 @@ import { TRIGGER_TYPES } from '../../constants'; */ export type Notification = NotificationServicesController.Types.INotification; +export type HandleNotificationCallback = ( + data: Notification['data'] | undefined +) => void + +export enum PressActionId { + OPEN_NOTIFICATIONS_VIEW = 'open-notifications-view-press-action-id', + OPEN_TRANSACTIONS_VIEW = 'open-transactions-view-press-action-id' +} + +export const LAUNCH_ACTIVITY = 'com.metamask.ui.MainActivity'; + /** * NotificationFC is the shared component interface for all notification components */ @@ -84,6 +95,22 @@ export const NotificationTransactionTypes = { cancelled: 'cancelled', received: 'received', received_payment: 'received_payment', + eth_received: 'eth_received', + features_announcement: 'features_announcement', + metamask_swap_completed: 'metamask_swap_completed', + erc20_sent: 'erc20_sent', + erc20_received: 'erc20_received', + eth_sent: 'eth_sent', + rocketpool_stake_completed: 'rocketpool_stake_completed', + rocketpool_unstake_completed: 'rocketpool_unstake_completed', + lido_stake_completed: 'lido_stake_completed', + lido_withdrawal_requested: 'lido_withdrawal_requested', + lido_withdrawal_completed: 'lido_withdrawal_completed', + lido_stake_ready_to_be_withdrawn: 'lido_stake_ready_to_be_withdrawn', + erc721_sent: 'erc721_sent', + erc721_received: 'erc721_received', + erc1155_sent: 'erc1155_sent', + erc1155_received: 'erc1155_received', } as const; export type NotificationTransactionTypesType = @@ -96,10 +123,10 @@ export interface MarketingNotificationData { } export const STAKING_PROVIDER_MAP: Record< - | 'lido_stake_completed' - | 'rocketpool_stake_completed' - | 'rocketpool_unstake_completed' - | 'lido_withdrawal_completed', +NotificationServicesController.Constants.TRIGGER_TYPES.LIDO_STAKE_COMPLETED +| NotificationServicesController.Constants.TRIGGER_TYPES.ROCKETPOOL_STAKE_COMPLETED +| NotificationServicesController.Constants.TRIGGER_TYPES.ROCKETPOOL_UNSTAKE_COMPLETED +| NotificationServicesController.Constants.TRIGGER_TYPES.LIDO_WITHDRAWAL_COMPLETED, string > = { [TRIGGER_TYPES.LIDO_STAKE_COMPLETED]: 'Lido-staked ETH', diff --git a/locales/languages/en.json b/locales/languages/en.json index 61291b5d831..0e22f5e0b41 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2199,7 +2199,7 @@ "label_swapped": "Swapped", "label_to": "To" }, - "received_from": "Received from {{address}}", + "received_from": "Received {{amount}} {{ticker}} from {{address}}", "nft_sent": "Sent NFT to {{address}}", "erc721_sent": "Sent NFT to {{address}}", "erc1155_sent": "Sent NFT to {{address}}", @@ -2216,8 +2216,23 @@ "success_withdrawal_title": "Withdrawal Complete!", "error_title": "Oops, something went wrong :/", "received_title": "You received {{amount}} {{assetType}}", - "default_message_title": "New transaction notification", - "default_message_description": "Tap to view this transaction", + "metamask_swap_completed_title": "Swap completed", + "erc20_sent_title": "Funds sent", + "erc20_received_title": "Funds received", + "eth_sent_title": "Funds sent", + "eth_received_title": "Funds received", + "rocketpool_stake_completed_title": "Stake complete", + "rocketpool_unstake_completed_title": "Unstake complete", + "lido_stake_completed_title": "Stake complete", + "lido_withdrawal_requested_title": "Withdrawal requested", + "lido_withdrawal_completed_title": "Withdrawal completed", + "lido_stake_ready_to_be_withdrawn_title": "Stake ready for withdrawal ", + "erc721_sent_title": "NFT sent", + "erc721_received_title": "NFT received", + "erc1155_sent_title": "NFT sent", + "erc1155_received_title": "NFT received", + "default_message_title": "MetaMask", + "default_message_description": "Tap to view", "received_payment_title": "Instant payment received", "pending_message": "Waiting for confirmation", "pending_deposit_message": "Waiting for deposit to complete", @@ -2233,6 +2248,17 @@ "cancelled_message": "Tap to view this transaction", "received_message": "Tap to view this transaction", "received_payment_message": "You received {{amount}} DAI", + "eth_received_message": "You received some ETH", + "metamask_swap_completed_message": "Swapped {{symbolIn}} for {{symbolOut}}", + "erc20_sent_message": "Sent to {{address}}", + "erc20_received_message": "Received from {{address}}", + "eth_sent_message": "Sent to {{address}}", + "rocketpool_stake_completed_message": "Staked", + "rocketpool_unstake_completed_message": "Unstaking complete", + "lido_stake_completed_message": "Staked", + "lido_withdrawal_requested_message": "Unstaking requested", + "lido_withdrawal_completed_message": "Unstaking complete", + "lido_stake_ready_to_be_withdrawn_message": "Withdrawal requested", "push_notification_content": { "metamask_swap_completed_title": "Swap completed", "metamask_swap_completed_description": "Your MetaMask Swap was successful",