Files
mattermost-mobile/app/init/push_notifications.ts

300 lines
12 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppState, DeviceEventEmitter, Platform} from 'react-native';
import {
Notification,
NotificationAction,
NotificationBackgroundFetchResult,
NotificationCategory,
NotificationCompletion,
Notifications,
NotificationTextInput,
Registered,
} from 'react-native-notifications';
import {requestNotifications} from 'react-native-permissions';
import {storeDeviceToken} from '@actions/app/global';
import {markChannelAsViewed} from '@actions/local/channel';
import {backgroundNotification, openNotification} from '@actions/remote/notifications';
import {markThreadAsRead} from '@actions/remote/thread';
import {Device, Events, Navigation, PushNotification, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getLocalizedMessage, t} from '@i18n';
import NativeNotifications from '@notifications';
import {getServerDisplayName} from '@queries/app/servers';
import {getCurrentChannelId} from '@queries/servers/system';
import {getIsCRTEnabled, getThreadById} from '@queries/servers/thread';
import {dismissOverlay, showOverlay} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {isBetaApp} from '@utils/general';
import {isMainActivity, isTablet} from '@utils/helpers';
import {logInfo} from '@utils/log';
import {convertToNotificationData} from '@utils/notification';
class PushNotifications {
configured = false;
init(register: boolean) {
if (register) {
this.registerIfNeeded();
}
Notifications.events().registerNotificationOpened(this.onNotificationOpened);
Notifications.events().registerRemoteNotificationsRegistered(this.onRemoteNotificationsRegistered);
Notifications.events().registerNotificationReceivedBackground(this.onNotificationReceivedBackground);
Notifications.events().registerNotificationReceivedForeground(this.onNotificationReceivedForeground);
}
async registerIfNeeded() {
const isRegistered = await Notifications.isRegisteredForRemoteNotifications();
if (!isRegistered) {
await requestNotifications(['alert', 'sound', 'badge']);
Notifications.registerRemoteNotifications();
}
}
createReplyCategory = () => {
const replyTitle = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.title'));
const replyButton = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.button'));
const replyPlaceholder = getLocalizedMessage(DEFAULT_LOCALE, t('mobile.push_notification_reply.placeholder'));
const replyTextInput: NotificationTextInput = {buttonTitle: replyButton, placeholder: replyPlaceholder};
const replyAction = new NotificationAction(PushNotification.REPLY_ACTION, 'background', replyTitle, true, replyTextInput);
return new NotificationCategory(PushNotification.CATEGORY, [replyAction]);
};
getServerUrlFromNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
if (!payload?.channel_id && (!payload?.server_url || !payload.server_id)) {
return payload?.server_url;
}
let serverUrl = payload.server_url;
if (!serverUrl && payload.server_id) {
serverUrl = await DatabaseManager.getServerUrlFromIdentifier(payload.server_id);
}
return serverUrl;
};
handleClearNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
const serverUrl = await this.getServerUrlFromNotification(notification);
if (serverUrl && payload?.channel_id) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (database) {
const isCRTEnabled = await getIsCRTEnabled(database);
if (isCRTEnabled && payload.root_id) {
const thread = await getThreadById(database, payload.root_id);
if (thread?.isFollowing) {
markThreadAsRead(serverUrl, payload.team_id, payload.post_id);
}
} else {
markChannelAsViewed(serverUrl, payload.channel_id);
}
}
}
};
handleInAppNotification = async (serverUrl: string, notification: NotificationWithData) => {
const {payload} = notification;
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (database) {
const isTabletDevice = await isTablet();
const displayName = await getServerDisplayName(serverUrl);
const channelId = await getCurrentChannelId(database);
const isCRTEnabled = await getIsCRTEnabled(database);
let serverName;
if (Object.keys(DatabaseManager.serverDatabases).length > 1) {
serverName = displayName;
}
const isThreadNotification = Boolean(payload?.root_id);
const isSameChannelNotification = payload?.channel_id === channelId;
const isSameThreadNotification = isThreadNotification && payload?.root_id === EphemeralStore.getCurrentThreadId();
let isInChannelScreen = NavigationStore.getVisibleScreen() === Screens.CHANNEL;
if (isTabletDevice) {
isInChannelScreen = NavigationStore.getVisibleTab() === Screens.HOME;
}
const isInThreadScreen = NavigationStore.getVisibleScreen() === Screens.THREAD;
// Conditions:
// 1. If not in channel screen or thread screen, show the notification
const condition1 = !isInChannelScreen && !isInThreadScreen;
// 2. If is in channel screen,
// - Show notification of other channels
// or
// - Show notification if CRT is enabled and it's a thread notification (doesn't matter if it's the same channel)
const condition2 = isInChannelScreen && (!isSameChannelNotification || (isCRTEnabled && isThreadNotification));
// 3. If is in thread screen,
// - Show the notification if it doesn't belong to the thread
const condition3 = isInThreadScreen && !isSameThreadNotification;
if (condition1 || condition2 || condition3) {
DeviceEventEmitter.emit(Navigation.NAVIGATION_SHOW_OVERLAY);
const screen = Screens.IN_APP_NOTIFICATION;
const passProps = {
notification,
serverName,
serverUrl,
};
// Dismiss the screen if it's already visible or else it blocks the navigation
await dismissOverlay(screen);
showOverlay(screen, passProps);
}
}
};
handleMessageNotification = async (notification: NotificationWithData) => {
const {payload, foreground, userInteraction} = notification;
const serverUrl = await this.getServerUrlFromNotification(notification);
if (serverUrl) {
if (foreground) {
// Move this to a local action
this.handleInAppNotification(serverUrl, notification);
} else if (userInteraction && !payload?.userInfo?.local) {
// Handle notification tapped
openNotification(serverUrl, notification);
} else {
backgroundNotification(serverUrl, notification);
}
}
};
handleSessionNotification = async (notification: NotificationWithData) => {
logInfo('Session expired notification');
const serverUrl = await this.getServerUrlFromNotification(notification);
if (serverUrl) {
if (notification.userInteraction) {
DeviceEventEmitter.emit(Events.SESSION_EXPIRED, serverUrl);
} else {
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl});
}
}
};
processNotification = async (notification: NotificationWithData) => {
const {payload} = notification;
if (payload) {
switch (payload.type) {
case PushNotification.NOTIFICATION_TYPE.CLEAR:
this.handleClearNotification(notification);
break;
case PushNotification.NOTIFICATION_TYPE.MESSAGE:
this.handleMessageNotification(notification);
break;
case PushNotification.NOTIFICATION_TYPE.SESSION:
this.handleSessionNotification(notification);
break;
}
}
};
localNotification = (notification: Notification) => {
Notifications.postLocalNotification(notification);
};
// This triggers when a notification is tapped and the app was in the background (iOS)
onNotificationOpened = (incoming: Notification, completion: () => void) => {
const notification = convertToNotificationData(incoming, false);
notification.userInteraction = true;
this.processNotification(notification);
completion();
};
// This triggers when the app was in the background (iOS)
onNotificationReceivedBackground = async (incoming: Notification, completion: (response: NotificationBackgroundFetchResult) => void) => {
const notification = convertToNotificationData(incoming, false);
this.processNotification(notification);
completion(NotificationBackgroundFetchResult.NEW_DATA);
};
// This triggers when the app was in the foreground (Android and iOS)
// Also triggers when the app was in the background (Android)
onNotificationReceivedForeground = (incoming: Notification, completion: (response: NotificationCompletion) => void) => {
const notification = convertToNotificationData(incoming, false);
if (AppState.currentState !== 'inactive') {
notification.foreground = AppState.currentState === 'active' && isMainActivity();
this.processNotification(notification);
}
completion({alert: false, sound: true, badge: true});
};
onRemoteNotificationsRegistered = async (event: Registered) => {
if (!this.configured) {
this.configured = true;
const {deviceToken} = event;
let prefix;
if (Platform.OS === 'ios') {
prefix = Device.PUSH_NOTIFY_APPLE_REACT_NATIVE;
if (isBetaApp) {
prefix = `${prefix}beta`;
}
} else {
prefix = Device.PUSH_NOTIFY_ANDROID_REACT_NATIVE;
}
storeDeviceToken(`${prefix}-v2:${deviceToken}`);
// Store the device token in the default database
this.requestNotificationReplyPermissions();
}
return null;
};
removeChannelNotifications = async (serverUrl: string, channelId: string) => {
NativeNotifications.removeChannelNotifications(serverUrl, channelId);
};
removeServerNotifications = (serverUrl: string) => {
NativeNotifications.removeServerNotifications(serverUrl);
};
removeThreadNotifications = async (serverUrl: string, threadId: string) => {
NativeNotifications.removeThreadNotifications(serverUrl, threadId);
};
requestNotificationReplyPermissions = () => {
if (Platform.OS === 'ios') {
const replyCategory = this.createReplyCategory();
Notifications.setCategories([replyCategory]);
}
};
scheduleNotification = (notification: Notification) => {
if (notification.fireDate) {
if (Platform.OS === 'ios') {
notification.fireDate = new Date(notification.fireDate).toISOString();
}
return Notifications.postLocalNotification(notification);
}
return 0;
};
cancelScheduleNotification = (notificationId: number) => {
Notifications.cancelLocalNotification(notificationId);
};
}
export default new PushNotifications();