diff --git a/lib/utils/migration-utils.js b/lib/utils/migration-utils.js index e9bd15ecf..850b40827 100644 --- a/lib/utils/migration-utils.js +++ b/lib/utils/migration-utils.js @@ -1,95 +1,122 @@ // @flow import type { TranslatedThreadMessageInfos } from './message-ops-utils.js'; import { entries } from './objects.js'; import { keyserverPrefixID } from './validation-utils.js'; -import { convertRawThreadInfoToNewIDSchema } from '../_generated/migration-utils.js'; +import { + convertRawMessageInfoToNewIDSchema, + convertRawThreadInfoToNewIDSchema, +} from '../_generated/migration-utils.js'; import { parsePendingThreadID, getPendingThreadID, draftKeySuffix, } from '../shared/thread-utils.js'; import type { ClientDBDraftInfo, ClientDBDraftStoreOperation, DraftStore, } from '../types/draft-types'; +import type { RawMessageInfo } from '../types/message-types.js'; import type { ThreadStoreThreadInfos } from '../types/thread-types.js'; function convertDraftKeyToNewIDSchema(key: string): string { const threadID = key.slice(0, -draftKeySuffix.length); const pendingIDContents = parsePendingThreadID(threadID); if (!pendingIDContents) { return `${keyserverPrefixID}|${key}`; } const { threadType, sourceMessageID, memberIDs } = pendingIDContents; if (!sourceMessageID) { return key; } const convertedThreadID = getPendingThreadID( threadType, memberIDs, `${keyserverPrefixID}|${sourceMessageID}`, ); return `${convertedThreadID}${draftKeySuffix}`; } function convertDraftStoreToNewIDSchema(store: DraftStore): DraftStore { return { drafts: Object.fromEntries( entries(store.drafts).map(([key, value]) => [ convertDraftKeyToNewIDSchema(key), value, ]), ), }; } function generateIDSchemaMigrationOpsForDrafts( drafts: $ReadOnlyArray, ): $ReadOnlyArray { const operations = drafts.map(draft => ({ type: 'update', payload: { key: convertDraftKeyToNewIDSchema(draft.key), text: draft.text, }, })); return [{ type: 'remove_all' }, ...operations]; } function convertMessageStoreThreadsToNewIDSchema( messageStoreThreads: TranslatedThreadMessageInfos, ): TranslatedThreadMessageInfos { return Object.fromEntries( entries(messageStoreThreads).map(([id, translatedThreadMessageInfo]) => [ `${keyserverPrefixID}|` + id, translatedThreadMessageInfo, ]), ); } function convertThreadStoreThreadInfosToNewIDSchema( threadStoreThreadInfos: ThreadStoreThreadInfos, ): ThreadStoreThreadInfos { return Object.fromEntries( entries(threadStoreThreadInfos).map(([id, threadInfo]) => [ `${keyserverPrefixID}|` + id, convertRawThreadInfoToNewIDSchema(threadInfo), ]), ); } +function convertNotificationThreadIDToNewIDSchema(threadID: string): string { + if (threadID.indexOf('|') === -1) { + return `${keyserverPrefixID}|${threadID}`; + } + return threadID; +} + +function convertNotificationMessageInfoToNewIDSchema( + messageInfosString: ?string, +): ?$ReadOnlyArray { + let messageInfos: ?$ReadOnlyArray = null; + if (messageInfosString) { + messageInfos = JSON.parse(messageInfosString); + } + + if (messageInfos?.some(message => message.threadID.indexOf('|') === -1)) { + messageInfos = messageInfos?.map(convertRawMessageInfoToNewIDSchema); + } + return messageInfos; +} + export { convertDraftKeyToNewIDSchema, convertDraftStoreToNewIDSchema, generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, + convertNotificationThreadIDToNewIDSchema, + convertNotificationMessageInfoToNewIDSchema, }; diff --git a/native/push/android.js b/native/push/android.js index 942e7e1e6..2872f94e8 100644 --- a/native/push/android.js +++ b/native/push/android.js @@ -1,79 +1,100 @@ // @flow import { NativeModules, NativeEventEmitter } from 'react-native'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; +import type { RawMessageInfo } from 'lib/types/message-types.js'; +import { + convertNotificationThreadIDToNewIDSchema, + convertNotificationMessageInfoToNewIDSchema, +} from 'lib/utils/migration-utils.js'; type CommAndroidNotificationsConstants = { +NOTIFICATIONS_IMPORTANCE_HIGH: number, +COMM_ANDROID_NOTIFICATIONS_TOKEN: 'commAndroidNotificationsToken', +COMM_ANDROID_NOTIFICATIONS_MESSAGE: 'commAndroidNotificationsMessage', +COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED: 'commAndroidNotificationsNotificationOpened', }; type CommAndroidNotificationsModuleType = { +removeAllActiveNotificationsForThread: (threadID: string) => void, +getInitialNotificationThreadID: () => Promise, +createChannel: ( channelID: string, name: string, importance: number, description: ?string, ) => void, +getConstants: () => CommAndroidNotificationsConstants, +setBadge: (count: number) => void, +removeAllDeliveredNotifications: () => void, +hasPermission: () => Promise, +getToken: () => Promise, +requestNotificationsPermission: () => Promise, +canRequestNotificationsPermissionFromUser: () => Promise, ...CommAndroidNotificationsConstants, }; export type AndroidMessage = { +body: string, +title: string, +threadID: string, +prefix?: string, +messageInfos: ?string, }; +export type ParsedAndroidMessage = { + ...AndroidMessage, + +messageInfos: ?$ReadOnlyArray, +}; + +function parseAndroidMessage(message: AndroidMessage): ParsedAndroidMessage { + return { + ...message, + threadID: convertNotificationThreadIDToNewIDSchema(message.threadID), + messageInfos: convertNotificationMessageInfoToNewIDSchema( + message.messageInfos, + ), + }; +} + const { CommAndroidNotificationsEventEmitter } = NativeModules; const CommAndroidNotifications: CommAndroidNotificationsModuleType = NativeModules.CommAndroidNotifications; const androidNotificationChannelID = 'default'; function handleAndroidMessage( - message: AndroidMessage, + message: ParsedAndroidMessage, updatesCurrentAsOf: number, handleIfActive?: ( threadID: string, texts: { body: string, title: ?string }, ) => boolean, ) { const { title, prefix, threadID } = message; let { body } = message; ({ body } = mergePrefixIntoBody({ body, title, prefix })); if (handleIfActive) { const texts = { title, body }; const isActive = handleIfActive(threadID, texts); if (isActive) { return; } } } function getCommAndroidNotificationsEventEmitter(): NativeEventEmitter<{ commAndroidNotificationsToken: [string], commAndroidNotificationsMessage: [AndroidMessage], commAndroidNotificationsNotificationOpened: [string], }> { return new NativeEventEmitter(CommAndroidNotificationsEventEmitter); } export { + parseAndroidMessage, androidNotificationChannelID, handleAndroidMessage, getCommAndroidNotificationsEventEmitter, CommAndroidNotifications, }; diff --git a/native/push/comm-ios-notification.js b/native/push/comm-ios-notification.js index b59b4b628..b8bef9569 100644 --- a/native/push/comm-ios-notification.js +++ b/native/push/comm-ios-notification.js @@ -1,57 +1,75 @@ // @flow import { NativeModules } from 'react-native'; +import type { RawMessageInfo } from 'lib/types/message-types.js'; +import { + convertNotificationThreadIDToNewIDSchema, + convertNotificationMessageInfoToNewIDSchema, +} from 'lib/utils/migration-utils.js'; + const { CommIOSNotifications } = NativeModules; // This is the basic data we receive from Objective-C // Its keys are explained as follow: // `id` - unique ID generated by keyserver // `message` - comes from `alert` property of raw Apple payload // which carries displayable content of the notification // `body` and `title` - actual content of the // message and sender name respectively export type CoreIOSNotificationData = { +id: string, +message: ?string, +threadID: string, +title: ?string, +messageInfos: ?string, +body: ?string, +prefix?: string, }; // Objective-C can also include notification request identifier // associated with certain notification so that we can interact // with notification center from JS. Read for explanation: // https://developer.apple.com/documentation/usernotifications/unnotificationrequest?language=objc export type CoreIOSNotificationDataWithRequestIdentifier = { ...CoreIOSNotificationData, +identifier: string, }; +export type ParsedCoreIOSNotificationData = { + ...CoreIOSNotificationData, + +messageInfos: ?$ReadOnlyArray, +}; + export class CommIOSNotification { - data: CoreIOSNotificationData; + data: ParsedCoreIOSNotificationData; remoteNotificationCompleteCallbackCalled: boolean; constructor(notification: CoreIOSNotificationData) { - this.data = notification; this.remoteNotificationCompleteCallbackCalled = false; + + this.data = { + ...notification, + threadID: convertNotificationThreadIDToNewIDSchema(notification.threadID), + messageInfos: convertNotificationMessageInfoToNewIDSchema( + notification.messageInfos, + ), + }; } getMessage(): ?string { return this.data.message; } - getData(): CoreIOSNotificationData { + getData(): ParsedCoreIOSNotificationData { return this.data; } finish(fetchResult: string) { if (!this.data.id || this.remoteNotificationCompleteCallbackCalled) { return; } CommIOSNotifications.completeNotif(this.data.id, fetchResult); this.remoteNotificationCompleteCallbackCalled = true; } } diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index ab0cf4782..9497cfb88 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,654 +1,672 @@ // @flow import * as Haptics from 'expo-haptics'; import * as React from 'react'; import { Platform, Alert, LogBox } from 'react-native'; import { Notification as InAppNotification } from 'react-native-in-app-message'; import { useDispatch } from 'react-redux'; import { setDeviceTokenActionTypes, setDeviceToken, } from 'lib/actions/device-actions.js'; import { saveMessagesActionType } from 'lib/actions/message-actions.js'; import { unreadCount, threadInfoSelector, } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ConnectionInfo } from 'lib/types/socket-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; +import { + convertNotificationMessageInfoToNewIDSchema, + convertNotificationThreadIDToNewIDSchema, +} from 'lib/utils/migration-utils.js'; import { type NotifPermissionAlertInfo, recordNotifPermissionAlertActionType, shouldSkipPushPermissionAlert, } from 'lib/utils/push-alerts.js'; import sleep from 'lib/utils/sleep.js'; import { + parseAndroidMessage, androidNotificationChannelID, handleAndroidMessage, getCommAndroidNotificationsEventEmitter, type AndroidMessage, CommAndroidNotifications, } from './android.js'; import { CommIOSNotification, type CoreIOSNotificationData, type CoreIOSNotificationDataWithRequestIdentifier, } from './comm-ios-notification.js'; import InAppNotif from './in-app-notif.react.js'; import { requestIOSPushPermissions, iosPushPermissionResponseReceived, CommIOSNotifications, getCommIOSNotificationsEventEmitter, } from './ios.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle.js'; import { replaceWithThreadActionType } from '../navigation/action-types.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { RootContext, type RootContextType } from '../root-context.js'; import type { EventSubscription } from '../types/react-native.js'; import { type GlobalTheme } from '../types/themes.js'; LogBox.ignoreLogs([ // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); type BaseProps = { +navigation: RootNavigationProp<'App'>, }; type Props = { ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +unreadCount: number, +deviceToken: ?string, +threadInfos: { +[id: string]: ThreadInfo }, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +connection: ConnectionInfo, +updatesCurrentAsOf: number, +activeTheme: ?GlobalTheme, +loggedIn: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: (deviceToken: ?string) => Promise, // withRootContext +rootContext: ?RootContextType, }; type State = { +inAppNotifProps: ?{ +customComponent: React.Node, +blurType: ?('xlight' | 'dark'), +onPress: () => void, }, }; class PushHandler extends React.PureComponent { state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidNotificationsEventSubscriptions: Array = []; androidNotificationsPermissionPromise: ?Promise = undefined; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?EventSubscription; iosNotificationEventSubscriptions: Array = []; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { const commIOSNotificationsEventEmitter = getCommIOSNotificationsEventEmitter(); this.iosNotificationEventSubscriptions.push( commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTERED_EVENT, registration => this.registerPushPermissions(registration?.deviceToken), ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTRATION_FAILED_EVENT, this.failedToRegisterPushPermissionsIOS, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_FOREGROUND_EVENT, this.iosForegroundNotificationReceived, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants().NOTIFICATION_OPENED_EVENT, this.iosNotificationOpened, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_BACKGROUND_EVENT, - backgroundData => this.saveMessageInfos(backgroundData?.messageInfos), + this.iosBackgroundNotificationReceived, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.createChannel( androidNotificationChannelID, 'Default', CommAndroidNotifications.getConstants().NOTIFICATIONS_IMPORTANCE_HIGH, 'Comm notifications channel', ); const commAndroidNotificationsEventEmitter = getCommAndroidNotificationsEventEmitter(); this.androidNotificationsEventSubscriptions.push( commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_TOKEN, this.handleAndroidDeviceToken, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_MESSAGE, this.androidMessageReceived, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, this.androidNotificationOpened, ), ); } if (this.props.connection.status === 'connected') { this.updateBadgeCount(); } } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { for (const iosNotificationEventSubscription of this .iosNotificationEventSubscriptions) { iosNotificationEventSubscription.remove(); } } else if (Platform.OS === 'android') { for (const androidNotificationsEventSubscription of this .androidNotificationsEventSubscriptions) { androidNotificationsEventSubscription.remove(); } this.androidNotificationsEventSubscriptions = []; } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { this.ensurePushNotifsEnabled(); } else if (this.props.deviceToken) { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for this.setDeviceToken(this.props.deviceToken); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } if ( this.props.connection.status === 'connected' && (prevProps.connection.status !== 'connected' || this.props.unreadCount !== prevProps.unreadCount) ) { this.updateBadgeCount(); } for (const threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if ( (this.props.loggedIn && !prevProps.loggedIn) || (!this.props.deviceToken && prevProps.deviceToken) ) { this.ensurePushNotifsEnabled(); } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { Haptics.notificationAsync(); InAppNotification.show(); } } updateBadgeCount() { const curUnreadCount = this.props.unreadCount; if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(curUnreadCount); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(curUnreadCount); } } clearAllNotifs() { if (Platform.OS === 'ios') { CommIOSNotifications.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { CommIOSNotifications.getDeliveredNotifications(notifications => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllActiveNotificationsForThread( activeThread, ); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: $ReadOnlyArray, ) { const identifiersToClear = []; for (const notification of notifications) { if (notification.threadID === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { CommIOSNotifications.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'ios') { const missingDeviceToken = this.props.deviceToken === null || this.props.deviceToken === undefined; await requestIOSPushPermissions(missingDeviceToken); } else if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); } } async ensureAndroidPushNotifsEnabled() { const permissionPromisesResult = await Promise.all([ CommAndroidNotifications.hasPermission(), CommAndroidNotifications.canRequestNotificationsPermissionFromUser(), ]); let [hasPermission] = permissionPromisesResult; const [, canRequestPermission] = permissionPromisesResult; if (!hasPermission && canRequestPermission) { const permissionResponse = await (async () => { // We issue a call to sleep to match iOS behavior where prompt // doesn't appear immediately but after logged-out modal disappears await sleep(10); await this.requestAndroidNotificationsPermission(); })(); hasPermission = permissionResponse; } if (!hasPermission) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); return; } try { const fcmToken = await CommAndroidNotifications.getToken(); await this.handleAndroidDeviceToken(fcmToken); } catch (e) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); } } requestAndroidNotificationsPermission = () => { if (!this.androidNotificationsPermissionPromise) { this.androidNotificationsPermissionPromise = (async () => { const notifPermission = await CommAndroidNotifications.requestNotificationsPermission(); this.androidNotificationsPermissionPromise = undefined; return notifPermission; })(); } return this.androidNotificationsPermissionPromise; }; handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotifThreadID = await CommAndroidNotifications.getInitialNotificationThreadID(); if (initialNotifThreadID) { await this.androidNotificationOpened(initialNotifThreadID); } } registerPushPermissions = (deviceToken: ?string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } if (deviceToken !== this.props.deviceToken) { this.setDeviceToken(deviceToken); } }; setDeviceToken(deviceToken: ?string) { this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceToken), ); } failedToRegisterPushPermissionsIOS = () => { this.setDeviceToken(null); if (!this.props.loggedIn) { return; } iosPushPermissionResponseReceived(); }; failedToRegisterPushPermissionsAndroid = ( shouldShowAlertOnAndroid: boolean, ) => { this.setDeviceToken(null); if (!this.props.loggedIn) { return; } if (shouldShowAlertOnAndroid) { this.showNotifAlertOnAndroid(); } }; showNotifAlertOnAndroid() { const alertInfo = this.props.notifPermissionAlertInfo; if (shouldSkipPushPermissionAlert(alertInfo)) { return; } this.props.dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); Alert.alert( 'Unable to initialize notifs!', 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.', undefined, { cancelable: true }, ); } navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigateToThread({ threadInfo }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } - saveMessageInfos(messageInfosString: ?string) { - if (!messageInfosString) { + saveMessageInfos(rawMessageInfos: ?$ReadOnlyArray) { + if (!rawMessageInfos) { return; } - const rawMessageInfos: $ReadOnlyArray = - JSON.parse(messageInfosString); const { updatesCurrentAsOf } = this.props; this.props.dispatch({ type: saveMessagesActionType, payload: { rawMessageInfos, updatesCurrentAsOf }, }); } iosForegroundNotificationReceived = ( rawNotification: CoreIOSNotificationData, ) => { const notification = new CommIOSNotification(rawNotification); if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NO_DATA, ); return; } const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); let title = notification.getData().title; let body = notification.getData().body; if (title && body) { ({ title, body } = mergePrefixIntoBody({ title, body })); } else { body = notification.getMessage(); } if (body) { this.showInAppNotification(threadID, body, title); } else { console.log( 'Non-rescind foreground notification without alert received!', ); } notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; + iosBackgroundNotificationReceived = backgroundData => { + const convertedMessageInfos = convertNotificationMessageInfoToNewIDSchema( + backgroundData.messageInfos, + ); + + if (!convertedMessageInfos) { + return; + } + + this.saveMessageInfos(convertedMessageInfos); + }; + onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (rawNotification: CoreIOSNotificationData) => { const notification = new CommIOSNotification(rawNotification); this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); this.onPressNotificationForThread(threadID, true); notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } androidNotificationOpened = async (threadID: string) => { + const convertedThreadID = + convertNotificationThreadIDToNewIDSchema(threadID); this.onPushNotifBootsApp(); - this.onPressNotificationForThread(threadID, true); + this.onPressNotificationForThread(convertedThreadID, true); }; androidMessageReceived = async (message: AndroidMessage) => { + const parsedMessage = parseAndroidMessage(message); this.onPushNotifBootsApp(); - const { messageInfos } = message; + const { messageInfos } = parsedMessage; this.saveMessageInfos(messageInfos); handleAndroidMessage( - message, + parsedMessage, this.props.updatesCurrentAsOf, this.handleAndroidNotificationIfActive, ); }; handleAndroidNotificationIfActive = ( threadID: string, texts: { body: string, title: ?string }, ) => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render() { return ( ); } } const ConnectedPushHandler: React.ComponentType = React.memo(function ConnectedPushHandler(props: BaseProps) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const boundUnreadCount = useSelector(unreadCount); const deviceToken = useSelector(state => state.deviceToken); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( state => state.notifPermissionAlertInfo, ); const connection = useSelector(state => state.connection); const updatesCurrentAsOf = useSelector(state => state.updatesCurrentAsOf); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const boundSetDeviceToken = useServerCall(setDeviceToken); const rootContext = React.useContext(RootContext); return ( ); }); export default ConnectedPushHandler; diff --git a/web/push-notif/push-notifs-handler.js b/web/push-notif/push-notifs-handler.js index 861656dba..99521ba29 100644 --- a/web/push-notif/push-notifs-handler.js +++ b/web/push-notif/push-notifs-handler.js @@ -1,190 +1,194 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { setDeviceToken, setDeviceTokenActionTypes, } from 'lib/actions/device-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; +import { convertNotificationThreadIDToNewIDSchema } from 'lib/utils/migration-utils.js'; import { shouldSkipPushPermissionAlert, recordNotifPermissionAlertActionType, } from 'lib/utils/push-alerts.js'; import electron from '../electron.js'; import PushNotifModal from '../modals/push-notif-modal.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; function useCreateDesktopPushSubscription() { const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useServerCall(setDeviceToken); React.useEffect( () => electron?.onDeviceTokenRegistered?.(token => { dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(token), ); }), [callSetDeviceToken, dispatchActionPromise], ); const dispatch = useDispatch(); React.useEffect( () => electron?.onNotificationClicked?.(({ threadID }) => { + const convertedThreadID = + convertNotificationThreadIDToNewIDSchema(threadID); + const payload = { chatMode: 'view', - activeChatThreadID: threadID, + activeChatThreadID: convertedThreadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); }), [dispatch], ); } function useCreatePushSubscription(): () => Promise { const publicKey = useSelector(state => state.pushApiPublicKey); const dispatchActionPromise = useDispatchActionPromise(); const callSetDeviceToken = useServerCall(setDeviceToken); return React.useCallback(async () => { if (!publicKey) { return; } const workerRegistration = await navigator.serviceWorker?.ready; if (!workerRegistration) { return; } const subscription = await workerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey, }); dispatchActionPromise( setDeviceTokenActionTypes, callSetDeviceToken(JSON.stringify(subscription)), ); }, [callSetDeviceToken, dispatchActionPromise, publicKey]); } function PushNotificationsHandler(): React.Node { useCreateDesktopPushSubscription(); const createPushSubscription = useCreatePushSubscription(); const notifPermissionAlertInfo = useSelector( state => state.notifPermissionAlertInfo, ); const modalContext = useModalContext(); const loggedIn = useSelector(isLoggedIn); const dispatch = useDispatch(); const supported = 'Notification' in window && !electron; React.useEffect(() => { (async () => { if (!navigator.serviceWorker || !supported) { return; } await navigator.serviceWorker.register('/worker/notif', { scope: '/' }); if (Notification.permission === 'granted') { // Make sure the subscription is current if we have the permissions await createPushSubscription(); } else if ( Notification.permission === 'default' && loggedIn && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { // Ask existing users that are already logged in for permission modalContext.pushModal(); dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Ask for permission on login const prevLoggedIn = React.useRef(loggedIn); React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return; } if (!prevLoggedIn.current && loggedIn) { if (Notification.permission === 'granted') { createPushSubscription(); } else if ( Notification.permission === 'default' && !shouldSkipPushPermissionAlert(notifPermissionAlertInfo) ) { modalContext.pushModal(); dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); } } prevLoggedIn.current = loggedIn; }, [ createPushSubscription, dispatch, loggedIn, modalContext, notifPermissionAlertInfo, prevLoggedIn, supported, ]); // Redirect to thread on notification click React.useEffect(() => { if (!navigator.serviceWorker || !supported) { return undefined; } const callback = (event: MessageEvent) => { if (typeof event.data !== 'object' || !event.data) { return; } if (event.data.targetThreadID) { const payload = { chatMode: 'view', activeChatThreadID: event.data.targetThreadID, tab: 'chat', }; dispatch({ type: updateNavInfoActionType, payload }); } }; navigator.serviceWorker.addEventListener('message', callback); return () => navigator.serviceWorker?.removeEventListener('message', callback); }, [dispatch, supported]); return null; } export { PushNotificationsHandler, useCreatePushSubscription }; diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js index 786a5f81b..3f625dbed 100644 --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -1,74 +1,78 @@ // @flow import type { WebNotification } from 'lib/types/notif-types.js'; +import { convertNotificationThreadIDToNewIDSchema } from 'lib/utils/migration-utils.js'; declare class PushMessageData { json(): Object; } declare class PushEvent extends ExtendableEvent { +data: PushMessageData; } declare var clients: Clients; declare function skipWaiting(): Promise; self.addEventListener('install', () => { skipWaiting(); }); self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil(clients.claim()); }); self.addEventListener('push', (event: PushEvent) => { const data: WebNotification = event.data.json(); event.waitUntil( (async () => { let body = data.body; if (data.prefix) { body = `${data.prefix} ${body}`; } await self.registration.showNotification(data.title, { body, badge: 'https://web.comm.app/favicon.ico', icon: 'https://web.comm.app/favicon.ico', tag: data.id, data: { unreadCount: data.unreadCount, threadID: data.threadID, }, }); })(), ); }); self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close(); event.waitUntil( (async () => { const clientList: Array = (await clients.matchAll({ type: 'window', }): any); const selectedClient = clientList.find(client => client.focused) ?? clientList[0]; + const threadID = convertNotificationThreadIDToNewIDSchema( + event.notification.data.threadID, + ); + if (selectedClient) { if (!selectedClient.focused) { await selectedClient.focus(); } selectedClient.postMessage({ - targetThreadID: event.notification.data.threadID, + targetThreadID: threadID, }); } else { const url = (process.env.NODE_ENV === 'production' ? 'https://web.comm.app' - : 'http://localhost:3000/comm') + - `/chat/thread/${event.notification.data.threadID}/`; + : 'http://localhost:3000/comm') + `/chat/thread/${threadID}/`; clients.openWindow(url); } })(), ); });