From de30b5a69ae38c4a3dc3e68f3df98e46448e3e07 Mon Sep 17 00:00:00 2001 From: Dylan Nienberg <87150991+Sparowhawk@users.noreply.github.com> Date: Fri, 4 Oct 2024 11:56:24 -0500 Subject: [PATCH] CU/8253-Dylan-NotificationRQMigration (#8254) Co-authored-by: Theo Bentum Co-authored-by: Therese <94404065+TKDickson@users.noreply.github.com> --- .../react-native-notifications+5.1.0.patch | 94 ++++++++ VAMobile/src/App.tsx | 32 +-- .../api/notifications/getPushPreferences.tsx | 41 ++++ .../getSystemNotificationSettings.tsx | 30 +++ VAMobile/src/api/notifications/index.ts | 5 + VAMobile/src/api/notifications/queryKeys.ts | 4 + .../src/api/notifications/registerDevice.tsx | 65 +++++ .../notifications/updatePushPreferences.tsx | 47 ++++ .../{store => }/api/types/Notifications.ts | 13 + VAMobile/src/api/types/index.ts | 1 + .../NotificationManager.tsx | 72 ++++-- .../DeveloperScreen/DeveloperScreen.tsx | 5 +- .../NotificationsSettingsScreen.test.tsx | 178 ++++++++------ .../NotificationsSettingsScreen.tsx | 120 ++++++--- VAMobile/src/store/api/demo/notifications.ts | 2 +- VAMobile/src/store/api/types/index.ts | 1 - VAMobile/src/store/index.ts | 2 - VAMobile/src/store/slices/authSlice.ts | 12 +- VAMobile/src/store/slices/index.ts | 3 - .../src/store/slices/notificationSlice.ts | 227 ------------------ VAMobile/src/testUtils.tsx | 2 - 21 files changed, 568 insertions(+), 388 deletions(-) create mode 100644 VAMobile/patches/react-native-notifications+5.1.0.patch create mode 100644 VAMobile/src/api/notifications/getPushPreferences.tsx create mode 100644 VAMobile/src/api/notifications/getSystemNotificationSettings.tsx create mode 100644 VAMobile/src/api/notifications/index.ts create mode 100644 VAMobile/src/api/notifications/queryKeys.ts create mode 100644 VAMobile/src/api/notifications/registerDevice.tsx create mode 100644 VAMobile/src/api/notifications/updatePushPreferences.tsx rename VAMobile/src/{store => }/api/types/Notifications.ts (83%) delete mode 100644 VAMobile/src/store/slices/notificationSlice.ts diff --git a/VAMobile/patches/react-native-notifications+5.1.0.patch b/VAMobile/patches/react-native-notifications+5.1.0.patch new file mode 100644 index 00000000000..43dd571766c --- /dev/null +++ b/VAMobile/patches/react-native-notifications+5.1.0.patch @@ -0,0 +1,94 @@ +diff --git a/node_modules/react-native-notifications/android/app/src/reactNative59/java/com/wix/reactnativenotifications/NotificationManagerCompatFacade.java b/node_modules/react-native-notifications/android/app/src/reactNative59/java/com/wix/reactnativenotifications/NotificationManagerCompatFacade.java +index f9c858b..94ea188 100644 +--- a/node_modules/react-native-notifications/android/app/src/reactNative59/java/com/wix/reactnativenotifications/NotificationManagerCompatFacade.java ++++ b/node_modules/react-native-notifications/android/app/src/reactNative59/java/com/wix/reactnativenotifications/NotificationManagerCompatFacade.java +@@ -2,8 +2,8 @@ + package com.wix.reactnativenotifications; + + import android.content.Context; +-import android.support.annotation.NonNull; +-import android.support.v4.app.NotificationManagerCompat; ++import androidx.annotation.NonNull; ++import androidx.core.app.NotificationManagerCompat; + + public abstract class NotificationManagerCompatFacade { + public static NotificationManagerCompat from(@NonNull Context context) { +diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java +index 90969b2..4c00e69 100644 +--- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java ++++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java +@@ -63,7 +63,7 @@ public class RNNotificationsModule extends ReactContextBaseJavaModule implements + @Override + public void onNewIntent(Intent intent) { + if (NotificationIntentAdapter.canHandleIntent(intent)) { +- Bundle notificationData = intent.getExtras(); ++ Bundle notificationData = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent); + final IPushNotification notification = PushNotification.get(getReactApplicationContext().getApplicationContext(), notificationData); + if (notification != null) { + notification.onOpened(); +diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsPackage.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsPackage.java +index 5b7f15f..7b3ee7e 100644 +--- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsPackage.java ++++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsPackage.java +@@ -15,6 +15,7 @@ import com.wix.reactnativenotifications.core.AppLifecycleFacade; + import com.wix.reactnativenotifications.core.AppLifecycleFacadeHolder; + import com.wix.reactnativenotifications.core.InitialNotificationHolder; + import com.wix.reactnativenotifications.core.NotificationIntentAdapter; ++import com.wix.reactnativenotifications.core.ReactAppLifecycleFacade; + import com.wix.reactnativenotifications.core.notification.IPushNotification; + import com.wix.reactnativenotifications.core.notification.PushNotification; + import com.wix.reactnativenotifications.core.notificationdrawer.IPushNotificationsDrawer; +@@ -66,7 +67,12 @@ public class RNNotificationsPackage implements ReactPackage, AppLifecycleFacade. + + @Override + public void onActivityStarted(Activity activity) { +- if (InitialNotificationHolder.getInstance().get() == null) { ++ boolean isReactInitialized = false; ++ if (AppLifecycleFacadeHolder.get() instanceof ReactAppLifecycleFacade) { ++ isReactInitialized = AppLifecycleFacadeHolder.get().isReactInitialized(); ++ } ++ ++ if (InitialNotificationHolder.getInstance().get() == null && !isReactInitialized) { + callOnOpenedIfNeed(activity); + } + } +diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/NotificationIntentAdapter.java b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/NotificationIntentAdapter.java +index 1e7e871..62e5cb8 100644 +--- a/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/NotificationIntentAdapter.java ++++ b/node_modules/react-native-notifications/lib/android/app/src/main/java/com/wix/reactnativenotifications/core/NotificationIntentAdapter.java +@@ -14,17 +14,9 @@ public class NotificationIntentAdapter { + + @SuppressLint("UnspecifiedImmutableFlag") + public static PendingIntent createPendingNotificationIntent(Context appContext, PushNotificationProps notification) { +- if (canHandleTrampolineActivity(appContext)) { +- Intent intent = new Intent(appContext, ProxyService.class); +- intent.putExtra(PUSH_NOTIFICATION_EXTRA_NAME, notification.asBundle()); +- return PendingIntent.getService(appContext, (int) System.currentTimeMillis(), intent, PendingIntent.FLAG_ONE_SHOT); +- } else { +- Intent mainActivityIntent = appContext.getPackageManager().getLaunchIntentForPackage(appContext.getPackageName()); +- mainActivityIntent.putExtra(PUSH_NOTIFICATION_EXTRA_NAME, notification.asBundle()); +- TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(appContext); +- taskStackBuilder.addNextIntentWithParentStack(mainActivityIntent); +- return taskStackBuilder.getPendingIntent((int) System.currentTimeMillis(), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE); +- } ++ Intent intent = appContext.getPackageManager().getLaunchIntentForPackage(appContext.getPackageName()); ++ intent.putExtra(PUSH_NOTIFICATION_EXTRA_NAME, notification.asBundle()); ++ return PendingIntent.getActivity(appContext, (int) System.currentTimeMillis(), intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE); + } + + public static boolean canHandleTrampolineActivity(Context appContext) { +diff --git a/node_modules/react-native-notifications/lib/android/app/src/reactNative59/java/com/wix/reactnativenotifications/NotificationManagerCompatFacade.java b/node_modules/react-native-notifications/lib/android/app/src/reactNative59/java/com/wix/reactnativenotifications/NotificationManagerCompatFacade.java +index f9c858b..94ea188 100644 +--- a/node_modules/react-native-notifications/lib/android/app/src/reactNative59/java/com/wix/reactnativenotifications/NotificationManagerCompatFacade.java ++++ b/node_modules/react-native-notifications/lib/android/app/src/reactNative59/java/com/wix/reactnativenotifications/NotificationManagerCompatFacade.java +@@ -2,8 +2,8 @@ + package com.wix.reactnativenotifications; + + import android.content.Context; +-import android.support.annotation.NonNull; +-import android.support.v4.app.NotificationManagerCompat; ++import androidx.annotation.NonNull; ++import androidx.core.app.NotificationManagerCompat; + + public abstract class NotificationManagerCompatFacade { + public static NotificationManagerCompat from(@NonNull Context context) { diff --git a/VAMobile/src/App.tsx b/VAMobile/src/App.tsx index 31e4f52d1c0..5f0ba1d06f3 100644 --- a/VAMobile/src/App.tsx +++ b/VAMobile/src/App.tsx @@ -47,7 +47,7 @@ import { profileAddressType } from 'screens/HomeScreen/ProfileScreen/ContactInfo import EditAddressScreen from 'screens/HomeScreen/ProfileScreen/ContactInformationScreen/EditAddressScreen' import store, { RootState } from 'store' import { injectStore } from 'store/api/api' -import { AnalyticsState, AuthState, NotificationsState, handleTokenCallbackUrl, initializeAuth } from 'store/slices' +import { AnalyticsState, AuthState, handleTokenCallbackUrl, initializeAuth } from 'store/slices' import { SettingsState } from 'store/slices' import { AccessibilityState, @@ -64,7 +64,7 @@ import { useHeaderStyles, useTopPaddingAsHeaderStyles } from 'utils/hooks/header import i18n from 'utils/i18n' import { isIOS } from 'utils/platform' -import NotificationManager from './components/NotificationManager' +import NotificationManager, { useNotificationContext } from './components/NotificationManager' import VeteransCrisisLineScreen from './screens/HomeScreen/VeteransCrisisLineScreen/VeteransCrisisLineScreen' import OnboardingCarousel from './screens/OnboardingCarousel' import EditDirectDepositScreen from './screens/PaymentsScreen/DirectDepositScreen/EditDirectDepositScreen' @@ -189,6 +189,7 @@ export function AuthGuard() { const dispatch = useAppDispatch() const { initializing, loggedIn, syncing, firstTimeLogin, canStoreWithBiometric, displayBiometricsPreferenceScreen } = useSelector((state) => state.auth) + const { tappedForegroundNotification, setTappedForegroundNotification } = useNotificationContext() const { loadingRemoteConfig, remoteConfigActivated } = useSelector( (state) => state.settings, ) @@ -268,18 +269,22 @@ export function AuthGuard() { useEffect(() => { console.debug('AuthGuard: initializing') - dispatch(initializeAuth()) - - const listener = (event: { url: string }): void => { - if (event.url?.startsWith('vamobile://login-success?')) { - dispatch(handleTokenCallbackUrl(event.url)) + if (loggedIn && tappedForegroundNotification) { + console.debug('User tapped foreground notification. Skipping initializeAuth.') + setTappedForegroundNotification(false) + } else if (!loggedIn) { + dispatch(initializeAuth()) + const listener = (event: { url: string }): void => { + if (event.url?.startsWith('vamobile://login-success?')) { + dispatch(handleTokenCallbackUrl(event.url)) + } + } + const sub = Linking.addEventListener('url', listener) + return (): void => { + sub?.remove() } } - const sub = Linking.addEventListener('url', listener) - return (): void => { - sub?.remove() - } - }, [dispatch]) + }, [dispatch, loggedIn, tappedForegroundNotification, setTappedForegroundNotification]) useEffect(() => { // Log campaign analytics if the app is launched by a campaign link @@ -392,8 +397,7 @@ export function AppTabs() { export function AuthedApp() { const headerStyles = useHeaderStyles() - const { initialUrl } = useSelector((state) => state.notifications) - + const { initialUrl } = useNotificationContext() const homeScreens = getHomeScreens() const benefitsScreens = getBenefitsScreens() const healthScreens = getHealthScreens() diff --git a/VAMobile/src/api/notifications/getPushPreferences.tsx b/VAMobile/src/api/notifications/getPushPreferences.tsx new file mode 100644 index 00000000000..b06129ed3d7 --- /dev/null +++ b/VAMobile/src/api/notifications/getPushPreferences.tsx @@ -0,0 +1,41 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import { useQuery } from '@tanstack/react-query' + +import { GetPushPrefsResponse, LoadPushPreferencesData } from 'api/types' +import store from 'store' +import { get } from 'store/api' + +import { notificationKeys } from './queryKeys' +import { DEVICE_ENDPOINT_SID } from './registerDevice' + +/** + * Fetch user push preferences + */ +const getPushPreferences = async (): Promise => { + const endpoint_sid = await AsyncStorage.getItem(DEVICE_ENDPOINT_SID) + const demoMode = store.getState().demo.demoMode + let response + if (endpoint_sid) { + response = await get(`/v0/push/prefs/${endpoint_sid}`) + } else if (demoMode) { + response = await get(`/v0/push/prefs/`) + } + return { + preferences: response?.data.attributes.preferences || [], + } +} + +/** + * Returns a query for user push preferences + */ +export const usePushPreferences = (options?: { enabled?: boolean }) => { + return useQuery({ + ...options, + queryKey: notificationKeys.pushPreferences, + queryFn: () => getPushPreferences(), + meta: { + errorName: 'getPushPreferences: Service error', + }, + }) +} diff --git a/VAMobile/src/api/notifications/getSystemNotificationSettings.tsx b/VAMobile/src/api/notifications/getSystemNotificationSettings.tsx new file mode 100644 index 00000000000..e089fe03f94 --- /dev/null +++ b/VAMobile/src/api/notifications/getSystemNotificationSettings.tsx @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' + +import { LoadSystemNotificationsData } from 'api/types' +import { notificationsEnabled } from 'utils/notifications' + +import { notificationKeys } from './queryKeys' + +/** + * Fetch user system notification settings + */ +const getSystemNotificationsSettings = async (): Promise => { + const systemNotificationsOn = await notificationsEnabled() + return { + systemNotificationsOn, + } +} + +/** + * Returns a query for user system notification settings + */ +export const useSystemNotificationsSettings = (options?: { enabled?: boolean }) => { + return useQuery({ + ...options, + queryKey: notificationKeys.systemSettings, + queryFn: () => getSystemNotificationsSettings(), + meta: { + errorName: 'getSystemNotificationsSettings: Failed to retrieve system notification setting', + }, + }) +} diff --git a/VAMobile/src/api/notifications/index.ts b/VAMobile/src/api/notifications/index.ts new file mode 100644 index 00000000000..261f426d3c2 --- /dev/null +++ b/VAMobile/src/api/notifications/index.ts @@ -0,0 +1,5 @@ +export * from './getPushPreferences' +export * from './getSystemNotificationSettings' +export * from './queryKeys' +export * from './registerDevice' +export * from './updatePushPreferences' diff --git a/VAMobile/src/api/notifications/queryKeys.ts b/VAMobile/src/api/notifications/queryKeys.ts new file mode 100644 index 00000000000..d22c10771f1 --- /dev/null +++ b/VAMobile/src/api/notifications/queryKeys.ts @@ -0,0 +1,4 @@ +export const notificationKeys = { + pushPreferences: ['pushPreferences'] as const, + systemSettings: ['systemSettings'] as const, +} diff --git a/VAMobile/src/api/notifications/registerDevice.tsx b/VAMobile/src/api/notifications/registerDevice.tsx new file mode 100644 index 00000000000..921297ecaa1 --- /dev/null +++ b/VAMobile/src/api/notifications/registerDevice.tsx @@ -0,0 +1,65 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import { useMutation } from '@tanstack/react-query' + +import { PUSH_APP_NAME, PushOsName, PushRegistration, PushRegistrationResponse, RegisterDeviceParams } from 'api/types' +import { UserAnalytics } from 'constants/analytics' +import { put } from 'store/api' +import { logNonFatalErrorToFirebase, setAnalyticsUserProperty } from 'utils/analytics' +import { isErrorObject } from 'utils/common' +import { getDeviceName } from 'utils/deviceData' +import { isIOS } from 'utils/platform' + +export const DEVICE_TOKEN_KEY = '@store_device_token' +export const DEVICE_ENDPOINT_SID = '@store_device_endpoint_sid' +export const USER_ID = '@store_user_id' +/** + * Registers device for push notifications + */ +const registerDevice = async ( + registerDeviceParams: RegisterDeviceParams, +): Promise => { + if (registerDeviceParams.deviceToken) { + const savedToken = await AsyncStorage.getItem(DEVICE_TOKEN_KEY) + const savedSid = await AsyncStorage.getItem(DEVICE_ENDPOINT_SID) + const savedUserID = await AsyncStorage.getItem(USER_ID) + const isNewUser = !savedUserID || (registerDeviceParams.userID && savedUserID !== registerDeviceParams.userID) + const deviceName = await getDeviceName() + if (!savedToken || savedToken !== registerDeviceParams.deviceToken || !savedSid || isNewUser) { + const params: PushRegistration = { + deviceName: deviceName, + deviceToken: registerDeviceParams.deviceToken, + appName: PUSH_APP_NAME, + osName: isIOS() ? PushOsName.ios : PushOsName.android, + debug: false, + } + return put('/v0/push/register', params) + } + } else { + await AsyncStorage.removeItem(DEVICE_TOKEN_KEY) + } +} + +/** + * Returns a mutation for registering users device for push notifications + */ +export const useRegisterDevice = () => { + return useMutation({ + mutationFn: registerDevice, + onSettled: async (data, error, variables) => { + setAnalyticsUserProperty(UserAnalytics.vama_uses_notifications(variables.deviceToken ? true : false)) + }, + onSuccess: async (response, variables) => { + if (response) { + await AsyncStorage.setItem(DEVICE_ENDPOINT_SID, response?.data.attributes.endpointSid) + variables.deviceToken && (await AsyncStorage.setItem(DEVICE_TOKEN_KEY, variables.deviceToken)) + variables.userID && (await AsyncStorage.setItem(USER_ID, variables.userID)) + } + }, + onError: (error) => { + if (isErrorObject(error)) { + logNonFatalErrorToFirebase(error, 'registerDevice: Service error') + } + }, + }) +} diff --git a/VAMobile/src/api/notifications/updatePushPreferences.tsx b/VAMobile/src/api/notifications/updatePushPreferences.tsx new file mode 100644 index 00000000000..8d463de8598 --- /dev/null +++ b/VAMobile/src/api/notifications/updatePushPreferences.tsx @@ -0,0 +1,47 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { LoadPushPreferencesData, PushPreference } from 'api/types' +import { put } from 'store/api' +import { logNonFatalErrorToFirebase } from 'utils/analytics' +import { isErrorObject } from 'utils/common' + +import { notificationKeys } from './queryKeys' +import { DEVICE_ENDPOINT_SID } from './registerDevice' + +/** + * Updates a user's push preference + */ +const updatePushPreferences = async (preference: PushPreference) => { + const endpoint_sid = await AsyncStorage.getItem(DEVICE_ENDPOINT_SID) + const params = { preference: preference.preferenceId, enabled: !preference.value } + return put(`/v0/push/prefs/${endpoint_sid}`, params) +} + +/** + * Returns a mutation for updating users push preference + */ +export const useUpdatePushPreferences = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: updatePushPreferences, + onSuccess: (data: unknown, preference: PushPreference) => { + const pushPreferences = queryClient.getQueryData(notificationKeys.pushPreferences) as LoadPushPreferencesData + const index = pushPreferences.preferences.findIndex((p) => p.preferenceId === preference.preferenceId) + const newPrefSetting: PushPreference = { + preferenceId: preference.preferenceId, + preferenceName: preference.preferenceName, + value: !preference.value, + } + pushPreferences.preferences.splice(index, 1, newPrefSetting) + queryClient.setQueryData(notificationKeys.pushPreferences, pushPreferences) + }, + onError: (error) => { + if (isErrorObject(error)) { + logNonFatalErrorToFirebase(error, 'setPushPref: Service error') + } + }, + }) +} diff --git a/VAMobile/src/store/api/types/Notifications.ts b/VAMobile/src/api/types/Notifications.ts similarity index 83% rename from VAMobile/src/store/api/types/Notifications.ts rename to VAMobile/src/api/types/Notifications.ts index d7c70db839c..8a21e21265a 100644 --- a/VAMobile/src/store/api/types/Notifications.ts +++ b/VAMobile/src/api/types/Notifications.ts @@ -56,3 +56,16 @@ export type GetPushPrefsResponse = { } } } + +export type LoadPushPreferencesData = { + preferences: PushPreference[] +} + +export type LoadSystemNotificationsData = { + systemNotificationsOn: boolean +} + +export type RegisterDeviceParams = { + deviceToken?: string + userID?: string +} diff --git a/VAMobile/src/api/types/index.ts b/VAMobile/src/api/types/index.ts index b795a173f7a..6147a71e951 100644 --- a/VAMobile/src/api/types/index.ts +++ b/VAMobile/src/api/types/index.ts @@ -10,6 +10,7 @@ export * from './DisabilityRatingData' export * from './EmailData' export * from './FacilityData' export * from './LettersData' +export * from './Notifications' export * from './PaymentData' export * from './PhoneData' export * from './PersonalInformationData' diff --git a/VAMobile/src/components/NotificationManager/NotificationManager.tsx b/VAMobile/src/components/NotificationManager/NotificationManager.tsx index e3d333e649d..487303b7b47 100644 --- a/VAMobile/src/components/NotificationManager/NotificationManager.tsx +++ b/VAMobile/src/components/NotificationManager/NotificationManager.tsx @@ -1,48 +1,69 @@ -import React, { FC, useEffect, useState } from 'react' +import React, { Dispatch, FC, SetStateAction, createContext, useContext, useEffect, useState } from 'react' import { Linking, View } from 'react-native' import { NotificationBackgroundFetchResult, Notifications } from 'react-native-notifications' import { useSelector } from 'react-redux' +import { useRegisterDevice } from 'api/notifications' import { usePersonalInformation } from 'api/personalInformation/getPersonalInformation' import { Events } from 'constants/analytics' import { RootState } from 'store' import { AuthState } from 'store/slices' -import { - dispatchSetInitialUrl, - dispatchSetTappedForegroundNotification, - registerDevice, -} from 'store/slices/notificationSlice' import { logAnalyticsEvent } from 'utils/analytics' -import { useAppDispatch } from 'utils/hooks' const foregroundNotifications: Array = [] +interface NotificationContextType { + tappedForegroundNotification: boolean + initialUrl: string + setTappedForegroundNotification: Dispatch> + setInitialUrl: Dispatch> +} + +const NotificationContext = createContext({ + tappedForegroundNotification: false, + initialUrl: '', + setTappedForegroundNotification: () => {}, + setInitialUrl: () => {}, +}) + /** * notification manager component to handle all push logic */ const NotificationManager: FC = ({ children }) => { const { loggedIn } = useSelector((state) => state.auth) const { data: personalInformation } = usePersonalInformation({ enabled: loggedIn }) - const dispatch = useAppDispatch() + const { mutate: registerDevice } = useRegisterDevice() + const [tappedForegroundNotification, setTappedForegroundNotification] = useState(false) + const [initialUrl, setInitialUrl] = useState('') const [eventsRegistered, setEventsRegistered] = useState(false) + useEffect(() => { const register = () => { - Notifications.events().registerRemoteNotificationsRegistered((event) => { - console.debug('Device Token Received', event.deviceToken) - dispatch(registerDevice(event.deviceToken, undefined, personalInformation?.id)) + const registeredNotifications = Notifications.events().registerRemoteNotificationsRegistered((event) => { + const registerParams = { + deviceToken: event.deviceToken, + userID: personalInformation?.id, + } + registerDevice(registerParams) }) - Notifications.events().registerRemoteNotificationsRegistrationFailed((event) => { - //TODO: Log this error in crashlytics? - console.error(event) - dispatch(registerDevice()) + const failedNotifications = Notifications.events().registerRemoteNotificationsRegistrationFailed(() => { + const registerParams = { + deviceToken: undefined, + userID: undefined, + } + registerDevice(registerParams) + }) + Notifications.events().registerRemoteNotificationsRegistrationDenied(() => { + registeredNotifications.remove() + failedNotifications.remove() }) Notifications.registerRemoteNotifications() } - if (loggedIn) { + if (loggedIn && personalInformation?.id) { register() } - }, [dispatch, loggedIn, personalInformation?.id]) + }, [loggedIn, personalInformation?.id, registerDevice]) const registerNotificationEvents = () => { // Register callbacks for notifications that happen when the app is in the foreground @@ -60,18 +81,18 @@ const NotificationManager: FC = ({ children }) => { */ logAnalyticsEvent(Events.vama_notification_click(notification.payload.url)) if (foregroundNotifications.includes(notification.identifier)) { - dispatch(dispatchSetTappedForegroundNotification()) + setTappedForegroundNotification(true) } - // Open deep link from the notification when present. If the user is // not logged in, store the link so it can be opened after authentication. if (notification.payload.url) { if (loggedIn) { Linking.openURL(notification.payload.url) } else { - dispatch(dispatchSetInitialUrl(notification.payload.url)) + setInitialUrl(notification.payload.url) } } + console.debug('Notification opened by device user', notification) console.debug(`Notification opened with an action identifier: ${notification.identifier}`) completion() @@ -91,7 +112,7 @@ const NotificationManager: FC = ({ children }) => { console.debug('Initial notification was:', notification || 'N/A') if (notification?.payload.url) { - dispatch(dispatchSetInitialUrl(notification.payload.url)) + setInitialUrl(notification.payload.url) } }) .catch((err) => console.error('getInitialNotification() failed', err)) @@ -103,7 +124,14 @@ const NotificationManager: FC = ({ children }) => { } const s = { flex: 1 } - return {children} + return ( + + {children} + + ) } +export const useNotificationContext = () => useContext(NotificationContext) + export default NotificationManager diff --git a/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/DeveloperScreen/DeveloperScreen.tsx b/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/DeveloperScreen/DeveloperScreen.tsx index e093e725258..e3d91c91be1 100644 --- a/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/DeveloperScreen/DeveloperScreen.tsx +++ b/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/DeveloperScreen/DeveloperScreen.tsx @@ -9,6 +9,7 @@ import { Button } from '@department-of-veterans-affairs/mobile-component-library import { pick } from 'underscore' import { useAuthorizedServices } from 'api/authorizedServices/getAuthorizedServices' +import { DEVICE_ENDPOINT_SID, DEVICE_TOKEN_KEY } from 'api/notifications' import { Box, ButtonDecoratorType, @@ -25,7 +26,6 @@ import { RootState } from 'store' import { AnalyticsState } from 'store/slices' import { toggleFirebaseDebugMode } from 'store/slices/analyticsSlice' import { AuthState, debugResetFirstTimeLogin } from 'store/slices/authSlice' -import { DEVICE_ENDPOINT_SID, NotificationsState } from 'store/slices/notificationSlice' import { showSnackBar } from 'utils/common' import getEnv, { EnvVars } from 'utils/env' import { @@ -107,9 +107,10 @@ function DeveloperScreen({ navigation }: DeveloperScreenSettingsScreenProps) { } // push data - const { deviceToken } = useSelector((state) => state.notifications) const { firebaseDebugMode } = useSelector((state) => state.analytics) const [deviceAppSid, setDeviceAppSid] = useState('') + const [deviceToken, setDeviceToken] = useState('') + getAsyncStoredData(DEVICE_TOKEN_KEY, setDeviceToken) getAsyncStoredData(DEVICE_ENDPOINT_SID, setDeviceAppSid) Object.keys(tokenInfo).forEach((key) => { diff --git a/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/NotificationsSettingsScreen/NotificationsSettingsScreen.test.tsx b/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/NotificationsSettingsScreen/NotificationsSettingsScreen.test.tsx index 2712dd2d534..1491d01f6e0 100644 --- a/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/NotificationsSettingsScreen/NotificationsSettingsScreen.test.tsx +++ b/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/NotificationsSettingsScreen/NotificationsSettingsScreen.test.tsx @@ -1,12 +1,13 @@ import React from 'react' +import AsyncStorage from '@react-native-async-storage/async-storage' + import { screen } from '@testing-library/react-native' -import { CommonErrorTypesConstants } from 'constants/errors' -import { PushPreference } from 'store/api' -import { ScreenIDTypesConstants } from 'store/api/types' -import { ErrorsState, InitialState, initialErrorsState, initializeErrorsByScreenID } from 'store/slices' -import { context, mockNavProps, render } from 'testUtils' +import { notificationKeys } from 'api/notifications' +import { GetPushPrefsResponse, PushPreference } from 'api/types' +import * as api from 'store/api' +import { QueriesData, context, mockNavProps, render, waitFor, when } from 'testUtils' import NotificationsSettingsScreen from './NotificationsSettingsScreen' @@ -19,22 +20,6 @@ jest.mock('utils/notifications', () => { } }) -jest.mock('store/slices/', () => { - const actual = jest.requireActual('store/slices') - const notification = jest.requireActual('store/slices').initialNotificationsState - return { - ...actual, - loadPushPreferences: jest.fn(() => { - return { - type: '', - payload: { - ...notification, - }, - } - }), - } -}) - context('NotificationsSettingsScreen', () => { const apptPrefOn: PushPreference = { preferenceId: 'appointment_reminders', @@ -52,80 +37,123 @@ context('NotificationsSettingsScreen', () => { notificationsEnabled: boolean, systemNotificationsOn: boolean, preferences: PushPreference[], - errorsState: ErrorsState = initialErrorsState, ) => { const props = mockNavProps() mockPushEnabled = notificationsEnabled - render(, { - preloadedState: { - ...InitialState, - notifications: { - ...InitialState.notifications, - preferences, - systemNotificationsOn, + const notificationQueriesData: QueriesData = [ + { + queryKey: notificationKeys.pushPreferences, + data: { + preferences: preferences, }, - errors: errorsState, }, - }) + { + queryKey: notificationKeys.systemSettings, + data: { + systemNotificationsOn: systemNotificationsOn, + }, + }, + ] + + render(, { queriesData: notificationQueriesData }) } describe('appointment reminders switch', () => { - it('value should be true when pref is set to true', () => { - renderWithProps(false, true, [apptPrefOn]) - expect(screen.getByRole('switch', { name: 'Upcoming appointments' }).props.accessibilityState.checked).toEqual( - true, + it('value should be true when pref is set to true', async () => { + const prefMock = AsyncStorage.getItem as jest.Mock + when(prefMock).calledWith('@store_device_endpoint_sid').mockResolvedValue('1') + + const responseData: GetPushPrefsResponse = { + data: { + type: 'string', + id: 'string', + attributes: { + preferences: [apptPrefOn], + }, + }, + } + when(api.get as jest.Mock) + .calledWith('/v0/push/prefs/1') + .mockResolvedValue(responseData) + renderWithProps(true, true, [apptPrefOn]) + await waitFor(() => + expect(screen.getByRole('switch', { name: 'Upcoming appointments' }).props.accessibilityState.checked).toEqual( + true, + ), ) }) - it('value should be false when pref is set to true', () => { - renderWithProps(false, true, [apptPrefOff]) - expect(screen.getByRole('switch', { name: 'Upcoming appointments' }).props.accessibilityState.checked).toEqual( - false, + it('value should be false when pref is set to true', async () => { + const prefMock = AsyncStorage.getItem as jest.Mock + when(prefMock).calledWith('@store_device_endpoint_sid').mockResolvedValue('1') + + const responseData: GetPushPrefsResponse = { + data: { + type: 'string', + id: 'string', + attributes: { + preferences: [apptPrefOff], + }, + }, + } + when(api.get as jest.Mock) + .calledWith('/v0/push/prefs/1') + .mockResolvedValue(responseData) + renderWithProps(true, true, [apptPrefOff]) + await waitFor(() => + expect(screen.getByRole('switch', { name: 'Upcoming appointments' }).props.accessibilityState.checked).toEqual( + false, + ), ) }) }) describe('when system notifications are disabled', () => { - it('hides the notification switches', () => { + it('hides the notification switches', async () => { + const prefMock = AsyncStorage.getItem as jest.Mock + when(prefMock).calledWith('@store_device_endpoint_sid').mockResolvedValue('1') + + const responseData: GetPushPrefsResponse = { + data: { + type: 'string', + id: 'string', + attributes: { + preferences: [apptPrefOff], + }, + }, + } + when(api.get as jest.Mock) + .calledWith('/v0/push/prefs/1') + .mockResolvedValue(responseData) renderWithProps(false, false, [apptPrefOff]) - expect(screen.queryByRole('switch', { name: 'Upcoming appointments' })).toBeFalsy() - expect(screen.getByText('To get app notifications, turn them on in your device settings.')).toBeTruthy() + await waitFor(() => expect(screen.queryByRole('switch', { name: 'Upcoming appointments' })).toBeFalsy()) + await waitFor(() => + expect(screen.getByText('To get app notifications, turn them on in your device settings.')).toBeTruthy(), + ) }) }) - describe('when screen loads link should always exist', () => { - renderWithProps(false, true, [apptPrefOn]) - expect(screen.getByRole('link', { name: 'Manage email and text notifications on VA.gov' })).toBeTruthy() - }) - - describe('when common error occurs', () => { - it('should render error component when the stores screenID matches the components screenID', () => { - const errorsByScreenID = initializeErrorsByScreenID() - errorsByScreenID[ScreenIDTypesConstants.NOTIFICATIONS_SETTINGS_SCREEN] = - CommonErrorTypesConstants.NETWORK_CONNECTION_ERROR - - const errorState: ErrorsState = { - ...initialErrorsState, - errorsByScreenID, - } - - renderWithProps(false, true, [apptPrefOn], errorState) - expect(screen.getByText("The app can't be loaded.")).toBeTruthy() - }) - - it('should not render error component when the stores screenID does not match the components screenID', () => { - const errorsByScreenID = initializeErrorsByScreenID() - errorsByScreenID[ScreenIDTypesConstants.ASK_FOR_CLAIM_DECISION_SCREEN_ID] = - CommonErrorTypesConstants.NETWORK_CONNECTION_ERROR - - const errorState: ErrorsState = { - ...initialErrorsState, - errorsByScreenID, - } - - renderWithProps(false, true, [apptPrefOn], errorState) - expect(screen.queryByText("The app can't be loaded.")).toBeFalsy() - }) + it("renders error component when preferences can't be loaded", async () => { + const responseData: GetPushPrefsResponse = { + data: { + type: 'string', + id: 'string', + attributes: { + preferences: [], + }, + }, + } + when(api.get as jest.Mock) + .calledWith('/v0/push/prefs/1') + .mockResolvedValue(responseData) + renderWithProps(true, true, []) + await waitFor(() => + expect( + screen.getByText( + "We're sorry. Something went wrong on our end. Please refresh this screen or try again later.", + ), + ).toBeTruthy(), + ) }) }) diff --git a/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/NotificationsSettingsScreen/NotificationsSettingsScreen.tsx b/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/NotificationsSettingsScreen/NotificationsSettingsScreen.tsx index d71c8785d11..318d3fea879 100644 --- a/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/NotificationsSettingsScreen/NotificationsSettingsScreen.tsx +++ b/VAMobile/src/screens/HomeScreen/ProfileScreen/SettingsScreen/NotificationsSettingsScreen/NotificationsSettingsScreen.tsx @@ -1,11 +1,25 @@ -import React, { ReactNode, useEffect } from 'react' +import React, { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { Alert, Linking } from 'react-native' import { Notifications } from 'react-native-notifications' -import { useSelector } from 'react-redux' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { useIsFocused } from '@react-navigation/native' import { StackScreenProps } from '@react-navigation/stack' +import { MutateOptions, useQueryClient } from '@tanstack/react-query' + +import { + DEVICE_ENDPOINT_SID, + DEVICE_TOKEN_KEY, + notificationKeys, + usePushPreferences, + useRegisterDevice, + useSystemNotificationsSettings, + useUpdatePushPreferences, +} from 'api/notifications' +import { usePersonalInformation } from 'api/personalInformation/getPersonalInformation' +import { PushRegistrationResponse, RegisterDeviceParams } from 'api/types' import { AlertWithHaptics, Box, @@ -21,13 +35,11 @@ import { import { Events } from 'constants/analytics' import { NAMESPACE } from 'constants/namespaces' import { HomeStackParamList } from 'screens/HomeScreen/HomeStackScreens' -import { RootState } from 'store' import { ScreenIDTypesConstants } from 'store/api/types' -import { NotificationsState, loadPushPreferences, registerDevice, setPushPref } from 'store/slices' import { a11yLabelVA } from 'utils/a11yLabel' import { logAnalyticsEvent } from 'utils/analytics' import getEnv from 'utils/env' -import { useAppDispatch, useError, useOnResumeForeground, useTheme } from 'utils/hooks' +import { useOnResumeForeground, useTheme } from 'utils/hooks' import { screenContentAllowed } from 'utils/waygateConfig' const { LINK_URL_VA_NOTIFICATIONS } = getEnv() @@ -36,11 +48,37 @@ type NotificationsSettingsScreenProps = StackScreenProps((state) => state.notifications) + const isFocused = useIsFocused() + + const { + data: systemNotificationData, + isFetching: loadingSystemNotification, + refetch: refetchSystemNotificationSettings, + } = useSystemNotificationsSettings({ + enabled: screenContentAllowed('WG_NotificationsSettings'), + }) + const { + data: pushPreferences, + isFetching: loadingPreferences, + error: hasError, + refetch: refetchPushPreferences, + } = usePushPreferences({ + enabled: + isFocused && systemNotificationData?.systemNotificationsOn && screenContentAllowed('WG_NotificationsSettings'), + }) + const { data: personalInformation } = usePersonalInformation() + const { mutate: registerDevice, isPending: registeringDevice } = useRegisterDevice() + const { mutate: setPushPref, isPending: settingPreference } = useUpdatePushPreferences() + + const openSettings = () => { + queryClient.invalidateQueries({ + queryKey: [notificationKeys.systemSettings], + }) + Linking.openSettings() + } const goToSettings = () => { logAnalyticsEvent(Events.vama_click(t('notifications.settings.alert.openSettings'), t('notifications.title'))) Alert.alert(t('leavingApp.title'), t('leavingApp.body.settings'), [ @@ -48,33 +86,54 @@ function NotificationsSettingsScreen({ navigation }: NotificationsSettingsScreen text: t('leavingApp.cancel'), style: 'cancel', }, - { text: t('leavingApp.ok'), onPress: () => Linking.openSettings(), style: 'default' }, + { text: t('leavingApp.ok'), onPress: openSettings, style: 'default' }, ]) } - const dispatch = useAppDispatch() - useOnResumeForeground(() => { - if (deviceToken) { - dispatch(loadPushPreferences(ScreenIDTypesConstants.NOTIFICATIONS_SETTINGS_SCREEN)) + const fetchPreferences = async () => { + const endpoint_sid = await AsyncStorage.getItem(DEVICE_ENDPOINT_SID) + const deviceToken = await AsyncStorage.getItem(DEVICE_TOKEN_KEY) + + if (endpoint_sid && deviceToken) { + refetchPushPreferences() } else { Notifications.events().registerRemoteNotificationsRegistered((event) => { - dispatch(registerDevice(event.deviceToken, true)) + const registerParams = { + deviceToken: event.deviceToken, + userID: personalInformation?.id, + } + const mutateOptions: MutateOptions = + { + onSettled: () => { + refetchPushPreferences() + }, + } + registerDevice(registerParams, mutateOptions) }) Notifications.events().registerRemoteNotificationsRegistrationFailed(() => { - dispatch(registerDevice()) + const registerParams = { + deviceToken: undefined, + userID: undefined, + } + const mutateOptions: MutateOptions = + { + onSettled: () => { + refetchPushPreferences() + }, + } + registerDevice(registerParams, mutateOptions) }) Notifications.registerRemoteNotifications() } - }) + } - useEffect(() => { - if (screenContentAllowed('WG_NotificationsSettings')) { - dispatch(loadPushPreferences(ScreenIDTypesConstants.NOTIFICATIONS_SETTINGS_SCREEN)) - } - }, [dispatch]) + useOnResumeForeground(fetchPreferences) + useOnResumeForeground(refetchSystemNotificationSettings) const preferenceList = (): ReactNode => { - const prefsItems = preferences.map((pref): SimpleListItemObj => { + if (!pushPreferences) return <> + + const prefsItems = pushPreferences.preferences.map((pref): SimpleListItemObj => { return { a11yHintText: t('notifications.settings.switch.a11yHint', { notificationChannelName: pref.preferenceName }), text: pref.preferenceName, @@ -84,7 +143,7 @@ function NotificationsSettingsScreen({ navigation }: NotificationsSettingsScreen }, onPress: () => { logAnalyticsEvent(Events.vama_toggle(pref.preferenceName, !pref.value, t('notifications.title'))) - dispatch(setPushPref(pref)) + setPushPref(pref) }, } }) @@ -95,20 +154,25 @@ function NotificationsSettingsScreen({ navigation }: NotificationsSettingsScreen ) } - const loadingCheck = loadingPreferences || registeringDevice || settingPreference + const loadingCheck = loadingPreferences || loadingSystemNotification || registeringDevice || settingPreference return ( - {hasError ? ( - - ) : loadingCheck ? ( + {loadingCheck ? ( + ) : hasError || + (systemNotificationData?.systemNotificationsOn && pushPreferences && pushPreferences.preferences.length < 1) ? ( + ) : ( - {systemNotificationsOn ? ( + {systemNotificationData?.systemNotificationsOn ? ( <> {t('notifications.settings.personalize.text.systemNotificationsOn')} diff --git a/VAMobile/src/store/api/demo/notifications.ts b/VAMobile/src/store/api/demo/notifications.ts index 279addb4c0b..4a4c433e452 100644 --- a/VAMobile/src/store/api/demo/notifications.ts +++ b/VAMobile/src/store/api/demo/notifications.ts @@ -1,4 +1,4 @@ -import { GetPushPrefsResponse } from '../types' +import { GetPushPrefsResponse } from 'api/types' /** * Type denoting the demo data store diff --git a/VAMobile/src/store/api/types/index.ts b/VAMobile/src/store/api/types/index.ts index 70e56a5964e..5405a144e7d 100644 --- a/VAMobile/src/store/api/types/index.ts +++ b/VAMobile/src/store/api/types/index.ts @@ -1,5 +1,4 @@ export * from './UserData' export * from './Errors' export * from './Screens' -export * from './Notifications' export * from './auth' diff --git a/VAMobile/src/store/index.ts b/VAMobile/src/store/index.ts index 984394d117f..baa2aa2a762 100644 --- a/VAMobile/src/store/index.ts +++ b/VAMobile/src/store/index.ts @@ -5,7 +5,6 @@ import analyticsReducer from 'store/slices/analyticsSlice' import authReducer from 'store/slices/authSlice' import demoReducer from 'store/slices/demoSlice' import errorReducer from 'store/slices/errorSlice' -import notificationReducer from 'store/slices/notificationSlice' import settingsReducer from 'store/slices/settingsSlice' import snackbarReducer from 'store/slices/snackBarSlice' @@ -17,7 +16,6 @@ const store = configureStore({ demo: demoReducer, errors: errorReducer, analytics: analyticsReducer, - notifications: notificationReducer, snackBar: snackbarReducer, settings: settingsReducer, }, diff --git a/VAMobile/src/store/slices/authSlice.ts b/VAMobile/src/store/slices/authSlice.ts index c3f83f2fae3..d558c7968ee 100644 --- a/VAMobile/src/store/slices/authSlice.ts +++ b/VAMobile/src/store/slices/authSlice.ts @@ -33,7 +33,6 @@ import { clearCookies } from 'utils/rnAuthSesson' import { dispatchSetAnalyticsLogin } from './analyticsSlice' import { updateDemoMode } from './demoSlice' -import { dispatchResetTappedForegroundNotification } from './notificationSlice' const { AUTH_SIS_ENDPOINT, @@ -610,16 +609,7 @@ export const startBiometricsLogin = (): AppThunk => async (dispatch, getState) = await attemptIntializeAuthWithRefreshToken(dispatch, refreshToken) } -export const initializeAuth = (): AppThunk => async (dispatch, getState) => { - const { loggedIn } = getState().auth - const { tappedForegroundNotification } = getState().notifications - - if (loggedIn && tappedForegroundNotification) { - console.debug('User tapped foreground notification. Skipping initializeAuth.') - dispatch(dispatchResetTappedForegroundNotification()) - return - } - +export const initializeAuth = (): AppThunk => async (dispatch) => { let refreshToken: string | undefined await dispatch(checkFirstTimeLogin()) const pType = await getAuthLoginPromptType() diff --git a/VAMobile/src/store/slices/index.ts b/VAMobile/src/store/slices/index.ts index a5de3547750..23ea6d83249 100644 --- a/VAMobile/src/store/slices/index.ts +++ b/VAMobile/src/store/slices/index.ts @@ -4,7 +4,6 @@ import { initialAnalyticsState } from 'store/slices/analyticsSlice' import { initialAuthState } from 'store/slices/authSlice' import { initialDemoState } from 'store/slices/demoSlice' import { initialErrorsState } from 'store/slices/errorSlice' -import { initialNotificationsState } from 'store/slices/notificationSlice' import { initialSettingsState } from 'store/slices/settingsSlice' import { initialSnackBarState } from 'store/slices/snackBarSlice' @@ -12,7 +11,6 @@ export * from './accessibilitySlice' export * from './analyticsSlice' export * from './authSlice' export * from './errorSlice' -export * from './notificationSlice' export * from './snackBarSlice' export * from './settingsSlice' @@ -20,7 +18,6 @@ export const InitialState: RootState = { auth: initialAuthState, errors: initialErrorsState, accessibility: initialAccessibilityState, - notifications: initialNotificationsState, demo: initialDemoState, analytics: initialAnalyticsState, snackBar: initialSnackBarState, diff --git a/VAMobile/src/store/slices/notificationSlice.ts b/VAMobile/src/store/slices/notificationSlice.ts deleted file mode 100644 index 49ecf9d0fa8..00000000000 --- a/VAMobile/src/store/slices/notificationSlice.ts +++ /dev/null @@ -1,227 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' - -import { PayloadAction, createSlice } from '@reduxjs/toolkit' - -import { UserAnalytics } from 'constants/analytics' -import { AppThunk } from 'store' -import { logNonFatalErrorToFirebase, setAnalyticsUserProperty } from 'utils/analytics' -import { isErrorObject } from 'utils/common' -import { getDeviceName } from 'utils/deviceData' -import { getCommonErrorFromAPIError } from 'utils/errors' -import { notificationsEnabled } from 'utils/notifications' -import { isIOS } from 'utils/platform' - -import * as api from '../api' -import { - GetPushPrefsResponse, - PUSH_APP_NAME, - PushOsName, - PushPreference, - ScreenIDTypes, - ScreenIDTypesConstants, -} from '../api' -import { dispatchClearErrors, dispatchSetError, dispatchSetTryAgainFunction } from './errorSlice' - -export const DEVICE_TOKEN_KEY = '@store_device_token' -export const DEVICE_ENDPOINT_SID = '@store_device_endpoint_sid' -export const USER_ID = '@store_user_id' -const notificationsNonFatalErrorString = 'Notifications Service Error' - -export type NotificationsState = { - deviceToken?: string - registeringDevice: boolean - preferences: PushPreference[] - loadingPreferences: boolean - settingPreference: boolean - systemNotificationsOn: boolean - tappedForegroundNotification?: boolean - initialUrl?: string -} - -export const initialNotificationsState: NotificationsState = { - deviceToken: undefined, - registeringDevice: false, - preferences: [], - loadingPreferences: false, - settingPreference: false, - systemNotificationsOn: false, - tappedForegroundNotification: false, - initialUrl: undefined, -} - -/** - * Redux Action for registering a device token with VA Push Notifications - * - * Registers the device with the push service, then saves the device token and endpoint SID from Vetext to AsyncStorage - * - * @param deviceToken - string generated by Firebase(Android) or NotificationService(iOS) to register device - */ -export const registerDevice = - (deviceToken?: string, refreshNotificationScreen?: boolean, userID?: string): AppThunk => - async (dispatch) => { - dispatch(dispatchStartRegisterDevice()) - setAnalyticsUserProperty(UserAnalytics.vama_uses_notifications(deviceToken ? true : false)) - try { - if (deviceToken) { - const savedToken = await AsyncStorage.getItem(DEVICE_TOKEN_KEY) - const savedSid = await AsyncStorage.getItem(DEVICE_ENDPOINT_SID) - const savedUserID = await AsyncStorage.getItem(USER_ID) - const isNewUser = !savedUserID || (userID && savedUserID !== userID) - const deviceName = await getDeviceName() - console.debug(`saved endpointSid: ${savedSid}`) - // if there is no saved token, we have not registered - // if there is a token and it is different, we need to register the change with VETEXT - // if the endpoint sid is missing, we need to register again to retrieve it - // if a new user is logged in, we need to register to prevent push notifications for previously logged-in user - if (!savedToken || savedToken !== deviceToken || !savedSid || isNewUser) { - const params: api.PushRegistration = { - deviceName, - deviceToken, - appName: PUSH_APP_NAME, - osName: isIOS() ? PushOsName.ios : PushOsName.android, - debug: false, //TODO debug true is suppose to only work for ios but is currently causing a 502 error(android always set to false) - } - const response = await api.put('/v0/push/register', params) - console.debug(`push registration response: ${response}`) - if (response) { - await AsyncStorage.setItem(DEVICE_ENDPOINT_SID, response.data.attributes.endpointSid) - await AsyncStorage.setItem(DEVICE_TOKEN_KEY, deviceToken) - userID && (await AsyncStorage.setItem(USER_ID, userID)) - } - } - } else { - await AsyncStorage.removeItem(DEVICE_TOKEN_KEY) - } - if (refreshNotificationScreen) { - dispatch(loadPushPreferences(ScreenIDTypesConstants.NOTIFICATIONS_SETTINGS_SCREEN)) - } - } catch (e) { - logNonFatalErrorToFirebase(e, `registerDevice: ${notificationsNonFatalErrorString}`) - console.error(e) - } - dispatch(dispatchUpdateDeviceToken(deviceToken)) - } - -/** - * Redux Action to set the push preference with Vetext - * - * @param preference - push preference object for the preference to by updated - */ -export const setPushPref = - (preference: PushPreference): AppThunk => - async (dispatch) => { - dispatch(dispatchStartSetPreference()) - try { - const endpoint_sid = await AsyncStorage.getItem(DEVICE_ENDPOINT_SID) - const params = { preference: preference.preferenceId, enabled: !preference.value } - await api.put(`/v0/push/prefs/${endpoint_sid}`, params) - const newPrefSetting: api.PushPreference = { - preferenceId: preference.preferenceId, - preferenceName: preference.preferenceName, - value: !preference.value, - } - dispatch(dispatchEndSetPreference(newPrefSetting)) - } catch (e) { - logNonFatalErrorToFirebase(e, `setPushPref: ${notificationsNonFatalErrorString}`) - console.error(e) - dispatch(dispatchEndSetPreference(undefined)) - } - } - -/** - * Redux Action to fetch preferences for the device from Vetext. - */ -export const loadPushPreferences = - (screenID?: ScreenIDTypes): AppThunk => - async (dispatch) => { - const retryFunction = () => dispatch(loadPushPreferences(screenID)) - dispatch(dispatchClearErrors(screenID)) - dispatch(dispatchSetTryAgainFunction(retryFunction)) - - dispatch(dispatchStartLoadPreferences()) - const systemNotificationsOn = await notificationsEnabled() - try { - const endpoint_sid = await AsyncStorage.getItem(DEVICE_ENDPOINT_SID) - const response = await api.get(`/v0/push/prefs/${endpoint_sid}`) - dispatch( - dispatchEndLoadPreferences({ systemNotificationsOn, preferences: response?.data.attributes.preferences }), - ) - } catch (error) { - if (isErrorObject(error)) { - logNonFatalErrorToFirebase(error, `loadPushPreferences: ${notificationsNonFatalErrorString}`) - console.error(error) - dispatch(dispatchSetError({ errorType: getCommonErrorFromAPIError(error), screenID })) - dispatch(dispatchEndLoadPreferences({ systemNotificationsOn, preferences: [] })) - } - } - } - -/** - * Redux slice that will create the actions and reducers - */ -const notificationSlice = createSlice({ - name: 'notification', - initialState: initialNotificationsState, - reducers: { - dispatchStartRegisterDevice: (state) => { - state.registeringDevice = true - }, - - dispatchStartLoadPreferences: (state) => { - state.loadingPreferences = true - }, - - dispatchStartSetPreference: (state) => { - state.settingPreference = true - }, - - dispatchUpdateDeviceToken: (state, action: PayloadAction) => { - state.deviceToken = action.payload - state.registeringDevice = true - }, - - dispatchEndSetPreference: (state, action: PayloadAction) => { - const pref = action.payload - if (pref) { - const index = state.preferences.findIndex((p) => p.preferenceId === pref.preferenceId) - state.preferences.splice(index, 1, pref) - } - - state.settingPreference = false - }, - - dispatchEndLoadPreferences: ( - state, - action: PayloadAction<{ systemNotificationsOn: boolean; preferences?: PushPreference[] }>, - ) => { - const { systemNotificationsOn, preferences } = action.payload - state.preferences = preferences || [] - state.systemNotificationsOn = systemNotificationsOn - state.loadingPreferences = false - state.registeringDevice = false - }, - - dispatchSetTappedForegroundNotification: (state) => { - state.tappedForegroundNotification = true - }, - dispatchResetTappedForegroundNotification: (state) => { - state.tappedForegroundNotification = false - }, - dispatchSetInitialUrl: (state, action) => { - state.initialUrl = action.payload - }, - }, -}) - -export const { - dispatchEndLoadPreferences, - dispatchEndSetPreference, - dispatchStartLoadPreferences, - dispatchStartRegisterDevice, - dispatchStartSetPreference, - dispatchUpdateDeviceToken, - dispatchSetTappedForegroundNotification, - dispatchResetTappedForegroundNotification, - dispatchSetInitialUrl, -} = notificationSlice.actions -export default notificationSlice.reducer diff --git a/VAMobile/src/testUtils.tsx b/VAMobile/src/testUtils.tsx index b492fbf3395..be051175d9a 100644 --- a/VAMobile/src/testUtils.tsx +++ b/VAMobile/src/testUtils.tsx @@ -20,7 +20,6 @@ import analyticsReducer from 'store/slices/analyticsSlice' import authReducer from 'store/slices/authSlice' import demoReducer from 'store/slices/demoSlice' import errorReducer from 'store/slices/errorSlice' -import notificationReducer from 'store/slices/notificationSlice' import settingsReducer from 'store/slices/settingsSlice' import snackbarReducer from 'store/slices/snackBarSlice' import theme from 'styles/themes/standardTheme' @@ -81,7 +80,6 @@ const getConfiguredStore = (state?: Partial) => { demo: demoReducer as any, errors: errorReducer as any, analytics: analyticsReducer as any, - notifications: notificationReducer as any, snackBar: snackbarReducer as any, settings: settingsReducer as any, },