Skip to content

Commit

Permalink
feat: delayed push notifications (#254)
Browse files Browse the repository at this point in the history
  • Loading branch information
ice-orion authored Jan 18, 2024
1 parent ce4b465 commit bd284fb
Show file tree
Hide file tree
Showing 11 changed files with 190 additions and 9 deletions.
6 changes: 4 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// SPDX-License-Identifier: ice License 1.0

import {registerBackgroundTasksHeadlessTask} from '@store/modules/BackgroundTasks/headless';
import {registerBackgroundMessageHandler} from '@store/modules/PushNotifications/headless';
import {AppRegistry} from 'react-native';
import 'react-native-url-polyfill/auto';
import {name as appName} from './app.json';
import {App} from './src/App';

import {LoggingWrapper} from '@services/logging';

AppRegistry.registerComponent(appName, () => LoggingWrapper(App));

registerBackgroundTasksHeadlessTask();
registerBackgroundMessageHandler();

AppRegistry.registerComponent(appName, () => LoggingWrapper(App));
1 change: 1 addition & 0 deletions src/store/configureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const store = configureStore({
'utilityProcessStatuses.SET_WALKTHROUGH_STEP_ELEMENT_DATA.payload.elementData',
'walkthrough.stepElements',
'utilityProcessStatuses.SYNC_CONTACTS_BACKGROUND_TASK.payload.finishTask',
'utilityProcessStatuses.DATA_MESSAGE_ARRIVE.payload.finishTask',
],
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
Expand Down
8 changes: 8 additions & 0 deletions src/store/modules/PushNotifications/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ const NOTIFICATION_ARRIVE = createAction('NOTIFICATION_ARRIVE', {
STATE: (payload: {message?: FirebaseMessagingTypes.RemoteMessage}) => payload,
});

const DATA_MESSAGE_ARRIVE = createAction('DATA_MESSAGE_ARRIVE', {
STATE: (payload: {
message: FirebaseMessagingTypes.RemoteMessage;
finishTask?: () => void;
}) => payload,
});

export const PushNotificationsActions = Object.freeze({
NOTIFICATION_PRESS,
NOTIFICATION_ARRIVE,
DATA_MESSAGE_ARRIVE,
});
30 changes: 30 additions & 0 deletions src/store/modules/PushNotifications/headless/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: ice License 1.0

import messaging, {
FirebaseMessagingTypes,
} from '@react-native-firebase/messaging';
import {store} from '@store/configureStore';
import {PushNotificationsActions} from '@store/modules/PushNotifications/actions';
import {isDataOnlyMessage} from '@store/modules/PushNotifications/utils/isDataOnlyMessage';

/**
* Resolve handler promise only when all the work is done
*/
const backgroundMessageHandler = async (
message: FirebaseMessagingTypes.RemoteMessage,
) => {
if (isDataOnlyMessage(message)) {
await new Promise<void>(resolve => {
store.dispatch(
PushNotificationsActions.DATA_MESSAGE_ARRIVE.STATE.create({
message,
finishTask: resolve,
}),
);
});
}
};

export const registerBackgroundMessageHandler = () => {
messaging().setBackgroundMessageHandler(backgroundMessageHandler);
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ export function useSubscribeToChannelTopic(channelName: NotificationDomain) {
const userId = useSelector(userIdSelector);

useEffect(() => {
const topicName = `${channelName}_${language}`;
const deprecatedTopicName = `${channelName}_${language}`;
const topicName = `${channelName}_${language}_v2`;

if (channelEnabled && language && userId) {
messaging().unsubscribeFromTopic(deprecatedTopicName).catch(logError);
messaging().subscribeToTopic(topicName).catch(logError);
}
return () => {
if (language) {
messaging().unsubscribeFromTopic(topicName).catch(logError);
messaging().unsubscribeFromTopic(deprecatedTopicName).catch(logError);
}
};
}, [channelEnabled, channelName, language, userId]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {FirebaseMessagingTypes} from '@react-native-firebase/messaging';
import messaging from '@react-native-firebase/messaging';
import {PushNotificationsActions} from '@store/modules/PushNotifications/actions';
import {CHANNEL_ID} from '@store/modules/PushNotifications/constants';
import {isDataOnlyMessage} from '@store/modules/PushNotifications/utils/isDataOnlyMessage';
import {useEffect} from 'react';
import {useDispatch} from 'react-redux';

Expand Down Expand Up @@ -50,11 +51,19 @@ export function useSubscribeToPushNotifications() {

const unsubscribeFromOnMessage = messaging().onMessage(
(message: FirebaseMessagingTypes.RemoteMessage) => {
dispatch(
PushNotificationsActions.NOTIFICATION_ARRIVE.STATE.create({
message,
}),
);
if (isDataOnlyMessage(message)) {
dispatch(
PushNotificationsActions.DATA_MESSAGE_ARRIVE.STATE.create({
message,
}),
);
} else {
dispatch(
PushNotificationsActions.NOTIFICATION_ARRIVE.STATE.create({
message,
}),
);
}
},
);

Expand Down
100 changes: 100 additions & 0 deletions src/store/modules/PushNotifications/sagas/handleDataMessageSaga.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: ice License 1.0

import {COLORS} from '@constants/colors';
import notifee, {
AndroidImportance,
AndroidStyle,
Notification,
TimestampTrigger,
TriggerType,
} from '@notifee/react-native';
import {dayjs} from '@services/dayjs';
import {logError} from '@services/logging';
import {isAppActiveSelector} from '@store/modules/AppCommon/selectors';
import {PushNotificationsActions} from '@store/modules/PushNotifications/actions';
import {CHANNEL_ID} from '@store/modules/PushNotifications/constants';
import {
DataMessageType,
DelayedDataMessageData,
} from '@store/modules/PushNotifications/types';
import {isDataOnlyMessage} from '@store/modules/PushNotifications/utils/isDataOnlyMessage';
import {call, SagaReturnType, select} from 'redux-saga/effects';

export function* handleDataMessageSaga({
payload: {message, finishTask},
}: ReturnType<
typeof PushNotificationsActions.DATA_MESSAGE_ARRIVE.STATE.create
>) {
try {
if (!isDataOnlyMessage(message)) {
throw new Error('Message is not data-only');
}

switch (message.data?.type as DataMessageType) {
case 'delayed':
yield call(handleDelayedDataMessage, {
data: message.data as DelayedDataMessageData,
});
break;
default:
logError(`Unable to handle data message type: ${message.data?.type}`);
}
} finally {
if (finishTask) {
yield call(finishTask);
}
}
}

function* handleDelayedDataMessage({data}: {data: DelayedDataMessageData}) {
const {title, body, imageUrl, minDelaySec, maxDelaySec} = data;

const minDelay = minDelaySec ? parseInt(minDelaySec, 10) : 0;
const maxDelay = maxDelaySec ? parseInt(maxDelaySec, 10) : 0;

if (isNaN(minDelay) || isNaN(maxDelay)) {
throw new Error(
`Delayed message min / max delay is incorrect, minDelay=${minDelaySec} maxDelay=${maxDelaySec}`,
);
}

const isAppActive: SagaReturnType<typeof isAppActiveSelector> = yield select(
isAppActiveSelector,
);

const delaySec = isAppActive
? 0
: Math.round(minDelay + Math.random() * (maxDelay - minDelay));

const notification: Notification = {
title,
body,
data,
android: {
channelId: CHANNEL_ID,
smallIcon: 'ic_stat_notification',
sound: 'default',
color: COLORS.primaryLight,
...(imageUrl
? {
largeIcon: imageUrl,
style: {type: AndroidStyle.BIGPICTURE, picture: imageUrl},
}
: {}),
importance: AndroidImportance.HIGH,
pressAction: {
id: 'default',
},
},
};

if (delaySec > 0) {
const trigger: TimestampTrigger = {
type: TriggerType.TIMESTAMP,
timestamp: dayjs().add(delaySec, 's').valueOf(),
};
yield call(notifee.createTriggerNotification, notification, trigger);
} else {
yield call(notifee.displayNotification, notification);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: ice License 1.0

import {COLORS} from '@constants/colors';
import notifee, {AndroidImportance, AndroidStyle} from '@notifee/react-native';
import {isSplashHiddenSelector} from '@store/modules/AppCommon/selectors';
import {LinkingActions} from '@store/modules/Linking/actions';
Expand Down Expand Up @@ -28,7 +29,7 @@ export function* handleNotificationArriveSaga(
channelId: CHANNEL_ID,
smallIcon: 'ic_stat_notification',
sound: 'default',
color: '#1B47C3',
color: COLORS.primaryLight,
...(message.notification.android?.imageUrl
? {
largeIcon: message.notification.android?.imageUrl,
Expand Down
5 changes: 5 additions & 0 deletions src/store/modules/PushNotifications/sagas/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: ice License 1.0

import {PushNotificationsActions} from '@store/modules/PushNotifications/actions';
import {handleDataMessageSaga} from '@store/modules/PushNotifications/sagas/handleDataMessageSaga';
import {handleNotificationArriveSaga} from '@store/modules/PushNotifications/sagas/handleNotificationArrive';
import {handleNotificationPressSaga} from '@store/modules/PushNotifications/sagas/handleNotificationPress';
import {takeEvery, takeLatest} from 'redux-saga/effects';
Expand All @@ -14,4 +15,8 @@ export const pushNotificationsWatchers = [
PushNotificationsActions.NOTIFICATION_ARRIVE.STATE.type,
handleNotificationArriveSaga,
),
takeEvery(
PushNotificationsActions.DATA_MESSAGE_ARRIVE.STATE.type,
handleDataMessageSaga,
),
];
11 changes: 11 additions & 0 deletions src/store/modules/PushNotifications/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: ice License 1.0

export type DataMessageType = 'delayed';

export type DelayedDataMessageData = {
title: string;
body: string;
minDelaySec?: string;
maxDelaySec?: string;
imageUrl?: string;
};
10 changes: 10 additions & 0 deletions src/store/modules/PushNotifications/utils/isDataOnlyMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: ice License 1.0

import {FirebaseMessagingTypes} from '@react-native-firebase/messaging';
import {isEmpty} from 'lodash';

export const isDataOnlyMessage = (
message: FirebaseMessagingTypes.RemoteMessage,
) => {
return message.data && isEmpty(message.notification ?? {});
};

0 comments on commit bd284fb

Please sign in to comment.