diff --git a/lib/shared/account-utils.js b/lib/shared/account-utils.js index 73887f68c..176769d3f 100644 --- a/lib/shared/account-utils.js +++ b/lib/shared/account-utils.js @@ -1,117 +1,64 @@ // @flow -import { - logInActionSources, - type LogInActionSource, -} from '../types/account-types.js'; -import type { AppState } from '../types/redux-types.js'; -import type { PreRequestUserState } from '../types/session-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; import { isValidEthereumAddress } from '../utils/siwe-utils.js'; const usernameMaxLength = 191; const usernameMinLength = 1; const secondCharRange = `{${usernameMinLength - 1},${usernameMaxLength - 1}}`; const validUsernameRegexString = `^[a-zA-Z0-9][a-zA-Z0-9-_]${secondCharRange}$`; const validUsernameRegex: RegExp = new RegExp(validUsernameRegexString); // usernames used to be less restrictive (eg single chars were allowed) // use oldValidUsername when dealing with existing accounts const oldValidUsernameRegexString = '[a-zA-Z0-9-_]+'; const oldValidUsernameRegex: RegExp = new RegExp( `^${oldValidUsernameRegexString}$`, ); const validEmailRegex: RegExp = new RegExp( /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+/.source + /@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/.source + /(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.source, ); const validHexColorRegex: RegExp = /^[a-fA-F0-9]{6}$/; -function invalidSessionDowngrade( - currentReduxState: AppState, - actionCurrentUserInfo: ?CurrentUserInfo, - preRequestUserState: ?PreRequestUserState, -): boolean { - // If this action represents a session downgrade - oldState has a loggedIn - // currentUserInfo, but the action has an anonymous one - then it is only - // valid if the currentUserInfo used for the request matches what oldState - // currently has. If the currentUserInfo in Redux has changed since the - // request, and is currently loggedIn, then the session downgrade does not - // apply to it. In this case we will simply swallow the action. - const currentCurrentUserInfo = currentReduxState.currentUserInfo; - return !!( - currentCurrentUserInfo && - !currentCurrentUserInfo.anonymous && - // Note that an undefined actionCurrentUserInfo represents an action that - // doesn't affect currentUserInfo, whereas a null one represents an action - // that sets it to null - (actionCurrentUserInfo === null || - (actionCurrentUserInfo && actionCurrentUserInfo.anonymous)) && - preRequestUserState && - (preRequestUserState.currentUserInfo?.id !== currentCurrentUserInfo.id || - preRequestUserState.cookie !== currentReduxState.cookie || - preRequestUserState.sessionID !== currentReduxState.sessionID) - ); -} - -function invalidSessionRecovery( - currentReduxState: AppState, - actionCurrentUserInfo: CurrentUserInfo, - logInActionSource: ?LogInActionSource, -): boolean { - if ( - logInActionSource !== - logInActionSources.cookieInvalidationResolutionAttempt && - logInActionSource !== logInActionSources.socketAuthErrorResolutionAttempt - ) { - return false; - } - return ( - !currentReduxState.dataLoaded || - currentReduxState.currentUserInfo?.id !== actionCurrentUserInfo.id - ); -} - function accountHasPassword(currentUserInfo: ?CurrentUserInfo): boolean { return currentUserInfo?.username ? !isValidEthereumAddress(currentUserInfo.username) : false; } function userIdentifiedByETHAddress( userInfo: ?{ +username?: ?string, ... }, ): boolean { return userInfo?.username ? isValidEthereumAddress(userInfo?.username) : false; } function getETHAddressForUserInfo( userInfo: ?{ +username?: ?string, ... }, ): ?string { if (!userInfo) { return null; } const { username } = userInfo; const ethAddress = username && userIdentifiedByETHAddress(userInfo) ? username : null; return ethAddress; } export { usernameMaxLength, oldValidUsernameRegexString, validUsernameRegex, oldValidUsernameRegex, validEmailRegex, - invalidSessionDowngrade, - invalidSessionRecovery, validHexColorRegex, accountHasPassword, userIdentifiedByETHAddress, getETHAddressForUserInfo, }; diff --git a/lib/shared/account-utils.js b/lib/shared/session-utils.js similarity index 53% copy from lib/shared/account-utils.js copy to lib/shared/session-utils.js index 73887f68c..49eca75da 100644 --- a/lib/shared/account-utils.js +++ b/lib/shared/session-utils.js @@ -1,117 +1,56 @@ // @flow import { logInActionSources, type LogInActionSource, } from '../types/account-types.js'; import type { AppState } from '../types/redux-types.js'; import type { PreRequestUserState } from '../types/session-types.js'; import type { CurrentUserInfo } from '../types/user-types.js'; -import { isValidEthereumAddress } from '../utils/siwe-utils.js'; - -const usernameMaxLength = 191; -const usernameMinLength = 1; -const secondCharRange = `{${usernameMinLength - 1},${usernameMaxLength - 1}}`; -const validUsernameRegexString = `^[a-zA-Z0-9][a-zA-Z0-9-_]${secondCharRange}$`; -const validUsernameRegex: RegExp = new RegExp(validUsernameRegexString); - -// usernames used to be less restrictive (eg single chars were allowed) -// use oldValidUsername when dealing with existing accounts -const oldValidUsernameRegexString = '[a-zA-Z0-9-_]+'; -const oldValidUsernameRegex: RegExp = new RegExp( - `^${oldValidUsernameRegexString}$`, -); - -const validEmailRegex: RegExp = new RegExp( - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+/.source + - /@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/.source + - /(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.source, -); - -const validHexColorRegex: RegExp = /^[a-fA-F0-9]{6}$/; function invalidSessionDowngrade( currentReduxState: AppState, actionCurrentUserInfo: ?CurrentUserInfo, preRequestUserState: ?PreRequestUserState, ): boolean { // If this action represents a session downgrade - oldState has a loggedIn // currentUserInfo, but the action has an anonymous one - then it is only // valid if the currentUserInfo used for the request matches what oldState // currently has. If the currentUserInfo in Redux has changed since the // request, and is currently loggedIn, then the session downgrade does not // apply to it. In this case we will simply swallow the action. const currentCurrentUserInfo = currentReduxState.currentUserInfo; return !!( currentCurrentUserInfo && !currentCurrentUserInfo.anonymous && // Note that an undefined actionCurrentUserInfo represents an action that // doesn't affect currentUserInfo, whereas a null one represents an action // that sets it to null (actionCurrentUserInfo === null || (actionCurrentUserInfo && actionCurrentUserInfo.anonymous)) && preRequestUserState && (preRequestUserState.currentUserInfo?.id !== currentCurrentUserInfo.id || preRequestUserState.cookie !== currentReduxState.cookie || preRequestUserState.sessionID !== currentReduxState.sessionID) ); } function invalidSessionRecovery( currentReduxState: AppState, actionCurrentUserInfo: CurrentUserInfo, logInActionSource: ?LogInActionSource, ): boolean { if ( logInActionSource !== logInActionSources.cookieInvalidationResolutionAttempt && logInActionSource !== logInActionSources.socketAuthErrorResolutionAttempt ) { return false; } return ( !currentReduxState.dataLoaded || currentReduxState.currentUserInfo?.id !== actionCurrentUserInfo.id ); } -function accountHasPassword(currentUserInfo: ?CurrentUserInfo): boolean { - return currentUserInfo?.username - ? !isValidEthereumAddress(currentUserInfo.username) - : false; -} - -function userIdentifiedByETHAddress( - userInfo: ?{ +username?: ?string, ... }, -): boolean { - return userInfo?.username - ? isValidEthereumAddress(userInfo?.username) - : false; -} - -function getETHAddressForUserInfo( - userInfo: ?{ +username?: ?string, ... }, -): ?string { - if (!userInfo) { - return null; - } - const { username } = userInfo; - const ethAddress = - username && userIdentifiedByETHAddress(userInfo) ? username : null; - - return ethAddress; -} - -export { - usernameMaxLength, - oldValidUsernameRegexString, - validUsernameRegex, - oldValidUsernameRegex, - validEmailRegex, - invalidSessionDowngrade, - invalidSessionRecovery, - validHexColorRegex, - accountHasPassword, - userIdentifiedByETHAddress, - getETHAddressForUserInfo, -}; +export { invalidSessionDowngrade, invalidSessionRecovery }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index 7fb8a38d5..7c083db57 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,494 +1,494 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; 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.js'; import { siweAuthActionTypes } from 'lib/actions/siwe-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions.js'; import baseReducer from 'lib/reducers/master-reducer.js'; import { processThreadStoreOperations } from 'lib/reducers/thread-reducer.js'; import { invalidSessionDowngrade, invalidSessionRecovery, -} from 'lib/shared/account-utils.js'; +} from 'lib/shared/session-utils.js'; import { isStaff } from 'lib/shared/staff-utils.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import type { Dispatch, BaseAction } from 'lib/types/redux-types.js'; import { rehydrateActionType } from 'lib/types/redux-types.js'; import type { SetSessionPayload } from 'lib/types/session-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import type { ThreadStoreOperation } from 'lib/types/thread-types.js'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; import { setNewSessionActionType } from 'lib/utils/action-utils.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { resetUserStateActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, setStoreLoadedActionType, type Action, } from './action-types.js'; import { remoteReduxDevServerConfig } from './dev-tools.js'; import { defaultDimensionsInfo } from './dimensions-updater.react.js'; import { persistConfig, setPersistor } from './persist.js'; import { processDBStoreOperations } from './redux-utils.js'; import type { AppState } from './state-types.js'; import { defaultNavInfo } from '../navigation/default-state.js'; import { getGlobalNavContext } from '../navigation/icky-global.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import reactotron from '../reactotron.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { defaultConnectivityInfo } from '../types/connectivity.js'; import { defaultGlobalThemeInfo } from '../types/themes.js'; import { isStaffRelease } from '../utils/staff-utils.js'; import { defaultURLPrefix, natNodeServer, setCustomServer, getDevServerHostname, } from '../utils/url-utils.js'; 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, 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: {}, commServicesAccessToken: null, }: 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; } if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } 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 === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { const updatedThreads = { [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }; state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, ...updatedThreads, }, }, }; processDBStoreOperations({ draftStoreOperations: [], messageStoreOperations: [ { type: 'replace_threads', payload: { threads: updatedThreads, }, }, ], threadStoreOperations: [], }); } return state; } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } 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, ]; processDBStoreOperations({ draftStoreOperations, messageStoreOperations, threadStoreOperations: threadStoreOperationsWithUnreadFix, }); 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/index.js'); 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/web/redux/redux-setup.js b/web/redux/redux-setup.js index 08b0693b0..46b495060 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,319 +1,319 @@ // @flow import invariant from 'invariant'; import type { PersistState } from 'redux-persist/es/types.js'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions.js'; import baseReducer from 'lib/reducers/master-reducer.js'; import { nonThreadCalendarFilters } from 'lib/selectors/calendar-filter-selectors.js'; import { mostRecentlyReadThreadSelector } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; -import { invalidSessionDowngrade } from 'lib/shared/account-utils.js'; +import { invalidSessionDowngrade } from 'lib/shared/session-utils.js'; import type { Shape } from 'lib/types/core.js'; import type { CryptoStore, OLMIdentityKeys, PickledOLMAccount, } from 'lib/types/crypto-types.js'; import type { DraftStore } from 'lib/types/draft-types.js'; import type { EnabledApps } from 'lib/types/enabled-apps.js'; import type { EntryStore } from 'lib/types/entry-types.js'; import { type CalendarFilter, calendarThreadFilterTypes, } from 'lib/types/filter-types.js'; import type { LifecycleState } from 'lib/types/lifecycle-state-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MessageStore } from 'lib/types/message-types.js'; import type { UserPolicies } from 'lib/types/policy-types.js'; import type { BaseAction } from 'lib/types/redux-types.js'; import type { ReportStore } from 'lib/types/report-types.js'; import type { ConnectionInfo } from 'lib/types/socket-types.js'; import type { ThreadStore } from 'lib/types/thread-types.js'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types.js'; import { setNewSessionActionType } from 'lib/utils/action-utils.js'; import type { NotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { updateWindowActiveActionType, setDeviceIDActionType, updateNavInfoActionType, updateWindowDimensionsActionType, updateCalendarCommunityFilter, clearCalendarCommunityFilter, } from './action-types.js'; import { reduceCryptoStore, setPrimaryIdentityKeys, setNotificationIdentityKeys, setPickledNotificationAccount, setPickledPrimaryAccount, } from './crypto-store-reducer.js'; import { reduceDeviceID } from './device-id-reducer.js'; import reduceNavInfo from './nav-reducer.js'; import { getVisibility } from './visibility.js'; import { filterThreadIDsBelongingToCommunity } from '../selectors/calendar-selectors.js'; import { activeThreadSelector } from '../selectors/nav-selectors.js'; import { type NavInfo } from '../types/nav-types.js'; export type WindowDimensions = { width: number, height: number }; export type AppState = { navInfo: NavInfo, deviceID: ?string, currentUserInfo: ?CurrentUserInfo, draftStore: DraftStore, sessionID: ?string, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, calendarPickedCommunityID: ?string, urlPrefix: string, windowDimensions: WindowDimensions, cookie?: void, deviceToken: ?string, baseHref: string, notifPermissionAlertInfo: NotifPermissionAlertInfo, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, lifecycleState: LifecycleState, enabledApps: EnabledApps, reportStore: ReportStore, nextLocalID: number, dataLoaded: boolean, windowActive: boolean, userPolicies: UserPolicies, cryptoStore: CryptoStore, pushApiPublicKey: ?string, _persist: ?PersistState, +commServicesAccessToken: ?string, }; export type Action = | BaseAction | { type: 'UPDATE_NAV_INFO', payload: Shape } | { type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, } | { type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, } | { type: 'SET_DEVICE_ID', payload: string, } | { +type: 'SET_PRIMARY_IDENTITY_KEYS', payload: ?OLMIdentityKeys } | { +type: 'SET_NOTIFICATION_IDENTITY_KEYS', payload: ?OLMIdentityKeys } | { +type: 'SET_PICKLED_PRIMARY_ACCOUNT', payload: ?PickledOLMAccount } | { +type: 'SET_PICKLED_NOTIFICATION_ACCOUNT', payload: ?PickledOLMAccount } | { +type: 'UPDATE_CALENDAR_COMMUNITY_FILTER', +payload: string, } | { +type: 'CLEAR_CALENDAR_COMMUNITY_FILTER', +payload: void, }; export function reducer(oldState: AppState | void, action: Action): AppState { invariant(oldState, 'should be set'); let state = oldState; if (action.type === updateWindowDimensionsActionType) { return validateState(oldState, { ...state, windowDimensions: action.payload, }); } else if (action.type === updateWindowActiveActionType) { return validateState(oldState, { ...state, windowActive: action.payload, }); } else if (action.type === updateCalendarCommunityFilter) { const nonThreadFilters = nonThreadCalendarFilters(state.calendarFilters); const threadIDs = Array.from( filterThreadIDsBelongingToCommunity( action.payload, state.threadStore.threadInfos, ), ); return { ...state, calendarFilters: [ ...nonThreadFilters, { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs, }, ], calendarPickedCommunityID: action.payload, }; } else if (action.type === clearCalendarCommunityFilter) { const nonThreadFilters = nonThreadCalendarFilters(state.calendarFilters); return { ...state, calendarFilters: nonThreadFilters, calendarPickedCommunityID: null, }; } else if (action.type === setNewSessionActionType) { if ( invalidSessionDowngrade( oldState, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, ) ) { return oldState; } state = { ...state, sessionID: action.payload.sessionChange.sessionID, }; } else if ( (action.type === logOutActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return oldState; } if ( action.type !== updateNavInfoActionType && action.type !== setDeviceIDActionType && action.type !== setPrimaryIdentityKeys && action.type !== setNotificationIdentityKeys && action.type !== setPickledPrimaryAccount && action.type !== setPickledNotificationAccount ) { state = baseReducer(state, action).state; } state = { ...state, navInfo: reduceNavInfo( state.navInfo, action, state.threadStore.threadInfos, ), deviceID: reduceDeviceID(state.deviceID, action), cryptoStore: reduceCryptoStore(state.cryptoStore, action), }; return validateState(oldState, state); } function validateState(oldState: AppState, state: AppState): AppState { if ( (state.navInfo.activeChatThreadID && !state.navInfo.pendingThread && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID]) || (!state.navInfo.activeChatThreadID && isLoggedIn(state)) ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentlyReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && getVisibility().hidden() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but visibilityjs reports the window is not visible', ); } if ( activeThread && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread && typeof document !== 'undefined' && document && 'hasFocus' in document && !document.hasFocus() ) { console.warn( `thread ${activeThread} is active and unread, ` + 'but document.hasFocus() is false', ); } if ( activeThread && !getVisibility().hidden() && typeof document !== 'undefined' && document && 'hasFocus' in document && document.hasFocus() && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread ) { // Makes sure a currently focused thread is never unread state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { // Update messageStore.threads[activeThread].lastNavigatedTo state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [activeThread]: { ...state.messageStore.threads[activeThread], lastNavigatedTo: Date.now(), }, }, }, }; } return state; }