diff --git a/native/ios/Comm/AppDelegate.mm b/native/ios/Comm/AppDelegate.mm index aca73226f..7956b3b7a 100644 --- a/native/ios/Comm/AppDelegate.mm +++ b/native/ios/Comm/AppDelegate.mm @@ -1,364 +1,345 @@ #import "AppDelegate.h" #import #import #import #import #if RCT_NEW_ARCH_ENABLED #import #import #import #import #import #import #import static NSString *const kRNConcurrentRoot = @"concurrentRoot"; @interface AppDelegate () < RCTCxxBridgeDelegate, RCTTurboModuleManagerDelegate> { RCTTurboModuleManager *_turboModuleManager; RCTSurfacePresenterBridgeAdapter *_bridgeAdapter; std::shared_ptr _reactNativeConfig; facebook::react::ContextContainer::Shared _contextContainer; } @end #endif +#import "CommIOSNotifications.h" #import "Orientation.h" -#import "RNNotifications.h" #import #import #import #import #import #import #import #import "CommCoreModule.h" #import "GlobalDBSingleton.h" #import "Logger.h" #import "MessageOperationsUtilities.h" #import "SQLiteQueryExecutor.h" #import "TemporaryMessageStorage.h" #import "ThreadOperations.h" #import "Tools.h" #import #import #import #import #import #import NSString *const backgroundNotificationTypeKey = @"backgroundNotifType"; NSString *const setUnreadStatusKey = @"setUnreadStatus"; @interface AppDelegate () < RCTCxxBridgeDelegate, RCTTurboModuleManagerDelegate> { } @end @implementation AppDelegate - (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [self attemptDatabaseInitialization]; return YES; } - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { RCTAppSetupPrepareApp(application); [self moveMessagesToDatabase]; [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAmbient error:nil]; RCTBridge *bridge = [self.reactDelegate createBridgeWithDelegate:self launchOptions:launchOptions]; #if RCT_NEW_ARCH_ENABLED _contextContainer = std::make_shared(); _reactNativeConfig = std::make_shared(); _contextContainer->insert("ReactNativeConfig", _reactNativeConfig); _bridgeAdapter = [[RCTSurfacePresenterBridgeAdapter alloc] initWithBridge:bridge contextContainer:_contextContainer]; bridge.surfacePresenter = _bridgeAdapter.surfacePresenter; #endif NSDictionary *initProps = [self prepareInitialProps]; UIView *rootView = [self.reactDelegate createRootViewWithBridge:bridge moduleName:@"Comm" initialProperties:initProps]; if (@available(iOS 13.0, *)) { rootView.backgroundColor = [UIColor systemBackgroundColor]; } else { rootView.backgroundColor = [UIColor whiteColor]; } self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; UIViewController *rootViewController = [self.reactDelegate createRootViewController]; rootViewController.view = rootView; self.window.rootViewController = rootViewController; [self.window makeKeyAndVisible]; [super application:application didFinishLaunchingWithOptions:launchOptions]; // This prevents a very small flicker from occurring before expo-splash-screen // is able to display UIView *launchScreenView = [[UIStoryboard storyboardWithName:@"SplashScreen" bundle:nil] instantiateInitialViewController] .view; launchScreenView.frame = self.window.bounds; ((RCTRootView *)rootView).loadingView = launchScreenView; ((RCTRootView *)rootView).loadingViewFadeDelay = 0; ((RCTRootView *)rootView).loadingViewFadeDuration = 0.001; return YES; } - (NSArray> *)extraModulesForBridge:(RCTBridge *)bridge { // If you'd like to export some custom RCTBridgeModules that are not Expo // modules, add them here! return @[]; } /// This method controls whether the `concurrentRoot`feature of React18 is /// turned on or off. /// /// @see: https://reactjs.org/blog/2022/03/29/react-v18.html /// @note: This requires to be rendering on Fabric (i.e. on the New /// Architecture). /// @return: `true` if the `concurrentRoot` feture is enabled. Otherwise, it /// returns `false`. - (BOOL)concurrentRootEnabled { // Switch this bool to turn on and off the concurrent root return true; } - (NSDictionary *)prepareInitialProps { NSMutableDictionary *initProps = [NSMutableDictionary new]; #ifdef RCT_NEW_ARCH_ENABLED initProps[kRNConcurrentRoot] = @([self concurrentRootEnabled]); #endif return initProps; } - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { - [RNNotifications + [CommIOSNotifications didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; } - (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error { - [RNNotifications didFailToRegisterForRemoteNotificationsWithError:error]; + [CommIOSNotifications didFailToRegisterForRemoteNotificationsWithError:error]; } // Required for the notification event. You must call the completion handler // after handling the remote notification. - (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)notification fetchCompletionHandler: (void (^)(UIBackgroundFetchResult))completionHandler { BOOL handled = NO; if (notification[@"aps"][@"content-available"] && notification[backgroundNotificationTypeKey]) { handled = [self handleBackgroundNotification:notification fetchCompletionHandler:completionHandler]; } if (handled) { return; } - [RNNotifications didReceiveRemoteNotification:notification - fetchCompletionHandler:completionHandler]; + [CommIOSNotifications didReceiveRemoteNotification:notification + fetchCompletionHandler:completionHandler]; } - (BOOL)handleBackgroundNotification:(NSDictionary *)notification fetchCompletionHandler: (void (^)(UIBackgroundFetchResult))completionHandler { if ([notification[backgroundNotificationTypeKey] isEqualToString:@"CLEAR"]) { if (notification[setUnreadStatusKey] && notification[@"threadID"]) { std::string threadID = std::string([notification[@"threadID"] UTF8String]); // this callback may be called from inactive state so we need // to initialize the database [self attemptDatabaseInitialization]; comm::GlobalDBSingleton::instance.scheduleOrRun([threadID]() mutable { comm::ThreadOperations::updateSQLiteUnreadStatus(threadID, false); }); } - [[UNUserNotificationCenter currentNotificationCenter] - getDeliveredNotificationsWithCompletionHandler:^( - NSArray *notifications) { - for (UNNotification *notif in notifications) { - if ([notification[@"notificationId"] - isEqual:notif.request.content.userInfo[@"id"]]) { - NSArray *identifiers = - [NSArray arrayWithObjects:notif.request.identifier, nil]; - [[UNUserNotificationCenter currentNotificationCenter] - removeDeliveredNotificationsWithIdentifiers:identifiers]; - } - } - dispatch_async(dispatch_get_main_queue(), ^{ - completionHandler(UIBackgroundFetchResultNewData); - }); - }]; + [CommIOSNotifications + clearNotificationFromNotificationsCenter:notification[@"notificationId"] + completionHandler:completionHandler]; return YES; } return NO; } -// Required for the localNotification event. -- (void)application:(UIApplication *)application - didReceiveLocalNotification:(UILocalNotification *)notification { - [RNNotifications didReceiveLocalNotification:notification]; -} - - (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window { return [Orientation getOrientation]; } - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { #if DEBUG return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; #else return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; #endif } #if RCT_NEW_ARCH_ENABLED #pragma mark - RCTCxxBridgeDelegate - (std::unique_ptr) jsExecutorFactoryForBridge:(RCTBridge *)bridge { _turboModuleManager = [[RCTTurboModuleManager alloc] initWithBridge:bridge delegate:self jsInvoker:bridge.jsCallInvoker]; return RCTAppSetupDefaultJsExecutorFactory(bridge, _turboModuleManager); } #pragma mark RCTTurboModuleManagerDelegate - (Class)getModuleClassFromName:(const char *)name { return RCTCoreModulesClassProvider(name); } - (std::shared_ptr) getTurboModule:(const std::string &)name jsInvoker:(std::shared_ptr)jsInvoker { return nullptr; } - (std::shared_ptr) getTurboModule:(const std::string &)name initParams: (const facebook::react::ObjCTurboModule::InitParams &)params { return nullptr; } - (id)getModuleInstanceFromClass:(Class)moduleClass { return RCTAppSetupDefaultModuleFromClass(moduleClass); } #endif using JSExecutorFactory = facebook::react::JSExecutorFactory; using HermesExecutorFactory = facebook::react::HermesExecutorFactory; using Runtime = facebook::jsi::Runtime; - (std::unique_ptr)jsExecutorFactoryForBridge: (RCTBridge *)bridge { __weak __typeof(self) weakSelf = self; const auto commRuntimeInstaller = [weakSelf, bridge](facebook::jsi::Runtime &rt) { if (!bridge) { return; } __typeof(self) strongSelf = weakSelf; if (strongSelf) { std::shared_ptr nativeModule = std::make_shared(bridge.jsCallInvoker); rt.global().setProperty( rt, facebook::jsi::PropNameID::forAscii(rt, "CommCoreModule"), facebook::jsi::Object::createFromHostObject(rt, nativeModule)); } }; const auto installer = reanimated::REAJSIExecutorRuntimeInstaller(bridge, commRuntimeInstaller); return std::make_unique( facebook::react::RCTJSIExecutorRuntimeInstaller(installer), JSIExecutor::defaultTimeoutInvoker, makeRuntimeConfig(3072)); } - (void)attemptDatabaseInitialization { std::string sqliteFilePath = std::string([[Tools getSQLiteFilePath] UTF8String]); // Previous Comm versions used app group location for SQLite // database, so that NotificationService was able to acces it directly. // Unfortunately it caused errores related to system locks. The code // below re-migrates SQLite from app group to app specific location // on devices where previous Comm version was installed. NSString *appGroupSQLiteFilePath = [Tools getAppGroupSQLiteFilePath]; if ([NSFileManager.defaultManager fileExistsAtPath:appGroupSQLiteFilePath] && std::rename( std::string([appGroupSQLiteFilePath UTF8String]).c_str(), sqliteFilePath.c_str())) { throw std::runtime_error( "Failed to move SQLite database from app group to default location"); } comm::SQLiteQueryExecutor::initialize(sqliteFilePath); } - (void)moveMessagesToDatabase { TemporaryMessageStorage *temporaryStorage = [[TemporaryMessageStorage alloc] init]; NSArray *messages = [temporaryStorage readAndClearMessages]; for (NSString *message in messages) { std::string messageInfos = std::string([message UTF8String]); comm::GlobalDBSingleton::instance.scheduleOrRun([messageInfos]() mutable { comm::MessageOperationsUtilities::storeMessageInfos(messageInfos); }); } } // Copied from // ReactAndroid/src/main/java/com/facebook/hermes/reactexecutor/OnLoad.cpp static ::hermes::vm::RuntimeConfig makeRuntimeConfig(::hermes::vm::gcheapsize_t heapSizeMB) { namespace vm = ::hermes::vm; auto gcConfigBuilder = vm::GCConfig::Builder() .withName("RN") // For the next two arguments: avoid GC before TTI by initializing the // runtime to allocate directly in the old generation, but revert to // normal operation when we reach the (first) TTI point. .withAllocInYoung(false) .withRevertToYGAtTTI(true); if (heapSizeMB > 0) { gcConfigBuilder.withMaxHeapSize(heapSizeMB << 20); } return vm::RuntimeConfig::Builder() .withGCConfig(gcConfigBuilder.build()) .build(); } @end diff --git a/native/push/ios.js b/native/push/ios.js index 69f3335d9..e8a18a189 100644 --- a/native/push/ios.js +++ b/native/push/ios.js @@ -1,38 +1,82 @@ // @flow -import NotificationsIOS from 'react-native-notifications'; +import { NativeModules, NativeEventEmitter } from 'react-native'; + +import type { + CoreIOSNotificationData, + CoreIOSNotificationDataWithRequestIdentifier, +} from './comm-ios-notification'; type PushPermissions = { alert?: boolean, badge?: boolean, sound?: boolean }; +type CommIOSNotificationsModuleType = { + +requestPermissions: () => void, + +checkPermissions: () => PushPermissions, + +consumeBackgroundQueue: () => void, + +setBadgesCount: (count: number) => void, + +removeAllDeliveredNotifications: () => void, + +removeDeliveredNotifications: (identifiers: $ReadOnlyArray) => void, + +getDeliveredNotifications: ( + callback: ( + notifications: $ReadOnlyArray, + ) => void, + ) => void, + +completeNotif: (id: string, fetchResult: string) => void, + +getConstants: () => { [string]: string }, + // required since CommIOSNotifications subclasses RCTEventEmitter + +addListener: (eventName: string) => void, + +removeListeners: (count: number) => void, + ... +}; + +const CommIOSNotifications: CommIOSNotificationsModuleType = + NativeModules.CommIOSNotifications; + let currentlyActive = false; let firstRun = true; async function requestIOSPushPermissions(missingDeviceToken: boolean) { let permissionNeeded = firstRun || missingDeviceToken; firstRun = false; if (!permissionNeeded) { - const permissions: PushPermissions = await NotificationsIOS.checkPermissions(); + const permissions: PushPermissions = await CommIOSNotifications.checkPermissions(); permissionNeeded = permissionMissing(permissions); } if (permissionNeeded) { if (currentlyActive) { return; } currentlyActive = true; - await NotificationsIOS.requestPermissions(); + await CommIOSNotifications.requestPermissions(); } - NotificationsIOS.consumeBackgroundQueue(); + CommIOSNotifications.consumeBackgroundQueue(); } function iosPushPermissionResponseReceived() { currentlyActive = false; } function permissionMissing(permissions: PushPermissions) { return !permissions.alert || !permissions.badge || !permissions.sound; } -export { requestIOSPushPermissions, iosPushPermissionResponseReceived }; +function getCommIOSNotificationsEventEmitter(): NativeEventEmitter< + $ReadOnly<{ + remoteNotificationsRegistered: [{ +deviceToken: ?string }], + remoteNotificationsRegistrationFailed: [void], + notificationReceivedForeground: [CoreIOSNotificationData], + notificationOpened: [CoreIOSNotificationData], + }>, +> { + return new NativeEventEmitter(CommIOSNotifications); +} + +export { + requestIOSPushPermissions, + iosPushPermissionResponseReceived, + CommIOSNotifications, + getCommIOSNotificationsEventEmitter, +}; diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index a0a07013e..d1023c1d2 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,627 +1,623 @@ // @flow import * as Haptics from 'expo-haptics'; import * as React from 'react'; import { AppRegistry, Platform, Alert, LogBox } from 'react-native'; import type { RemoteMessage, NotificationOpen } from 'react-native-firebase'; import { Notification as InAppNotification } from 'react-native-in-app-message'; import NotificationsIOS from 'react-native-notifications'; import { useDispatch } from 'react-redux'; import { setDeviceTokenActionTypes, setDeviceToken, } from 'lib/actions/device-actions'; import { saveMessagesActionType } from 'lib/actions/message-actions'; import { unreadCount, threadInfoSelector, } from 'lib/selectors/thread-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils'; import type { RawMessageInfo } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ConnectionInfo } from 'lib/types/socket-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle'; import { replaceWithThreadActionType } from '../navigation/action-types'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import { recordNotifPermissionAlertActionType, clearAndroidNotificationsActionType, } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { RootContext, type RootContextType } from '../root-context'; import type { EventSubscription } from '../types/react-native'; import { type GlobalTheme } from '../types/themes'; import { type NotifPermissionAlertInfo } from './alerts'; import { androidNotificationChannelID, handleAndroidMessage, androidBackgroundMessageTask, } from './android'; +import { + CommIOSNotification, + type CoreIOSNotificationData, +} from './comm-ios-notification'; import { getFirebase } from './firebase'; import InAppNotif from './in-app-notif.react'; import { requestIOSPushPermissions, iosPushPermissionResponseReceived, + getCommIOSNotificationsEventEmitter, } from './ios'; LogBox.ignoreLogs([ // react-native-firebase 'Require cycle: ../node_modules/react-native-firebase', // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); const msInDay = 24 * 60 * 60 * 1000; 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; androidTokenListener: ?() => void = null; androidMessageListener: ?() => void = null; androidNotifOpenListener: ?() => void = null; 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') { - NotificationsIOS.addEventListener( - 'remoteNotificationsRegistered', - this.registerPushPermissions, - ); - NotificationsIOS.addEventListener( - 'remoteNotificationsRegistrationFailed', - this.failedToRegisterPushPermissions, - ); - NotificationsIOS.addEventListener( - 'notificationReceivedForeground', - this.iosForegroundNotificationReceived, - ); - NotificationsIOS.addEventListener( - 'notificationOpened', - this.iosNotificationOpened, + const commIOSNotificationsEventEmitter = getCommIOSNotificationsEventEmitter(); + this.iosNotificationEventSubscriptions.push( + commIOSNotificationsEventEmitter.addListener( + 'remoteNotificationsRegistered', + registration => + this.registerPushPermissions(registration?.deviceToken), + ), + commIOSNotificationsEventEmitter.addListener( + 'remoteNotificationsRegistrationFailed', + this.failedToRegisterPushPermissions, + ), + commIOSNotificationsEventEmitter.addListener( + 'notificationReceivedForeground', + this.iosForegroundNotificationReceived, + ), + commIOSNotificationsEventEmitter.addListener( + 'notificationOpened', + this.iosNotificationOpened, + ), ); } else if (Platform.OS === 'android') { const firebase = getFirebase(); const channel = new firebase.notifications.Android.Channel( androidNotificationChannelID, 'Default', firebase.notifications.Android.Importance.Max, ).setDescription('Comm notifications channel'); firebase.notifications().android.createChannel(channel); this.androidTokenListener = firebase .messaging() .onTokenRefresh(this.handleAndroidDeviceToken); this.androidMessageListener = firebase .messaging() .onMessage(this.androidMessageReceived); this.androidNotifOpenListener = firebase .notifications() .onNotificationOpened(this.androidNotificationOpened); } if (this.props.connection.status === 'connected') { this.updateBadgeCount(); } } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { - NotificationsIOS.removeEventListener( - 'remoteNotificationsRegistered', - this.registerPushPermissions, - ); - NotificationsIOS.removeEventListener( - 'remoteNotificationsRegistrationFailed', - this.failedToRegisterPushPermissions, - ); - NotificationsIOS.removeEventListener( - 'notificationReceivedForeground', - this.iosForegroundNotificationReceived, - ); - NotificationsIOS.removeEventListener( - 'notificationOpened', - this.iosNotificationOpened, - ); + for (const iosNotificationEventSubscription of this + .iosNotificationEventSubscriptions) { + iosNotificationEventSubscription.remove(); + } } else if (Platform.OS === 'android') { if (this.androidTokenListener) { this.androidTokenListener(); this.androidTokenListener = null; } if (this.androidMessageListener) { this.androidMessageListener(); this.androidMessageListener = null; } if (this.androidNotifOpenListener) { this.androidNotifOpenListener(); this.androidNotifOpenListener = null; } } } 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') { NotificationsIOS.setBadgesCount(curUnreadCount); } else if (Platform.OS === 'android') { getFirebase().notifications().setBadge(curUnreadCount); } } clearAllNotifs() { if (Platform.OS === 'ios') { NotificationsIOS.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { getFirebase().notifications().removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { NotificationsIOS.getDeliveredNotifications(notifications => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { this.props.dispatch({ type: clearAndroidNotificationsActionType, payload: { threadID: activeThread }, }); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: Object[], ) { const identifiersToClear = []; for (const notification of notifications) { if (notification['thread-id'] === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { NotificationsIOS.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 firebase = getFirebase(); const hasPermission = await firebase.messaging().hasPermission(); if (!hasPermission) { this.failedToRegisterPushPermissions(); return; } const fcmToken = await firebase.messaging().getToken(); if (fcmToken) { await this.handleAndroidDeviceToken(fcmToken); } else { this.failedToRegisterPushPermissions(); } } handleAndroidDeviceToken = async (deviceToken: ?string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotif = await getFirebase() .notifications() .getInitialNotification(); if (initialNotif) { await this.androidNotificationOpened(initialNotif); } } 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), ); } failedToRegisterPushPermissions = () => { this.setDeviceToken(null); if (!this.props.loggedIn) { return; } const deviceType = Platform.OS; if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } else { this.showNotifAlertOnAndroid(); } }; showNotifAlertOnAndroid() { const alertInfo = this.props.notifPermissionAlertInfo; if ( (alertInfo.totalAlerts > 3 && alertInfo.lastAlertTime > Date.now() - msInDay) || (alertInfo.totalAlerts > 6 && alertInfo.lastAlertTime > Date.now() - msInDay * 3) || (alertInfo.totalAlerts > 9 && alertInfo.lastAlertTime > Date.now() - msInDay * 7) ) { 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) { return; } const rawMessageInfos: $ReadOnlyArray = JSON.parse( messageInfosString, ); const { updatesCurrentAsOf } = this.props; this.props.dispatch({ type: saveMessagesActionType, payload: { rawMessageInfos, updatesCurrentAsOf }, }); } - iosForegroundNotificationReceived = notification => { - if ( - notification.getData() && - notification.getData().managedAps && - notification.getData().managedAps.action === 'CLEAR' - ) { - notification.finish(NotificationsIOS.FetchResult.NoData); - return; - } + 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(NotificationsIOS.FetchResult.NoData); return; } const threadID = notification.getData().threadID; - if (!threadID) { - console.log('Notification with missing threadID received!'); - notification.finish(NotificationsIOS.FetchResult.NoData); - return; - } const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); - let title = null; - let body = notification.getMessage(); - if (notification.getData().title) { - ({ title, body } = mergePrefixIntoBody(notification.getData())); + + 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!', + ); } - this.showInAppNotification(threadID, body, title); notification.finish(NotificationsIOS.FetchResult.NewData); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } - iosNotificationOpened = notification => { + iosNotificationOpened = (rawNotification: CoreIOSNotificationData) => { + const notification = new CommIOSNotification(rawNotification); this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; - if (!threadID) { - console.log('Notification with missing threadID received!'); - notification.finish(NotificationsIOS.FetchResult.NoData); - return; - } const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); this.onPressNotificationForThread(threadID, true); notification.finish(NotificationsIOS.FetchResult.NewData); }; 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 (notificationOpen: NotificationOpen) => { this.onPushNotifBootsApp(); const { threadID } = notificationOpen.notification.data; this.onPressNotificationForThread(threadID, true); }; androidMessageReceived = async (message: RemoteMessage) => { this.onPushNotifBootsApp(); const { data } = message; const { messageInfos } = data; this.saveMessageInfos(messageInfos); handleAndroidMessage( message, 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 ( ); } } AppRegistry.registerHeadlessTask( 'RNFirebaseBackgroundMessage', () => androidBackgroundMessageTask, ); 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;