diff --git a/native/push/android.js b/native/push/android.js index e2e9f3387..23196d78e 100644 --- a/native/push/android.js +++ b/native/push/android.js @@ -1,95 +1,78 @@ // @flow import invariant from 'invariant'; import { NativeModules } from 'react-native'; import type { RemoteMessage } from 'react-native-firebase'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils'; -import { - recordAndroidNotificationActionType, - rescindAndroidNotificationActionType, -} from '../redux/action-types'; -import { store, dispatch } from '../redux/redux-setup'; +import { store } from '../redux/redux-setup'; import { getFirebase } from './firebase'; type CommAndroidNotificationsModuleType = { +removeAllActiveNotificationsForThread: (threadID: string) => void, ... }; const CommAndroidNotifications: CommAndroidNotificationsModuleType = NativeModules.CommAndroidNotifications; const androidNotificationChannelID = 'default'; const vibrationSpec = [500, 500]; function handleAndroidMessage( message: RemoteMessage, updatesCurrentAsOf: number, handleIfActive?: ( threadID: string, texts: { body: string, title: ?string }, ) => boolean, ) { const firebase = getFirebase(); const { data } = message; const { rescind, rescindID } = data; if (rescind) { invariant(rescindID, 'rescind message without notifID'); - dispatch({ - type: rescindAndroidNotificationActionType, - payload: { notifID: rescindID, threadID: data.threadID }, - }); return; } const { id, title, prefix, threadID } = data; let { body } = data; ({ body } = mergePrefixIntoBody({ body, title, prefix })); if (handleIfActive) { const texts = { title, body }; const isActive = handleIfActive(threadID, texts); if (isActive) { return; } } const notification = new firebase.notifications.Notification() .setNotificationId(id) .setBody(body) .setData({ threadID }) .android.setTag(id) .android.setChannelId(androidNotificationChannelID) .android.setDefaults([firebase.notifications.Android.Defaults.All]) .android.setVibrate(vibrationSpec) .android.setAutoCancel(true) .android.setLargeIcon('@mipmap/ic_launcher') .android.setSmallIcon('@drawable/notif_icon'); if (title) { notification.setTitle(title); } firebase.notifications().displayNotification(notification); - - // We keep track of what notifs have been rendered for a given thread so - // that we can clear them immediately (without waiting for the rescind) - // when the user navigates to that thread. Since we can't do this while - // the app is closed, we rely on the rescind notif in that case. - dispatch({ - type: recordAndroidNotificationActionType, - payload: { threadID, notifID: id }, - }); } async function androidBackgroundMessageTask(message: RemoteMessage) { const { updatesCurrentAsOf } = store.getState(); handleAndroidMessage(message, updatesCurrentAsOf); } export { androidNotificationChannelID, handleAndroidMessage, androidBackgroundMessageTask, CommAndroidNotifications, }; diff --git a/native/push/reducer.js b/native/push/reducer.js deleted file mode 100644 index fb1d8cda2..000000000 --- a/native/push/reducer.js +++ /dev/null @@ -1,91 +0,0 @@ -// @flow - -import { - logOutActionTypes, - deleteAccountActionTypes, -} from 'lib/actions/user-actions'; -import { setNewSessionActionType } from 'lib/utils/action-utils'; - -import { - recordAndroidNotificationActionType, - clearAndroidNotificationsActionType, - rescindAndroidNotificationActionType, - type Action, -} from '../redux/action-types'; - -type RecordAndroidNotificationPayload = { - +threadID: string, - +notifID: string, -}; - -type ClearAndroidNotificationsPayload = { - +threadID: string, -}; - -type RescindAndroidNotificationPayload = { - +notifID: string, - +threadID: string, -}; - -export type AndroidNotificationActions = - | { - +type: 'RECORD_ANDROID_NOTIFICATION', - +payload: RecordAndroidNotificationPayload, - } - | { - +type: 'CLEAR_ANDROID_NOTIFICATIONS', - +payload: ClearAndroidNotificationsPayload, - } - | { - +type: 'RESCIND_ANDROID_NOTIFICATION', - +payload: RescindAndroidNotificationPayload, - }; - -function reduceThreadIDsToNotifIDs( - state: { [threadID: string]: string[] }, - action: Action, -): { [threadID: string]: string[] } { - if (action.type === recordAndroidNotificationActionType) { - const existingNotifIDs = state[action.payload.threadID]; - let set; - if (existingNotifIDs) { - set = new Set([...existingNotifIDs, action.payload.notifID]); - } else { - set = new Set([action.payload.notifID]); - } - return { - ...state, - [action.payload.threadID]: [...set], - }; - } else if (action.type === clearAndroidNotificationsActionType) { - if (!state[action.payload.threadID]) { - return state; - } - return { - ...state, - [action.payload.threadID]: [], - }; - } else if (action.type === rescindAndroidNotificationActionType) { - const { threadID, notifID } = action.payload; - const existingNotifIDs = state[threadID]; - if (!existingNotifIDs) { - return state; - } - const filtered = existingNotifIDs.filter(id => id !== notifID); - if (filtered.length === existingNotifIDs.length) { - return state; - } - return { ...state, [threadID]: filtered }; - } else if ( - action.type === logOutActionTypes.success || - action.type === deleteAccountActionTypes.success || - (action.type === setNewSessionActionType && - action.payload.sessionChange.cookieInvalidated) - ) { - return {}; - } else { - return state; - } -} - -export { reduceThreadIDsToNotifIDs }; diff --git a/native/redux/action-types.js b/native/redux/action-types.js index 0550f24fa..3aa811cd2 100644 --- a/native/redux/action-types.js +++ b/native/redux/action-types.js @@ -1,83 +1,73 @@ // @flow import type { Orientations } from 'react-native-orientation-locker'; import { saveMessagesActionType } from 'lib/actions/message-actions'; import type { Shape } from 'lib/types/core'; import type { BaseAction } from 'lib/types/redux-types'; -import type { AndroidNotificationActions } from '../push/reducer'; import type { DeviceCameraInfo } from '../types/camera'; import type { ConnectivityInfo } from '../types/connectivity'; import type { GlobalThemeInfo } from '../types/themes'; import type { DimensionsInfo } from './dimensions-updater.react'; import type { AppState } from './state-types'; export const resetUserStateActionType = 'RESET_USER_STATE'; export const recordNotifPermissionAlertActionType = 'RECORD_NOTIF_PERMISSION_ALERT'; -export const recordAndroidNotificationActionType = - 'RECORD_ANDROID_NOTIFICATION'; -export const clearAndroidNotificationsActionType = - 'CLEAR_ANDROID_NOTIFICATIONS'; -export const rescindAndroidNotificationActionType = - 'RESCIND_ANDROID_NOTIFICATION'; export const updateDimensionsActiveType = 'UPDATE_DIMENSIONS'; export const updateConnectivityActiveType = 'UPDATE_CONNECTIVITY'; export const updateThemeInfoActionType = 'UPDATE_THEME_INFO'; export const updateDeviceCameraInfoActionType = 'UPDATE_DEVICE_CAMERA_INFO'; export const updateDeviceOrientationActionType = 'UPDATE_DEVICE_ORIENTATION'; export const updateThreadLastNavigatedActionType = 'UPDATE_THREAD_LAST_NAVIGATED'; export const setStoreLoadedActionType = 'SET_STORE_LOADED'; export const setReduxStateActionType = 'SET_REDUX_STATE'; export const backgroundActionTypes: Set = new Set([ saveMessagesActionType, - recordAndroidNotificationActionType, - rescindAndroidNotificationActionType, ]); export type Action = | BaseAction - | AndroidNotificationActions | { +type: 'SET_REDUX_STATE', +payload: { +state: AppState, +hideFromMonitor: boolean }, } | { +type: 'SET_CUSTOM_SERVER', +payload: string, } | { +type: 'RECORD_NOTIF_PERMISSION_ALERT', +payload: { +time: number }, } | { +type: 'RESET_USER_STATE' } | { +type: 'UPDATE_DIMENSIONS', +payload: Shape, } | { +type: 'UPDATE_CONNECTIVITY', +payload: ConnectivityInfo, } | { +type: 'UPDATE_THEME_INFO', +payload: Shape, } | { +type: 'UPDATE_DEVICE_CAMERA_INFO', +payload: Shape, } | { +type: 'UPDATE_DEVICE_ORIENTATION', +payload: Orientations, } | { +type: 'UPDATE_THREAD_LAST_NAVIGATED', +payload: { +threadID: string, +time: number }, } | { +type: 'SET_STORE_LOADED', }; diff --git a/native/redux/persist.js b/native/redux/persist.js index d4e5b5d9d..feea4e378 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,570 +1,573 @@ // @flow import AsyncStorage from '@react-native-async-storage/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createMigrate, createTransform } from 'redux-persist'; import type { Transform } from 'redux-persist/es/types'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils'; import { getContainingThreadID, getCommunity } from 'lib/shared/thread-utils'; import { unshimMessageStore, unshimFunc } from 'lib/shared/unshim-utils'; import { defaultEnabledApps } from 'lib/types/enabled-apps'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import { type LocalMessageInfo, type MessageStore, messageTypes, type ClientDBMessageStoreOperation, } from 'lib/types/message-types'; import type { ClientDBMessageInfo } from 'lib/types/message-types'; import { defaultConnectionInfo } from 'lib/types/socket-types'; import { translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, } from 'lib/utils/message-ops-utils'; import { convertThreadStoreOperationsToClientDBOperations } from 'lib/utils/thread-ops-utils'; import { commCoreModule } from '../native-modules'; import { defaultNotifPermissionAlertInfo } from '../push/alerts'; import { defaultDeviceCameraInfo } from '../types/camera'; import { defaultGlobalThemeInfo } from '../types/themes'; import { isTaskCancelledError } from '../utils/error-handling'; import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration'; import type { AppState } from './state-types'; const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: state => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, - threadIDsToNotifIDs: state.threadIDsToNotifIDs, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: state => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: state => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: (state: AppState) => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: (state: AppState) => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [messageTypes.IMAGES]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => state, [15]: state => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: state => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: state => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: state => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: state => { const threadInfos = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), [22]: state => { for (const key in state.drafts) { const value = state.drafts[key]; try { commCoreModule.updateDraft(key, value); } catch (e) { if (!isTaskCancelledError(e)) { throw e; } } } return { ...state, drafts: undefined, }; }, [23]: state => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [24]: state => ({ ...state, enabledApps: defaultEnabledApps, }), [25]: state => ({ ...state, crashReportsEnabled: __DEV__, }), [26]: state => { const { currentUserInfo } = state; if (currentUserInfo.anonymous) { return state; } return { ...state, crashReportsEnabled: undefined, currentUserInfo: { id: currentUserInfo.id, username: currentUserInfo.username, }, enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, }; }, [27]: state => ({ ...state, queuedReports: undefined, enabledReports: undefined, threadStore: { ...state.threadStore, inconsistencyReports: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: undefined, }, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [ ...state.entryStore.inconsistencyReports, ...state.threadStore.inconsistencyReports, ...state.queuedReports, ], }, }), [28]: state => { const threadParentToChildren = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? state.threadStore.threadInfos[threadInfo.parentThreadID] : null; const parentIndex = parentThreadInfo ? parentThreadInfo.id : '-1'; if (!threadParentToChildren[parentIndex]) { threadParentToChildren[parentIndex] = []; } threadParentToChildren[parentIndex].push(threadID); } const rootIDs = threadParentToChildren['-1']; if (!rootIDs) { // This should never happen, but if it somehow does we'll let the state // check mechanism resolve it... return state; } const threadInfos = {}; const stack = [...rootIDs]; while (stack.length > 0) { const threadID = stack.shift(); const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; threadInfos[threadID] = { ...threadInfo, containingThreadID: getContainingThreadID( parentThreadInfo, threadInfo.type, ), community: getCommunity(parentThreadInfo), }; const children = threadParentToChildren[threadID]; if (children) { stack.push(...children); } } return { ...state, threadStore: { ...state.threadStore, threadInfos } }; }, [29]: (state: AppState) => { const updatedThreadInfos = migrateThreadStoreForEditThreadPermissions( state.threadStore.threadInfos, ); return { ...state, threadStore: { ...state.threadStore, threadInfos: updatedThreadInfos, }, }; }, [30]: (state: AppState) => { const threadInfos = state.threadStore.threadInfos; const operations = [ { type: 'remove_all', }, ...Object.keys(threadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: threadInfos[id] }, })), ]; try { commCoreModule.processThreadStoreOperationsSync( convertThreadStoreOperationsToClientDBOperations(operations), ); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [31]: (state: AppState) => { const messages = state.messageStore.messages; const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...Object.keys(messages).map((id: string) => ({ type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo(messages[id]), })), ]; try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [32]: (state: AppState) => { // 1. Get messages from SQLite `messages` table. const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. "Unshim" translated `RawMessageInfo`s. const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set([messageTypes.MULTIMEDIA])), ); // 4. Translate unshimmed `RawMessageInfo`s back to `ClientDBMessageInfo`s. const unshimmedClientDBMessageInfos = unshimmedRawMessageInfos.map( translateRawMessageInfoToClientDBMessageInfo, ); // 5. Construct `ClientDBMessageStoreOperation`s to clear SQLite `messages` // table and repopulate with unshimmed `ClientDBMessageInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...unshimmedClientDBMessageInfos.map((message: ClientDBMessageInfo) => ({ type: 'replace', payload: message, })), ]; // 6. Try processing `ClientDBMessageStoreOperation`s and log out if // `processMessageStoreOperationsSync(...)` throws an exception. try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return state; }, [33]: (state: AppState) => { // 1. Get messages from SQLite `messages` table. const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. "Unshim" translated `RawMessageInfo`s. const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set([messageTypes.REACTION])), ); // 4. Translate unshimmed `RawMessageInfo`s back to `ClientDBMessageInfo`s. const unshimmedClientDBMessageInfos = unshimmedRawMessageInfos.map( translateRawMessageInfoToClientDBMessageInfo, ); // 5. Construct `ClientDBMessageStoreOperation`s to clear SQLite `messages` // table and repopulate with unshimmed `ClientDBMessageInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...unshimmedClientDBMessageInfos.map((message: ClientDBMessageInfo) => ({ type: 'replace', payload: message, })), ]; // 6. Try processing `ClientDBMessageStoreOperation`s and log out if // `processMessageStoreOperationsSync(...)` throws an exception. try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return state; }, + [34]: state => { + const { threadIDsToNotifIDs, ...stateSansThreadIDsToNotifIDs } = state; + return stateSansThreadIDsToNotifIDs; + }, }; // After migration 31, we'll no longer want to persist `messageStore.messages` // via redux-persist. However, we DO want to continue persisting everything in // `messageStore` EXCEPT for `messages`. The `blacklist` property in // `persistConfig` allows us to specify top-level keys that shouldn't be // persisted. However, we aren't able to specify nested keys in `blacklist`. // As a result, if we want to prevent nested keys from being persisted we'll // need to use `createTransform(...)` to specify an `inbound` function that // allows us to modify the `state` object before it's passed through // `JSON.stringify(...)` and written to disk. We specify the keys for which // this transformation should be executed in the `whitelist` property of the // `config` object that's passed to `createTransform(...)`. // eslint-disable-next-line no-unused-vars type PersistedThreadMessageInfo = { +startReached: boolean, +lastNavigatedTo: number, +lastPruned: number, }; type PersistedMessageStore = { +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: number, +threads: { +[threadID: string]: PersistedThreadMessageInfo }, }; const messageStoreMessagesBlocklistTransform: Transform = createTransform( (state: MessageStore): PersistedMessageStore => { const { messages, threads, ...messageStoreSansMessages } = state; // We also do not want to persist `messageStore.threads[ID].messageIDs` // because they can be deterministically computed based on messages we have // from SQLite const threadsToPersist = {}; for (const threadID in threads) { const { messageIDs, ...threadsData } = threads[threadID]; threadsToPersist[threadID] = threadsData; } return { ...messageStoreSansMessages, threads: threadsToPersist }; }, (state: MessageStore): MessageStore => { const { threads: persistedThreads, ...messageStore } = state; const threads = {}; for (const threadID in persistedThreads) { threads[threadID] = { ...persistedThreads[threadID], messageIDs: [] }; } // We typically expect `messageStore.messages` to be `undefined` because // messages are persisted in the SQLite `messages` table rather than via // `redux-persist`. In this case we want to set `messageStore.messages` // to {} so we don't run into issues with `messageStore.messages` being // `undefined` (https://phab.comm.dev/D5545). // // However, in the case that a user is upgrading from a client where // `persistConfig.version` < 31, we expect `messageStore.messages` to // contain messages stored via `redux-persist` that we need in order // to correctly populate the SQLite `messages` table in migration 31 // (https://phab.comm.dev/D2600). // // However, because `messageStoreMessagesBlocklistTransform` modifies // `messageStore` before migrations are run, we need to make sure we aren't // inadvertently clearing `messageStore.messages` (by setting to {}) before // messages are stored in SQLite (https://linear.app/comm/issue/ENG-2377). return { ...messageStore, threads, messages: messageStore.messages ?? {} }; }, { whitelist: ['messageStore'] }, ); const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: [ 'loadingStatuses', 'lifecycleState', 'dimensions', 'draftStore', 'connectivity', 'deviceOrientation', 'frozen', 'threadStore', 'storeLoaded', ], debug: __DEV__, - version: 33, + version: 34, transforms: [messageStoreMessagesBlocklistTransform], migrate: (createMigrate(migrations, { debug: __DEV__ }): any), timeout: ((__DEV__ ? 0 : undefined): number | void), }; const codeVersion: number = commCoreModule.getCodeVersion(); // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor(): empty { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index d87f87833..16c6278f2 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,565 +1,547 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import ExitApp from 'react-native-exit-app'; import Orientation from 'react-native-orientation-locker'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import thunk from 'redux-thunk'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions'; import { setDeviceTokenActionTypes } from 'lib/actions/device-actions'; import { siweAuthActionTypes } from 'lib/actions/siwe-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { processThreadStoreOperations } from 'lib/reducers/thread-reducer'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/account-utils'; import { isStaff } from 'lib/shared/user-utils'; import { defaultEnabledApps } from 'lib/types/enabled-apps'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import type { Dispatch, BaseAction } from 'lib/types/redux-types'; import { rehydrateActionType } from 'lib/types/redux-types'; import type { SetSessionPayload } from 'lib/types/session-types'; import { defaultConnectionInfo, incrementalStateSyncActionType, } from 'lib/types/socket-types'; import type { ThreadStoreOperation } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { convertMessageStoreOperationsToClientDBOperations } from 'lib/utils/message-ops-utils'; import { convertThreadStoreOperationsToClientDBOperations } from 'lib/utils/thread-ops-utils'; import { commCoreModule } from '../native-modules'; import { defaultNavInfo } from '../navigation/default-state'; import { getGlobalNavContext } from '../navigation/icky-global'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { defaultNotifPermissionAlertInfo } from '../push/alerts'; -import { reduceThreadIDsToNotifIDs } from '../push/reducer'; import reactotron from '../reactotron'; import { defaultDeviceCameraInfo } from '../types/camera'; import { defaultConnectivityInfo } from '../types/connectivity'; import { defaultGlobalThemeInfo } from '../types/themes'; import { isTaskCancelledError } from '../utils/error-handling'; import { isStaffRelease } from '../utils/staff-utils'; import { defaultURLPrefix, natNodeServer, setCustomServer, getDevServerHostname, } from '../utils/url-utils'; import { resetUserStateActionType, recordNotifPermissionAlertActionType, - recordAndroidNotificationActionType, - clearAndroidNotificationsActionType, - rescindAndroidNotificationActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, setStoreLoadedActionType, type Action, } from './action-types'; import { remoteReduxDevServerConfig } from './dev-tools'; import { defaultDimensionsInfo } from './dimensions-updater.react'; import { persistConfig, setPersistor } from './persist'; import type { AppState } from './state-types'; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, draftStore: { drafts: {} }, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }, threadStore: { threadInfos: {}, }, userStore: { userInfos: {}, inconsistencyReports: [], }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, storeLoaded: false, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, cookie: null, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix, customServer: natNodeServer, - threadIDsToNotifIDs: {}, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], lifecycleState: 'active', enabledApps: defaultEnabledApps, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [], }, nextLocalID: 0, _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, userPolicies: {}, }: AppState); function reducer(state: AppState = defaultState, action: Action) { if (action.type === setReduxStateActionType) { return action.payload.state; } // We want to alert staff/developers if there's a difference between the keys // we expect to see REHYDRATED and the keys that are actually REHYDRATED. // Context: https://linear.app/comm/issue/ENG-2127/ if ( action.type === rehydrateActionType && (__DEV__ || isStaffRelease || (state.currentUserInfo && state.currentUserInfo.id && isStaff(state.currentUserInfo.id))) ) { // 1. Construct set of keys expected to be REHYDRATED const defaultKeys = Object.keys(defaultState); const expectedKeys = defaultKeys.filter( each => !persistConfig.blacklist.includes(each), ); const expectedKeysSet = new Set(expectedKeys); // 2. Construct set of keys actually REHYDRATED const rehydratedKeys = Object.keys(action.payload ?? {}); const rehydratedKeysSet = new Set(rehydratedKeys); // 3. Determine the difference between the two sets const expectedKeysNotRehydrated = expectedKeys.filter( each => !rehydratedKeysSet.has(each), ); const rehydratedKeysNotExpected = rehydratedKeys.filter( each => !expectedKeysSet.has(each), ); // 4. Display alerts with the differences between the two sets if (expectedKeysNotRehydrated.length > 0) { Alert.alert( `EXPECTED KEYS NOT REHYDRATED: ${JSON.stringify( expectedKeysNotRehydrated, )}`, ); } if (rehydratedKeysNotExpected.length > 0) { Alert.alert( `REHYDRATED KEYS NOT EXPECTED: ${JSON.stringify( rehydratedKeysNotExpected, )}`, ); } } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return state; } if ( (action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, action.payload.logInActionSource, )) || ((action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success) && invalidSessionRecovery( state, action.payload.currentUserInfo, action.payload.logInActionSource, )) ) { return state; } - const threadIDsToNotifIDs = reduceThreadIDsToNotifIDs( - state.threadIDsToNotifIDs, - action, - ); - state = { ...state, threadIDsToNotifIDs }; - if ( - action.type === recordAndroidNotificationActionType || - action.type === clearAndroidNotificationsActionType || - action.type === rescindAndroidNotificationActionType - ) { - return state; - } - if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === recordNotifPermissionAlertActionType) { return { ...state, notifPermissionAlertInfo: { totalAlerts: state.notifPermissionAlertInfo.totalAlerts + 1, lastAlertTime: action.payload.time, }, }; } else if (action.type === resetUserStateActionType) { const cookie = state.cookie && state.cookie.startsWith('anonymous=') ? state.cookie : null; const currentUserInfo = state.currentUserInfo && state.currentUserInfo.anonymous ? state.currentUserInfo : null; return { ...state, currentUserInfo, cookie, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { return { ...state, globalThemeInfo: { ...state.globalThemeInfo, ...action.payload, }, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setDeviceTokenActionTypes.success) { return { ...state, deviceToken: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }, }, }; } return state; } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } else if (action.type === incrementalStateSyncActionType) { let wipeDeviceToken = false; for (const update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.deviceToken ) { wipeDeviceToken = true; break; } } if (wipeDeviceToken) { state = { ...state, deviceToken: null, }; } } if (action.type === setStoreLoadedActionType) { return { ...state, storeLoaded: true, }; } if (action.type === setClientDBStoreActionType) { state = { ...state, storeLoaded: true, }; const currentLoggedInUserID = state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id; const actionCurrentLoggedInUserID = action.payload.currentUserID; if ( !currentLoggedInUserID || !actionCurrentLoggedInUserID || actionCurrentLoggedInUserID !== currentLoggedInUserID ) { // If user is logged out now, was logged out at the time action was // dispatched or their ID changed between action dispatch and a // call to reducer we ignore the SQLite data since it is not valid return state; } } const baseReducerResult = baseReducer(state, (action: BaseAction)); state = baseReducerResult.state; const { storeOperations } = baseReducerResult; const { draftStoreOperations, threadStoreOperations, messageStoreOperations, } = storeOperations; const fixUnreadActiveThreadResult = fixUnreadActiveThread(state, action); state = fixUnreadActiveThreadResult.state; const threadStoreOperationsWithUnreadFix = [ ...threadStoreOperations, ...fixUnreadActiveThreadResult.threadStoreOperations, ]; const convertedThreadStoreOperations = convertThreadStoreOperationsToClientDBOperations( threadStoreOperationsWithUnreadFix, ); const convertedMessageStoreOperations = convertMessageStoreOperationsToClientDBOperations( messageStoreOperations, ); (async () => { try { const promises = []; if (convertedThreadStoreOperations.length > 0) { promises.push( commCoreModule.processThreadStoreOperations( convertedThreadStoreOperations, ), ); } if (convertedMessageStoreOperations.length > 0) { promises.push( commCoreModule.processMessageStoreOperations( convertedMessageStoreOperations, ), ); } if (draftStoreOperations.length > 0) { promises.push( commCoreModule.processDraftStoreOperations(draftStoreOperations), ); } await Promise.all(promises); } catch (e) { if (isTaskCancelledError(e)) { return; } ExitApp.exitApp(); } })(); return state; } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', 'Your app version is pretty old, and the server doesn’t know how to ' + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], { cancelable: true }, ); } else { Alert.alert( 'Session invalidated', 'We’re sorry, but your session was invalidated by the server. ' + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. type FixUnreadActiveThreadResult = { +state: AppState, +threadStoreOperations: $ReadOnlyArray, }; function fixUnreadActiveThread( state: AppState, action: *, ): FixUnreadActiveThreadResult { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( !activeThread || !state.threadStore.threadInfos[activeThread]?.currentUser.unread || (NativeAppState.currentState !== 'active' && (appLastBecameInactive + 10000 >= Date.now() || backgroundActionTypes.has(action.type))) ) { return { state, threadStoreOperations: [] }; } const updatedActiveThreadInfo = { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: activeThread, threadInfo: updatedActiveThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state.threadStore, threadStoreOperations, ); return { state: { ...state, threadStore: updatedThreadStore }, threadStoreOperations, }; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middleware = applyMiddleware(thunk, reduxLoggerMiddleware); let composeFunc = compose; if (__DEV__ && global.HermesInternal) { const { composeWithDevTools } = require('remote-redux-devtools/src'); composeFunc = composeWithDevTools({ name: 'Redux', hostname: getDevServerHostname(), ...remoteReduxDevServerConfig, }); } else if (global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeFunc = global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux', }); } let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; export { store, dispatch, appBecameInactive }; diff --git a/native/redux/state-types.js b/native/redux/state-types.js index c80281299..b9ef84025 100644 --- a/native/redux/state-types.js +++ b/native/redux/state-types.js @@ -1,60 +1,59 @@ // @flow import type { Orientations } from 'react-native-orientation-locker'; import type { PersistState } from 'redux-persist/src/types'; import type { DraftStore } from 'lib/types/draft-types'; import type { EnabledApps } from 'lib/types/enabled-apps'; import type { EntryStore } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { UserPolicies } from 'lib/types/policy-types'; import type { ReportStore } from 'lib/types/report-types'; import type { ConnectionInfo } from 'lib/types/socket-types'; import type { ThreadStore } from 'lib/types/thread-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import type { NavInfo } from '../navigation/default-state'; import type { NotifPermissionAlertInfo } from '../push/alerts'; import type { DeviceCameraInfo } from '../types/camera'; import type { ConnectivityInfo } from '../types/connectivity'; import type { GlobalThemeInfo } from '../types/themes'; import type { DimensionsInfo } from './dimensions-updater.react'; export type AppState = { navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, draftStore: DraftStore, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, storeLoaded: boolean, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, cookie: ?string, deviceToken: ?string, dataLoaded: boolean, urlPrefix: string, customServer: ?string, - threadIDsToNotifIDs: { [threadID: string]: string[] }, notifPermissionAlertInfo: NotifPermissionAlertInfo, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, lifecycleState: LifecycleState, enabledApps: EnabledApps, reportStore: ReportStore, nextLocalID: number, _persist: ?PersistState, sessionID?: void, dimensions: DimensionsInfo, connectivity: ConnectivityInfo, globalThemeInfo: GlobalThemeInfo, deviceCameraInfo: DeviceCameraInfo, deviceOrientation: Orientations, frozen: boolean, userPolicies: UserPolicies, };