diff --git a/keyserver/src/responders/redux-state-responders.js b/keyserver/src/responders/redux-state-responders.js index a7f02db8a..90aa608c8 100644 --- a/keyserver/src/responders/redux-state-responders.js +++ b/keyserver/src/responders/redux-state-responders.js @@ -1,363 +1,367 @@ // @flow import _keyBy from 'lodash/fp/keyBy.js'; import t, { type TInterface } from 'tcomb'; import { baseLegalPolicies } from 'lib/facts/policies.js'; import { daysToEntriesFromEntryInfos } from 'lib/reducers/entry-reducer.js'; import { freshMessageStore } from 'lib/reducers/message-reducer.js'; import { mostRecentlyReadThread } from 'lib/selectors/thread-selectors.js'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils.js'; import { threadHasPermission, threadIsPending, parsePendingThreadID, createPendingThread, } from 'lib/shared/thread-utils.js'; import { canUseDatabaseOnWeb } from 'lib/shared/web-database.js'; import { entryStoreValidator } from 'lib/types/entry-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { inviteLinksStoreValidator, type CommunityLinks, } from 'lib/types/link-types.js'; import { defaultNumberPerThread, messageStoreValidator, type MessageStore, } from 'lib/types/message-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { threadStoreValidator } from 'lib/types/thread-types.js'; import { currentUserInfoValidator, userInfosValidator, type GlobalAccountUserInfo, } from 'lib/types/user-types.js'; import { currentDateInTimeZone } from 'lib/utils/date-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { urlInfoValidator } from 'lib/utils/url-utils.js'; import { tShape, ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { navInfoValidator } from 'web/types/nav-types.js'; import type { InitialReduxStateResponse, InitialKeyserverInfo, InitialReduxStateRequest, ExcludedData, } from 'web/types/redux-types.js'; import { navInfoFromURL } from 'web/url-utils.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchPrimaryInviteLinks } from '../fetchers/link-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { hasAnyNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchCurrentUserInfo, fetchKnownUserInfos, fetchUserInfos, } from '../fetchers/user-fetchers.js'; import { getWebPushConfig } from '../push/providers.js'; import { setNewSession } from '../session/cookies.js'; import { Viewer } from '../session/viewer.js'; const excludedDataValidator: TInterface = tShape({ threadStore: t.maybe(t.Bool), }); export const initialReduxStateRequestValidator: TInterface = tShape({ urlInfo: urlInfoValidator, excludedData: excludedDataValidator, + clientUpdatesCurrentAsOf: t.Number, }); const initialKeyserverInfoValidator = tShape({ sessionID: t.maybe(t.String), updatesCurrentAsOf: t.Number, }); export const initialReduxStateValidator: TInterface = tShape({ navInfo: navInfoValidator, currentUserInfo: currentUserInfoValidator, entryStore: entryStoreValidator, threadStore: threadStoreValidator, userInfos: userInfosValidator, messageStore: messageStoreValidator, pushApiPublicKey: t.maybe(t.String), commServicesAccessToken: t.Nil, inviteLinksStore: inviteLinksStoreValidator, keyserverInfo: initialKeyserverInfoValidator, }); async function getInitialReduxStateResponder( viewer: Viewer, request: InitialReduxStateRequest, ): Promise { - const { urlInfo, excludedData } = request; + const { urlInfo, excludedData, clientUpdatesCurrentAsOf } = request; const useDatabase = viewer.loggedIn && canUseDatabaseOnWeb(viewer.userID); const hasNotAcknowledgedPoliciesPromise = hasAnyNotAcknowledgedPolicies( viewer.id, baseLegalPolicies, ); const initialNavInfoPromise = (async () => { try { let backupInfo = { now: currentDateInTimeZone(viewer.timeZone), }; // Some user ids in selectedUserList might not exist in the userInfos // (e.g. they were included in the results of the user search endpoint) // Because of that we keep their userInfos inside the navInfo. if (urlInfo.selectedUserList) { const fetchedUserInfos = await fetchUserInfos(urlInfo.selectedUserList); const userInfos: { [string]: GlobalAccountUserInfo } = {}; for (const userID in fetchedUserInfos) { const userInfo = fetchedUserInfos[userID]; if (userInfo.username) { userInfos[userID] = { ...userInfo, username: userInfo.username, }; } } backupInfo = { userInfos, ...backupInfo }; } return navInfoFromURL(urlInfo, backupInfo); } catch (e) { throw new ServerError(e.message); } })(); const calendarQueryPromise = (async () => { const initialNavInfo = await initialNavInfoPromise; return { startDate: initialNavInfo.startDate, endDate: initialNavInfo.endDate, filters: defaultCalendarFilters, }; })(); const messageSelectionCriteria = { joinedThreads: true }; - const initialTime = Date.now(); + const serverUpdatesCurrentAsOf = + useDatabase && clientUpdatesCurrentAsOf + ? clientUpdatesCurrentAsOf + : Date.now(); const threadInfoPromise = fetchThreadInfos(viewer); const messageInfoPromise = fetchMessageInfos( viewer, messageSelectionCriteria, defaultNumberPerThread, ); const entryInfoPromise = (async () => { const calendarQuery = await calendarQueryPromise; return await fetchEntryInfos(viewer, [calendarQuery]); })(); const currentUserInfoPromise = fetchCurrentUserInfo(viewer); const userInfoPromise = fetchKnownUserInfos(viewer); const sessionIDPromise = (async () => { const calendarQuery = await calendarQueryPromise; if (viewer.loggedIn) { - await setNewSession(viewer, calendarQuery, initialTime); + await setNewSession(viewer, calendarQuery, serverUpdatesCurrentAsOf); } return viewer.sessionID; })(); const threadStorePromise = (async () => { if (excludedData.threadStore && useDatabase) { return { threadInfos: {} }; } const [{ threadInfos }, hasNotAcknowledgedPolicies] = await Promise.all([ threadInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); return { threadInfos: hasNotAcknowledgedPolicies ? {} : threadInfos }; })(); const messageStorePromise: Promise = (async () => { const [ { threadInfos }, { rawMessageInfos, truncationStatuses }, hasNotAcknowledgedPolicies, ] = await Promise.all([ threadInfoPromise, messageInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); if (hasNotAcknowledgedPolicies) { return { messages: {}, threads: {}, local: {}, currentAsOf: { [ashoatKeyserverID]: 0 }, }; } const { messageStore: freshStore } = freshMessageStore( rawMessageInfos, truncationStatuses, { [ashoatKeyserverID]: mostRecentMessageTimestamp( rawMessageInfos, - initialTime, + serverUpdatesCurrentAsOf, ), }, threadInfos, ); return freshStore; })(); const entryStorePromise = (async () => { const [{ rawEntryInfos }, hasNotAcknowledgedPolicies] = await Promise.all([ entryInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); if (hasNotAcknowledgedPolicies) { return { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }; } return { entryInfos: _keyBy('id')(rawEntryInfos), daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos), - lastUserInteractionCalendar: initialTime, + lastUserInteractionCalendar: serverUpdatesCurrentAsOf, }; })(); const userInfosPromise = (async () => { const [userInfos, hasNotAcknowledgedPolicies] = await Promise.all([ userInfoPromise, hasNotAcknowledgedPoliciesPromise, ]); return hasNotAcknowledgedPolicies ? {} : userInfos; })(); const navInfoPromise = (async () => { const [ { threadInfos }, messageStore, currentUserInfo, userInfos, finalNavInfo, ] = await Promise.all([ threadInfoPromise, messageStorePromise, currentUserInfoPromise, userInfosPromise, initialNavInfoPromise, ]); const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID; if ( requestedActiveChatThreadID && !threadIsPending(requestedActiveChatThreadID) && !threadHasPermission( threadInfos[requestedActiveChatThreadID], threadPermissions.VISIBLE, ) ) { finalNavInfo.activeChatThreadID = null; } if (!finalNavInfo.activeChatThreadID) { const mostRecentThread = mostRecentlyReadThread( messageStore, threadInfos, ); if (mostRecentThread) { finalNavInfo.activeChatThreadID = mostRecentThread; } } const curActiveChatThreadID = finalNavInfo.activeChatThreadID; if ( curActiveChatThreadID && threadIsPending(curActiveChatThreadID) && finalNavInfo.pendingThread?.id !== curActiveChatThreadID ) { const pendingThreadData = parsePendingThreadID(curActiveChatThreadID); if ( pendingThreadData && pendingThreadData.threadType !== threadTypes.SIDEBAR && currentUserInfo.id ) { const members = [...pendingThreadData.memberIDs, currentUserInfo.id] .map(id => { const userInfo = userInfos[id]; if (!userInfo || !userInfo.username) { return undefined; } const { username } = userInfo; return { id, username }; }) .filter(Boolean); const newPendingThread = createPendingThread({ viewerID: currentUserInfo.id, threadType: pendingThreadData.threadType, members, }); finalNavInfo.activeChatThreadID = newPendingThread.id; finalNavInfo.pendingThread = newPendingThread; } } return finalNavInfo; })(); const currentAsOfPromise = (async () => { const hasNotAcknowledgedPolicies = await hasNotAcknowledgedPoliciesPromise; - return hasNotAcknowledgedPolicies ? 0 : initialTime; + return hasNotAcknowledgedPolicies ? 0 : serverUpdatesCurrentAsOf; })(); const pushApiPublicKeyPromise: Promise = (async () => { const pushConfig = await getWebPushConfig(); if (!pushConfig) { if (process.env.NODE_ENV !== 'development') { console.warn('keyserver/secrets/web_push_config.json should exist'); } return null; } return pushConfig.publicKey; })(); const inviteLinksStorePromise = (async () => { const primaryInviteLinks = await fetchPrimaryInviteLinks(viewer); const links: { [string]: CommunityLinks } = {}; for (const link of primaryInviteLinks) { if (link.primary) { links[link.communityID] = { primaryLink: link, }; } } return { links, }; })(); const keyserverInfoPromise = (async () => { const { sessionID, updatesCurrentAsOf } = await promiseAll({ sessionID: sessionIDPromise, updatesCurrentAsOf: currentAsOfPromise, }); return { sessionID, updatesCurrentAsOf, }; })(); const initialReduxState: InitialReduxStateResponse = await promiseAll({ navInfo: navInfoPromise, currentUserInfo: currentUserInfoPromise, entryStore: entryStorePromise, threadStore: threadStorePromise, userInfos: userInfosPromise, messageStore: messageStorePromise, pushApiPublicKey: pushApiPublicKeyPromise, commServicesAccessToken: null, inviteLinksStore: inviteLinksStorePromise, keyserverInfo: keyserverInfoPromise, }); return initialReduxState; } export { getInitialReduxStateResponder }; diff --git a/lib/selectors/keyserver-selectors.js b/lib/selectors/keyserver-selectors.js index 374feda02..47db79859 100644 --- a/lib/selectors/keyserver-selectors.js +++ b/lib/selectors/keyserver-selectors.js @@ -1,156 +1,171 @@ // @flow import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import type { PlatformDetails } from '../types/device-types'; import type { KeyserverInfo, KeyserverInfos, SelectedKeyserverInfo, } from '../types/keyserver-types'; import type { AppState } from '../types/redux-types.js'; import type { ConnectionInfo } from '../types/socket-types.js'; import type { UserInfos } from '../types/user-types.js'; const baseCookieSelector: ( keyserverID: string, ) => (state: AppState) => ?string = keyserverID => (state: AppState) => state.keyserverStore.keyserverInfos[keyserverID]?.cookie; const cookieSelector: (keyserverID: string) => (state: AppState) => ?string = _memoize(baseCookieSelector); const cookiesSelector: (state: AppState) => { +[keyserverID: string]: ?string, } = createSelector( (state: AppState) => state.keyserverStore.keyserverInfos, (infos: { +[key: string]: KeyserverInfo }) => { const cookies: { [string]: ?string } = {}; for (const keyserverID in infos) { cookies[keyserverID] = infos[keyserverID].cookie; } return cookies; }, ); const baseSessionIDSelector: ( keyserverID: string, ) => (state: AppState) => ?string = keyserverID => (state: AppState) => state.keyserverStore.keyserverInfos[keyserverID]?.sessionID; const sessionIDSelector: (keyserverID: string) => (state: AppState) => ?string = _memoize(baseSessionIDSelector); const baseUpdatesCurrentAsOfSelector: ( keyserverID: string, ) => (state: AppState) => number = keyserverID => (state: AppState) => state.keyserverStore.keyserverInfos[keyserverID]?.updatesCurrentAsOf ?? 0; const updatesCurrentAsOfSelector: ( keyserverID: string, ) => (state: AppState) => number = _memoize(baseUpdatesCurrentAsOfSelector); +const allUpdatesCurrentAsOfSelector: (state: AppState) => { + +[keyserverID: string]: number, +} = createSelector( + (state: AppState) => state.keyserverStore.keyserverInfos, + (infos: { +[key: string]: KeyserverInfo }) => { + const allUpdatesCurrentAsOf: { [string]: number } = {}; + for (const keyserverID in infos) { + allUpdatesCurrentAsOf[keyserverID] = + infos[keyserverID].updatesCurrentAsOf; + } + return allUpdatesCurrentAsOf; + }, +); + const baseCurrentAsOfSelector: ( keyserverID: string, ) => (state: AppState) => number = keyserverID => (state: AppState) => state.messageStore.currentAsOf[keyserverID] ?? 0; const currentAsOfSelector: ( keyserverID: string, ) => (state: AppState) => number = _memoize(baseCurrentAsOfSelector); const baseUrlPrefixSelector: ( keyserverID: string, ) => (state: AppState) => ?string = keyserverID => (state: AppState) => state.keyserverStore.keyserverInfos[keyserverID]?.urlPrefix; const urlPrefixSelector: (keyserverID: string) => (state: AppState) => ?string = _memoize(baseUrlPrefixSelector); const baseConnectionSelector: ( keyserverID: string, ) => (state: AppState) => ?ConnectionInfo = keyserverID => (state: AppState) => state.keyserverStore.keyserverInfos[keyserverID]?.connection; const connectionSelector: ( keyserverID: string, ) => (state: AppState) => ?ConnectionInfo = _memoize(baseConnectionSelector); const baseLastCommunicatedPlatformDetailsSelector: ( keyserverID: string, ) => (state: AppState) => ?PlatformDetails = keyserverID => (state: AppState) => state.keyserverStore.keyserverInfos[keyserverID] ?.lastCommunicatedPlatformDetails; const lastCommunicatedPlatformDetailsSelector: ( keyserverID: string, ) => (state: AppState) => ?PlatformDetails = _memoize( baseLastCommunicatedPlatformDetailsSelector, ); const selectedKeyserversSelector: ( state: AppState, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.keyserverStore.keyserverInfos, (state: AppState) => state.userStore.userInfos, (keyserverInfos: KeyserverInfos, userInfos: UserInfos) => { const result = []; for (const key in keyserverInfos) { const keyserverInfo = keyserverInfos[key]; const keyserverAdminUsername = userInfos[key]?.username; if (!keyserverAdminUsername) { continue; } const keyserverAdminUserInfo = { id: userInfos[key].id, username: keyserverAdminUsername, }; result.push({ keyserverAdminUserInfo, keyserverInfo, }); } return result; }, ); const deviceTokensSelector: (state: AppState) => { +[keyserverID: string]: ?string, } = createSelector( (state: AppState) => state.keyserverStore.keyserverInfos, (infos: { +[key: string]: KeyserverInfo }) => { const deviceTokens: { [string]: ?string } = {}; for (const keyserverID in infos) { deviceTokens[keyserverID] = infos[keyserverID].deviceToken; } return deviceTokens; }, ); const baseDeviceTokenSelector: ( keyserverID: string, ) => (state: AppState) => ?string = keyserverID => (state: AppState) => state.keyserverStore.keyserverInfos[keyserverID]?.deviceToken; const deviceTokenSelector: ( keyserverID: string, ) => (state: AppState) => ?string = _memoize(baseDeviceTokenSelector); export { cookieSelector, cookiesSelector, sessionIDSelector, updatesCurrentAsOfSelector, currentAsOfSelector, urlPrefixSelector, connectionSelector, lastCommunicatedPlatformDetailsSelector, deviceTokensSelector, deviceTokenSelector, selectedKeyserversSelector, + allUpdatesCurrentAsOfSelector, }; diff --git a/web/redux/action-types.js b/web/redux/action-types.js index 80a21beec..d395cfa46 100644 --- a/web/redux/action-types.js +++ b/web/redux/action-types.js @@ -1,150 +1,172 @@ // @flow import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { extractKeyserverIDFromID } from 'lib/utils/action-utils.js'; import { useKeyserverCall } from 'lib/utils/keyserver-call.js'; import type { CallKeyserverEndpoint } from 'lib/utils/keyserver-call.js'; +import type { URLInfo } from 'lib/utils/url-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import type { + ExcludedData, InitialReduxState, InitialReduxStateResponse, InitialKeyserverInfo, InitialReduxStateRequest, } from '../types/redux-types.js'; export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; export const updateWindowDimensionsActionType = 'UPDATE_WINDOW_DIMENSIONS'; export const updateWindowActiveActionType = 'UPDATE_WINDOW_ACTIVE'; export const setInitialReduxState = 'SET_INITIAL_REDUX_STATE'; const getInitialReduxStateCallServerEndpointOptions = { timeout: 300000 }; + +type GetInitialReduxStateInput = { + +urlInfo: URLInfo, + +excludedData: ExcludedData, + +allUpdatesCurrentAsOf: { + +[keyserverID: string]: number, + }, +}; const getInitialReduxState = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, - ): ((input: InitialReduxStateRequest) => Promise) => + ): ((input: GetInitialReduxStateInput) => Promise) => async input => { const requests: { [string]: InitialReduxStateRequest } = {}; - const { urlInfo, excludedData } = input; + const { urlInfo, excludedData, allUpdatesCurrentAsOf } = input; const { thread, inviteSecret, ...rest } = urlInfo; const threadKeyserverID = thread ? extractKeyserverIDFromID(thread) : null; for (const keyserverID of allKeyserverIDs) { + const clientUpdatesCurrentAsOf = allUpdatesCurrentAsOf[keyserverID]; + const keyserverExcludedData: ExcludedData = { + threadStore: !!excludedData.threadStore && !!clientUpdatesCurrentAsOf, + }; if (keyserverID === threadKeyserverID) { - requests[keyserverID] = { urlInfo, excludedData }; + requests[keyserverID] = { + urlInfo, + excludedData: keyserverExcludedData, + clientUpdatesCurrentAsOf, + }; } else { - requests[keyserverID] = { urlInfo: rest, excludedData }; + requests[keyserverID] = { + urlInfo: rest, + excludedData: keyserverExcludedData, + clientUpdatesCurrentAsOf, + }; } } const responses: { +[string]: InitialReduxStateResponse } = await callKeyserverEndpoint( 'get_initial_redux_state', requests, getInitialReduxStateCallServerEndpointOptions, ); const { currentUserInfo, userInfos, pushApiPublicKey, commServicesAccessToken, navInfo, } = responses[ashoatKeyserverID]; const dataLoaded = currentUserInfo && !currentUserInfo.anonymous; const actualizedCalendarQuery = { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: defaultCalendarFilters, }; const entryStore = { daysToEntries: {}, entryInfos: {}, lastUserInteractionCalendar: 0, }; const threadStore = { threadInfos: {}, }; const messageStore = { currentAsOf: {}, local: {}, messages: {}, threads: {}, }; const inviteLinksStore = { links: {}, }; let keyserverInfos: { [keyserverID: string]: InitialKeyserverInfo } = {}; for (const keyserverID in responses) { entryStore.daysToEntries = { ...entryStore.daysToEntries, ...responses[keyserverID].entryStore.daysToEntries, }; entryStore.entryInfos = { ...entryStore.entryInfos, ...responses[keyserverID].entryStore.entryInfos, }; entryStore.lastUserInteractionCalendar = Math.max( entryStore.lastUserInteractionCalendar, responses[keyserverID].entryStore.lastUserInteractionCalendar, ); threadStore.threadInfos = { ...threadStore.threadInfos, ...responses[keyserverID].threadStore.threadInfos, }; messageStore.currentAsOf = { ...messageStore.currentAsOf, ...responses[keyserverID].messageStore.currentAsOf, }; messageStore.messages = { ...messageStore.messages, ...responses[keyserverID].messageStore.messages, }; messageStore.threads = { ...messageStore.threads, ...responses[keyserverID].messageStore.threads, }; inviteLinksStore.links = { ...inviteLinksStore.links, ...responses[keyserverID].inviteLinksStore.links, }; keyserverInfos = { ...keyserverInfos, [keyserverID]: responses[keyserverID].keyserverInfo, }; } return { navInfo: { ...navInfo, inviteSecret, }, currentUserInfo, entryStore, threadStore, userInfos, actualizedCalendarQuery, messageStore, dataLoaded, pushApiPublicKey, commServicesAccessToken, inviteLinksStore, keyserverInfos, }; }; function useGetInitialReduxState(): ( - input: InitialReduxStateRequest, + input: GetInitialReduxStateInput, ) => Promise { return useKeyserverCall(getInitialReduxState); } export { useGetInitialReduxState }; diff --git a/web/redux/initial-state-gate.js b/web/redux/initial-state-gate.js index 6ea6b4858..7dacb7b60 100644 --- a/web/redux/initial-state-gate.js +++ b/web/redux/initial-state-gate.js @@ -1,136 +1,142 @@ // @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 { ThreadStoreOperation } from 'lib/ops/thread-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/thread-types.js'; import { convertIDToNewSchema } from 'lib/utils/migration-utils.js'; import { entries } from 'lib/utils/objects.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { setInitialReduxState, useGetInitialReduxState, } from './action-types.js'; import { useSelector } from './redux-utils.js'; import { getClientStore, processDBStoreOperations, } from '../database/utils/store.js'; import Loading from '../loading.react.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) { prevIsRehydrated.current = isRehydrated; (async () => { try { let urlInfo = infoFromURL(decodeURI(window.location.href)); // Handle older links if (urlInfo.thread) { urlInfo = { ...urlInfo, thread: convertIDToNewSchema(urlInfo.thread, ashoatKeyserverID), }; } const clientDBStore = await getClientStore(); const payload = await callGetInitialReduxState({ urlInfo, - excludedData: { threadStore: !!clientDBStore.threadStore }, + excludedData: { + threadStore: !!clientDBStore.threadStore, + }, + allUpdatesCurrentAsOf, }); const currentLoggedInUserID = payload.currentUserInfo?.anonymous ? undefined : payload.currentUserInfo?.id; const useDatabase = canUseDatabaseOnWeb(currentLoggedInUserID); if (!currentLoggedInUserID || !useDatabase) { dispatch({ type: setInitialReduxState, payload }); return; } if (clientDBStore.threadStore) { // If there is data in the DB, populate the store dispatch({ type: setClientDBStoreActionType, payload: clientDBStore, }); const { threadStore, ...rest } = payload; dispatch({ type: setInitialReduxState, payload: rest }); return; } else { // When there is no data in the DB, it's necessary to migrate data // from the keyserver payload to the DB const { threadStore: { threadInfos }, } = payload; const threadStoreOperations: ThreadStoreOperation[] = entries( threadInfos, ).map(([id, threadInfo]: [string, RawThreadInfo]) => ({ type: 'replace', payload: { id, threadInfo, }, })); await processDBStoreOperations({ threadStoreOperations, draftStoreOperations: [], messageStoreOperations: [], reportStoreOperations: [], userStoreOperations: [], }); } dispatch({ type: setInitialReduxState, payload }); } catch (err) { setInitError(err); } })(); } - }, [callGetInitialReduxState, dispatch, isRehydrated]); + }, [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 b1fad469a..a22156c24 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,352 +1,347 @@ // @flow import invariant from 'invariant'; import type { PersistState } from 'redux-persist/es/types.js'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions.js'; import { type ThreadStoreOperation, threadStoreOpsHandlers, } from 'lib/ops/thread-store-ops.js'; import baseReducer from 'lib/reducers/master-reducer.js'; import { mostRecentlyReadThreadSelector } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { invalidSessionDowngrade } from 'lib/shared/session-utils.js'; -import { canUseDatabaseOnWeb } from 'lib/shared/web-database.js'; import type { Shape } from 'lib/types/core.js'; import type { CryptoStore } 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, CalendarQuery } 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 { 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 { 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 { setNewSessionActionType } from 'lib/utils/action-utils.js'; import type { NotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { updateWindowActiveActionType, updateNavInfoActionType, updateWindowDimensionsActionType, setInitialReduxState, } from './action-types.js'; import { reduceCommunityPickerStore } from './community-picker-reducer.js'; import { reduceCryptoStore, setCryptoStore } from './crypto-store-reducer.js'; import reduceNavInfo from './nav-reducer.js'; import { onStateDifference } from './redux-debug-utils.js'; import { getVisibility } from './visibility.js'; import { processDBStoreOperations } from '../database/utils/store.js'; import { activeThreadSelector } from '../selectors/nav-selectors.js'; import { type NavInfo } from '../types/nav-types.js'; import type { InitialReduxState } from '../types/redux-types.js'; export type WindowDimensions = { width: number, height: number }; export type CommunityPickerStore = { +chat: ?string, +calendar: ?string, }; export type AppState = { +navInfo: NavInfo, +currentUserInfo: ?CurrentUserInfo, +draftStore: DraftStore, +entryStore: EntryStore, +threadStore: ThreadStore, +userStore: UserStore, +messageStore: MessageStore, +loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, +calendarFilters: $ReadOnlyArray, +communityPickerStore: CommunityPickerStore, +windowDimensions: WindowDimensions, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +actualizedCalendarQuery: CalendarQuery, +watchedThreadIDs: $ReadOnlyArray, +lifecycleState: LifecycleState, +enabledApps: EnabledApps, +reportStore: ReportStore, +nextLocalID: number, +dataLoaded: boolean, +windowActive: boolean, +userPolicies: UserPolicies, +cryptoStore: ?CryptoStore, +pushApiPublicKey: ?string, +_persist: ?PersistState, +commServicesAccessToken: ?string, +inviteLinksStore: InviteLinksStore, +keyserverStore: KeyserverStore, +threadActivityStore: ThreadActivityStore, +initialStateLoaded: boolean, +integrityStore: IntegrityStore, +globalThemeInfo: GlobalThemeInfo, }; export type Action = | BaseAction | { type: 'UPDATE_NAV_INFO', payload: Shape } | { type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, } | { type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, } | { +type: 'SET_CRYPTO_STORE', payload: CryptoStore } | { +type: 'SET_INITIAL_REDUX_STATE', payload: InitialReduxState }; export function reducer(oldState: AppState | void, action: Action): AppState { invariant(oldState, 'should be set'); let state = oldState; let storeOperations: StoreOperations = { draftStoreOperations: [], threadStoreOperations: [], messageStoreOperations: [], reportStoreOperations: [], userStoreOperations: [], }; if (action.type === setInitialReduxState) { const { userInfos, keyserverInfos, ...rest } = action.payload; const newKeyserverInfos = { ...state.keyserverStore.keyserverInfos }; for (const keyserverID in keyserverInfos) { - const newUpdatesCurrentAsOf = canUseDatabaseOnWeb(rest.currentUserInfo.id) - ? newKeyserverInfos[keyserverID].updatesCurrentAsOf - : keyserverInfos[keyserverID].updatesCurrentAsOf; newKeyserverInfos[keyserverID] = { ...newKeyserverInfos[keyserverID], ...keyserverInfos[keyserverID], - updatesCurrentAsOf: newUpdatesCurrentAsOf, }; } return validateStateAndProcessDBOperations( oldState, { ...state, ...rest, userStore: { userInfos }, keyserverStore: { ...state.keyserverStore, keyserverInfos: newKeyserverInfos, }, initialStateLoaded: true, }, storeOperations, ); } else if (action.type === updateWindowDimensionsActionType) { return validateStateAndProcessDBOperations( oldState, { ...state, windowDimensions: action.payload, }, storeOperations, ); } else if (action.type === updateWindowActiveActionType) { return validateStateAndProcessDBOperations( oldState, { ...state, windowActive: action.payload, }, storeOperations, ); } else if (action.type === setNewSessionActionType) { if ( invalidSessionDowngrade( oldState, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, ) ) { return oldState; } state = { ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: { ...state.keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...state.keyserverStore.keyserverInfos[ashoatKeyserverID], 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 !== setCryptoStore ) { const baseReducerResult = baseReducer(state, action, onStateDifference); state = baseReducerResult.state; storeOperations = baseReducerResult.storeOperations; } const communityPickerStore = reduceCommunityPickerStore( state.communityPickerStore, action, ); state = { ...state, navInfo: reduceNavInfo( state.navInfo, action, state.threadStore.threadInfos, ), cryptoStore: reduceCryptoStore(state.cryptoStore, action), communityPickerStore, }; return validateStateAndProcessDBOperations(oldState, state, storeOperations); } function validateStateAndProcessDBOperations( 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 updateActiveThreadOps.push({ type: 'replace', payload: { id: activeThread, threadInfo: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].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, ], }; } processDBStoreOperations(storeOperations, state.currentUserInfo?.id); return state; } diff --git a/web/types/redux-types.js b/web/types/redux-types.js index 0d9c1d2f7..eb191ffc3 100644 --- a/web/types/redux-types.js +++ b/web/types/redux-types.js @@ -1,52 +1,53 @@ // @flow import type { EntryStore, CalendarQuery } from 'lib/types/entry-types.js'; import type { InviteLinksStore } from 'lib/types/link-types.js'; import type { MessageStore } from 'lib/types/message-types.js'; import type { ThreadStore } from 'lib/types/thread-types.js'; import type { CurrentUserInfo, UserInfos } from 'lib/types/user-types.js'; import type { URLInfo } from 'lib/utils/url-utils.js'; import type { NavInfo } from '../types/nav-types.js'; export type InitialReduxStateResponse = { +navInfo: NavInfo, +currentUserInfo: CurrentUserInfo, +entryStore: EntryStore, +threadStore: ThreadStore, +userInfos: UserInfos, +messageStore: MessageStore, +pushApiPublicKey: ?string, +commServicesAccessToken: null, +inviteLinksStore: InviteLinksStore, +keyserverInfo: InitialKeyserverInfo, }; export type InitialReduxState = { +navInfo: NavInfo, +currentUserInfo: CurrentUserInfo, +entryStore: EntryStore, +threadStore: ThreadStore, +userInfos: UserInfos, +messageStore: MessageStore, +pushApiPublicKey: ?string, +commServicesAccessToken: null, +inviteLinksStore: InviteLinksStore, +dataLoaded: boolean, +actualizedCalendarQuery: CalendarQuery, +keyserverInfos: { +[keyserverID: string]: InitialKeyserverInfo }, }; export type InitialKeyserverInfo = { +sessionID: ?string, +updatesCurrentAsOf: number, }; export type ExcludedData = { +threadStore?: boolean, }; export type InitialReduxStateRequest = { +urlInfo: URLInfo, +excludedData: ExcludedData, + +clientUpdatesCurrentAsOf: number, };