diff --git a/app/actions/notification/helpers/index.ts b/app/actions/notification/helpers/index.ts index 27c05de367c..b613ca97691 100644 --- a/app/actions/notification/helpers/index.ts +++ b/app/actions/notification/helpers/index.ts @@ -45,7 +45,6 @@ export const enableProfileSyncing = async () => { export const disableProfileSyncing = async () => { try { - await Engine.context.NotificationServicesController.disableNotificationServices(); await Engine.context.UserStorageController.disableProfileSyncing(); } catch (error) { return getErrorMessage(error); diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index 99dfd161e7f..c1423528f85 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -169,10 +169,9 @@ const AssetOverview: React.FC = ({ ); const itemAddress = safeToChecksumAddress(asset.address); - const exchangeRate = - itemAddress && itemAddress in tokenExchangeRates - ? tokenExchangeRates?.[itemAddress]?.price - : undefined; + const exchangeRate = itemAddress + ? tokenExchangeRates?.[itemAddress]?.price + : undefined; let balance, balanceFiat; if (asset.isETH) { @@ -190,7 +189,7 @@ const AssetOverview: React.FC = ({ ); } else { balance = - itemAddress && itemAddress in tokenBalances + itemAddress && tokenBalances?.[itemAddress] ? renderFromTokenMinimalUnit(tokenBalances[itemAddress], asset.decimals) : 0; balanceFiat = balanceToFiat( diff --git a/app/components/UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.tsx b/app/components/UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.tsx index b0ac86bd5a9..300cd8a1c48 100644 --- a/app/components/UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.tsx +++ b/app/components/UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal.tsx @@ -1,5 +1,5 @@ // Third party dependencies. -import React, { useRef } from 'react'; +import React, { useCallback, useRef } from 'react'; import { View } from 'react-native'; // External dependencies. @@ -25,8 +25,22 @@ import Icon, { IconName, IconSize, } from '../../../../component-library/components/Icons/Icon'; +import Routes from '../../../../constants/navigation/Routes'; +import { + asyncAlert, + requestPushNotificationsPermission, +} from '../../../../util/notifications'; +import { useEnableNotifications } from '../../../../util/notifications/hooks/useNotifications'; -const BasicFunctionalityModal = () => { +interface Props { + route: { + params: { + caller: string; + }; + }; +} + +const BasicFunctionalityModal = ({ route }: Props) => { const { colors } = useTheme(); const styles = createStyles(colors); const bottomSheetRef = useRef(null); @@ -36,10 +50,34 @@ const BasicFunctionalityModal = () => { (state: RootState) => state?.settings?.basicFunctionalityEnabled, ); - const closeBottomSheet = () => { + const { enableNotifications } = useEnableNotifications(); + + const enableNotificationsFromModal = useCallback(async () => { + const nativeNotificationStatus = await requestPushNotificationsPermission( + asyncAlert, + ); + + if (nativeNotificationStatus) { + /** + * Although this is an async function, we are dispatching an action (firing & forget) + * to emulate optimistic UI. + * + */ + enableNotifications(); + } + }, [enableNotifications]); + + const closeBottomSheet = async () => { bottomSheetRef.current?.onCloseBottomSheet(() => dispatch(toggleBasicFunctionality(!isEnabled)), ); + + if ( + route.params.caller === Routes.SETTINGS.NOTIFICATIONS || + route.params.caller === Routes.NOTIFICATIONS.OPT_IN + ) { + await enableNotificationsFromModal(); + } }; const handleSwitchToggle = () => { diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index f7cb21ebe62..3f7a06fef4b 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -119,6 +119,21 @@ const styles = StyleSheet.create({ height: 24, marginLeft: 16, }, + notificationsWrapper: { + position: 'relative', + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + notificationsBadge: { + width: 8, + height: 8, + borderRadius: 4, + + position: 'absolute', + top: 2, + right: 10, + }, }); const metamask_name = require('../../../images/metamask-name.png'); // eslint-disable-line @@ -901,6 +916,7 @@ export function getWalletNavbarOptions( navigation, themeColors, isNotificationEnabled, + unreadNotificationCount, ) { const innerStyles = StyleSheet.create({ headerStyle: { @@ -999,14 +1015,26 @@ export function getWalletNavbarOptions( headerRight: () => ( {isNotificationsFeatureEnabled() && ( - + + + + )} await notifee.getBadgeCount(); + + unreadedCount().then((count) => { + if (count > 0) { + notifee.setBadgeCount(count - 1); + } else { + notifee.setBadgeCount(0); + } + }); }, [markNotificationAsRead, props.navigation], ); @@ -89,7 +99,12 @@ function NotificationsListItem(props: NotificationsListItemProps) { handleOnPress={() => onNotificationClick(props.notification)} styles={styles} simultaneousHandlers={undefined} + isRead={props.notification.isRead} > + diff --git a/app/components/UI/Notification/List/styles.ts b/app/components/UI/Notification/List/styles.ts index fb0db3c3d27..9e13f2c06ca 100644 --- a/app/components/UI/Notification/List/styles.ts +++ b/app/components/UI/Notification/List/styles.ts @@ -11,11 +11,46 @@ export const createStyles = ({ colors, typography }: Theme) => backgroundColor: colors.background.default, marginHorizontal: 8, }, + itemContainer: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 8, + }, + unreadItemContainer: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 8, + backgroundColor: colors.info.muted, + }, + readItemContainer: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 8, + backgroundColor: colors.background.default, + }, + unreadDot: { + width: 4, + height: 4, + borderRadius: 2, + backgroundColor: colors.info.default, + position: 'absolute', + marginTop: 16, + marginLeft: -6, + }, + readDot: { + width: 4, + height: 4, + borderRadius: 2, + position: 'absolute', + marginTop: 16, + marginLeft: -6, + }, wrapper: { flex: 1, paddingVertical: 10, justifyContent: 'center', borderRadius: 10, + backgroundColor: colors.primary.default, }, loaderContainer: { position: 'absolute', diff --git a/app/components/UI/Notification/NotificationMenuItem/Icon.tsx b/app/components/UI/Notification/NotificationMenuItem/Icon.tsx index a18b019673d..73007ebb3e6 100644 --- a/app/components/UI/Notification/NotificationMenuItem/Icon.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/Icon.tsx @@ -10,7 +10,10 @@ import RemoteImage from '../../../../components/Base/RemoteImage'; import METAMASK_FOX from '../../../../images/fox.png'; import { View } from 'react-native'; -type NotificationIconProps = Pick; +type NotificationIconProps = Pick< + NotificationMenuItem, + 'image' | 'badgeIcon' | 'isRead' +>; function MenuIcon(props: NotificationIconProps) { const { styles } = useStyles(); @@ -47,20 +50,23 @@ function NotificationIcon(props: NotificationIconProps) { const { styles } = useStyles(); return ( - - - } - style={styles.badgeWrapper} - > - - - + + + + } + style={styles.badgeWrapper} + > + + + + + ); } diff --git a/app/components/UI/Notification/NotificationMenuItem/Root.tsx b/app/components/UI/Notification/NotificationMenuItem/Root.tsx index f6f5e84e8a8..be38a64f492 100644 --- a/app/components/UI/Notification/NotificationMenuItem/Root.tsx +++ b/app/components/UI/Notification/NotificationMenuItem/Root.tsx @@ -27,6 +27,7 @@ interface NotificationRootProps styles: NotificationListStyles; handleOnPress: () => void; onDismiss?: () => void; + isRead?: boolean; } const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -39,6 +40,7 @@ function NotificationRoot({ styles, onDismiss, simultaneousHandlers, + isRead, }: NotificationRootProps) { const transX = useSharedValue(0); const itemHeight = useSharedValue(); @@ -75,7 +77,8 @@ function NotificationRoot({ const rChildrenStyle = useAnimatedStyle(() => ({ transform: [{ translateX: transX.value }], - ...styles.container, + ...styles.itemContainer, + ...(!isRead ? styles.unreadItemContainer : styles.readItemContainer), })); const rIconStyle = useAnimatedStyle(() => { @@ -86,14 +89,8 @@ function NotificationRoot({ return { opacity: opct }; }); - const rContainerStyle = useAnimatedStyle(() => ({ - height: itemHeight.value, - paddingVertical: paddingVertical.value, - opacity: opacity.value, - })); - return ( - + +[ - - - + + + - + testID="badge-badgenotifications" + > + + - - + , + , +] `; diff --git a/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Root.test.tsx.snap b/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Root.test.tsx.snap index d055b2e865b..fe74c3ff047 100644 --- a/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Root.test.tsx.snap +++ b/app/components/UI/Notification/NotificationMenuItem/__snapshots__/Root.test.tsx.snap @@ -1,18 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`NotificationRoot matches snapshot 1`] = ` - + We’d like to gather basic usage data to improve MetaMask. Know that we never sell the data you provide here. + + Learn how we protect your privacy while collecting usage data for your profile. + color: colors.text.default, paddingVertical: 10, }, + linkText: { + ...fontStyles.normal, + fontSize: 14, + color: colors.info.default, + paddingVertical: 10, + }, wrapper: { marginHorizontal: 20, }, @@ -565,6 +572,10 @@ class OptinMetrics extends PureComponent { if (currentYOffset >= endThreshold) this.onScrollEndReached(); }; + handleLink = () => { + Linking.openURL(AppConstants.URLS.PROFILE_SYNC); + }; + render() { const { isDataCollectionForMarketingEnabled, @@ -604,6 +615,9 @@ class OptinMetrics extends PureComponent { : 'privacy_policy.description_content_1_legacy', )} + + {strings('privacy_policy.description_content_3')} + {strings( isPastPrivacyPolicyDate diff --git a/app/components/UI/ProfileSyncing/ProfileSyncing.tsx b/app/components/UI/ProfileSyncing/ProfileSyncing.tsx index 6682ff5df2e..3631c6d1bcb 100644 --- a/app/components/UI/ProfileSyncing/ProfileSyncing.tsx +++ b/app/components/UI/ProfileSyncing/ProfileSyncing.tsx @@ -1,8 +1,6 @@ import React, { useEffect } from 'react'; -import { useSelector } from 'react-redux'; import { View, Switch, Linking } from 'react-native'; -import { RootState } from '../../../reducers'; import Text, { TextVariant, @@ -13,22 +11,19 @@ import { strings } from '../../../../locales/i18n'; import styles from './ProfileSyncing.styles'; import { ProfileSyncingComponentProps } from './ProfileSyncing.types'; import AppConstants from '../../../core/AppConstants'; -import { selectIsProfileSyncingEnabled } from '../../../selectors/notifications'; import { useProfileSyncing } from '../../../util/notifications/hooks/useProfileSyncing'; export default function ProfileSyncingComponent({ handleSwitchToggle, + isBasicFunctionalityEnabled, + isProfileSyncingEnabled, }: Readonly) { const theme = useTheme(); const { colors } = theme; const { disableProfileSyncing } = useProfileSyncing(); - const isProfileSyncingEnabled = useSelector(selectIsProfileSyncingEnabled); - const isBasicFunctionalityEnabled = useSelector( - (state: RootState) => state?.settings?.basicFunctionalityEnabled, - ); const handleLink = () => { - Linking.openURL(AppConstants.URLS.PRIVACY_POLICY_2024); + Linking.openURL(AppConstants.URLS.PROFILE_SYNC); }; useEffect(() => { diff --git a/app/components/UI/ProfileSyncing/ProfileSyncing.types.ts b/app/components/UI/ProfileSyncing/ProfileSyncing.types.ts index 1fe29f86dcd..fba3206d3c4 100644 --- a/app/components/UI/ProfileSyncing/ProfileSyncing.types.ts +++ b/app/components/UI/ProfileSyncing/ProfileSyncing.types.ts @@ -1,3 +1,5 @@ export interface ProfileSyncingComponentProps { handleSwitchToggle: () => void; + isBasicFunctionalityEnabled: boolean; + isProfileSyncingEnabled: boolean | null; } diff --git a/app/components/UI/ProfileSyncing/ProfileSyncingModal/ProfileSyncingModal.tsx b/app/components/UI/ProfileSyncing/ProfileSyncingModal/ProfileSyncingModal.tsx index 8f95fef991c..c47a85f4e38 100644 --- a/app/components/UI/ProfileSyncing/ProfileSyncingModal/ProfileSyncingModal.tsx +++ b/app/components/UI/ProfileSyncing/ProfileSyncingModal/ProfileSyncingModal.tsx @@ -25,7 +25,6 @@ import Icon, { } from '../../../../component-library/components/Icons/Icon'; import { selectIsProfileSyncingEnabled } from '../../../../selectors/notifications'; import { useProfileSyncing } from '../../../../util/notifications/hooks/useProfileSyncing'; -import { useDisableNotifications } from '../../../../util/notifications/hooks/useNotifications'; const ProfileSyncingModal = () => { const { colors } = useTheme(); @@ -33,7 +32,6 @@ const ProfileSyncingModal = () => { const bottomSheetRef = useRef(null); const [isChecked, setIsChecked] = React.useState(false); const { disableProfileSyncing } = useProfileSyncing(); - const { disableNotifications } = useDisableNotifications(); const isProfileSyncingEnabled = useSelector(selectIsProfileSyncingEnabled); @@ -42,7 +40,6 @@ const ProfileSyncingModal = () => { bottomSheetRef.current?.onCloseBottomSheet(async () => { if (isProfileSyncingEnabled) { await disableProfileSyncing(); - await disableNotifications(); } }); }; diff --git a/app/components/UI/ProfileSyncing/__snapshots__/ProfileSyncing.test.tsx.snap b/app/components/UI/ProfileSyncing/__snapshots__/ProfileSyncing.test.tsx.snap index cf1b7ec1739..941fe16a15f 100644 --- a/app/components/UI/ProfileSyncing/__snapshots__/ProfileSyncing.test.tsx.snap +++ b/app/components/UI/ProfileSyncing/__snapshots__/ProfileSyncing.test.tsx.snap @@ -54,7 +54,7 @@ exports[`ProfileSyncing should render correctly 1`] = ` } thumbTintColor="#ffffff" tintColor="#bbc0c566" - value={true} + value={false} /> flexWrap: 'wrap', }, headerText: { - width: '100%', + width: Device.isAndroid() ? '80%' : '100%', textAlign: 'center', }, announcementDescriptionText: { - ...typography.lBodyMD, + ...typography.sBodyMD, color: colors.text.default, marginHorizontal: 1, // Announcement Description has some underlying padding that we want to remove. marginTop: -16, + textAlign: 'justify', }, backIcon: { marginLeft: 16, diff --git a/app/components/Views/Notifications/OptIn/__snapshots__/index.test.tsx.snap b/app/components/Views/Notifications/OptIn/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..07ca5e9b88c --- /dev/null +++ b/app/components/Views/Notifications/OptIn/__snapshots__/index.test.tsx.snap @@ -0,0 +1,263 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OptIn should render correctly 1`] = ` +[ + + + Turn on notifications + + + + + + Stay in the loop on what's happening in your wallet with notifications. + + + To use this feature, we’ll generate an anonymous ID for your account. It’s used only for syncing your data in MetaMask and doesn't link to your activities or other identifiers, ensuring your privacy. + + + Learn how we protect your privacy while using this feature. + + + + You can turn off notifications at any time in + + Settings > Notifications. + + + + + + Cancel + + + + + Turn on + + + + , + , +] +`; diff --git a/app/components/Views/Notifications/OptIn/index.test.tsx b/app/components/Views/Notifications/OptIn/index.test.tsx new file mode 100644 index 00000000000..bd62c3d961e --- /dev/null +++ b/app/components/Views/Notifications/OptIn/index.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import OptIn from './'; +import { RootState } from '../../../../reducers'; +import { backgroundState } from '../../../../util/test/initial-root-state'; +import renderWithProvider, { + DeepPartial, +} from '../../../../util/test/renderWithProvider'; + +const mockedDispatch = jest.fn(); + +const mockInitialState: DeepPartial = { + settings: {}, + engine: { + backgroundState: { + ...backgroundState, + NotificationServicesController: { + metamaskNotificationsList: [], + }, + }, + }, +}; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + // TODO: Replace "any" with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useSelector: (fn: any) => fn(mockInitialState), +})); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: jest.fn(), + dispatch: mockedDispatch, + }), + }; +}); + +describe('OptIn', () => { + it('should render correctly', () => { + const { toJSON } = renderWithProvider(); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/Views/Notifications/OptIn/index.tsx b/app/components/Views/Notifications/OptIn/index.tsx index fb516a409c3..45a4d5d99bc 100644 --- a/app/components/Views/Notifications/OptIn/index.tsx +++ b/app/components/Views/Notifications/OptIn/index.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { Fragment, useCallback } from 'react'; import { Image, View, Linking } from 'react-native'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import { useNavigation } from '@react-navigation/native'; import Button, { ButtonVariants, @@ -14,105 +14,131 @@ import { useTheme } from '../../../../util/theme'; import EnableNotificationsCardPlaceholder from '../../../../images/enableNotificationsCard.png'; import { createStyles } from './styles'; import Routes from '../../../../constants/navigation/Routes'; -import { CONSENSYS_PRIVACY_POLICY } from '../../../../constants/urls'; import { useSelector } from 'react-redux'; -import { mmStorage } from '../../../../util/notifications'; -import { STORAGE_IDS } from '../../../../util/notifications/settings/storage/constants'; -import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications'; +import { + asyncAlert, + requestPushNotificationsPermission, +} from '../../../../util/notifications'; +import AppConstants from '../../../../core/AppConstants'; +import { RootState } from '../../../../reducers'; +import { useEnableNotifications } from '../../../../util/notifications/hooks/useNotifications'; +import SwitchLoadingModal from '../../../../components/UI/Notification/SwitchLoadingModal'; const OptIn = () => { const theme = useTheme(); const styles = createStyles(theme); const navigation = useNavigation(); - const isNotificationEnabled = useSelector( - selectIsMetamaskNotificationsEnabled, - ); - - const navigateToNotificationsSettings = () => { - navigation.navigate(Routes.SETTINGS.NOTIFICATIONS); - }; + const basicFunctionalityEnabled = useSelector( + (state: RootState) => state.settings.basicFunctionalityEnabled, + ); + const { enableNotifications } = useEnableNotifications(); + const [optimisticLoading, setOptimisticLoading] = React.useState(false); const navigateToMainWallet = () => { navigation.navigate(Routes.WALLET_VIEW); }; - const goToLearnMore = () => { - Linking.openURL(CONSENSYS_PRIVACY_POLICY); - }; - - useFocusEffect(() => { - if (isNotificationEnabled) { - navigateToMainWallet(); + const toggleNotificationsEnabled = useCallback(async () => { + if (!basicFunctionalityEnabled) { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.BASIC_FUNCTIONALITY, + params: { + caller: Routes.NOTIFICATIONS.OPT_IN, + }, + }); } else { - const count = mmStorage.getLocal( - STORAGE_IDS.PUSH_NOTIFICATIONS_PROMPT_COUNT, + const nativeNotificationStatus = await requestPushNotificationsPermission( + asyncAlert, ); - const times = count + 1 || 1; - mmStorage.saveLocal(STORAGE_IDS.PUSH_NOTIFICATIONS_PROMPT_COUNT, times); + if (nativeNotificationStatus) { + /** + * Although this is an async function, we are dispatching an action (firing & forget) + * to emulate optimistic UI. + * Setting a standard timeout to emulate loading state + * for 5 seconds. This only happens during the first time the user + * optIn to notifications. + */ + enableNotifications(); + setOptimisticLoading(true); + setTimeout(() => { + setOptimisticLoading(false); + navigation.navigate(Routes.NOTIFICATIONS.VIEW); + }, 5000); + } } - }); + }, [basicFunctionalityEnabled, enableNotifications, navigation]); + + const goToLearnMore = () => { + Linking.openURL(AppConstants.URLS.PROFILE_SYNC); + }; return ( - - - {strings('notifications.activation_card.title')} - - - - - - {strings('notifications.activation_card.description_1')} - + + + + {strings('notifications.activation_card.title')} + + + + + + {strings('notifications.activation_card.description_1')} + - - {strings('notifications.activation_card.description_2')}{' '} - {strings('notifications.activation_card.learn_more')} + {strings('notifications.activation_card.description_2')}{' '} + + {strings('notifications.activation_card.learn_more')} + - - - {strings('notifications.activation_card.manage_preferences_1')} - - {strings('notifications.activation_card.manage_preferences_2')} + + {strings('notifications.activation_card.manage_preferences_1')} + + {strings('notifications.activation_card.manage_preferences_2')} + - - -