Skip to content

Commit

Permalink
chore: [IOCOM-1740] Push Notification Data (#6211)
Browse files Browse the repository at this point in the history
## Short description
This PR:
- adds the system notification receiving permission status to redux, not
persisted
- enables the direct opening of the system notification receiving
permission screen, instead of the general app settings

## List of changes proposed in this pull request
Permission status on redux:
 - dedicated reducer `pushNotifications/store/reducers/permissions.ts`
- updated by dispatching
`pushNotifications/store/actions/permissions.ts`
 - part of a larger reducer `pushNotifications/store/reducers/index.ts`
- this last one is persisted and its data have been extracted and
migrated from the root persistor, that is why there is a migration in
`ts/boot/configureStoreAndPersistor.ts`
- also, since this dedicated reducer has now a custom persistor, its
persistence data must be retained after a logout/login from the same
user, in `ts/store/reducers/index.ts`

- the saga that handles the permission retrieval and action dispatching
is `pushNotifications/sagas/notificationPermissionsListener.ts` and it
is forked (so it is always running) by the startupSaga in
`ts/sagas/startup.ts`.
- the permission update is done only if it is different from the one
saved in redux. Such comparison and update is handled by common saga
functions in `pushNotifications /sagas/common.ts`

System Permissions opening:
- `react-native-notification-utils`: library to open the system
notification permissions screen
- `openSystemNotificationSettingsScreen` in
`pushNotifications/utils/index.ts` is the utility function that wraps
the call to the library
- `OnboardingNotificationsInfoScreenConsent` has been changed in order
to call the function above

Push Token Upload:
- there may have been ad edge case where the push notification token was
becoming available after the function to upload it to the backend had
run. In order to prevent it the following has been done
- `pushNotificationTokenUpload` in
`pushNotifications/sagas/pushNotificationTokenUpload.ts` is the saga
that uploads the token.
- it is forked (and not just called) by the startupSaga in
`ts/sagas/startup.ts`
- its first task is to wait for the token to be available. It does so by
waiting for `awaitForPushNotificationToken` saga to return it. Please
note that a token is never regenerated while the application is running
 - after that, the saga body has been refactored to increase readability

Refactorings
Actions, reducers and sagas have been split in order to differentiate
between:
- `installation`: handles the token memorization
- `pendingMessage`: handles message id coming from a push notification
- `permissions`: handles the system permission status

Embedded analytics events have been moved to their own file and enriched
when they were missing standard category and type.

## How to test
Using the io-dev-api-server, make sure that:
- the permission in redux is always up-to date
- the push notification token is always sent to backend when it is
available
- push instructions' CTA in the onboarding flow opens the system screen
(using the io-dev-api-server, remove any reference to
`optInNotificationPreferences` in `profile.ts`, make sure to start the
application from the beginning and deny the push notification permission
when asked)

---------

Co-authored-by: Martino Cesari Tomba <[email protected]>
  • Loading branch information
Vangaorth and forrest57 authored Oct 1, 2024
1 parent 12a8e9a commit ceb5a5c
Show file tree
Hide file tree
Showing 50 changed files with 1,561 additions and 754 deletions.
6 changes: 6 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,8 @@ PODS:
- React-Core
- react-native-netinfo (6.0.6):
- React-Core
- react-native-notifications-utils (0.3.0):
- React-Core
- react-native-pager-view (6.2.3):
- RCT-Folly (= 2021.07.22.00)
- React-Core
Expand Down Expand Up @@ -733,6 +735,7 @@ DEPENDENCIES:
- react-native-get-random-values (from `../node_modules/react-native-get-random-values`)
- react-native-image-picker (from `../node_modules/react-native-image-picker`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
- react-native-notifications-utils (from `../node_modules/react-native-notifications-utils`)
- react-native-pager-view (from `../node_modules/react-native-pager-view`)
- react-native-pdf (from `../node_modules/react-native-pdf`)
- react-native-pdf-thumbnail (from `../node_modules/react-native-pdf-thumbnail`)
Expand Down Expand Up @@ -906,6 +909,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-image-picker"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
react-native-notifications-utils:
:path: "../node_modules/react-native-notifications-utils"
react-native-pager-view:
:path: "../node_modules/react-native-pager-view"
react-native-pdf:
Expand Down Expand Up @@ -1067,6 +1072,7 @@ SPEC CHECKSUMS:
react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06
react-native-image-picker: 60f4246eb5bb7187fc15638a8c1f13abd3820695
react-native-netinfo: 40b91995cd49c33ae57996486db76f0af067b630
react-native-notifications-utils: f083307d1261f34444a89ca2f9b69f920c9b81e7
react-native-pager-view: 948dc00b6545d82b53e5f99cafef4a8521c60dd4
react-native-pdf: 79aa75e39a80c1d45ffe58aa500f3cf08f267a2e
react-native-pdf-thumbnail: a042fffdab7a49f0f9df0e11da0a90beebfd4241
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,10 @@
"@react-navigation/stack": "6.3.20",
"@redux-saga/testing-utils": "^1.1.3",
"@sentry/react-native": "^5.32.0",
"@shopify/react-native-skia": "^1.3.13",
"@shopify/flash-list": "~1.7.0",
"@xstate/react": "^4.0.1",
"@shopify/react-native-skia": "^1.3.13",
"@textlint/markdown-to-ast": "^14.0.4",
"@xstate/react": "^4.0.1",
"async-mutex": "^0.1.3",
"buffer": "^4.9.1",
"color": "^3.0.0",
Expand Down Expand Up @@ -180,6 +180,7 @@
"react-native-linear-gradient": "^2.5.6",
"react-native-markdown-display": "^7.0.2",
"react-native-masked-text": "^1.13.0",
"react-native-notifications-utils": "^0.3.0",
"react-native-pager-view": "^6.2.3",
"react-native-pdf": "6.7.4",
"react-native-pdf-thumbnail": "^1.2.1",
Expand Down
6 changes: 5 additions & 1 deletion ts/boot/__tests__/persistedStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ describe("Check the addition for new fields to the persisted store. If one of th
id: "fakeInstallationId"
}
};
expect(notifications).toMatchSnapshot();
const notificationsWithoutPermissions = _.omit(
notifications,
"permissions"
);
expect(notificationsWithoutPermissions).toMatchSnapshot();
});
it("Freeze 'profile' state", () => {
expect(globalState.profile).toMatchSnapshot();
Expand Down
24 changes: 20 additions & 4 deletions ts/boot/configureStoreAndPersistor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ import {
INSTALLATION_INITIAL_STATE,
InstallationState
} from "../store/reducers/installation";
import { NotificationsState } from "../features/pushNotifications/store/reducers";
import {
NOTIFICATIONS_STORE_VERSION,
NotificationsState
} from "../features/pushNotifications/store/reducers";
import { getInitialState as getInstallationInitialState } from "../features/pushNotifications/store/reducers/installation";
import { GlobalState, PersistedGlobalState } from "../store/reducers/types";
import { walletsPersistConfig } from "../store/reducers/wallet";
Expand All @@ -53,7 +56,7 @@ import { configureReactotron } from "./configureRectotron";
/**
* Redux persist will migrate the store to the current version
*/
const CURRENT_REDUX_STORE_VERSION = 36;
const CURRENT_REDUX_STORE_VERSION = 37;

// see redux-persist documentation:
// https://github.com/rt2zz/redux-persist/blob/master/docs/migrations.md
Expand Down Expand Up @@ -440,7 +443,21 @@ const migrations: MigrationManifest = {
}),
// Remove isNewScanSectionEnabled from persistedPreferences
"36": (state: PersistedState) =>
omit(state, "persistedPreferences.isNewScanSectionEnabled")
omit(state, "persistedPreferences.isNewScanSectionEnabled"),
// Move 'notifications' from root persistor to its own
"37": (state: PersistedState) => {
const typedState = state as GlobalState;
return {
...state,
notifications: {
...typedState.notifications,
_persist: {
version: NOTIFICATIONS_STORE_VERSION,
rehydrated: true
}
}
};
}
};

