diff --git a/ts/features/landingScreenMultiBanner/components/LandingScreenBannerPicker.tsx b/ts/features/landingScreenMultiBanner/components/LandingScreenBannerPicker.tsx index e0c5be7a579..7d91aed71ad 100644 --- a/ts/features/landingScreenMultiBanner/components/LandingScreenBannerPicker.tsx +++ b/ts/features/landingScreenMultiBanner/components/LandingScreenBannerPicker.tsx @@ -3,6 +3,7 @@ import { useIODispatch, useIOSelector } from "../../../store/hooks"; import { landingScreenBannerToRenderSelector } from "../store/selectors"; import { updateLandingScreenBannerVisibility } from "../store/actions"; import { landingScreenBannerMap } from "../utils/landingScreenBannerMap"; +import { usePushNotificationsBannerTracking } from "../../pushNotifications/hooks/usePushNotificationsBannerTracking"; export const LandingScreenBannerPicker = () => { const dispatch = useIODispatch(); @@ -19,6 +20,8 @@ export const LandingScreenBannerPicker = () => { } }, [bannerToRender, dispatch]); + usePushNotificationsBannerTracking(); + if (bannerToRender === undefined) { return null; } diff --git a/ts/features/landingScreenMultiBanner/components/__tests__/LandingScreenBannerPicker.test.tsx b/ts/features/landingScreenMultiBanner/components/__tests__/LandingScreenBannerPicker.test.tsx index 852000fdb2c..952e73aa596 100644 --- a/ts/features/landingScreenMultiBanner/components/__tests__/LandingScreenBannerPicker.test.tsx +++ b/ts/features/landingScreenMultiBanner/components/__tests__/LandingScreenBannerPicker.test.tsx @@ -12,6 +12,7 @@ import { updateLandingScreenBannerVisibility } from "../../store/actions"; import * as SELECTORS from "../../store/selectors"; import { LandingScreenBannerId } from "../../utils/landingScreenBannerMap"; import { LandingScreenBannerPicker } from "../LandingScreenBannerPicker"; +import * as hooks from "../../../pushNotifications/hooks/usePushNotificationsBannerTracking"; jest.mock("../../utils/landingScreenBannerMap", () => ({ get landingScreenBannerMap() { @@ -89,6 +90,16 @@ describe("LandingBannerPicker", () => { updateLandingScreenBannerVisibility({ id: bannerId, enabled: false }) ); }); + it("should include 'usePushNotificationsBannerTracking'", () => { + const spyUsePushNotificationsBannerTracking = jest.spyOn( + hooks, + "usePushNotificationsBannerTracking" + ); + + renderComponent(); + + expect(spyUsePushNotificationsBannerTracking.mock.calls.length).toBe(1); + }); }); const renderComponent = () => { diff --git a/ts/features/pushNotifications/analytics/__tests__/index.test.ts b/ts/features/pushNotifications/analytics/__tests__/index.test.ts index 6ecbef8c54a..cf2640ebf75 100644 --- a/ts/features/pushNotifications/analytics/__tests__/index.test.ts +++ b/ts/features/pushNotifications/analytics/__tests__/index.test.ts @@ -7,6 +7,14 @@ import { trackNotificationsOptInReminderOnPermissionsOff, trackNotificationsOptInReminderStatus, trackNotificationsOptInSkipSystemPermissions, + trackPushNotificationBannerDismissAlert, + trackPushNotificationBannerDismissOutcome, + trackPushNotificationBannerForceShow, + trackPushNotificationBannerStillHidden, + trackPushNotificationsBannerClosure, + trackPushNotificationsBannerTap, + trackPushNotificationsBannerVisualized, + trackPushNotificationSystemPopupShown, trackPushNotificationTokenUploadFailure, trackPushNotificationTokenUploadSucceeded, trackSystemNotificationPermissionScreenOutcome, @@ -15,6 +23,8 @@ import { import { PushNotificationsContentTypeEnum } from "../../../../../definitions/backend/PushNotificationsContentType"; import { ReminderStatusEnum } from "../../../../../definitions/backend/ReminderStatus"; import * as Mixpanel from "../../../../mixpanel"; +import ROUTES from "../../../../navigation/routes"; +import { MESSAGES_ROUTES } from "../../../messages/navigation/routes"; describe("pushNotifications analytics", () => { beforeEach(() => { @@ -236,6 +246,136 @@ describe("pushNotifications analytics", () => { new_notification_status: notificationPermissionsEnabled }); }); + [MESSAGES_ROUTES.MESSAGES_HOME, ROUTES.SETTINGS_MAIN].forEach(route => { + it(`'trackPushNotificationsBannerVisualized' should have expected event name and properties for '${route}'`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackPushNotificationsBannerVisualized(route); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe("BANNER"); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "screen_view", + banner_id: "push_notif_activation", + banner_page: + route === "MESSAGES_HOME" ? "MESSAGES_HOME" : "SETTINGS_MAIN", + banner_landing: "os_notification_settings" + }); + }); + it(`'trackPushNotificationsBannerTap' should have expected event name and properties for '${route}'`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackPushNotificationsBannerTap(route); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe("TAP_BANNER"); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + banner_id: "push_notif_activation", + banner_page: + route === "MESSAGES_HOME" ? "MESSAGES_HOME" : "SETTINGS_MAIN", + banner_landing: "os_notification_settings" + }); + }); + }); + it(`'trackPushNotificationsBannerClosure' should have expected event name and properties`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackPushNotificationsBannerClosure(); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe("CLOSE_BANNER"); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + banner_id: "push_notif_activation", + banner_page: "MESSAGES_HOME", + banner_landing: "os_notification_settings" + }); + }); + it(`'trackPushNotificationSystemPopupShown' should have expected event name and properties`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackPushNotificationSystemPopupShown(); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe("PUSH_NOTIF_SYSTEM_ALERT"); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "screen_view" + }); + }); + it(`'trackPushNotificationBannerDismissAlert' should have expected event name and properties`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackPushNotificationBannerDismissAlert(); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "PUSH_NOTIF_THIRD_DISMISS_ALERT" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "screen_view" + }); + }); + ["deactivate" as const, "remind_later" as const].forEach(outcome => + it(`'trackPushNotificationBannerDismissOutcome' should have expected event name and properties for '${outcome}'`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackPushNotificationBannerDismissOutcome(outcome); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "PUSH_NOTIF_THIRD_DISMISS_ALERT_INTERACTION" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + outcome + }); + }) + ); + it(`'trackPushNotificationBannerForceShow' should have expected event name and properties`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackPushNotificationBannerForceShow(); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "PUSH_NOTIF_BANNER_FORCE_SHOW" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "TECH", + event_type: undefined + }); + }); + it(`'trackPushNotificationBannerStillHidden' should have expected event name and properties`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + const unreadMessagesCount = 3; + + void trackPushNotificationBannerStillHidden(unreadMessagesCount); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe( + "PUSH_NOTIF_BANNER_STILL_HIDDEN" + ); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "TECH", + event_type: undefined, + unread_count: unreadMessagesCount + }); + }); }); const getMockMixpanelTrack = () => diff --git a/ts/features/pushNotifications/analytics/index.ts b/ts/features/pushNotifications/analytics/index.ts index aa1f7bbc395..6203c989447 100644 --- a/ts/features/pushNotifications/analytics/index.ts +++ b/ts/features/pushNotifications/analytics/index.ts @@ -1,7 +1,9 @@ import { PushNotificationsContentTypeEnum } from "../../../../definitions/backend/PushNotificationsContentType"; import { ReminderStatusEnum } from "../../../../definitions/backend/ReminderStatus"; import { mixpanelTrack } from "../../../mixpanel"; +import ROUTES from "../../../navigation/routes"; import { buildEventProperties } from "../../../utils/analytics"; +import { MESSAGES_ROUTES } from "../../messages/navigation/routes"; export const trackNotificationInstallationTokenNotChanged = () => void mixpanelTrack( @@ -90,3 +92,73 @@ export const trackNotificationPermissionsStatus = ( }); void mixpanelTrack(eventName, props); }; + +export const trackPushNotificationsBannerVisualized = ( + bannerPage: typeof MESSAGES_ROUTES.MESSAGES_HOME | typeof ROUTES.SETTINGS_MAIN +) => { + const eventName = "BANNER"; + const props = buildEventProperties("UX", "screen_view", { + banner_id: "push_notif_activation", + banner_page: bannerPage, + banner_landing: "os_notification_settings" + }); + void mixpanelTrack(eventName, props); +}; + +export const trackPushNotificationsBannerTap = ( + bannerPage: typeof MESSAGES_ROUTES.MESSAGES_HOME | typeof ROUTES.SETTINGS_MAIN +) => { + const eventName = "TAP_BANNER"; + const props = buildEventProperties("UX", "action", { + banner_id: "push_notif_activation", + banner_page: bannerPage, + banner_landing: "os_notification_settings" + }); + void mixpanelTrack(eventName, props); +}; + +export const trackPushNotificationsBannerClosure = () => { + const eventName = "CLOSE_BANNER"; + const props = buildEventProperties("UX", "action", { + banner_id: "push_notif_activation", + banner_page: MESSAGES_ROUTES.MESSAGES_HOME, + banner_landing: "os_notification_settings" + }); + void mixpanelTrack(eventName, props); +}; + +export const trackPushNotificationSystemPopupShown = () => { + const eventName = "PUSH_NOTIF_SYSTEM_ALERT"; + const props = buildEventProperties("UX", "screen_view"); + void mixpanelTrack(eventName, props); +}; + +export const trackPushNotificationBannerDismissAlert = () => { + const eventName = "PUSH_NOTIF_THIRD_DISMISS_ALERT"; + const props = buildEventProperties("UX", "screen_view"); + void mixpanelTrack(eventName, props); +}; + +export const trackPushNotificationBannerDismissOutcome = ( + outcome: "deactivate" | "remind_later" +) => { + const eventName = "PUSH_NOTIF_THIRD_DISMISS_ALERT_INTERACTION"; + const props = buildEventProperties("UX", "action", { outcome }); + void mixpanelTrack(eventName, props); +}; + +export const trackPushNotificationBannerForceShow = () => { + const eventName = "PUSH_NOTIF_BANNER_FORCE_SHOW"; + const props = buildEventProperties("TECH", undefined); + void mixpanelTrack(eventName, props); +}; + +export const trackPushNotificationBannerStillHidden = ( + unreadMessagesCount: number +) => { + const eventName = "PUSH_NOTIF_BANNER_STILL_HIDDEN"; + const props = buildEventProperties("TECH", undefined, { + unread_count: unreadMessagesCount + }); + void mixpanelTrack(eventName, props); +}; diff --git a/ts/features/pushNotifications/components/PushNotificationsBanner.tsx b/ts/features/pushNotifications/components/PushNotificationsBanner.tsx index 410bb0d02d1..94ed4de84df 100644 --- a/ts/features/pushNotifications/components/PushNotificationsBanner.tsx +++ b/ts/features/pushNotifications/components/PushNotificationsBanner.tsx @@ -19,6 +19,15 @@ import { shouldResetNotificationBannerDismissStateSelector, timesPushNotificationBannerDismissedSelector } from "../store/selectors/notificationsBannerDismissed"; +import { MESSAGES_ROUTES } from "../../messages/navigation/routes"; +import { + trackPushNotificationBannerDismissAlert, + trackPushNotificationBannerDismissOutcome, + trackPushNotificationBannerForceShow, + trackPushNotificationsBannerClosure, + trackPushNotificationsBannerTap, + trackPushNotificationsBannerVisualized +} from "../analytics"; type Props = { closeHandler: () => void; }; @@ -31,9 +40,13 @@ export const PushNotificationsBanner = ({ closeHandler }: Props) => { React.useEffect(() => { if (shouldResetDismissState) { + trackPushNotificationBannerForceShow(); dispatch(resetNotificationBannerDismissState()); } }, [dispatch, shouldResetDismissState]); + React.useEffect(() => { + trackPushNotificationsBannerVisualized(MESSAGES_ROUTES.MESSAGES_HOME); + }, []); const dismissionCount = useIOSelector( timesPushNotificationBannerDismissedSelector @@ -41,7 +54,9 @@ export const PushNotificationsBanner = ({ closeHandler }: Props) => { const discardModal = usePushNotificationsBannerBottomSheet(closeHandler); const onClose = () => { + trackPushNotificationsBannerClosure(); if (dismissionCount >= 2) { + trackPushNotificationBannerDismissAlert(); discardModal.present(); } else { dispatch(setUserDismissedNotificationsBanner()); @@ -49,6 +64,11 @@ export const PushNotificationsBanner = ({ closeHandler }: Props) => { } }; + const onPress = React.useCallback(() => { + trackPushNotificationsBannerTap(MESSAGES_ROUTES.MESSAGES_HOME); + openSystemNotificationSettingsScreen(); + }, []); + return ( { size="big" onClose={onClose} labelClose={I18n.t("global.buttons.close")} - onPress={openSystemNotificationSettingsScreen} + onPress={onPress} /> {discardModal.bottomSheet} @@ -73,8 +93,15 @@ const usePushNotificationsBannerBottomSheet = ( ) => { const dispatch = useIODispatch(); - const fullCloseHandler = () => + const internalRemindLaterHandler = () => { + trackPushNotificationBannerDismissOutcome("remind_later"); + remindLaterHandler(); + }; + + const fullCloseHandler = () => { + trackPushNotificationBannerDismissOutcome("deactivate"); dispatch(setPushNotificationBannerForceDismissed()); + }; return useIOBottomSheetModal({ title: I18n.t( @@ -88,7 +115,7 @@ const usePushNotificationsBannerBottomSheet = ( label: I18n.t( "features.messages.pushNotifications.banner.bottomSheet.cta" ), - onPress: remindLaterHandler + onPress: internalRemindLaterHandler }, secondary: { label: I18n.t( diff --git a/ts/features/pushNotifications/components/__tests__/PushNotificationsBanner.test.tsx b/ts/features/pushNotifications/components/__tests__/PushNotificationsBanner.test.tsx index 3c3c66684cc..439cadeaa61 100644 --- a/ts/features/pushNotifications/components/__tests__/PushNotificationsBanner.test.tsx +++ b/ts/features/pushNotifications/components/__tests__/PushNotificationsBanner.test.tsx @@ -1,6 +1,7 @@ import { fireEvent } from "@testing-library/react-native"; import * as React from "react"; import { createStore } from "redux"; +import { constUndefined } from "fp-ts/lib/function"; import I18n from "../../../../i18n"; import { applicationChangeState } from "../../../../store/actions/application"; import * as IOHOOKS from "../../../../store/hooks"; @@ -13,41 +14,72 @@ import * as SELECTORS from "../../store/selectors"; import * as NOTIFICATION_DISMISS_SELECTORS from "../../store/selectors/notificationsBannerDismissed"; import * as UTILS from "../../utils"; import { PushNotificationsBanner } from "../PushNotificationsBanner"; +import * as analytics from "../../analytics"; +import { setPushNotificationBannerForceDismissed } from "../../store/actions/userBehaviour"; -const testPressHandler = jest.fn(); -jest - .spyOn(UTILS, "openSystemNotificationSettingsScreen") - .mockImplementation(testPressHandler); describe("PushNotificationsBanner", () => { - beforeEach(() => { - jest.clearAllMocks(); + afterEach(() => { + jest.restoreAllMocks(); }); - it("should render correctly", () => { + it("should render correctly and call 'trackPushNotificationsBannerVisualized'", () => { + const spyOnMockedTrackPushNotificationsBannerVisualized = jest + .spyOn(analytics, "trackPushNotificationsBannerVisualized") + .mockImplementation(_ => undefined); const component = renderTestingComponent( null} /> ); expect(component.toJSON()).toMatchSnapshot(); + expect( + spyOnMockedTrackPushNotificationsBannerVisualized + ).toHaveBeenCalledTimes(1); + expect( + spyOnMockedTrackPushNotificationsBannerVisualized + ).toHaveBeenCalledWith(MESSAGES_ROUTES.MESSAGES_HOME); }); it("should call openSystemNotificationSettingsScreen on press", () => { + const spyOnMockedOpenSystemNotificationSettingsScreen = jest + .spyOn(UTILS, "openSystemNotificationSettingsScreen") + .mockImplementation(constUndefined); + const spyOnMockedTrackPushNotificationsBannerTap = jest + .spyOn(analytics, "trackPushNotificationsBannerTap") + .mockImplementation(_ => undefined); const component = renderTestingComponent( ); - fireEvent(component.getByTestId("pushNotificationsBanner"), "press"); - expect(testPressHandler).toHaveBeenCalledTimes(1); + fireEvent(component.getByTestId("pushNotificationsBanner"), "onPress"); + + expect(spyOnMockedTrackPushNotificationsBannerTap).toHaveBeenCalledTimes(1); + expect(spyOnMockedTrackPushNotificationsBannerTap).toHaveBeenCalledWith( + MESSAGES_ROUTES.MESSAGES_HOME + ); + expect( + spyOnMockedOpenSystemNotificationSettingsScreen + ).toHaveBeenCalledTimes(1); }); - it("should correctly dispatch the closeHandler", () => { + it("should correctly dispatch the closeHandler and call 'trackPushNotificationsBannerClosure' when the dismission count is less than two", () => { + const spyOnMockedTrackPushNotificationsBannerClosure = jest + .spyOn(analytics, "trackPushNotificationsBannerClosure") + .mockImplementation(constUndefined); + const spyOnMockedTrackPushNotificationBannerDismissAlert = jest + .spyOn(analytics, "trackPushNotificationBannerDismissAlert") + .mockImplementation(constUndefined); const testClose = jest.fn(); const component = renderTestingComponent( ); fireEvent( component.getByA11yLabel(I18n.t("global.buttons.close")), - "press" + "onPress" ); + expect( + spyOnMockedTrackPushNotificationsBannerClosure + ).toHaveBeenCalledTimes(1); + expect( + spyOnMockedTrackPushNotificationBannerDismissAlert + ).toHaveBeenCalledTimes(0); expect(testClose).toHaveBeenCalledTimes(1); }); - it.each([0, 1, 2, 3, 4, 5, 6, 7, 8])( // dismissed should never be more than 2, but just in case "should only open BS after third banner dismissal (current: %p )", @@ -62,6 +94,9 @@ describe("PushNotificationsBanner", () => { "timesPushNotificationBannerDismissedSelector" ) .mockImplementation(() => timeDismissed); + const spyOnMockedTrackPushNotificationBannerDismissAlert = jest + .spyOn(analytics, "trackPushNotificationBannerDismissAlert") + .mockImplementation(constUndefined); jest.spyOn(BS, "useIOBottomSheetModal").mockImplementation(_ => ({ bottomSheet: <>, dismiss: jest.fn(), @@ -72,9 +107,15 @@ describe("PushNotificationsBanner", () => { ); fireEvent( component.getByA11yLabel(I18n.t("global.buttons.close")), - "press" + "onPress" + ); + const dismissionThresholdReached = timeDismissed >= 2; + expect( + spyOnMockedTrackPushNotificationBannerDismissAlert + ).toHaveBeenCalledTimes(dismissionThresholdReached ? 1 : 0); + expect(testPresentBS).toHaveBeenCalledTimes( + dismissionThresholdReached ? 1 : 0 ); - expect(testPresentBS).toHaveBeenCalledTimes(timeDismissed >= 2 ? 1 : 0); } ); it('should reset the dismiss state if "shouldResetNotificationBannerDismissStateSelector" evaluates to "true" ', () => { @@ -88,16 +129,91 @@ describe("PushNotificationsBanner", () => { jest .spyOn(IOHOOKS, "useIODispatch") .mockImplementation(() => ioDispatchMock); + const spyOnMockedTrackPushNotificationBannerForceShow = jest + .spyOn(analytics, "trackPushNotificationBannerForceShow") + .mockImplementation(constUndefined); const component = renderTestingComponent( null} /> ); expect(component).not.toBeNull(); + + expect( + spyOnMockedTrackPushNotificationBannerForceShow + ).toHaveBeenCalledTimes(1); expect(ioDispatchMock).toHaveBeenCalledWith( ACTIONS.resetNotificationBannerDismissState() ); }); + it("bottom sheet primary action should call 'trackPushNotificationBannerDismissOutcome' and the close handler", () => { + const spyOnBS = jest.spyOn(BS, "useIOBottomSheetModal"); + const spyOnMockedTrackPushNotificationBannerDismissOutcome = jest + .spyOn(analytics, "trackPushNotificationBannerDismissOutcome") + .mockImplementation(_ => undefined); + const mockCloseHandler = jest.fn(); + renderTestingComponent( + + ); + + expect(spyOnBS.mock.calls.length).toBe(1); + expect(spyOnBS.mock.calls[0].length).toBe(1); + expect(spyOnBS.mock.calls[0][0]).toBeDefined(); + + const primaryActionCallback = + spyOnBS.mock.calls[0][0].footer?.props.actions.primary.onPress; + expect(primaryActionCallback).toBeDefined(); + expect(typeof primaryActionCallback).toBe("function"); + + primaryActionCallback(); + + expect( + spyOnMockedTrackPushNotificationBannerDismissOutcome + ).toHaveBeenCalledTimes(1); + expect( + spyOnMockedTrackPushNotificationBannerDismissOutcome + ).toHaveBeenCalledWith("remind_later"); + + expect(mockCloseHandler).toHaveBeenCalledTimes(1); + }); + it("bottom sheet secondary action should call 'trackPushNotificationBannerDismissOutcome' and dispatch 'setPushNotificationBannerForceDismissed'", () => { + const mockedDispatch = jest.fn(); + jest + .spyOn(IOHOOKS, "useIODispatch") + .mockImplementation(() => mockedDispatch); + const spyOnBS = jest.spyOn(BS, "useIOBottomSheetModal"); + const spyOnMockedTrackPushNotificationBannerDismissOutcome = jest + .spyOn(analytics, "trackPushNotificationBannerDismissOutcome") + .mockImplementation(_ => undefined); + const mockCloseHandler = jest.fn(); + + renderTestingComponent( + + ); + + expect(spyOnBS.mock.calls.length).toBe(1); + expect(spyOnBS.mock.calls[0].length).toBe(1); + expect(spyOnBS.mock.calls[0][0]).toBeDefined(); + + const secondaryActionCallback = + spyOnBS.mock.calls[0][0].footer?.props.actions.secondary.onPress; + expect(secondaryActionCallback).toBeDefined(); + expect(typeof secondaryActionCallback).toBe("function"); + + secondaryActionCallback(); + + expect( + spyOnMockedTrackPushNotificationBannerDismissOutcome + ).toHaveBeenCalledTimes(1); + expect( + spyOnMockedTrackPushNotificationBannerDismissOutcome + ).toHaveBeenCalledWith("deactivate"); + + expect(mockedDispatch).toHaveBeenCalledTimes(1); + expect(mockedDispatch).toHaveBeenCalledWith( + setPushNotificationBannerForceDismissed() + ); + }); }); const renderTestingComponent = (component: React.ReactElement) => { diff --git a/ts/features/pushNotifications/components/__tests__/__snapshots__/PushNotificationsBanner.test.tsx.snap b/ts/features/pushNotifications/components/__tests__/__snapshots__/PushNotificationsBanner.test.tsx.snap index 80e7332c575..9833d6d6123 100644 --- a/ts/features/pushNotifications/components/__tests__/__snapshots__/PushNotificationsBanner.test.tsx.snap +++ b/ts/features/pushNotifications/components/__tests__/__snapshots__/PushNotificationsBanner.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`PushNotificationsBanner should render correctly 1`] = ` +exports[`PushNotificationsBanner should render correctly and call 'trackPushNotificationsBannerVisualized' 1`] = ` { + afterEach(() => { + jest.restoreAllMocks(); + }); + it("should not call 'trackPushNotificationBannerStillHidden' if the banner has not been forced dismissed", () => { + jest + .spyOn(selectors, "isForceDismissAndNotUnreadMessagesHiddenSelector") + .mockReturnValue(false); + jest + .spyOn(selectors, "unreadMessagesCountAfterForceDismissionSelector") + .mockReturnValue(15); + const mockTrackPushNotificationBannerStillHidden = jest + .spyOn(analytics, "trackPushNotificationBannerStillHidden") + .mockImplementation(_unreadMessageCount => undefined); + + renderScreen(); + + expect(mockTrackPushNotificationBannerStillHidden.mock.calls.length).toBe( + 0 + ); + }); + it("should not call 'trackPushNotificationBannerStillHidden' if data about unread messages is not available yet", () => { + jest + .spyOn(selectors, "isForceDismissAndNotUnreadMessagesHiddenSelector") + .mockReturnValue(true); + jest + .spyOn(selectors, "unreadMessagesCountAfterForceDismissionSelector") + .mockReturnValue(undefined); + const mockTrackPushNotificationBannerStillHidden = jest + .spyOn(analytics, "trackPushNotificationBannerStillHidden") + .mockImplementation(_unreadMessageCount => undefined); + + renderScreen(); + + expect(mockTrackPushNotificationBannerStillHidden.mock.calls.length).toBe( + 0 + ); + }); + it("should call 'trackPushNotificationBannerStillHidden' with proper parameters", () => { + jest + .spyOn(selectors, "isForceDismissAndNotUnreadMessagesHiddenSelector") + .mockReturnValue(true); + jest + .spyOn(selectors, "unreadMessagesCountAfterForceDismissionSelector") + .mockReturnValue(15); + const mockTrackPushNotificationBannerStillHidden = jest + .spyOn(analytics, "trackPushNotificationBannerStillHidden") + .mockImplementation(_unreadMessageCount => undefined); + + renderScreen(); + + expect(mockTrackPushNotificationBannerStillHidden.mock.calls.length).toBe( + 1 + ); + expect( + mockTrackPushNotificationBannerStillHidden.mock.calls[0].length + ).toBe(1); + expect(mockTrackPushNotificationBannerStillHidden.mock.calls[0][0]).toBe( + 15 + ); + }); +}); + +const renderScreen = () => { + const initialState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, initialState as any); + + return renderScreenWithNavigationStoreContext( + HookWrapper, + MESSAGES_ROUTES.MESSAGES_HOME, + {}, + store + ); +}; + +const HookWrapper = () => { + usePushNotificationsBannerTracking(); + return undefined; +}; diff --git a/ts/features/pushNotifications/hooks/usePushNotificationsBannerTracking.tsx b/ts/features/pushNotifications/hooks/usePushNotificationsBannerTracking.tsx new file mode 100644 index 00000000000..68a16c86f06 --- /dev/null +++ b/ts/features/pushNotifications/hooks/usePushNotificationsBannerTracking.tsx @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { useIOSelector, useIOStore } from "../../../store/hooks"; +import { + isForceDismissAndNotUnreadMessagesHiddenSelector, + unreadMessagesCountAfterForceDismissionSelector +} from "../store/selectors/notificationsBannerDismissed"; +import { trackPushNotificationBannerStillHidden } from "../analytics"; + +export const usePushNotificationsBannerTracking = () => { + const store = useIOStore(); + const isBannerForceDismissHidden = useIOSelector( + isForceDismissAndNotUnreadMessagesHiddenSelector + ); + + useEffect(() => { + if (isBannerForceDismissHidden) { + const unreadMessageCountMaybe = + unreadMessagesCountAfterForceDismissionSelector(store.getState()); + if (unreadMessageCountMaybe != null) { + trackPushNotificationBannerStillHidden(unreadMessageCountMaybe); + } + } + }, [isBannerForceDismissHidden, store]); + + return undefined; +}; diff --git a/ts/features/pushNotifications/sagas/__tests__/profileAndSystemNotificationsPermissions.test.tsx b/ts/features/pushNotifications/sagas/__tests__/profileAndSystemNotificationsPermissions.test.tsx index 93d8c761975..1944dfc5bb7 100644 --- a/ts/features/pushNotifications/sagas/__tests__/profileAndSystemNotificationsPermissions.test.tsx +++ b/ts/features/pushNotifications/sagas/__tests__/profileAndSystemNotificationsPermissions.test.tsx @@ -12,7 +12,8 @@ import { PushNotificationsContentTypeEnum } from "../../../../../definitions/bac import { ReminderStatusEnum } from "../../../../../definitions/backend/ReminderStatus"; import { trackNotificationsOptInPreviewStatus, - trackNotificationsOptInReminderStatus + trackNotificationsOptInReminderStatus, + trackPushNotificationSystemPopupShown } from "../../analytics"; import { updateMixpanelSuperProperties } from "../../../../mixpanelConfig/superProperties"; import { updateMixpanelProfileProperties } from "../../../../mixpanelConfig/profileProperties"; @@ -21,6 +22,7 @@ import { updateNotificationPermissionsIfNeeded } from "../common"; import { setPushPermissionsRequestDuration } from "../../store/actions/environment"; +import { hasUserSeenSystemNotificationsPromptSelector } from "../../store/selectors"; const generateUserProfile = ( hasDoneNotificationOptIn: boolean, @@ -83,8 +85,12 @@ describe("checkNotificationsPreferencesSaga", () => { .call(requestNotificationPermissions) .next(true) .call(performance.now) - .next(10) - .put(setPushPermissionsRequestDuration(10)) + .next(1000) + .put(setPushPermissionsRequestDuration(1000)) + .next() + .select(hasUserSeenSystemNotificationsPromptSelector) + .next(true) + .call(trackPushNotificationSystemPopupShown) .next() .call(updateNotificationPermissionsIfNeeded, true) .next() @@ -134,6 +140,8 @@ describe("checkNotificationsPreferencesSaga", () => { .next(10) .put(setPushPermissionsRequestDuration(10)) .next() + .select(hasUserSeenSystemNotificationsPromptSelector) + .next(false) .call(updateNotificationPermissionsIfNeeded, false) .next() .call( @@ -207,8 +215,12 @@ describe("checkNotificationsPreferencesSaga", () => { .call(requestNotificationPermissions) .next(true) .call(performance.now) - .next(10) - .put(setPushPermissionsRequestDuration(10)) + .next(1000) + .put(setPushPermissionsRequestDuration(1000)) + .next() + .select(hasUserSeenSystemNotificationsPromptSelector) + .next(true) + .call(trackPushNotificationSystemPopupShown) .next() .call(updateNotificationPermissionsIfNeeded, true) .next() @@ -235,6 +247,8 @@ describe("checkNotificationsPreferencesSaga", () => { .next(10) .put(setPushPermissionsRequestDuration(10)) .next() + .select(hasUserSeenSystemNotificationsPromptSelector) + .next(false) .call(updateNotificationPermissionsIfNeeded, false) .next() .select() @@ -293,8 +307,12 @@ describe("checkNotificationsPreferencesSaga", () => { .call(requestNotificationPermissions) .next(true) .call(performance.now) - .next(10) - .put(setPushPermissionsRequestDuration(10)) + .next(1000) + .put(setPushPermissionsRequestDuration(1000)) + .next() + .select(hasUserSeenSystemNotificationsPromptSelector) + .next(true) + .call(trackPushNotificationSystemPopupShown) .next() .call(updateNotificationPermissionsIfNeeded, true) .next() @@ -344,6 +362,8 @@ describe("checkNotificationsPreferencesSaga", () => { .next(10) .put(setPushPermissionsRequestDuration(10)) .next() + .select(hasUserSeenSystemNotificationsPromptSelector) + .next(false) .call(updateNotificationPermissionsIfNeeded, false) .next() .call( @@ -417,8 +437,12 @@ describe("checkNotificationsPreferencesSaga", () => { .call(requestNotificationPermissions) .next(true) .call(performance.now) - .next(10) - .put(setPushPermissionsRequestDuration(10)) + .next(1000) + .put(setPushPermissionsRequestDuration(1000)) + .next() + .select(hasUserSeenSystemNotificationsPromptSelector) + .next(true) + .call(trackPushNotificationSystemPopupShown) .next() .call(updateNotificationPermissionsIfNeeded, true) .next() @@ -445,6 +469,8 @@ describe("checkNotificationsPreferencesSaga", () => { .next(10) .put(setPushPermissionsRequestDuration(10)) .next() + .select(hasUserSeenSystemNotificationsPromptSelector) + .next(false) .call(updateNotificationPermissionsIfNeeded, false) .next() .select() diff --git a/ts/features/pushNotifications/sagas/profileAndSystemNotificationsPermissions.ts b/ts/features/pushNotifications/sagas/profileAndSystemNotificationsPermissions.ts index ebe31d93988..44599f031c1 100644 --- a/ts/features/pushNotifications/sagas/profileAndSystemNotificationsPermissions.ts +++ b/ts/features/pushNotifications/sagas/profileAndSystemNotificationsPermissions.ts @@ -11,11 +11,13 @@ import { isProfileFirstOnBoarding } from "../../../store/reducers/profile"; import { GlobalState } from "../../../store/reducers/types"; import { trackNotificationsOptInPreviewStatus, - trackNotificationsOptInReminderStatus + trackNotificationsOptInReminderStatus, + trackPushNotificationSystemPopupShown } from "../analytics"; import { setPushPermissionsRequestDuration } from "../store/actions/environment"; import { notificationsInfoScreenConsent } from "../store/actions/profileNotificationPermissions"; import { requestNotificationPermissions } from "../utils"; +import { hasUserSeenSystemNotificationsPromptSelector } from "../store/selectors"; import { checkAndUpdateNotificationPermissionsIfNeeded, updateNotificationPermissionsIfNeeded @@ -76,6 +78,13 @@ export function* profileAndSystemNotificationsPermissions( const requestDuration = endRequestTime - startRequestTime; yield* put(setPushPermissionsRequestDuration(requestDuration)); + const systemPermissionPromptShown = yield* select( + hasUserSeenSystemNotificationsPromptSelector + ); + if (systemPermissionPromptShown) { + yield* call(trackPushNotificationSystemPopupShown); + } + yield* call( updateNotificationPermissionsIfNeeded, userHasGivenNotificationPermission diff --git a/ts/features/pushNotifications/store/selectors/__tests__/notificationsBannerDismissed.test.ts b/ts/features/pushNotifications/store/selectors/__tests__/notificationsBannerDismissed.test.ts index e80a3d48610..798e4c4a8f2 100644 --- a/ts/features/pushNotifications/store/selectors/__tests__/notificationsBannerDismissed.test.ts +++ b/ts/features/pushNotifications/store/selectors/__tests__/notificationsBannerDismissed.test.ts @@ -3,8 +3,11 @@ import { GlobalState } from "../../../../../store/reducers/types"; import * as ALL_PAGINATED from "../../../../messages/store/reducers/allPaginated"; import { UIMessage } from "../../../../messages/types"; import { + isForceDismissAndNotUnreadMessagesHiddenSelector, + pushNotificationsBannerForceDismissionDateSelector, shouldResetNotificationBannerDismissStateSelector, - timesPushNotificationBannerDismissedSelector + timesPushNotificationBannerDismissedSelector, + unreadMessagesCountAfterForceDismissionSelector } from "../notificationsBannerDismissed"; import { UserBehaviourState } from "../../reducers/userBehaviour"; @@ -48,6 +51,22 @@ const readMessage = { createdAt: new Date("2100-01-01") } as unknown as UIMessage; +describe("pushNotificationsBannerForceDismissionDateSelector", () => { + it("should return undefined when 'pushNotificationBannerForceDismissionDate' has no value", () => { + const dismissionDate = pushNotificationsBannerForceDismissionDateSelector( + getTestState({}) + ); + expect(dismissionDate).toBe(undefined); + }); + it("should return proper value when 'pushNotificationBannerForceDismissionDate' is defined", () => { + const dismissionDateUE = new Date().getTime(); + const dismissionDate = pushNotificationsBannerForceDismissionDateSelector( + getTestState({ forceDismissionDate: dismissionDateUE }) + ); + expect(dismissionDate).toBe(dismissionDateUE); + }); +}); + describe("timesPushNotificationsBannerDismissedSelector", () => { it("should return timesPushNotificationsBannerDismissed", () => { expect( @@ -58,37 +77,76 @@ describe("timesPushNotificationsBannerDismissedSelector", () => { }); }); -describe("shouldResetNotificationsBannerDismissStateSelector", () => { +describe("unreadMessagesCountAfterForceDismissionSelector, isForceDismissAndNotUnreadMessagesHiddenSelector, shouldResetNotificationBannerDismissStateSelector", () => { beforeEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); }); - it("should return false if 'messageList' is undefined", () => { - expect( - shouldResetNotificationBannerDismissStateSelector(getTestState({})) - ).toBe(false); + describe(`if 'messageList' is undefined`, () => { + const noMessageListTestState = getTestState({ + forceDismissionDate: 1, + timesDismissed: 3 + }); + it("unreadMessagesCountAfterForceDismissionSelector should return 'undefined'", () => { + expect( + unreadMessagesCountAfterForceDismissionSelector(noMessageListTestState) + ).toBeUndefined(); + }); + it("isForceDismissAndNotUnreadMessagesHiddenSelector should return 'false'", () => { + expect( + isForceDismissAndNotUnreadMessagesHiddenSelector(noMessageListTestState) + ).toBe(false); + }); + it("shouldResetNotificationBannerDismissStateSelector should return 'false'", () => { + expect( + shouldResetNotificationBannerDismissStateSelector( + noMessageListTestState + ) + ).toBe(false); + }); }); - it("should return false if 'forceDismissDate' is undefined", () => { - expect( - shouldResetNotificationBannerDismissStateSelector( - getTestState({ forceDismissionDate: undefined }) - ) - ).toBe(false); + describe("if 'forceDismissDate' is 'undefined'", () => { + const noForceDismissDateTestState = getTestState({ + messages: [unreadMessage, unreadMessage, unreadMessage, unreadMessage], + timesDismissed: 3 + }); + it("unreadMessagesCountAfterForceDismissionSelector should return 'undefined'", () => { + expect( + unreadMessagesCountAfterForceDismissionSelector( + noForceDismissDateTestState + ) + ).toBeUndefined(); + }); + it("isForceDismissAndNotUnreadMessagesHiddenSelector should return 'false'", () => { + expect( + isForceDismissAndNotUnreadMessagesHiddenSelector( + noForceDismissDateTestState + ) + ).toBe(false); + }); + it("shouldResetNotificationBannerDismissStateSelector should return 'false' ", () => { + expect( + shouldResetNotificationBannerDismissStateSelector( + noForceDismissDateTestState + ) + ).toBe(false); + }); }); - it.each` - unread | moreThanFour | isNew | expected - ${true} | ${true} | ${true} | ${true} - ${true} | ${true} | ${false} | ${false} - ${true} | ${false} | ${true} | ${false} - ${true} | ${false} | ${false} | ${false} - ${false} | ${true} | ${true} | ${false} - ${false} | ${true} | ${false} | ${false} - ${false} | ${false} | ${true} | ${false} - ${false} | ${false} | ${false} | ${false} + describe.each` + unread | moreThanFour | isNew | unreadCount | expected + ${true} | ${true} | ${true} | ${5} | ${true} + ${true} | ${true} | ${false} | ${0} | ${false} + ${true} | ${false} | ${true} | ${2} | ${false} + ${true} | ${false} | ${false} | ${0} | ${false} + ${false} | ${true} | ${true} | ${0} | ${false} + ${false} | ${true} | ${false} | ${0} | ${false} + ${false} | ${false} | ${true} | ${0} | ${false} + ${false} | ${false} | ${false} | ${0} | ${false} `( - "should return $expected when messageListForCategorySelector returns a list with {more than 4? $moreThanFour, unread? $unread, new? $isNew} messages", - ({ unread, moreThanFour, isNew, expected }) => { + `when 'messageListForCategorySelector' returns a list with {more than 4? $moreThanFour, unread? $unread, unreadCount? $unreadCount new? $isNew} messages`, + // `should return '$unreadCount' '!$expected' '$expected' when messageListForCategorySelector returns a list with {more than 4? $moreThanFour, unread? $unread, unreadCount? $unreadCount new? $isNew} messages`, + ({ unread, moreThanFour, isNew, unreadCount, expected }) => { const messageList = unread ? [ unreadMessage, @@ -106,16 +164,27 @@ describe("shouldResetNotificationsBannerDismissStateSelector", () => { jest .spyOn(ALL_PAGINATED, "messageListForCategorySelector") .mockImplementation(() => messageList); - expect( - shouldResetNotificationBannerDismissStateSelector( - getTestState({ - messages: messageList, - forceDismissionDate: isNew - ? new Date("2000-1-1").getTime() - : new Date("2500-1-1").getTime() - }) - ) - ).toBe(expected); + const testState = getTestState({ + messages: messageList, + forceDismissionDate: isNew + ? new Date("2000-1-1").getTime() + : new Date("2500-1-1").getTime() + }); + it(`unreadMessagesCountAfterForceDismissionSelector should return '${unreadCount}'`, () => { + expect(unreadMessagesCountAfterForceDismissionSelector(testState)).toBe( + unreadCount + ); + }); + it(`isForceDismissAndNotUnreadMessagesHiddenSelector should return '${!expected}'`, () => { + expect( + isForceDismissAndNotUnreadMessagesHiddenSelector(testState) + ).toBe(!expected); + }); + it(`shouldResetNotificationBannerDismissStateSelector should return '${expected}'`, () => { + expect( + shouldResetNotificationBannerDismissStateSelector(testState) + ).toBe(expected); + }); } ); }); diff --git a/ts/features/pushNotifications/store/selectors/notificationsBannerDismissed.ts b/ts/features/pushNotifications/store/selectors/notificationsBannerDismissed.ts index 93bc63a595b..7b44f579324 100644 --- a/ts/features/pushNotifications/store/selectors/notificationsBannerDismissed.ts +++ b/ts/features/pushNotifications/store/selectors/notificationsBannerDismissed.ts @@ -2,7 +2,7 @@ import * as pot from "@pagopa/ts-commons/lib/pot"; import { GlobalState } from "../../../../store/reducers/types"; import { UIMessage } from "../../../messages/types"; -const NEW_MESSAGES_COUNT_TO_RESET_FORCE_DISMISS = 4; +export const NEW_MESSAGES_COUNT_TO_RESET_FORCE_DISMISS = 4; export const pushNotificationsBannerForceDismissionDateSelector = ( state: GlobalState @@ -13,24 +13,52 @@ export const timesPushNotificationBannerDismissedSelector = ( state: GlobalState ) => state.notifications.userBehaviour.pushNotificationBannerDismissalCount; -export const shouldResetNotificationBannerDismissStateSelector = ( +export const unreadMessagesCountAfterForceDismissionSelector = ( state: GlobalState ) => { const forceDismissDate = pushNotificationsBannerForceDismissionDateSelector(state); + if (forceDismissDate === undefined) { + return undefined; + } const messagesList = pot.toUndefined( state.entities.messages.allPaginated.inbox.data )?.page; - - if (messagesList === undefined || forceDismissDate === undefined) { - return false; + if (!messagesList) { + return undefined; } - const newUnreadCount = messagesList.filter( + return messagesList.filter( (message: UIMessage) => message.createdAt.getTime() > forceDismissDate && !message.isRead ).length; +}; + +export const isForceDismissAndNotUnreadMessagesHiddenSelector = ( + state: GlobalState +) => { + const unreadMessageCountAfterForceDismissionMaybe = + unreadMessagesCountAfterForceDismissionSelector(state); + if (unreadMessageCountAfterForceDismissionMaybe == null) { + return false; + } + return ( + unreadMessageCountAfterForceDismissionMaybe < + NEW_MESSAGES_COUNT_TO_RESET_FORCE_DISMISS + ); +}; - return newUnreadCount >= NEW_MESSAGES_COUNT_TO_RESET_FORCE_DISMISS; +export const shouldResetNotificationBannerDismissStateSelector = ( + state: GlobalState +) => { + const unreadMessageCountAfterForceDismissionMaybe = + unreadMessagesCountAfterForceDismissionSelector(state); + if (unreadMessageCountAfterForceDismissionMaybe == null) { + return false; + } + return ( + unreadMessageCountAfterForceDismissionMaybe >= + NEW_MESSAGES_COUNT_TO_RESET_FORCE_DISMISS + ); }; diff --git a/ts/screens/profile/ProfileMainScreenTopBanner.tsx b/ts/screens/profile/ProfileMainScreenTopBanner.tsx index 907397b5ff0..25d88fb1672 100644 --- a/ts/screens/profile/ProfileMainScreenTopBanner.tsx +++ b/ts/screens/profile/ProfileMainScreenTopBanner.tsx @@ -4,7 +4,7 @@ import { ContentWrapper, VSpacer } from "@pagopa/io-app-design-system"; -import React, { useCallback } from "react"; +import React, { useCallback, useEffect } from "react"; import { setShowProfileBanner } from "../../features/profileSettings/store/actions"; import { profileBannerToShowSelector } from "../../features/profileSettings/store/selectors"; import { openSystemNotificationSettingsScreen } from "../../features/pushNotifications/utils"; @@ -12,6 +12,10 @@ import I18n from "../../i18n"; import { useIONavigation } from "../../navigation/params/AppParamsList"; import ROUTES from "../../navigation/routes"; import { useIODispatch, useIOSelector } from "../../store/hooks"; +import { + trackPushNotificationsBannerTap, + trackPushNotificationsBannerVisualized +} from "../../features/pushNotifications/analytics"; export const ProfileMainScreenTopBanner = () => { const bannerToShow = useIOSelector(profileBannerToShowSelector); @@ -30,6 +34,17 @@ export const ProfileMainScreenTopBanner = () => { [navigation] ); + const onPressNotifications = React.useCallback(() => { + trackPushNotificationsBannerTap(ROUTES.SETTINGS_MAIN); + openSystemNotificationSettingsScreen(); + }, []); + + useEffect(() => { + if (bannerToShow === "NOTIFICATIONS") { + trackPushNotificationsBannerVisualized(ROUTES.SETTINGS_MAIN); + } + }, [bannerToShow]); + switch (bannerToShow) { case "NOTIFICATIONS": return ( @@ -38,7 +53,7 @@ export const ProfileMainScreenTopBanner = () => { diff --git a/ts/screens/profile/__test__/ProfileMainScreenTopBanner.test.tsx b/ts/screens/profile/__test__/ProfileMainScreenTopBanner.test.tsx index 2cdb678210e..6cec2480ba5 100644 --- a/ts/screens/profile/__test__/ProfileMainScreenTopBanner.test.tsx +++ b/ts/screens/profile/__test__/ProfileMainScreenTopBanner.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent } from "@testing-library/react-native"; -import React from "react"; -import { renderComponent } from "../../../components/__tests__/ForceScrollDownView.test"; +import { fireEvent, render } from "@testing-library/react-native"; +import React, { PropsWithChildren } from "react"; +import { createStore } from "redux"; +import { Provider } from "react-redux"; import * as profileBannerImport from "../../../features/profileSettings/store/selectors"; import { GlobalState } from "../../../store/reducers/types"; import { ProfileMainScreenTopBanner } from "../ProfileMainScreenTopBanner"; @@ -9,6 +10,9 @@ import ROUTES from "../../../navigation/routes"; import TypedI18n from "../../../i18n"; import { setShowProfileBanner } from "../../../features/profileSettings/store/actions"; import { mockAccessibilityInfo } from "../../../utils/testAccessibility"; +import * as analytics from "../../../features/pushNotifications/analytics"; +import { appReducer } from "../../../store/reducers"; +import { applicationChangeState } from "../../../store/actions/application"; jest.spyOn(settingsNavigate, "openSystemNotificationSettingsScreen"); const mockNavigate = jest.fn(); @@ -62,6 +66,9 @@ describe("ProfileMainScreenTopBanner", () => { jest .spyOn(profileBannerImport, "profileBannerToShowSelector") .mockImplementation((_: GlobalState) => "NOTIFICATIONS"); + const spyOnMockTrackPushNotificationsBannerVisualized = jest + .spyOn(analytics, "trackPushNotificationsBannerVisualized") + .mockImplementation(_ => undefined); const root = renderComponent(); const component = root.getByTestId("notifications-banner"); @@ -70,6 +77,12 @@ describe("ProfileMainScreenTopBanner", () => { fireEvent.press(component); + expect( + spyOnMockTrackPushNotificationsBannerVisualized + ).toHaveBeenCalledTimes(1); + expect( + spyOnMockTrackPushNotificationsBannerVisualized + ).toHaveBeenCalledWith(ROUTES.SETTINGS_MAIN); expect( settingsNavigate.openSystemNotificationSettingsScreen ).toHaveBeenCalled(); @@ -106,4 +119,44 @@ describe("ProfileMainScreenTopBanner", () => { expect(mockDispatch).toHaveBeenCalledWith(setShowProfileBanner(false)); }); + it(`should call 'trackPushNotificationsBannerVisualized' on first rendering if the push notification banner is the one to show`, () => { + jest + .spyOn(profileBannerImport, "profileBannerToShowSelector") + .mockImplementation((_: GlobalState) => "NOTIFICATIONS"); + const spyOnMockTrackPushNotificationsBannerVisualized = jest + .spyOn(analytics, "trackPushNotificationsBannerVisualized") + .mockImplementation(_ => undefined); + renderComponent(); + expect( + spyOnMockTrackPushNotificationsBannerVisualized + ).toHaveBeenCalledTimes(1); + expect( + spyOnMockTrackPushNotificationsBannerVisualized + ).toHaveBeenCalledWith(ROUTES.SETTINGS_MAIN); + }); + it(`should not have called 'trackPushNotificationsBannerVisualized' on first rendering if the push notification banner is not the shown one`, () => { + jest + .spyOn(profileBannerImport, "profileBannerToShowSelector") + .mockImplementation((_: GlobalState) => "PROFILE_BANNER"); + const spyOnMockTrackPushNotificationsBannerVisualized = jest + .spyOn(analytics, "trackPushNotificationsBannerVisualized") + .mockImplementation(_ => undefined); + renderComponent(); + expect( + spyOnMockTrackPushNotificationsBannerVisualized + ).toHaveBeenCalledTimes(0); + }); }); + +export const renderComponent = (component: React.ReactElement) => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + + const Wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + return render(component, { + wrapper: Wrapper + }); +}; diff --git a/ts/screens/profile/analytics/__test__/index.test.ts b/ts/screens/profile/analytics/__test__/index.test.ts new file mode 100644 index 00000000000..0ebc5a430ea --- /dev/null +++ b/ts/screens/profile/analytics/__test__/index.test.ts @@ -0,0 +1,65 @@ +import * as Mixpanel from "../../../../mixpanel"; +import { + trackSettingsDiscoverBannerClosure, + trackSettingsDiscoverBannerTap, + trackSettingsDiscoverBannerVisualized +} from ".."; + +describe("index", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it(`'trackSettingsDiscoverBannerVisualized' should have expected event name and properties`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackSettingsDiscoverBannerVisualized(); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe("BANNER"); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "screen_view", + banner_id: "settingsDiscoveryBanner", + banner_page: "MESSAGES_HOME", + banner_landing: "SETTINGS_MAIN" + }); + }); + it(`'trackSettingsDiscoverBannerClosure' should have expected event name and properties`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackSettingsDiscoverBannerTap(); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe("TAP_BANNER"); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + banner_id: "settingsDiscoveryBanner", + banner_page: "MESSAGES_HOME", + banner_landing: "SETTINGS_MAIN" + }); + }); + it(`'trackSettingsDiscoverBannerTap' should have expected event name and properties`, () => { + const mockMixpanelTrack = getMockMixpanelTrack(); + + void trackSettingsDiscoverBannerClosure(); + + expect(mockMixpanelTrack.mock.calls.length).toBe(1); + expect(mockMixpanelTrack.mock.calls[0].length).toBe(2); + expect(mockMixpanelTrack.mock.calls[0][0]).toBe("CLOSE_BANNER"); + expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + banner_id: "settingsDiscoveryBanner", + banner_page: "MESSAGES_HOME", + banner_landing: "SETTINGS_MAIN" + }); + }); +}); + +const getMockMixpanelTrack = () => + jest + .spyOn(Mixpanel, "mixpanelTrack") + .mockImplementation((_event, _properties) => undefined); diff --git a/ts/screens/profile/analytics/index.ts b/ts/screens/profile/analytics/index.ts index 4d3a2374fe3..a05026a7250 100644 --- a/ts/screens/profile/analytics/index.ts +++ b/ts/screens/profile/analytics/index.ts @@ -1,7 +1,9 @@ import { ServicesPreferencesModeEnum } from "../../../../definitions/backend/ServicesPreferencesMode"; +import { MESSAGES_ROUTES } from "../../../features/messages/navigation/routes"; import { mixpanelTrack } from "../../../mixpanel"; import { updateMixpanelProfileProperties } from "../../../mixpanelConfig/profileProperties"; import { updateMixpanelSuperProperties } from "../../../mixpanelConfig/superProperties"; +import ROUTES from "../../../navigation/routes"; import { profileLoadSuccess } from "../../../store/actions/profile"; import { GlobalState } from "../../../store/reducers/types"; import { FlowType, buildEventProperties } from "../../../utils/analytics"; @@ -224,3 +226,33 @@ export async function trackNotificationPreferenceConfiguration( ) ); } + +export function trackSettingsDiscoverBannerVisualized() { + const eventName = "BANNER"; + const props = buildEventProperties("UX", "screen_view", { + banner_id: "settingsDiscoveryBanner", + banner_page: MESSAGES_ROUTES.MESSAGES_HOME, + banner_landing: ROUTES.SETTINGS_MAIN + }); + void mixpanelTrack(eventName, props); +} + +export function trackSettingsDiscoverBannerTap() { + const eventName = "TAP_BANNER"; + const props = buildEventProperties("UX", "action", { + banner_id: "settingsDiscoveryBanner", + banner_page: MESSAGES_ROUTES.MESSAGES_HOME, + banner_landing: ROUTES.SETTINGS_MAIN + }); + void mixpanelTrack(eventName, props); +} + +export function trackSettingsDiscoverBannerClosure() { + const eventName = "CLOSE_BANNER"; + const props = buildEventProperties("UX", "action", { + banner_id: "settingsDiscoveryBanner", + banner_page: MESSAGES_ROUTES.MESSAGES_HOME, + banner_landing: ROUTES.SETTINGS_MAIN + }); + void mixpanelTrack(eventName, props); +} diff --git a/ts/screens/profile/components/SettingsDiscoveryBanner.tsx b/ts/screens/profile/components/SettingsDiscoveryBanner.tsx index 03df0540852..89f522d28de 100644 --- a/ts/screens/profile/components/SettingsDiscoveryBanner.tsx +++ b/ts/screens/profile/components/SettingsDiscoveryBanner.tsx @@ -1,11 +1,16 @@ import { Banner, IOVisualCostants } from "@pagopa/io-app-design-system"; -import React, { createRef } from "react"; +import React, { createRef, useEffect } from "react"; import { StyleSheet, View } from "react-native"; import I18n from "../../../i18n"; import { useIONavigation } from "../../../navigation/params/AppParamsList"; import ROUTES from "../../../navigation/routes"; import { useIODispatch } from "../../../store/hooks"; import { setHasUserAcknowledgedSettingsBanner } from "../../../features/profileSettings/store/actions"; +import { + trackSettingsDiscoverBannerClosure, + trackSettingsDiscoverBannerTap, + trackSettingsDiscoverBannerVisualized +} from "../analytics"; type SettingsDiscoveryBannerProps = { handleOnClose: () => void; @@ -22,15 +27,21 @@ export const SettingsDiscoveryBanner = ({ const navigation = useIONavigation(); const dispatch = useIODispatch(); const handleOnPress = () => { + trackSettingsDiscoverBannerTap(); navigation.navigate(ROUTES.PROFILE_NAVIGATOR, { screen: ROUTES.SETTINGS_MAIN }); }; const closeHandler = React.useCallback(() => { + trackSettingsDiscoverBannerClosure(); dispatch(setHasUserAcknowledgedSettingsBanner(true)); handleOnClose(); }, [dispatch, handleOnClose]); + useEffect(() => { + trackSettingsDiscoverBannerVisualized(); + }, []); + return ( ); diff --git a/ts/screens/profile/components/__test__/SettingsDiscoveryBanner.test.tsx b/ts/screens/profile/components/__test__/SettingsDiscoveryBanner.test.tsx index d51615fa64d..0535c8ca4a6 100644 --- a/ts/screens/profile/components/__test__/SettingsDiscoveryBanner.test.tsx +++ b/ts/screens/profile/components/__test__/SettingsDiscoveryBanner.test.tsx @@ -1,19 +1,61 @@ import * as React from "react"; import { createStore } from "redux"; +import { constUndefined } from "fp-ts/lib/function"; +import { fireEvent } from "@testing-library/react-native"; import ROUTES from "../../../../navigation/routes"; import { applicationChangeState } from "../../../../store/actions/application"; import { appReducer } from "../../../../store/reducers"; import { GlobalState } from "../../../../store/reducers/types"; import { renderScreenWithNavigationStoreContext } from "../../../../utils/testWrapper"; import { SettingsDiscoveryBanner } from "../SettingsDiscoveryBanner"; +import * as analytics from "../../analytics"; +import I18n from "../../../../i18n"; describe("settingsDiscoveryBanner", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); it("should match snapshot", () => { const component = renderComponent( null} /> ); expect(component.toJSON()).toMatchSnapshot(); }); + it("should have called 'trackSettingsDiscoverBannerVisualized' on first rendering", () => { + const spyOnMockedTrackSettingsDiscoverBannerVisualized = jest + .spyOn(analytics, "trackSettingsDiscoverBannerVisualized") + .mockImplementation(constUndefined); + renderComponent( null} />); + expect( + spyOnMockedTrackSettingsDiscoverBannerVisualized + ).toHaveBeenCalledTimes(1); + }); + it("should have called 'trackSettingsDiscoverBannerTap' on first rendering", () => { + const spyOnMockedTrackSettingsDiscoverBannerTap = jest + .spyOn(analytics, "trackSettingsDiscoverBannerTap") + .mockImplementation(constUndefined); + const component = renderComponent( + null} /> + ); + const cta = component.getByTestId("settingsDiscoveryBannerCTA"); + fireEvent(cta, "onPress"); + expect(spyOnMockedTrackSettingsDiscoverBannerTap).toHaveBeenCalledTimes(1); + }); + it("should have called 'trackSettingsDiscoverBannerClosure' on first rendering", () => { + const spyOnMockedTrackSettingsDiscoverBannerClosure = jest + .spyOn(analytics, "trackSettingsDiscoverBannerClosure") + .mockImplementation(constUndefined); + const component = renderComponent( + null} /> + ); + const closeButton = component.getByA11yLabel( + I18n.t("global.buttons.close") + ); + fireEvent(closeButton, "onPress"); + expect(spyOnMockedTrackSettingsDiscoverBannerClosure).toHaveBeenCalledTimes( + 1 + ); + }); }); const renderComponent = (component: React.ReactElement) => { diff --git a/ts/screens/profile/components/__test__/__snapshots__/SettingsDiscoveryBanner.test.tsx.snap b/ts/screens/profile/components/__test__/__snapshots__/SettingsDiscoveryBanner.test.tsx.snap index 81717fcf663..16c99497cad 100644 --- a/ts/screens/profile/components/__test__/__snapshots__/SettingsDiscoveryBanner.test.tsx.snap +++ b/ts/screens/profile/components/__test__/__snapshots__/SettingsDiscoveryBanner.test.tsx.snap @@ -372,6 +372,7 @@ exports[`settingsDiscoveryBanner should match snapshot 1`] = ` onResponderTerminate={[Function]} onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} + testID="settingsDiscoveryBannerCTA" >