diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index 48fa79bcf..1d12aa381 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,672 +1,672 @@ // @flow import type { DeviceType } from 'lib/types/device-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import { type NotifPermissionAlertInfo, notifPermissionAlertInfoPropType, } from './alerts'; import { type ConnectionInfo, connectionInfoPropType, } from 'lib/types/socket-types'; import type { RemoteMessage, NotificationOpen } from 'react-native-firebase'; import { type GlobalTheme, globalThemePropType } from '../types/themes'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import * as React from 'react'; import PropTypes from 'prop-types'; import { AppRegistry, Platform, Alert, Vibration, - YellowBox, + LogBox, } from 'react-native'; import NotificationsIOS from 'react-native-notifications'; import { Notification as InAppNotification, TapticFeedback, } from 'react-native-in-app-message'; import { useDispatch } from 'react-redux'; import { unreadCount, threadInfoSelector, } from 'lib/selectors/thread-selectors'; import { setDeviceTokenActionTypes, setDeviceToken, } from 'lib/actions/device-actions'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import { recordNotifPermissionAlertActionType, clearAndroidNotificationsActionType, } from '../redux/action-types'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { requestIOSPushPermissions, iosPushPermissionResponseReceived, } from './ios'; import { androidNotificationChannelID, handleAndroidMessage, androidBackgroundMessageTask, } from './android'; import { getFirebase } from './firebase'; import { saveMessageInfos } from './utils'; import InAppNotif from './in-app-notif.react'; import { NavContext } from '../navigation/navigation-context'; import { RootContext, type RootContextType, rootContextPropType, } from '../root-context'; import { MessageListRouteName } from '../navigation/route-names'; import { replaceWithThreadActionType } from '../navigation/action-types'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle'; import { useSelector } from '../redux/redux-utils'; -YellowBox.ignoreWarnings([ +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; const supportsTapticFeedback = Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 10; 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, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: ( deviceToken: string, deviceType: DeviceType, ) => Promise, // withRootContext +rootContext: ?RootContextType, |}; type State = {| +inAppNotifProps: ?{| customComponent: React.Node, blurType: ?('xlight' | 'dark'), onPress: () => void, |}, |}; class PushHandler extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ navigate: PropTypes.func.isRequired, }).isRequired, activeThread: PropTypes.string, unreadCount: PropTypes.number.isRequired, deviceToken: PropTypes.string, threadInfos: PropTypes.objectOf(threadInfoPropType).isRequired, notifPermissionAlertInfo: notifPermissionAlertInfoPropType.isRequired, connection: connectionInfoPropType.isRequired, updatesCurrentAsOf: PropTypes.number.isRequired, activeTheme: globalThemePropType, loggedIn: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, setDeviceToken: PropTypes.func.isRequired, rootContext: rootContextPropType, }; state = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidTokenListener: ?() => void = null; androidMessageListener: ?() => void = null; androidNotifOpenListener: ?() => void = null; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?{ +remove: () => void }; 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, ); } else if (Platform.OS === 'android') { const firebase = getFirebase(); const channel = new firebase.notifications.Android.Channel( androidNotificationChannelID, 'Default', firebase.notifications.Android.Importance.Max, ).setDescription('SquadCal 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); } } 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, ); } 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 (let 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 ) { if (supportsTapticFeedback) { TapticFeedback.impact(); } else { Vibration.vibrate(400); } 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 (let 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) { try { await firebase.messaging().requestPermission(); } catch { 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, Platform.OS), undefined, deviceToken, ); } failedToRegisterPushPermissions = () => { if (!this.props.loggedIn) { return; } const deviceType = Platform.OS; if (deviceType === 'ios') { iosPushPermissionResponseReceived(); if (__DEV__) { // iOS simulator can't handle notifs return; } } 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() }, }); if (deviceType === 'ios') { Alert.alert( 'Need notif permissions', 'SquadCal needs notification permissions to keep you in the loop! ' + 'Please enable in Settings App -> Notifications -> SquadCal.', [{ text: 'OK' }], ); } else if (deviceType === 'android') { 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.navigation.navigate({ name: MessageListRouteName, key: `${MessageListRouteName}${threadInfo.id}`, params: { 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) { saveMessageInfos(messageInfosString, this.props.updatesCurrentAsOf); } iosForegroundNotificationReceived = notification => { if ( notification.getData() && notification.getData().managedAps && notification.getData().managedAps.action === 'CLEAR' ) { notification.finish(NotificationsIOS.FetchResult.NoData); return; } 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; if (messageInfos) { this.saveMessageInfos(messageInfos); } let title = null; let body = notification.getMessage(); if (notification.getData().title) { ({ title, body } = mergePrefixIntoBody(notification.getData())); } 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 => { 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; if (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(); 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, ); export default 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 dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const boundSetDeviceToken = useServerCall(setDeviceToken); const rootContext = React.useContext(RootContext); return ( ); }); diff --git a/native/root.react.js b/native/root.react.js index e8649e85d..6e157da63 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,277 +1,277 @@ // @flow import type { PossiblyStaleNavigationState } from '@react-navigation/native'; import * as React from 'react'; import { Provider } from 'react-redux'; -import { Platform, UIManager, View, StyleSheet, YellowBox } from 'react-native'; +import { Platform, UIManager, View, StyleSheet, LogBox } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { PersistGate } from 'redux-persist/integration/react'; import AsyncStorage from '@react-native-community/async-storage'; import { NavigationContainer } from '@react-navigation/native'; import invariant from 'invariant'; import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import * as SplashScreen from 'expo-splash-screen'; import { actionLogger } from 'lib/utils/action-logger'; import RootNavigator from './navigation/root-navigator.react'; import { store } from './redux/redux-setup'; import ConnectedStatusBar from './connected-status-bar.react'; import ErrorBoundary from './error-boundary.react'; import DisconnectedBarVisibilityHandler from './navigation/disconnected-bar-visibility-handler.react'; import { DimensionsUpdater } from './redux/dimensions-updater.react'; import ConnectivityUpdater from './redux/connectivity-updater.react'; import ThemeHandler from './themes/theme-handler.react'; import OrientationHandler from './navigation/orientation-handler.react'; import Socket from './socket.react'; import { getPersistor } from './redux/persist'; import { NavContext } from './navigation/navigation-context'; import { setGlobalNavContext } from './navigation/icky-global'; import { RootContext } from './root-context'; import NavigationHandler from './navigation/navigation-handler.react'; import { defaultNavigationState } from './navigation/default-state'; import InputStateContainer from './input/input-state-container.react'; import './themes/fonts'; import LifecycleHandler from './lifecycle/lifecycle-handler.react'; import { DarkTheme, LightTheme } from './themes/navigation'; import { validNavState } from './navigation/navigation-utils'; import { navStateAsyncStorageKey } from './navigation/persistance'; import { useSelector } from './redux/redux-utils'; -YellowBox.ignoreWarnings([ +LogBox.ignoreLogs([ // react-native-reanimated 'Please report: Excessive number of pending callbacks', ]); if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true); } const navInitAction = Object.freeze({ type: 'NAV/@@INIT' }); const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' }); function Root() { const navStateRef = React.useRef(); const navDispatchRef = React.useRef(); const navStateInitializedRef = React.useRef(false); React.useEffect(() => { (async () => { try { await SplashScreen.preventAutoHideAsync(); } catch {} })(); }, []); const [navContext, setNavContext] = React.useState(null); const updateNavContext = React.useCallback(() => { if ( !navStateRef.current || !navDispatchRef.current || !navStateInitializedRef.current ) { return; } const updatedNavContext = { state: navStateRef.current, dispatch: navDispatchRef.current, }; setNavContext(updatedNavContext); setGlobalNavContext(updatedNavContext); }, []); const [initialState, setInitialState] = React.useState( __DEV__ ? undefined : defaultNavigationState, ); React.useEffect(() => { Orientation.lockToPortrait(); (async () => { let loadedState = initialState; if (__DEV__) { try { const navStateString = await AsyncStorage.getItem( navStateAsyncStorageKey, ); if (navStateString) { const savedState = JSON.parse(navStateString); if (validNavState(savedState)) { loadedState = savedState; } } } catch {} } if (!loadedState) { loadedState = defaultNavigationState; } if (loadedState !== initialState) { setInitialState(loadedState); } navStateRef.current = loadedState; updateNavContext(); actionLogger.addOtherAction('navState', navInitAction, null, loadedState); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateNavContext]); const setNavStateInitialized = React.useCallback(() => { navStateInitializedRef.current = true; updateNavContext(); }, [updateNavContext]); const [rootContext, setRootContext] = React.useState(() => ({ setNavStateInitialized, })); const detectUnsupervisedBackgroundRef = React.useCallback( (detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => { setRootContext(prevRootContext => ({ ...prevRootContext, detectUnsupervisedBackground, })); }, [], ); const frozen = useSelector(state => state.frozen); const queuedActionsRef = React.useRef([]); const onNavigationStateChange = React.useCallback( (state: ?PossiblyStaleNavigationState) => { invariant(state, 'nav state should be non-null'); const prevState = navStateRef.current; navStateRef.current = state; updateNavContext(); const queuedActions = queuedActionsRef.current; queuedActionsRef.current = []; if (queuedActions.length === 0) { queuedActions.push(navUnknownAction); } for (let action of queuedActions) { actionLogger.addOtherAction('navState', action, prevState, state); } if (!__DEV__ || frozen) { return; } (async () => { try { await AsyncStorage.setItem( navStateAsyncStorageKey, JSON.stringify(state), ); } catch (e) { console.log('AsyncStorage threw while trying to persist navState', e); } })(); }, [updateNavContext, frozen], ); const navContainerRef = React.useRef(); const containerRef = React.useCallback( (navContainer: ?React.ElementRef) => { navContainerRef.current = navContainer; if (navContainer && !navDispatchRef.current) { navDispatchRef.current = navContainer.dispatch; updateNavContext(); } }, [updateNavContext], ); useReduxDevToolsExtension(navContainerRef); const navContainer = navContainerRef.current; React.useEffect(() => { if (!navContainer) { return; } return navContainer.addListener('__unsafe_action__', event => { const { action, noop } = event.data; const navState = navStateRef.current; if (noop) { actionLogger.addOtherAction('navState', action, navState, navState); return; } queuedActionsRef.current.push({ ...action, type: `NAV/${action.type}`, }); }); }, [navContainer]); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const theme = (() => { if (activeTheme === 'light') { return LightTheme; } else if (activeTheme === 'dark') { return DarkTheme; } return undefined; })(); const gated: React.Node = ( <> ); let navigation; if (initialState) { navigation = ( ); } return ( {gated} {navigation} ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); const AppRoot = () => ( ); export default AppRoot;