const isDebuggingInChrome = isDevEnv && !!window.navigator.userAgent;
Expand All @@ -456,7 +473,6 @@ const rootPersistConfig: PersistConfig = {
// Sections of the store that must be persisted and rehydrated with this storage.
whitelist: [
"onboarding",
"notifications",
"profile",
"persistedPreferences",
"installation",
Expand Down
176 changes: 176 additions & 0 deletions ts/features/pushNotifications/analytics/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import {
trackNewPushNotificationsTokenGenerated,
trackNotificationInstallationTokenNotChanged,
trackNotificationsOptInOpenSettings,
trackNotificationsOptInPreviewStatus,
trackNotificationsOptInReminderOnPermissionsOff,
trackNotificationsOptInReminderStatus,
trackNotificationsOptInSkipSystemPermissions,
trackPushNotificationTokenUploadFailure,
trackPushNotificationTokenUploadSucceeded
} from "..";
import { PushNotificationsContentTypeEnum } from "../../../../../definitions/backend/PushNotificationsContentType";
import { ReminderStatusEnum } from "../../../../../definitions/backend/ReminderStatus";
import * as Mixpanel from "../../../../mixpanel";

describe("pushNotifications analytics", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
it("'trackNotificationInstallationTokenNotChanged' should have expected event name and properties", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackNotificationInstallationTokenNotChanged();
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_INSTALLATION_TOKEN_NOT_CHANGED"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "TECH"
});
});
it("'trackNotificationsOptInPreviewStatus' should have expected event name and properties for 'ANONYMOUS' content type", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackNotificationsOptInPreviewStatus(
PushNotificationsContentTypeEnum.ANONYMOUS
);
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_OPTIN_PREVIEW_STATUS"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "UX",
event_type: "action",
enabled: false
});
});
it("'trackNotificationsOptInPreviewStatus' should have expected event name and properties for 'FULL' content type", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackNotificationsOptInPreviewStatus(
PushNotificationsContentTypeEnum.FULL
);
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_OPTIN_PREVIEW_STATUS"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "UX",
event_type: "action",
enabled: true
});
});
it("'trackNotificationsOptInReminderStatus' should have expected event name and properties for 'DISABLED' reminder", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackNotificationsOptInReminderStatus(ReminderStatusEnum.DISABLED);
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_OPTIN_REMINDER_STATUS"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "UX",
event_type: "action",
enabled: false
});
});
it("'trackNotificationsOptInReminderStatus' should have expected event name and properties for 'ENABLED' reminder", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackNotificationsOptInReminderStatus(ReminderStatusEnum.ENABLED);
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_OPTIN_REMINDER_STATUS"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "UX",
event_type: "action",
enabled: true
});
});
it("'trackNotificationsOptInReminderOnPermissionsOff' should have expected event name and properties", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackNotificationsOptInReminderOnPermissionsOff();
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_OPTIN_REMINDER_ON_PERMISSIONS_OFF"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "UX",
event_type: "control"
});
});
it("'trackNotificationsOptInOpenSettings' should have expected event name and properties", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackNotificationsOptInOpenSettings();
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_OPTIN_OPEN_SETTINGS"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "UX",
event_type: "action"
});
});
it("'trackNotificationsOptInSkipSystemPermissions' should have expected event name and properties", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackNotificationsOptInSkipSystemPermissions();
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_OPTIN_SKIP_SYSTEM_PERMISSIONS"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "UX",
event_type: "action"
});
});
it("'trackNewPushNotificationsTokenGenerated' should have expected event name and properties", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackNewPushNotificationsTokenGenerated();
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_INSTALLATION_TOKEN_UPDATE"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "TECH"
});
});
it("'trackPushNotificationTokenUploadSucceeded' should have expected event name and properties", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
void trackPushNotificationTokenUploadSucceeded();
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_INSTALLATION_TOKEN_REGISTERED"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "TECH"
});
});
it("'trackPushNotificationTokenUploadFailure' should have expected event name and properties", () => {
const mockMixpanelTrack = getMockMixpanelTrack();
const reason = "The reason";
void trackPushNotificationTokenUploadFailure(reason);
expect(mockMixpanelTrack.mock.calls.length).toBe(1);
expect(mockMixpanelTrack.mock.calls[0].length).toBe(2);
expect(mockMixpanelTrack.mock.calls[0][0]).toBe(
"NOTIFICATIONS_INSTALLATION_UPDATE_FAILURE"
);
expect(mockMixpanelTrack.mock.calls[0][1]).toEqual({
event_category: "KO",
event_type: "error",
reason
});
});
});

