diff --git a/lib/reducers/db-ops-reducer.test.js b/lib/reducers/db-ops-reducer.test.js index e04e17670..ffb790b29 100644 --- a/lib/reducers/db-ops-reducer.test.js +++ b/lib/reducers/db-ops-reducer.test.js @@ -1,36 +1,37 @@ // @flow import { reduceDBOpsStore } from './db-ops-reducer.js'; import { opsProcessingFinishedActionType } from '../actions/db-ops-actions.js'; import type { DBOpsStore } from '../types/db-ops-types.js'; const emptyOps = { draftStoreOperations: [], threadStoreOperations: [], messageStoreOperations: [], reportStoreOperations: [], userStoreOperations: [], keyserverStoreOperations: [], communityStoreOperations: [], integrityStoreOperations: [], syncedMetadataStoreOperations: [], auxUserStoreOperations: [], + threadActivityStoreOperations: [], }; describe('DB ops reducer', () => { const store: DBOpsStore = { queuedOps: [ { ops: emptyOps, messageID: '5' }, { ops: emptyOps }, { ops: null, messageID: '7' }, ], }; it('should remove the first entry', () => { const newState = reduceDBOpsStore(store, { type: opsProcessingFinishedActionType, }); expect(newState.queuedOps.length).toEqual(2); expect(newState.queuedOps[1].messageID).toEqual('7'); }); }); diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js index a8e41f02b..f8572c83c 100644 --- a/lib/reducers/master-reducer.js +++ b/lib/reducers/master-reducer.js @@ -1,239 +1,238 @@ // @flow import { reduceAlertStore } from './alert-reducer.js'; import { reduceAuxUserStore } from './aux-user-reducer.js'; import reduceCalendarFilters from './calendar-filters-reducer.js'; import { reduceCommunityStore } from './community-reducer.js'; import reduceCustomerServer from './custom-server-reducer.js'; import reduceDataLoaded from './data-loaded-reducer.js'; import { reduceDBOpsStore } from './db-ops-reducer.js'; import { reduceDraftStore } from './draft-reducer.js'; import reduceEnabledApps from './enabled-apps-reducer.js'; import { reduceEntryInfos } from './entry-reducer.js'; import { reduceIntegrityStore } from './integrity-reducer.js'; import reduceInviteLinks from './invite-links-reducer.js'; import reduceKeyserverStore from './keyserver-reducer.js'; import reduceLifecycleState from './lifecycle-state-reducer.js'; import { reduceLoadingStatuses } from './loading-reducer.js'; import { reduceMessageStore } from './message-reducer.js'; import reduceBaseNavInfo from './nav-reducer.js'; import policiesReducer from './policies-reducer.js'; import reduceReportStore from './report-store-reducer.js'; import reduceServicesAccessToken from './services-access-token-reducer.js'; import { reduceSyncedMetadataStore } from './synced-metadata-reducer.js'; import reduceGlobalThemeInfo from './theme-reducer.js'; import { reduceThreadActivity } from './thread-activity-reducer.js'; import { reduceThreadInfos } from './thread-reducer.js'; import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer.js'; import { addKeyserverActionType } from '../actions/keyserver-actions.js'; import { siweAuthActionTypes } from '../actions/siwe-actions.js'; import { keyserverRegisterActionTypes, logInActionTypes, keyserverAuthActionTypes, } from '../actions/user-actions.js'; import { keyserverStoreOpsHandlers, type ReplaceKeyserverOperation, } from '../ops/keyserver-store-ops.js'; import { isStaff } from '../shared/staff-utils.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState, BaseAction } from '../types/redux-types.js'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types.js'; import type { StoreOperations } from '../types/store-ops-types.js'; import { isDev } from '../utils/dev-utils.js'; export default function baseReducer>( state: T, action: BaseAction, onStateDifference: (message: string) => mixed, ): { state: T, storeOperations: StoreOperations } { const { threadStore, newThreadInconsistencies, threadStoreOperations } = reduceThreadInfos(state.threadStore, action); const { threadInfos } = threadStore; const [entryStore, newEntryInconsistencies] = reduceEntryInfos( state.entryStore, action, threadInfos, ); const onStateDifferenceForStaff = (message: string) => { const isCurrentUserStaff = state.currentUserInfo?.id ? isStaff(state.currentUserInfo.id) : false; if (isCurrentUserStaff || isDev) { onStateDifference(message); } }; const [userStore, newUserInconsistencies, userStoreOperations] = reduceUserInfos(state.userStore, action); const newInconsistencies = [ ...newEntryInconsistencies, ...newThreadInconsistencies, ...newUserInconsistencies, ]; // Only allow checkpoints to increase if we are connected // or if the action is a STATE_SYNC const { messageStoreOperations, messageStore: reducedMessageStore } = reduceMessageStore(state.messageStore, action, threadInfos); let messageStore = reducedMessageStore; let { keyserverStore, keyserverStoreOperations } = reduceKeyserverStore( state.keyserverStore, action, onStateDifferenceForStaff, ); if ( action.type !== incrementalStateSyncActionType && action.type !== fullStateSyncActionType && action.type !== keyserverRegisterActionTypes.success && action.type !== logInActionTypes.success && action.type !== siweAuthActionTypes.success && action.type !== keyserverAuthActionTypes.success && action.type !== addKeyserverActionType ) { const replaceOperations: ReplaceKeyserverOperation[] = []; for (const keyserverID in keyserverStore.keyserverInfos) { if ( keyserverStore.keyserverInfos[keyserverID].connection.status === 'connected' ) { continue; } if ( messageStore.currentAsOf[keyserverID] !== state.messageStore.currentAsOf[keyserverID] ) { messageStore = { ...messageStore, currentAsOf: { ...messageStore.currentAsOf, [keyserverID]: state.messageStore.currentAsOf[keyserverID], }, }; } if ( state.keyserverStore.keyserverInfos[keyserverID] && keyserverStore.keyserverInfos[keyserverID].updatesCurrentAsOf !== state.keyserverStore.keyserverInfos[keyserverID].updatesCurrentAsOf ) { replaceOperations.push({ type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...keyserverStore.keyserverInfos[keyserverID], updatesCurrentAsOf: state.keyserverStore.keyserverInfos[keyserverID] .updatesCurrentAsOf, }, }, }); } } keyserverStore = keyserverStoreOpsHandlers.processStoreOperations( keyserverStore, replaceOperations, ); keyserverStoreOperations = [ ...keyserverStoreOperations, ...replaceOperations, ]; } const { draftStore, draftStoreOperations } = reduceDraftStore( state.draftStore, action, ); const { reportStore, reportStoreOperations } = reduceReportStore( state.reportStore, action, newInconsistencies, ); const { communityStore, communityStoreOperations } = reduceCommunityStore( state.communityStore, action, ); const { integrityStore, integrityStoreOperations } = reduceIntegrityStore( state.integrityStore, action, onStateDifferenceForStaff, threadInfos, threadStoreOperations, ); const { syncedMetadataStore, syncedMetadataStoreOperations } = reduceSyncedMetadataStore(state.syncedMetadataStore, action); const { auxUserStore, auxUserStoreOperations } = reduceAuxUserStore( state.auxUserStore, action, ); - const { threadActivityStore } = reduceThreadActivity( - state.threadActivityStore, - action, - ); + const { threadActivityStore, threadActivityStoreOperations } = + reduceThreadActivity(state.threadActivityStore, action); return { state: { ...state, navInfo: reduceBaseNavInfo(state.navInfo, action), draftStore, entryStore, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), currentUserInfo: reduceCurrentUserInfo(state.currentUserInfo, action), threadStore, userStore, messageStore, calendarFilters: reduceCalendarFilters( state.calendarFilters, action, threadStore, ), alertStore: reduceAlertStore(state.alertStore, action), lifecycleState: reduceLifecycleState(state.lifecycleState, action), enabledApps: reduceEnabledApps(state.enabledApps, action), reportStore, dataLoaded: reduceDataLoaded(state.dataLoaded, action), userPolicies: policiesReducer(state.userPolicies, action), commServicesAccessToken: reduceServicesAccessToken( state.commServicesAccessToken, action, ), inviteLinksStore: reduceInviteLinks(state.inviteLinksStore, action), keyserverStore, integrityStore, globalThemeInfo: reduceGlobalThemeInfo(state.globalThemeInfo, action), customServer: reduceCustomerServer(state.customServer, action), communityStore, dbOpsStore: reduceDBOpsStore(state.dbOpsStore, action), syncedMetadataStore, auxUserStore, threadActivityStore, }, storeOperations: { draftStoreOperations, threadStoreOperations, messageStoreOperations, reportStoreOperations, userStoreOperations, keyserverStoreOperations, communityStoreOperations, integrityStoreOperations, syncedMetadataStoreOperations, auxUserStoreOperations, + threadActivityStoreOperations, }, }; } diff --git a/lib/types/store-ops-types.js b/lib/types/store-ops-types.js index 842fefafe..2d2a058c4 100644 --- a/lib/types/store-ops-types.js +++ b/lib/types/store-ops-types.js @@ -1,122 +1,124 @@ // @flow import type { AuxUserInfos } from './aux-user-types.js'; import type { CommunityInfos } from './community-types.js'; import type { DraftStoreOperation, ClientDBDraftStoreOperation, ClientDBDraftInfo, } from './draft-types.js'; import type { ThreadHashes } from './integrity-types.js'; import type { KeyserverInfos } from './keyserver-types.js'; import type { ClientDBMessageInfo, ClientDBThreadMessageInfo, } from './message-types.js'; import type { ClientReportCreationRequest } from './report-types.js'; import type { SyncedMetadata } from './synced-metadata-types.js'; import type { ClientDBThreadInfo, ThreadStore } from './thread-types.js'; import type { UserInfos } from './user-types.js'; import type { ClientDBAuxUserInfo, ClientDBAuxUserStoreOperation, AuxUserStoreOperation, } from '../ops/aux-user-store-ops.js'; import type { ClientDBCommunityInfo, ClientDBCommunityStoreOperation, CommunityStoreOperation, } from '../ops/community-store-ops.js'; import type { ClientDBIntegrityThreadHash, ClientDBIntegrityStoreOperation, IntegrityStoreOperation, } from '../ops/integrity-store-ops.js'; import type { ClientDBKeyserverInfo, ClientDBKeyserverStoreOperation, KeyserverStoreOperation, } from '../ops/keyserver-store-ops.js'; import type { ClientDBMessageStoreOperation, MessageStoreOperation, } from '../ops/message-store-ops.js'; import type { ReportStoreOperation, ClientDBReport, ClientDBReportStoreOperation, } from '../ops/report-store-ops.js'; import type { ClientDBSyncedMetadataEntry, ClientDBSyncedMetadataStoreOperation, } from '../ops/synced-metadata-store-ops.js'; import type { + ThreadActivityStoreOperation, ClientDBThreadActivityEntry, ClientDBThreadActivityStoreOperation, } from '../ops/thread-activity-store-ops.js'; import type { ClientDBThreadStoreOperation, ThreadStoreOperation, } from '../ops/thread-store-ops.js'; import type { ClientDBUserStoreOperation, UserStoreOperation, ClientDBUserInfo, } from '../ops/user-store-ops.js'; export type StoreOperations = { +draftStoreOperations: $ReadOnlyArray, +threadStoreOperations: $ReadOnlyArray, +messageStoreOperations: $ReadOnlyArray, +reportStoreOperations: $ReadOnlyArray, +userStoreOperations: $ReadOnlyArray, +keyserverStoreOperations: $ReadOnlyArray, +communityStoreOperations: $ReadOnlyArray, +integrityStoreOperations: $ReadOnlyArray, +syncedMetadataStoreOperations: $ReadOnlyArray, +auxUserStoreOperations: $ReadOnlyArray, + +threadActivityStoreOperations: $ReadOnlyArray, }; export type ClientDBStoreOperations = { +draftStoreOperations?: $ReadOnlyArray, +threadStoreOperations?: $ReadOnlyArray, +messageStoreOperations?: $ReadOnlyArray, +reportStoreOperations?: $ReadOnlyArray, +userStoreOperations?: $ReadOnlyArray, +keyserverStoreOperations?: $ReadOnlyArray, +communityStoreOperations?: $ReadOnlyArray, +integrityStoreOperations?: $ReadOnlyArray, +syncedMetadataStoreOperations?: $ReadOnlyArray, +auxUserStoreOperations?: $ReadOnlyArray, +threadActivityStoreOperations?: $ReadOnlyArray, }; export type ClientDBStore = { +messages: $ReadOnlyArray, +drafts: $ReadOnlyArray, +threads: $ReadOnlyArray, +messageStoreThreads: $ReadOnlyArray, +reports: $ReadOnlyArray, +users: $ReadOnlyArray, +keyservers: $ReadOnlyArray, +communities: $ReadOnlyArray, +integrityThreadHashes: $ReadOnlyArray, +syncedMetadata: $ReadOnlyArray, +auxUserInfos: $ReadOnlyArray, +threadActivityEntries: $ReadOnlyArray, }; export type ClientStore = { +currentUserID: ?string, +drafts: $ReadOnlyArray, +messages: ?$ReadOnlyArray, +threadStore: ?ThreadStore, +messageStoreThreads: ?$ReadOnlyArray, +reports: ?$ReadOnlyArray, +users: ?UserInfos, +keyserverInfos: ?KeyserverInfos, +communityInfos: ?CommunityInfos, +threadHashes: ?ThreadHashes, +syncedMetadata: ?SyncedMetadata, +auxUserInfos: ?AuxUserInfos, }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index e41f31489..82298074e 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,467 +1,469 @@ // @flow import { AppState as NativeAppState, Alert } from 'react-native'; 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, keyserverAuthActionTypes, deleteKeyserverAccountActionTypes, } from 'lib/actions/user-actions.js'; import { setNewSessionActionType } from 'lib/keyserver-conn/keyserver-conn-types.js'; import type { ThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { queueDBOps } from 'lib/reducers/db-ops-reducer.js'; import { reduceLoadingStatuses } from 'lib/reducers/loading-reducer.js'; import baseReducer from 'lib/reducers/master-reducer.js'; import { reduceCurrentUserInfo } from 'lib/reducers/user-reducer.js'; import { shouldClearData } from 'lib/shared/data-utils.js'; import { invalidSessionDowngrade, invalidSessionRecovery, identityInvalidSessionDowngrade, } from 'lib/shared/session-utils.js'; import { isStaff } from 'lib/shared/staff-utils.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 { reduxLoggerMiddleware } from 'lib/utils/action-logger.js'; import { resetUserSpecificState } from 'lib/utils/reducers-utils.js'; import { updateDimensionsActiveType, updateConnectivityActiveType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, backgroundActionTypes, setReduxStateActionType, setStoreLoadedActionType, type Action, setLocalSettingsActionType, } from './action-types.js'; import { defaultState } from './default-state.js'; import { remoteReduxDevServerConfig } from './dev-tools.js'; import { persistConfig, setPersistor } from './persist.js'; import { onStateDifference } from './redux-debug-utils.js'; import type { AppState } from './state-types.js'; import { nonUserSpecificFieldsNative } from './state-types.js'; import { getGlobalNavContext } from '../navigation/icky-global.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import reactotron from '../reactotron.js'; import { AppOutOfDateAlertDetails } from '../utils/alert-messages.js'; import { isStaffRelease } from '../utils/staff-utils.js'; import { getDevServerHostname } from '../utils/url-utils.js'; function reducer(state: AppState = defaultState, inputAction: Action) { let action = inputAction; 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: $ReadOnlyArray = 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: $ReadOnlyArray = 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 === logOutActionTypes.success || action.type === deleteAccountActionTypes.success) && identityInvalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, ) ) { return { ...state, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } if ( action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, action.payload.keyserverID, ) ) { return { ...state, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } else if (action.type === deleteKeyserverAccountActionTypes.success) { const { currentUserInfo, preRequestUserState } = action.payload; const newKeyserverIDs = []; for (const keyserverID of action.payload.keyserverIDs) { if ( invalidSessionDowngrade( state, currentUserInfo, preRequestUserState, keyserverID, ) ) { continue; } newKeyserverIDs.push(keyserverID); } if (newKeyserverIDs.length === 0) { return { ...state, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } action = { ...action, payload: { ...action.payload, keyserverIDs: newKeyserverIDs, }, }; } if ( (action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && invalidSessionRecovery( state, action.payload.preRequestUserState?.currentUserInfo, action.payload.authActionSource, )) || ((action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success) && invalidSessionRecovery( state, action.payload.preRequestUserInfo, action.payload.authActionSource, )) || (action.type === keyserverAuthActionTypes.success && invalidSessionRecovery( state, action.payload.preRequestUserInfo, action.payload.authActionSource, )) ) { return state; } 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 === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setLocalSettingsActionType) { return { ...state, localSettings: { ...state.localSettings, ...action.payload }, }; } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); } 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; } } // We're calling this reducer twice: here and in the baseReducer. This call // is only used to determine the new current user ID. We don't want to use // the remaining part of the current user info, because it is possible that // the reducer returned a modified ID without cleared remaining parts of // the current user info - this would be a bug, but we want to be extra // careful when clearing the state. // When newCurrentUserInfo has the same ID as state.currentUserInfo the state // won't be cleared and the current user info determined in baseReducer will // be equal to the newCurrentUserInfo. // When newCurrentUserInfo has different ID than state.currentUserInfo, we // reset the state and pass it to the baseReducer. Then, in baseReducer, // reduceCurrentUserInfo acts on the cleared state and may return a different // result than newCurrentUserInfo. // Overall, the solution is a little wasteful, but makes us sure that we never // keep the info of the user when the current user ID changes. const newCurrentUserInfo = reduceCurrentUserInfo( state.currentUserInfo, action, ); if (shouldClearData(state.currentUserInfo?.id, newCurrentUserInfo?.id)) { state = resetUserSpecificState( state, defaultState, nonUserSpecificFieldsNative, ); } const baseReducerResult = baseReducer( state, (action: BaseAction), onStateDifference, ); state = baseReducerResult.state; const { storeOperations } = baseReducerResult; const { draftStoreOperations, threadStoreOperations, messageStoreOperations, reportStoreOperations, userStoreOperations, keyserverStoreOperations, communityStoreOperations, integrityStoreOperations, syncedMetadataStoreOperations, auxUserStoreOperations, + threadActivityStoreOperations, } = storeOperations; const fixUnreadActiveThreadResult = fixUnreadActiveThread(state, action); state = fixUnreadActiveThreadResult.state; const threadStoreOperationsWithUnreadFix = [ ...threadStoreOperations, ...fixUnreadActiveThreadResult.threadStoreOperations, ]; const ops = { draftStoreOperations, messageStoreOperations, threadStoreOperations: threadStoreOperationsWithUnreadFix, reportStoreOperations, userStoreOperations, keyserverStoreOperations, communityStoreOperations, integrityStoreOperations, syncedMetadataStoreOperations, auxUserStoreOperations, + threadActivityStoreOperations, }; state = { ...state, dbOpsStore: queueDBOps(state.dbOpsStore, action.messageID, ops), }; 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') { Alert.alert( AppOutOfDateAlertDetails.title, AppOutOfDateAlertDetails.message, [{ 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 activeThreadInfo = state.threadStore.threadInfos[activeThread]; const updatedActiveThreadInfo = { ...activeThreadInfo, currentUser: { ...activeThreadInfo.currentUser, unread: false, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: activeThread, threadInfo: updatedActiveThreadInfo, }, }, ]; const updatedThreadStore = threadStoreOpsHandlers.processStoreOperations( 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/native/redux/redux-utils.js b/native/redux/redux-utils.js index 7dec9cc24..49393f88a 100644 --- a/native/redux/redux-utils.js +++ b/native/redux/redux-utils.js @@ -1,156 +1,169 @@ // @flow import { useSelector as reactReduxUseSelector } from 'react-redux'; import { auxUserStoreOpsHandlers } from 'lib/ops/aux-user-store-ops.js'; import { communityStoreOpsHandlers } from 'lib/ops/community-store-ops.js'; import { integrityStoreOpsHandlers } from 'lib/ops/integrity-store-ops.js'; import { keyserverStoreOpsHandlers, getKeyserversToRemoveFromNotifsStore, } from 'lib/ops/keyserver-store-ops.js'; import { messageStoreOpsHandlers } from 'lib/ops/message-store-ops.js'; import { reportStoreOpsHandlers } from 'lib/ops/report-store-ops.js'; import { syncedMetadataStoreOpsHandlers } from 'lib/ops/synced-metadata-store-ops.js'; +import { threadActivityStoreOpsHandlers } from 'lib/ops/thread-activity-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { userStoreOpsHandlers } from 'lib/ops/user-store-ops.js'; import type { StoreOperations } from 'lib/types/store-ops-types.js'; import type { AppState } from './state-types.js'; import { commCoreModule } from '../native-modules.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; function useSelector( selector: (state: AppState) => SS, equalityFn?: (a: SS, b: SS) => boolean, ): SS { return reactReduxUseSelector(selector, equalityFn); } async function processDBStoreOperations( storeOperations: StoreOperations, ): Promise { const { draftStoreOperations, threadStoreOperations, messageStoreOperations, reportStoreOperations, userStoreOperations, keyserverStoreOperations, integrityStoreOperations, communityStoreOperations, syncedMetadataStoreOperations, auxUserStoreOperations, + threadActivityStoreOperations, } = storeOperations; const convertedThreadStoreOperations = threadStoreOpsHandlers.convertOpsToClientDBOps(threadStoreOperations); const convertedMessageStoreOperations = messageStoreOpsHandlers.convertOpsToClientDBOps(messageStoreOperations); const convertedReportStoreOperations = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); const convertedUserStoreOperations = userStoreOpsHandlers.convertOpsToClientDBOps(userStoreOperations); const convertedKeyserverStoreOperations = keyserverStoreOpsHandlers.convertOpsToClientDBOps(keyserverStoreOperations); const convertedCommunityStoreOperations = communityStoreOpsHandlers.convertOpsToClientDBOps(communityStoreOperations); const convertedSyncedMetadataStoreOperations = syncedMetadataStoreOpsHandlers.convertOpsToClientDBOps( syncedMetadataStoreOperations, ); const keyserversToRemoveFromNotifsStore = getKeyserversToRemoveFromNotifsStore(keyserverStoreOperations); const convertedIntegrityStoreOperations = integrityStoreOpsHandlers.convertOpsToClientDBOps(integrityStoreOperations); const convertedAuxUserStoreOperations = auxUserStoreOpsHandlers.convertOpsToClientDBOps(auxUserStoreOperations); + const convertedThreadActivityStoreOperations = + threadActivityStoreOpsHandlers.convertOpsToClientDBOps( + threadActivityStoreOperations, + ); 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), ); } if (convertedReportStoreOperations.length > 0) { promises.push( commCoreModule.processReportStoreOperations( convertedReportStoreOperations, ), ); } if (convertedUserStoreOperations.length > 0) { promises.push( commCoreModule.processUserStoreOperations(convertedUserStoreOperations), ); } if (convertedKeyserverStoreOperations.length > 0) { promises.push( commCoreModule.processKeyserverStoreOperations( convertedKeyserverStoreOperations, ), ); } if (convertedCommunityStoreOperations.length > 0) { promises.push( commCoreModule.processCommunityStoreOperations( convertedCommunityStoreOperations, ), ); } if (keyserversToRemoveFromNotifsStore.length > 0) { promises.push( commCoreModule.removeKeyserverDataFromNotifStorage( keyserversToRemoveFromNotifsStore, ), ); } if (convertedIntegrityStoreOperations.length > 0) { promises.push( commCoreModule.processIntegrityStoreOperations( convertedIntegrityStoreOperations, ), ); } if (convertedSyncedMetadataStoreOperations.length > 0) { promises.push( commCoreModule.processSyncedMetadataStoreOperations( convertedSyncedMetadataStoreOperations, ), ); } if (convertedAuxUserStoreOperations.length > 0) { promises.push( commCoreModule.processAuxUserStoreOperations( convertedAuxUserStoreOperations, ), ); } + if (convertedThreadActivityStoreOperations.length > 0) { + promises.push( + commCoreModule.processThreadActivityStoreOperations( + convertedThreadActivityStoreOperations, + ), + ); + } await Promise.all(promises); } catch (e) { if (isTaskCancelledError(e)) { return; } // this code will make an entry in SecureStore and cause re-creating // database when user will open app again commCoreModule.reportDBOperationsFailure(); commCoreModule.terminate(); } } export { useSelector, processDBStoreOperations }; diff --git a/web/redux/initial-state-gate.js b/web/redux/initial-state-gate.js index 05381e2ac..829646869 100644 --- a/web/redux/initial-state-gate.js +++ b/web/redux/initial-state-gate.js @@ -1,200 +1,201 @@ // @flow import * as React from 'react'; import { PersistGate } from 'redux-persist/es/integration/react.js'; import type { Persistor } from 'redux-persist/es/types'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import type { MessageStoreOperation } from 'lib/ops/message-store-ops.js'; import type { ThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import type { UserStoreOperation } from 'lib/ops/user-store-ops.js'; import { allUpdatesCurrentAsOfSelector } from 'lib/selectors/keyserver-selectors.js'; import { canUseDatabaseOnWeb } from 'lib/shared/web-database.js'; import type { RawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { LegacyRawThreadInfo } from 'lib/types/thread-types.js'; import { convertIDToNewSchema } from 'lib/utils/migration-utils.js'; import { entries, values } from 'lib/utils/objects.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { setInitialReduxState, useGetInitialReduxState, } from './action-types.js'; import { useSelector } from './redux-utils.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import Loading from '../loading.react.js'; import { getClientDBStore, processDBStoreOperations, } from '../shared-worker/utils/store.js'; import type { InitialReduxStateActionPayload } from '../types/redux-types.js'; type Props = { +persistor: Persistor, +children: React.Node, }; function InitialReduxStateGate(props: Props): React.Node { const { children, persistor } = props; const callGetInitialReduxState = useGetInitialReduxState(); const dispatch = useDispatch(); const [initError, setInitError] = React.useState(null); React.useEffect(() => { if (initError) { throw initError; } }, [initError]); const isRehydrated = useSelector(state => !!state._persist?.rehydrated); const allUpdatesCurrentAsOf = useSelector(allUpdatesCurrentAsOfSelector); const prevIsRehydrated = React.useRef(false); React.useEffect(() => { if (prevIsRehydrated.current || !isRehydrated) { return; } prevIsRehydrated.current = isRehydrated; void (async () => { try { let urlInfo = infoFromURL(decodeURI(window.location.href)); // Handle older links if (urlInfo.thread) { urlInfo = { ...urlInfo, thread: convertIDToNewSchema( urlInfo.thread, authoritativeKeyserverID, ), }; } const clientDBStore = await getClientDBStore(); dispatch({ type: setClientDBStoreActionType, payload: clientDBStore, }); const payload = await callGetInitialReduxState({ urlInfo, excludedData: { threadStore: !!clientDBStore.threadStore, messageStore: !!clientDBStore.messages, userStore: !!clientDBStore.users, }, allUpdatesCurrentAsOf, }); const currentLoggedInUserID = payload.currentUserInfo?.anonymous ? null : payload.currentUserInfo?.id; const useDatabase = canUseDatabaseOnWeb(currentLoggedInUserID); if (!currentLoggedInUserID || !useDatabase) { dispatch({ type: setInitialReduxState, payload }); return; } let initialReduxState: InitialReduxStateActionPayload = payload; let threadStoreOperations: ThreadStoreOperation[] = []; if (clientDBStore.threadStore) { const { threadStore, ...rest } = initialReduxState; initialReduxState = rest; } else { // When there is no data in the DB, it's necessary to migrate data // from the keyserver payload to the DB threadStoreOperations = entries(payload.threadStore.threadInfos).map( ([id, threadInfo]: [ string, LegacyRawThreadInfo | RawThreadInfo, ]) => ({ type: 'replace', payload: { id, threadInfo, }, }), ); } let userStoreOperations: UserStoreOperation[] = []; if (clientDBStore.users) { const { userInfos, ...rest } = initialReduxState; initialReduxState = rest; } else { userStoreOperations = values(payload.userInfos).map(userInfo => ({ type: 'replace_user', payload: userInfo, })); } let messageStoreOperations: MessageStoreOperation[] = []; if (clientDBStore.messages) { const { messageStore, ...rest } = initialReduxState; initialReduxState = rest; } else { const { messages, threads } = payload.messageStore; messageStoreOperations = [ ...entries(messages).map(([id, messageInfo]) => ({ type: 'replace', payload: { id, messageInfo }, })), { type: 'replace_threads', payload: { threads }, }, ]; } if ( threadStoreOperations.length > 0 || userStoreOperations.length > 0 || messageStoreOperations.length > 0 ) { await processDBStoreOperations( { threadStoreOperations, draftStoreOperations: [], messageStoreOperations, reportStoreOperations: [], userStoreOperations, keyserverStoreOperations: [], communityStoreOperations: [], integrityStoreOperations: [], syncedMetadataStoreOperations: [], auxUserStoreOperations: [], + threadActivityStoreOperations: [], }, currentLoggedInUserID, ); } dispatch({ type: setInitialReduxState, payload: initialReduxState, }); } catch (err) { setInitError(err); } })(); }, [callGetInitialReduxState, dispatch, isRehydrated, allUpdatesCurrentAsOf]); const initialStateLoaded = useSelector(state => state.initialStateLoaded); const childFunction = React.useCallback( // This argument is passed from `PersistGate`. It means that the state is // rehydrated and we can start fetching the initial info. (bootstrapped: boolean) => { if (bootstrapped && initialStateLoaded) { return children; } else { return ; } }, [children, initialStateLoaded], ); return {childFunction}; } export default InitialReduxStateGate; diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index 4fd6e4bee..74da3084e 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,532 +1,533 @@ // @flow import invariant from 'invariant'; import type { PersistState } from 'redux-persist/es/types.js'; import { logOutActionTypes, deleteKeyserverAccountActionTypes, deleteAccountActionTypes, keyserverAuthActionTypes, } from 'lib/actions/user-actions.js'; import { setNewSessionActionType } from 'lib/keyserver-conn/keyserver-conn-types.js'; import { type ReplaceKeyserverOperation, keyserverStoreOpsHandlers, } from 'lib/ops/keyserver-store-ops.js'; import { type ThreadStoreOperation, threadStoreOpsHandlers, } from 'lib/ops/thread-store-ops.js'; import { queueDBOps } from 'lib/reducers/db-ops-reducer.js'; import { reduceLoadingStatuses } from 'lib/reducers/loading-reducer.js'; import baseReducer from 'lib/reducers/master-reducer.js'; import { reduceCurrentUserInfo } from 'lib/reducers/user-reducer.js'; import { mostRecentlyReadThreadSelector } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { shouldClearData } from 'lib/shared/data-utils.js'; import { invalidSessionDowngrade, identityInvalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/session-utils.js'; import type { AlertStore } from 'lib/types/alert-types.js'; import type { AuxUserStore } from 'lib/types/aux-user-types.js'; import type { CommunityStore } from 'lib/types/community-types.js'; import type { MessageID, DBOpsStore } from 'lib/types/db-ops-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 } from 'lib/types/filter-types.js'; import type { IntegrityStore } from 'lib/types/integrity-types.js'; import type { KeyserverStore } from 'lib/types/keyserver-types.js'; import type { LifecycleState } from 'lib/types/lifecycle-state-types.js'; import type { InviteLinksStore } from 'lib/types/link-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MessageStore } from 'lib/types/message-types.js'; import type { WebNavInfo } from 'lib/types/nav-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 { StoreOperations } from 'lib/types/store-ops-types.js'; import type { SyncedMetadataStore } from 'lib/types/synced-metadata-types.js'; import type { GlobalThemeInfo } from 'lib/types/theme-types.js'; import type { ThreadActivityStore } from 'lib/types/thread-activity-types'; import type { ThreadStore } from 'lib/types/thread-types.js'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types.js'; import { resetUserSpecificState } from 'lib/utils/reducers-utils.js'; import { updateWindowActiveActionType, updateNavInfoActionType, updateWindowDimensionsActionType, setInitialReduxState, } from './action-types.js'; import { reduceCommunityPickerStore } from './community-picker-reducer.js'; import { defaultWebState } from './default-state.js'; import reduceNavInfo from './nav-reducer.js'; import { onStateDifference } from './redux-debug-utils.js'; import { reduceServicesAccessToken } from './services-access-token-reducer.js'; import { getVisibility } from './visibility.js'; import { activeThreadSelector } from '../selectors/nav-selectors.js'; import type { InitialReduxStateActionPayload } from '../types/redux-types.js'; export type WindowDimensions = { width: number, height: number }; export type CommunityPickerStore = { +chat: ?string, +calendar: ?string, }; const nonUserSpecificFieldsWeb = [ 'loadingStatuses', 'windowDimensions', 'lifecycleState', 'windowActive', 'pushApiPublicKey', 'keyserverStore', 'initialStateLoaded', '_persist', 'customServer', ]; export type AppState = { +navInfo: WebNavInfo, +currentUserInfo: ?CurrentUserInfo, +draftStore: DraftStore, +entryStore: EntryStore, +threadStore: ThreadStore, +userStore: UserStore, +messageStore: MessageStore, +loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, +calendarFilters: $ReadOnlyArray, +communityPickerStore: CommunityPickerStore, +windowDimensions: WindowDimensions, +alertStore: AlertStore, +watchedThreadIDs: $ReadOnlyArray, +lifecycleState: LifecycleState, +enabledApps: EnabledApps, +reportStore: ReportStore, +dataLoaded: boolean, +windowActive: boolean, +userPolicies: UserPolicies, +pushApiPublicKey: ?string, +_persist: ?PersistState, +commServicesAccessToken: ?string, +inviteLinksStore: InviteLinksStore, +keyserverStore: KeyserverStore, +threadActivityStore: ThreadActivityStore, +initialStateLoaded: boolean, +integrityStore: IntegrityStore, +globalThemeInfo: GlobalThemeInfo, +customServer: ?string, +communityStore: CommunityStore, +dbOpsStore: DBOpsStore, +syncedMetadataStore: SyncedMetadataStore, +auxUserStore: AuxUserStore, }; export type Action = $ReadOnly< | BaseAction | { +messageID?: MessageID, ... | { +type: 'UPDATE_NAV_INFO', +payload: Partial } | { +type: 'UPDATE_WINDOW_DIMENSIONS', +payload: WindowDimensions, } | { +type: 'UPDATE_WINDOW_ACTIVE', +payload: boolean, } | { +type: 'SET_INITIAL_REDUX_STATE', +payload: InitialReduxStateActionPayload, }, }, >; function reducer(oldState: AppState | void, action: Action): AppState { invariant(oldState, 'should be set'); let state = oldState; let storeOperations: StoreOperations = { draftStoreOperations: [], threadStoreOperations: [], messageStoreOperations: [], reportStoreOperations: [], userStoreOperations: [], keyserverStoreOperations: [], communityStoreOperations: [], integrityStoreOperations: [], syncedMetadataStoreOperations: [], auxUserStoreOperations: [], + threadActivityStoreOperations: [], }; if ( (action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && invalidSessionRecovery( state, action.payload.preRequestUserState?.currentUserInfo, action.payload.authActionSource, )) || (action.type === keyserverAuthActionTypes.success && invalidSessionRecovery( state, action.payload.preRequestUserInfo, action.payload.authActionSource, )) ) { return state; } if ( (action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success) && identityInvalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, ) ) { return { ...oldState, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } if (action.type === setInitialReduxState) { const { userInfos, keyserverInfos, actualizedCalendarQuery, ...rest } = action.payload; const replaceOperations: ReplaceKeyserverOperation[] = []; for (const keyserverID in keyserverInfos) { replaceOperations.push({ type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverStore.keyserverInfos[keyserverID], ...keyserverInfos[keyserverID], actualizedCalendarQuery, }, }, }); } let newState = { ...state, ...rest, keyserverStore: keyserverStoreOpsHandlers.processStoreOperations( state.keyserverStore, replaceOperations, ), initialStateLoaded: true, }; if (userInfos) { newState = { ...newState, userStore: { userInfos } }; } return validateStateAndQueueOpsProcessing(action, oldState, newState, { ...storeOperations, keyserverStoreOperations: [ ...storeOperations.keyserverStoreOperations, ...replaceOperations, ], }); } else if (action.type === updateWindowDimensionsActionType) { return validateStateAndQueueOpsProcessing( action, oldState, { ...state, windowDimensions: action.payload, }, storeOperations, ); } else if (action.type === updateWindowActiveActionType) { return validateStateAndQueueOpsProcessing( action, oldState, { ...state, windowActive: action.payload, }, storeOperations, ); } else if (action.type === setNewSessionActionType) { const { keyserverID, sessionChange } = action.payload; if (!state.keyserverStore.keyserverInfos[keyserverID]) { if (sessionChange.cookie?.startsWith('user=')) { console.log( 'received sessionChange with user cookie, ' + `but keyserver ${keyserverID} is not in KeyserverStore!`, ); } return state; } if ( invalidSessionDowngrade( oldState, sessionChange.currentUserInfo, action.payload.preRequestUserState, keyserverID, ) ) { return { ...oldState, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } const replaceOperation: ReplaceKeyserverOperation = { type: 'replace_keyserver', payload: { id: keyserverID, keyserverInfo: { ...state.keyserverStore.keyserverInfos[keyserverID], sessionID: sessionChange.sessionID, }, }, }; state = { ...state, keyserverStore: keyserverStoreOpsHandlers.processStoreOperations( state.keyserverStore, [replaceOperation], ), }; storeOperations = { ...storeOperations, keyserverStoreOperations: [ ...storeOperations.keyserverStoreOperations, replaceOperation, ], }; } else if (action.type === deleteKeyserverAccountActionTypes.success) { const { currentUserInfo, preRequestUserState } = action.payload; const newKeyserverIDs = []; for (const keyserverID of action.payload.keyserverIDs) { if ( invalidSessionDowngrade( state, currentUserInfo, preRequestUserState, keyserverID, ) ) { continue; } newKeyserverIDs.push(keyserverID); } if (newKeyserverIDs.length === 0) { return { ...state, loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), }; } action = { ...action, payload: { ...action.payload, keyserverIDs: newKeyserverIDs, }, }; } if (action.type !== updateNavInfoActionType) { // We're calling this reducer twice: here and in the baseReducer. This call // is only used to determine the new current user ID. We don't want to use // the remaining part of the current user info, because it is possible that // the reducer returned a modified ID without cleared remaining parts of // the current user info - this would be a bug, but we want to be extra // careful when clearing the state. // When newCurrentUserInfo has the same ID as state.currentUserInfo // the state won't be cleared and the current user info determined in // baseReducer will be equal to the newCurrentUserInfo. // When newCurrentUserInfo has different ID than state.currentUserInfo, we // reset the state and pass it to the baseReducer. Then, in baseReducer, // reduceCurrentUserInfo acts on the cleared state and may return // a different result than newCurrentUserInfo. // Overall, the solution is a little wasteful, but makes us sure that we // never keep the info of the user when the current user ID changes. const newCurrentUserInfo = reduceCurrentUserInfo( state.currentUserInfo, action, ); if (shouldClearData(state.currentUserInfo?.id, newCurrentUserInfo?.id)) { state = resetUserSpecificState( state, defaultWebState, nonUserSpecificFieldsWeb, ); } const baseReducerResult = baseReducer(state, action, onStateDifference); state = baseReducerResult.state; storeOperations = { ...baseReducerResult.storeOperations, keyserverStoreOperations: [ ...storeOperations.keyserverStoreOperations, ...baseReducerResult.storeOperations.keyserverStoreOperations, ], }; } const communityPickerStore = reduceCommunityPickerStore( state.communityPickerStore, action, ); state = { ...state, navInfo: reduceNavInfo( state.navInfo, action, state.threadStore.threadInfos, ), communityPickerStore, commServicesAccessToken: reduceServicesAccessToken( state.commServicesAccessToken, action, ), }; return validateStateAndQueueOpsProcessing( action, oldState, state, storeOperations, ); } function validateStateAndQueueOpsProcessing( action: Action, oldState: AppState, state: AppState, storeOperations: StoreOperations, ): AppState { const updateActiveThreadOps: ThreadStoreOperation[] = []; 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 const activeThreadInfo = state.threadStore.threadInfos[activeThread]; updateActiveThreadOps.push({ type: 'replace', payload: { id: activeThread, threadInfo: { ...activeThreadInfo, currentUser: { ...activeThreadInfo.currentUser, unread: false, }, }, }, }); } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { const now = Date.now(); state = { ...state, threadActivityStore: { ...state.threadActivityStore, [(activeThread: string)]: { ...state.threadActivityStore[activeThread], lastNavigatedTo: now, }, }, }; } if (updateActiveThreadOps.length > 0) { state = { ...state, threadStore: threadStoreOpsHandlers.processStoreOperations( state.threadStore, updateActiveThreadOps, ), }; storeOperations = { ...storeOperations, threadStoreOperations: [ ...storeOperations.threadStoreOperations, ...updateActiveThreadOps, ], }; } // The operations were already dispatched from the main tab // For now the `dispatchSource` field is not included in any of the // redux actions and this causes flow to throw an error. // As soon as one of the actions is updated, this fix (and the corresponding // one in tab-synchronization.js) can be removed. // $FlowFixMe if (action.dispatchSource === 'tab-sync') { return state; } return { ...state, dbOpsStore: queueDBOps(state.dbOpsStore, action.messageID, storeOperations), }; } export { nonUserSpecificFieldsWeb, reducer }; diff --git a/web/shared-worker/utils/store.js b/web/shared-worker/utils/store.js index de9404665..5b001e7ea 100644 --- a/web/shared-worker/utils/store.js +++ b/web/shared-worker/utils/store.js @@ -1,224 +1,232 @@ // @flow import { auxUserStoreOpsHandlers } from 'lib/ops/aux-user-store-ops.js'; import { communityStoreOpsHandlers } from 'lib/ops/community-store-ops.js'; import { integrityStoreOpsHandlers } from 'lib/ops/integrity-store-ops.js'; import { keyserverStoreOpsHandlers } from 'lib/ops/keyserver-store-ops.js'; import { messageStoreOpsHandlers } from 'lib/ops/message-store-ops.js'; import { reportStoreOpsHandlers } from 'lib/ops/report-store-ops.js'; import { syncedMetadataStoreOpsHandlers } from 'lib/ops/synced-metadata-store-ops.js'; +import { threadActivityStoreOpsHandlers } from 'lib/ops/thread-activity-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { userStoreOpsHandlers } from 'lib/ops/user-store-ops.js'; import { canUseDatabaseOnWeb } from 'lib/shared/web-database.js'; import type { ClientStore, StoreOperations, } from 'lib/types/store-ops-types.js'; import { entries } from 'lib/utils/objects.js'; import { defaultWebState } from '../../redux/default-state.js'; import { workerRequestMessageTypes } from '../../types/worker-types.js'; import { getCommSharedWorker } from '../shared-worker-provider.js'; async function getClientDBStore(): Promise { const sharedWorker = await getCommSharedWorker(); let result: ClientStore = { currentUserID: null, drafts: [], messages: null, threadStore: null, messageStoreThreads: null, reports: null, users: null, keyserverInfos: defaultWebState.keyserverStore.keyserverInfos, communityInfos: null, threadHashes: null, syncedMetadata: null, auxUserInfos: null, }; const data = await sharedWorker.schedule({ type: workerRequestMessageTypes.GET_CLIENT_STORE, }); if (data?.store?.drafts) { result = { ...result, drafts: data.store.drafts, }; } if (data?.store?.reports) { result = { ...result, reports: reportStoreOpsHandlers.translateClientDBData(data.store.reports), }; } if (data?.store?.threads && data.store.threads.length > 0) { result = { ...result, threadStore: { threadInfos: threadStoreOpsHandlers.translateClientDBData( data.store.threads, ), }, }; } if (data?.store?.keyservers?.length) { result = { ...result, keyserverInfos: keyserverStoreOpsHandlers.translateClientDBData( data.store.keyservers, ), }; } if (data?.store?.communities) { result = { ...result, communityInfos: communityStoreOpsHandlers.translateClientDBData( data.store.communities, ), }; } if (data?.store?.integrityThreadHashes) { result = { ...result, threadHashes: integrityStoreOpsHandlers.translateClientDBData( data.store.integrityThreadHashes, ), }; } if (data?.store?.syncedMetadata) { result = { ...result, syncedMetadata: syncedMetadataStoreOpsHandlers.translateClientDBData( data.store.syncedMetadata, ), }; } if (data?.store?.auxUserInfos) { result = { ...result, auxUserInfos: auxUserStoreOpsHandlers.translateClientDBData( data.store.auxUserInfos, ), }; } if (data?.store?.users && data.store.users.length > 0) { result = { ...result, users: userStoreOpsHandlers.translateClientDBData(data.store.users), }; } if (data?.store?.messages && data.store.messages.length > 0) { result = { ...result, messages: data.store.messages, }; } if ( data?.store?.messageStoreThreads && data.store.messageStoreThreads.length > 0 ) { result = { ...result, messageStoreThreads: data.store.messageStoreThreads, }; } return result; } async function processDBStoreOperations( storeOperations: StoreOperations, userID: null | string, ): Promise { const { draftStoreOperations, threadStoreOperations, reportStoreOperations, keyserverStoreOperations, communityStoreOperations, integrityStoreOperations, syncedMetadataStoreOperations, auxUserStoreOperations, userStoreOperations, messageStoreOperations, + threadActivityStoreOperations, } = storeOperations; const canUseDatabase = canUseDatabaseOnWeb(userID); const convertedThreadStoreOperations = canUseDatabase ? threadStoreOpsHandlers.convertOpsToClientDBOps(threadStoreOperations) : []; const convertedReportStoreOperations = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); const convertedKeyserverStoreOperations = keyserverStoreOpsHandlers.convertOpsToClientDBOps(keyserverStoreOperations); const convertedCommunityStoreOperations = communityStoreOpsHandlers.convertOpsToClientDBOps(communityStoreOperations); const convertedIntegrityStoreOperations = integrityStoreOpsHandlers.convertOpsToClientDBOps(integrityStoreOperations); const convertedSyncedMetadataStoreOperations = syncedMetadataStoreOpsHandlers.convertOpsToClientDBOps( syncedMetadataStoreOperations, ); const convertedAuxUserStoreOperations = auxUserStoreOpsHandlers.convertOpsToClientDBOps(auxUserStoreOperations); const convertedUserStoreOperations = userStoreOpsHandlers.convertOpsToClientDBOps(userStoreOperations); const convertedMessageStoreOperations = messageStoreOpsHandlers.convertOpsToClientDBOps(messageStoreOperations); + const convertedThreadActivityStoreOperations = + threadActivityStoreOpsHandlers.convertOpsToClientDBOps( + threadActivityStoreOperations, + ); if ( convertedThreadStoreOperations.length === 0 && convertedReportStoreOperations.length === 0 && draftStoreOperations.length === 0 && convertedKeyserverStoreOperations.length === 0 && convertedCommunityStoreOperations.length === 0 && convertedIntegrityStoreOperations.length === 0 && convertedSyncedMetadataStoreOperations.length === 0 && convertedAuxUserStoreOperations.length === 0 && convertedUserStoreOperations.length === 0 && - convertedMessageStoreOperations.length === 0 + convertedMessageStoreOperations.length === 0 && + convertedThreadActivityStoreOperations.length === 0 ) { return; } const sharedWorker = await getCommSharedWorker(); const isSupported = await sharedWorker.isSupported(); if (!isSupported) { return; } try { await sharedWorker.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations, reportStoreOperations: convertedReportStoreOperations, threadStoreOperations: convertedThreadStoreOperations, keyserverStoreOperations: convertedKeyserverStoreOperations, communityStoreOperations: convertedCommunityStoreOperations, integrityStoreOperations: convertedIntegrityStoreOperations, syncedMetadataStoreOperations: convertedSyncedMetadataStoreOperations, auxUserStoreOperations: convertedAuxUserStoreOperations, userStoreOperations: convertedUserStoreOperations, messageStoreOperations: convertedMessageStoreOperations, + threadActivityStoreOperations: convertedThreadActivityStoreOperations, }, }); } catch (e) { console.log(e); if (canUseDatabase) { window.alert(e.message); if ( entries(storeOperations).some( ([key, ops]) => key !== 'draftStoreOperations' && key !== 'reportStoreOperations' && ops.length > 0, ) ) { await sharedWorker.init({ clearDatabase: true, markAsCorrupted: true }); location.reload(); } } } } export { getClientDBStore, processDBStoreOperations };