const getMockMixpanelTrack = () =>
jest
.spyOn(Mixpanel, "mixpanelTrack")
.mockImplementation((_event, _properties) => undefined);
45 changes: 30 additions & 15 deletions ts/features/pushNotifications/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,64 @@ import { ReminderStatusEnum } from "../../../../definitions/backend/ReminderStat
import { mixpanelTrack } from "../../../mixpanel";
import { buildEventProperties } from "../../../utils/analytics";

export function trackNotificationInstallationTokenNotChanged() {
void mixpanelTrack("NOTIFICATIONS_INSTALLATION_TOKEN_NOT_CHANGED");
}
export const trackNotificationInstallationTokenNotChanged = () =>
void mixpanelTrack(
"NOTIFICATIONS_INSTALLATION_TOKEN_NOT_CHANGED",
buildEventProperties("TECH", undefined)
);

export function trackNotificationsOptInPreviewStatus(
export const trackNotificationsOptInPreviewStatus = (
contentType: PushNotificationsContentTypeEnum
) {
) =>
void mixpanelTrack(
"NOTIFICATIONS_OPTIN_PREVIEW_STATUS",
buildEventProperties("UX", "action", {
enabled: contentType === PushNotificationsContentTypeEnum.FULL
})
);
}

export function trackNotificationsOptInReminderStatus(
export const trackNotificationsOptInReminderStatus = (
reminderStatus: ReminderStatusEnum
) {
) =>
void mixpanelTrack(
"NOTIFICATIONS_OPTIN_REMINDER_STATUS",
buildEventProperties("UX", "action", {
enabled: reminderStatus === ReminderStatusEnum.ENABLED
})
);
}

export function trackNotificationsOptInReminderOnPermissionsOff() {
export const trackNotificationsOptInReminderOnPermissionsOff = () =>
void mixpanelTrack(
"NOTIFICATIONS_OPTIN_REMINDER_ON_PERMISSIONS_OFF",
buildEventProperties("UX", "control")
);
}

export function trackNotificationsOptInOpenSettings() {
export const trackNotificationsOptInOpenSettings = () =>
void mixpanelTrack(
"NOTIFICATIONS_OPTIN_OPEN_SETTINGS",
buildEventProperties("UX", "action")
);
}

export function trackNotificationsOptInSkipSystemPermissions() {
export const trackNotificationsOptInSkipSystemPermissions = () =>
void mixpanelTrack(
"NOTIFICATIONS_OPTIN_SKIP_SYSTEM_PERMISSIONS",
buildEventProperties("UX", "action")
);
}

export const trackNewPushNotificationsTokenGenerated = () =>
void mixpanelTrack(
"NOTIFICATIONS_INSTALLATION_TOKEN_UPDATE",
buildEventProperties("TECH", undefined)
);

export const trackPushNotificationTokenUploadSucceeded = () =>
void mixpanelTrack(
"NOTIFICATIONS_INSTALLATION_TOKEN_REGISTERED",
buildEventProperties("TECH", undefined)
);

export const trackPushNotificationTokenUploadFailure = (reason: string) =>
void mixpanelTrack(
"NOTIFICATIONS_INSTALLATION_UPDATE_FAILURE",
buildEventProperties("KO", "error", { reason })
);
Loading

0 comments on commit ceb5a5c

Please sign in to comment.