diff --git a/lib/reducers/connection-reducer.js b/lib/reducers/connection-reducer.js index 8686fb198..7a28f45e0 100644 --- a/lib/reducers/connection-reducer.js +++ b/lib/reducers/connection-reducer.js @@ -1,119 +1,119 @@ // @flow import { updateActivityActionTypes } from '../actions/activity-actions'; import { updateCalendarQueryActionTypes } from '../actions/entry-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, registerActionTypes, } from '../actions/user-actions'; import { queueActivityUpdatesActionType } from '../types/activity-types'; import { defaultCalendarQuery } from '../types/entry-types'; import { type BaseAction, rehydrateActionType } from '../types/redux-types'; import { type ConnectionInfo, updateConnectionStatusActionType, fullStateSyncActionType, incrementalStateSyncActionType, setLateResponseActionType, updateDisconnectedBarActionType, } from '../types/socket-types'; import { setNewSessionActionType } from '../utils/action-utils'; import { getConfig } from '../utils/config'; -import { unsupervisedBackgroundActionType } from './foreground-reducer'; +import { unsupervisedBackgroundActionType } from './lifecycle-state-reducer'; export default function reduceConnectionInfo( state: ConnectionInfo, action: BaseAction, ): ConnectionInfo { if (action.type === updateConnectionStatusActionType) { return { ...state, status: action.payload.status, lateResponses: [] }; } else if (action.type === unsupervisedBackgroundActionType) { return { ...state, status: 'disconnected', lateResponses: [] }; } else if (action.type === queueActivityUpdatesActionType) { const { activityUpdates } = action.payload; return { ...state, queuedActivityUpdates: [ ...state.queuedActivityUpdates.filter((existingUpdate) => { for (let activityUpdate of activityUpdates) { if ( ((existingUpdate.focus && activityUpdate.focus) || (existingUpdate.focus === false && activityUpdate.focus !== undefined)) && existingUpdate.threadID === activityUpdate.threadID ) { return false; } } return true; }), ...activityUpdates, ], }; } else if (action.type === updateActivityActionTypes.success) { const { payload } = action; return { ...state, queuedActivityUpdates: state.queuedActivityUpdates.filter( (activityUpdate) => !payload.activityUpdates.includes(activityUpdate), ), }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return { ...state, queuedActivityUpdates: [], actualizedCalendarQuery: defaultCalendarQuery( getConfig().platformDetails.platform, ), }; } else if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success ) { return { ...state, actualizedCalendarQuery: action.payload.calendarResult.calendarQuery, }; } else if ( action.type === registerActionTypes.success || action.type === updateCalendarQueryActionTypes.success || action.type === fullStateSyncActionType || action.type === incrementalStateSyncActionType ) { return { ...state, actualizedCalendarQuery: action.payload.calendarQuery, }; } else if (action.type === rehydrateActionType) { if (!action.payload || !action.payload.connection) { return state; } return { ...action.payload.connection, status: 'connecting', queuedActivityUpdates: [], lateResponses: [], showDisconnectedBar: false, }; } else if (action.type === setLateResponseActionType) { const { messageID, isLate } = action.payload; const lateResponsesSet = new Set(state.lateResponses); if (isLate) { lateResponsesSet.add(messageID); } else { lateResponsesSet.delete(messageID); } return { ...state, lateResponses: [...lateResponsesSet] }; } else if (action.type === updateDisconnectedBarActionType) { return { ...state, showDisconnectedBar: action.payload.visible }; } return state; } diff --git a/lib/reducers/foreground-reducer.js b/lib/reducers/lifecycle-state-reducer.js similarity index 92% rename from lib/reducers/foreground-reducer.js rename to lib/reducers/lifecycle-state-reducer.js index 59e45a923..5c20f1c8e 100644 --- a/lib/reducers/foreground-reducer.js +++ b/lib/reducers/lifecycle-state-reducer.js @@ -1,22 +1,22 @@ // @flow import type { BaseAction } from '../types/redux-types'; export const unsupervisedBackgroundActionType = 'UNSUPERVISED_BACKGROUND'; export const updateLifecycleStateActionType = 'UPDATE_LIFECYCLE_STATE'; -export default function reduceForeground( +export default function reduceLifecycleState( state: boolean, action: BaseAction, ): boolean { if (action.type === unsupervisedBackgroundActionType) { return false; } else if (action.type === updateLifecycleStateActionType) { if (action.payload === 'active') { return true; } else if (action.payload === 'background') { return false; } } return state; } diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js index 8f5a34e60..7361fb65f 100644 --- a/lib/reducers/master-reducer.js +++ b/lib/reducers/master-reducer.js @@ -1,77 +1,77 @@ // @flow import type { BaseNavInfo } from '../types/nav-types'; import type { BaseAppState, BaseAction } from '../types/redux-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import reduceCalendarFilters from './calendar-filters-reducer'; import reduceConnectionInfo from './connection-reducer'; import reduceDataLoaded from './data-loaded-reducer'; import { reduceEntryInfos } from './entry-reducer'; -import reduceForeground from './foreground-reducer'; +import reduceLifecycleState from './lifecycle-state-reducer'; import { reduceLoadingStatuses } from './loading-reducer'; import reduceNextLocalID from './local-id-reducer'; import { reduceMessageStore } from './message-reducer'; import reduceBaseNavInfo from './nav-reducer'; import reduceQueuedReports from './report-reducer'; import reduceThreadInfos from './thread-reducer'; import reduceUpdatesCurrentAsOf from './updates-reducer'; import reduceURLPrefix from './url-prefix-reducer'; import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer'; export default function baseReducer>( state: T, action: BaseAction, ): T { const threadStore = reduceThreadInfos(state.threadStore, action); const { threadInfos } = threadStore; // Only allow checkpoints to increase if we are connected // or if the action is a STATE_SYNC let messageStore = reduceMessageStore( state.messageStore, action, threadInfos, ); let updatesCurrentAsOf = reduceUpdatesCurrentAsOf( state.updatesCurrentAsOf, action, ); const connection = reduceConnectionInfo(state.connection, action); if ( connection.status !== 'connected' && action.type !== incrementalStateSyncActionType && action.type !== fullStateSyncActionType ) { if (messageStore.currentAsOf !== state.messageStore.currentAsOf) { messageStore = { ...messageStore, currentAsOf: state.messageStore.currentAsOf, }; } if (updatesCurrentAsOf !== state.updatesCurrentAsOf) { updatesCurrentAsOf = state.updatesCurrentAsOf; } } return { ...state, navInfo: reduceBaseNavInfo(state.navInfo, action), entryStore: reduceEntryInfos(state.entryStore, action, threadInfos), loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), currentUserInfo: reduceCurrentUserInfo(state.currentUserInfo, action), threadStore, userStore: reduceUserInfos(state.userStore, action), messageStore: reduceMessageStore(state.messageStore, action, threadInfos), updatesCurrentAsOf, urlPrefix: reduceURLPrefix(state.urlPrefix, action), calendarFilters: reduceCalendarFilters(state.calendarFilters, action), connection, - foreground: reduceForeground(state.foreground, action), + lifecycleState: reduceLifecycleState(state.lifecycleState, action), nextLocalID: reduceNextLocalID(state.nextLocalID, action), queuedReports: reduceQueuedReports(state.queuedReports, action), dataLoaded: reduceDataLoaded(state.dataLoaded, action), }; } diff --git a/lib/socket/socket.react.js b/lib/socket/socket.react.js index 44e39033e..e87eb9137 100644 --- a/lib/socket/socket.react.js +++ b/lib/socket/socket.react.js @@ -1,750 +1,750 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle'; import PropTypes from 'prop-types'; import * as React from 'react'; import { updateActivityActionTypes } from '../actions/activity-actions'; import { socketAuthErrorResolutionAttempt, logOutActionTypes, } from '../actions/user-actions'; -import { unsupervisedBackgroundActionType } from '../reducers/foreground-reducer'; +import { unsupervisedBackgroundActionType } from '../reducers/lifecycle-state-reducer'; import { pingFrequency, serverRequestSocketTimeout, clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts'; import type { LogOutResult } from '../types/account-types'; import type { CalendarQuery } from '../types/entry-types'; import type { Dispatch } from '../types/redux-types'; import { serverRequestTypes, type ClientClientResponse, type ServerRequest, } from '../types/request-types'; import { type SessionState, type SessionIdentification, sessionIdentificationPropType, type PreRequestUserState, preRequestUserStatePropType, } from '../types/session-types'; import { clientSocketMessageTypes, type ClientClientSocketMessage, serverSocketMessageTypes, type ServerSocketMessage, stateSyncPayloadTypes, fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, connectionInfoPropType, type ConnectionInfo, type ClientInitialClientSocketMessage, type ClientResponsesClientSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionStatus, setLateResponseActionType, } from '../types/socket-types'; import { actionLogger } from '../utils/action-logger'; import type { DispatchActionPromise } from '../utils/action-utils'; import { setNewSessionActionType, fetchNewCookieFromNativeCredentials, } from '../utils/action-utils'; import { getConfig } from '../utils/config'; import { ServerError } from '../utils/errors'; import { promiseAll } from '../utils/promises'; import sleep from '../utils/sleep'; import ActivityHandler from './activity-handler.react'; import APIRequestHandler from './api-request-handler.react'; import CalendarQueryHandler from './calendar-query-handler.react'; import { InflightRequests, SocketTimeout, SocketOffline, } from './inflight-requests'; import MessageHandler from './message-handler.react'; import ReportHandler from './report-handler.react'; import RequestResponseHandler from './request-response-handler.react'; import UpdateHandler from './update-handler.react'; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; export type BaseSocketProps = {| +detectUnsupervisedBackgroundRef?: ( detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean, ) => void, |}; type Props = {| ...BaseSocketProps, // Redux state +active: boolean, +openSocket: () => WebSocket, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => $ReadOnlyArray, +activeThread: ?string, +sessionStateFunc: () => SessionState, +sessionIdentification: SessionIdentification, +cookie: ?string, +urlPrefix: string, +connection: ConnectionInfo, +currentCalendarQuery: () => CalendarQuery, +canSendReports: boolean, +frozen: boolean, +preRequestUserState: PreRequestUserState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logOut: (preRequestUserState: PreRequestUserState) => Promise, |}; type State = {| +inflightRequests: ?InflightRequests, |}; class Socket extends React.PureComponent { static propTypes = { detectUnsupervisedBackgroundRef: PropTypes.func, active: PropTypes.bool.isRequired, openSocket: PropTypes.func.isRequired, getClientResponses: PropTypes.func.isRequired, activeThread: PropTypes.string, sessionStateFunc: PropTypes.func.isRequired, sessionIdentification: sessionIdentificationPropType.isRequired, cookie: PropTypes.string, urlPrefix: PropTypes.string.isRequired, connection: connectionInfoPropType.isRequired, currentCalendarQuery: PropTypes.func.isRequired, canSendReports: PropTypes.bool.isRequired, frozen: PropTypes.bool.isRequired, preRequestUserState: preRequestUserStatePropType.isRequired, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, logOut: PropTypes.func.isRequired, }; state: State = { inflightRequests: null, }; socket: ?WebSocket; nextClientMessageID = 0; listeners: Set = new Set(); pingTimeoutID: ?TimeoutID; messageLastReceived: ?number; initialPlatformDetailsSent = getConfig().platformDetails.platform === 'web'; reopenConnectionAfterClosing = false; invalidationRecoveryInProgress = false; initializedWithUserState: ?PreRequestUserState; openSocket(newStatus: ConnectionStatus) { if ( this.props.frozen || (getConfig().platformDetails.platform !== 'web' && (!this.props.cookie || !this.props.cookie.startsWith('user='))) ) { return; } if (this.socket) { const { status } = this.props.connection; if (status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = true; return; } else if (status === 'disconnecting' && this.socket.readyState === 1) { this.markSocketInitialized(); return; } else if ( status === 'connected' || status === 'connecting' || status === 'reconnecting' ) { return; } if (this.socket.readyState < 2) { this.socket.close(); console.log(`this.socket seems open, but Redux thinks it's ${status}`); } } this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: newStatus }, }); const socket = this.props.openSocket(); const openObject = {}; socket.onopen = () => { if (this.socket === socket) { this.initializeSocket(); openObject.initializeMessageSent = true; } }; socket.onmessage = this.receiveMessage; socket.onclose = () => { if (this.socket === socket) { this.onClose(); } }; this.socket = socket; (async () => { await sleep(clientRequestVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.setLateResponse(-1, true); await sleep(remainingTimeAfterVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.finishClosingSocket(); })(); this.setState({ inflightRequests: new InflightRequests({ timeout: () => { if (this.socket === socket) { this.finishClosingSocket(); } }, setLateResponse: (messageID: number, isLate: boolean) => { if (this.socket === socket) { this.setLateResponse(messageID, isLate); } }, }), }); } markSocketInitialized() { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'connected' }, }); this.resetPing(); } closeSocket( // This param is a hack. When closing a socket there is a race between this // function and the one to propagate the activity update. We make sure that // the activity update wins the race by passing in this param. activityUpdatePending: boolean, ) { const { status } = this.props.connection; if (status === 'disconnected') { return; } else if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = false; return; } this.stopPing(); this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnecting' }, }); if (!activityUpdatePending) { this.finishClosingSocket(); } } forceCloseSocket() { this.stopPing(); const { status } = this.props.connection; if (status !== 'forcedDisconnecting' && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'forcedDisconnecting' }, }); } this.finishClosingSocket(); } finishClosingSocket(receivedResponseTo?: ?number) { const { inflightRequests } = this.state; if ( inflightRequests && !inflightRequests.allRequestsResolvedExcept(receivedResponseTo) ) { return; } if (this.socket && this.socket.readyState < 2) { // If it's not closing already, close it this.socket.close(); } this.socket = null; this.stopPing(); this.setState({ inflightRequests: null }); if (this.props.connection.status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } if (this.reopenConnectionAfterClosing) { this.reopenConnectionAfterClosing = false; if (this.props.active) { this.openSocket('connecting'); } } } reconnect = _throttle(() => this.openSocket('reconnecting'), 2000); componentDidMount() { if (this.props.detectUnsupervisedBackgroundRef) { this.props.detectUnsupervisedBackgroundRef( this.detectUnsupervisedBackground, ); } if (this.props.active) { this.openSocket('connecting'); } } componentWillUnmount() { this.closeSocket(false); this.reconnect.cancel(); } componentDidUpdate(prevProps: Props) { if (this.props.active && !prevProps.active) { this.openSocket('connecting'); } else if (!this.props.active && prevProps.active) { this.closeSocket(!!prevProps.activeThread); } else if ( this.props.active && prevProps.openSocket !== this.props.openSocket ) { // This case happens when the baseURL/urlPrefix is changed this.reopenConnectionAfterClosing = true; this.forceCloseSocket(); } else if ( this.props.active && this.props.connection.status === 'disconnected' && prevProps.connection.status !== 'disconnected' && !this.invalidationRecoveryInProgress ) { this.reconnect(); } } render() { // It's important that APIRequestHandler get rendered first here. This is so // that it is registered with Redux first, so that its componentDidUpdate // processes before the other Handlers. This allows APIRequestHandler to // register itself with action-utils before other Handlers call // dispatchActionPromise in response to the componentDidUpdate triggered by // the same Redux change (state.connection.status). return ( ); } sendMessageWithoutID = (message: ClientSocketMessageWithoutID) => { const id = this.nextClientMessageID++; // These conditions all do the same thing and the runtime checks are only // necessary for Flow if (message.type === clientSocketMessageTypes.INITIAL) { this.sendMessage(({ ...message, id }: ClientInitialClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.sendMessage( ({ ...message, id }: ClientResponsesClientSocketMessage), ); } else if (message.type === clientSocketMessageTypes.PING) { this.sendMessage(({ ...message, id }: PingClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.sendMessage(({ ...message, id }: AckUpdatesClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.sendMessage(({ ...message, id }: APIRequestClientSocketMessage)); } return id; }; sendMessage(message: ClientClientSocketMessage) { const socket = this.socket; invariant(socket, 'should be set'); socket.send(JSON.stringify(message)); } static messageFromEvent(event: MessageEvent): ?ServerSocketMessage { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return null; } try { return JSON.parse(event.data); } catch (e) { console.log(e); return null; } } receiveMessage = async (event: MessageEvent) => { const message = Socket.messageFromEvent(event); if (!message) { return; } const { inflightRequests } = this.state; if (!inflightRequests) { // inflightRequests can be falsey here if we receive a message after we've // begun shutting down the socket. It's possible for a React Native // WebSocket to deliver a message even after close() is called on it. In // this case the message is probably a PONG, which we can safely ignore. // If it's not a PONG, it has to be something server-initiated (like // UPDATES or MESSAGES), since InflightRequests.allRequestsResolvedExcept // will wait for all responses to client-initiated requests to be // delivered before closing a socket. UPDATES and MESSAGES are both // checkpointed on the client, so should be okay to just ignore here and // redownload them later, probably in an incremental STATE_SYNC. return; } // If we receive any message, that indicates that our connection is healthy, // so we can reset the ping timeout. this.resetPing(); inflightRequests.resolveRequestsForMessage(message); const { status } = this.props.connection; if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.finishClosingSocket( // We do this for Flow message.responseTo !== undefined ? message.responseTo : null, ); } for (let listener of this.listeners) { listener(message); } if (message.type === serverSocketMessageTypes.ERROR) { const { message: errorMessage, payload } = message; if (payload) { console.log(`socket sent error ${errorMessage} with payload`, payload); } else { console.log(`socket sent error ${errorMessage}`); } } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { const { sessionChange } = message; const cookie = sessionChange ? sessionChange.cookie : this.props.cookie; this.invalidationRecoveryInProgress = true; const recoverySessionChange = await fetchNewCookieFromNativeCredentials( this.props.dispatch, cookie, this.props.urlPrefix, socketAuthErrorResolutionAttempt, ); if (!recoverySessionChange && sessionChange) { // This should only happen in the cookieSources.BODY (native) case when // the resolution attempt failed const { cookie: newerCookie, currentUserInfo } = sessionChange; this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: true, currentUserInfo, cookie: newerCookie, }, preRequestUserState: this.initializedWithUserState, error: null, source: socketAuthErrorResolutionAttempt, }, }); } else if (!recoverySessionChange) { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } this.invalidationRecoveryInProgress = false; } }; addListener = (listener: SocketListener) => { this.listeners.add(listener); }; removeListener = (listener: SocketListener) => { this.listeners.delete(listener); }; onClose = () => { const { status } = this.props.connection; this.socket = null; this.stopPing(); if (this.state.inflightRequests) { this.state.inflightRequests.rejectAll(new Error('socket closed')); this.setState({ inflightRequests: null }); } const handled = this.detectUnsupervisedBackground(true); if (!handled && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } }; async sendInitialMessage() { const { inflightRequests } = this.state; invariant( inflightRequests, 'inflightRequests falsey inside sendInitialMessage', ); const messageID = this.nextClientMessageID++; const promises = {}; const clientResponses = []; if (!this.initialPlatformDetailsSent) { this.initialPlatformDetailsSent = true; clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } const { queuedActivityUpdates } = this.props.connection; if (queuedActivityUpdates.length > 0) { clientResponses.push({ type: serverRequestTypes.INITIAL_ACTIVITY_UPDATES, activityUpdates: queuedActivityUpdates, }); promises.activityUpdateMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, ); } const sessionState = this.props.sessionStateFunc(); const { sessionIdentification } = this.props; const initialMessage = { type: clientSocketMessageTypes.INITIAL, id: messageID, payload: { clientResponses, sessionState, sessionIdentification, }, }; this.initializedWithUserState = this.props.preRequestUserState; this.sendMessage(initialMessage); promises.stateSyncMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.STATE_SYNC, ); const { stateSyncMessage, activityUpdateMessage } = await promiseAll( promises, ); if (activityUpdateMessage) { this.props.dispatch({ type: updateActivityActionTypes.success, payload: { activityUpdates: queuedActivityUpdates, result: activityUpdateMessage.payload, }, }); } if (stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL) { const { sessionID, type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: fullStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); if (sessionID !== null && sessionID !== undefined) { invariant( this.initializedWithUserState, 'initializedWithUserState should be set when state sync received', ); this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: false, sessionID }, preRequestUserState: this.initializedWithUserState, error: null, source: undefined, }, }); } } else { const { type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: incrementalStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); } const currentAsOf = stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL ? stateSyncMessage.payload.updatesCurrentAsOf : stateSyncMessage.payload.updatesResult.currentAsOf; this.sendMessageWithoutID({ type: clientSocketMessageTypes.ACK_UPDATES, payload: { currentAsOf }, }); this.markSocketInitialized(); } initializeSocket = async (retriesLeft: number = 1) => { try { await this.sendInitialMessage(); } catch (e) { console.log(e); const { status } = this.props.connection; if ( e instanceof SocketTimeout || e instanceof SocketOffline || (status !== 'connecting' && status !== 'reconnecting') ) { // This indicates that the socket will be closed. Do nothing, since the // connection status update will trigger a reconnect. } else if ( retriesLeft === 0 || (e instanceof ServerError && e.message !== 'unknown_error') ) { if (e.message === 'not_logged_in') { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } else if (this.socket) { this.socket.close(); } } else { await this.initializeSocket(retriesLeft - 1); } } }; stopPing() { if (this.pingTimeoutID) { clearTimeout(this.pingTimeoutID); this.pingTimeoutID = null; } } resetPing() { this.stopPing(); const socket = this.socket; this.messageLastReceived = Date.now(); this.pingTimeoutID = setTimeout(() => { if (this.socket === socket) { this.sendPing(); } }, pingFrequency); } async sendPing() { if (this.props.connection.status !== 'connected') { // This generally shouldn't happen because anything that changes the // connection status should call stopPing(), but it's good to make sure return; } const messageID = this.sendMessageWithoutID({ type: clientSocketMessageTypes.PING, }); try { invariant( this.state.inflightRequests, 'inflightRequests falsey inside sendPing', ); await this.state.inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.PONG, ); } catch (e) {} } setLateResponse = (messageID: number, isLate: boolean) => { this.props.dispatch({ type: setLateResponseActionType, payload: { messageID, isLate }, }); }; cleanUpServerTerminatedSocket() { if (this.socket && this.socket.readyState < 2) { this.socket.close(); } else { this.onClose(); } } detectUnsupervisedBackground = (alreadyClosed: boolean) => { // On native, sometimes the app is backgrounded without the proper callbacks // getting triggered. This leaves us in an incorrect state for two reasons: // (1) The connection is still considered to be active, causing API requests // to be processed via socket and failing. // (2) We rely on flipping foreground state in Redux to detect activity // changes, and thus won't think we need to update activity. if ( this.props.connection.status !== 'connected' || !this.messageLastReceived || this.messageLastReceived + serverRequestSocketTimeout >= Date.now() || (actionLogger.mostRecentActionTime && actionLogger.mostRecentActionTime + 3000 < Date.now()) ) { return false; } if (!alreadyClosed) { this.cleanUpServerTerminatedSocket(); } this.props.dispatch({ type: unsupervisedBackgroundActionType, payload: null, }); return true; }; } export default Socket; diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js index b3bdc2c40..870acd223 100644 --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -1,828 +1,828 @@ // @flow import type { LogOutResult, LogInStartingPayload, LogInResult, RegisterResult, } from './account-types'; import type { ActivityUpdateSuccessPayload, QueueActivityUpdatesPayload, SetThreadUnreadStatusPayload, } from './activity-types'; import type { RawEntryInfo, EntryStore, CalendarQuery, SaveEntryPayload, CreateEntryPayload, DeleteEntryResponse, RestoreEntryPayload, FetchEntryInfosResult, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from './entry-types'; import type { CalendarFilter, CalendarThreadFilter, SetCalendarDeletedFilterPayload, } from './filter-types'; import type { LifecycleState } from './lifecycle-state-types'; import type { LoadingStatus, LoadingInfo } from './loading-types'; import type { UpdateMultimediaMessageMediaPayload } from './media-types'; import type { MessageStore, RawMultimediaMessageInfo, FetchMessageInfosPayload, SendMessagePayload, SaveMessagesPayload, NewMessagesPayload, MessageStorePrunePayload, LocallyComposedMessageInfo, } from './message-types'; import type { RawTextMessageInfo } from './messages/text'; import type { BaseNavInfo } from './nav-types'; import type { ClearDeliveredReportsPayload, ClientReportCreationRequest, QueueReportsPayload, } from './report-types'; import type { ProcessServerRequestsPayload } from './request-types'; import type { UserSearchResult } from './search-types'; import type { SetSessionPayload } from './session-types'; import type { ConnectionInfo, StateSyncFullActionPayload, StateSyncIncrementalActionPayload, UpdateConnectionStatusPayload, SetLateResponsePayload, UpdateDisconnectedBarPayload, } from './socket-types'; import type { SubscriptionUpdateResult } from './subscription-types'; import type { ThreadStore, ChangeThreadSettingsPayload, LeaveThreadPayload, NewThreadResult, ThreadJoinPayload, } from './thread-types'; import type { UpdatesResultWithUserInfos } from './update-types'; import type { CurrentUserInfo, UserStore } from './user-types'; export type BaseAppState = { navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, // millisecond timestamp loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, - foreground: boolean, + lifecycleState: boolean, nextLocalID: number, queuedReports: $ReadOnlyArray, dataLoaded: boolean, }; // Web JS runtime doesn't have access to the cookie for security reasons. // Native JS doesn't have a sessionID because the cookieID is used instead. // Web JS doesn't have a device token because it's not a device... export type NativeAppState = BaseAppState<*> & { sessionID?: void, deviceToken: ?string, cookie: ?string, }; export type WebAppState = BaseAppState<*> & { sessionID: ?string, deviceToken?: void, cookie?: void, }; export type AppState = NativeAppState | WebAppState; export type BaseAction = | {| +type: '@@redux/INIT', +payload?: void, |} | {| +type: 'FETCH_ENTRIES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_ENTRIES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_ENTRIES_SUCCESS', +payload: FetchEntryInfosResult, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_SUCCESS', +payload: LogOutResult, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_SUCCESS', +payload: LogOutResult, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_LOCAL_ENTRY', +payload: RawEntryInfo, |} | {| +type: 'CREATE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_ENTRY_SUCCESS', +payload: CreateEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_SUCCESS', +payload: SaveEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'CONCURRENT_MODIFICATION_RESET', +payload: {| +id: string, +dbText: string, |}, |} | {| +type: 'DELETE_ENTRY_STARTED', +loadingInfo: LoadingInfo, +payload: {| +localID: ?string, +serverID: ?string, |}, |} | {| +type: 'DELETE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ENTRY_SUCCESS', +payload: ?DeleteEntryResponse, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_IN_STARTED', +loadingInfo: LoadingInfo, +payload: LogInStartingPayload, |} | {| +type: 'LOG_IN_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_IN_SUCCESS', +payload: LogInResult, +loadingInfo: LoadingInfo, |} | {| +type: 'REGISTER_STARTED', +loadingInfo: LoadingInfo, +payload: LogInStartingPayload, |} | {| +type: 'REGISTER_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REGISTER_SUCCESS', +payload: RegisterResult, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_STARTED', +payload: {| calendarQuery: CalendarQuery |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_SUCCESS', +payload: LogInResult, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_SUCCESS', +payload: {| +email: string, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_SUCCESS', +payload: LeaveThreadPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_SUCCESS', +payload: NewThreadResult, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', +payload: {| +entryID: string, +text: string, +deleted: boolean, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_SUCCESS', +payload: RestoreEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_SUCCESS', +payload: ThreadJoinPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_SUCCESS', +payload: LeaveThreadPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_NEW_SESSION', +payload: SetSessionPayload, |} | {| +type: 'persist/REHYDRATE', +payload: ?BaseAppState<*>, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', +payload: FetchMessageInfosPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', +payload: FetchMessageInfosPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_STARTED', +loadingInfo: LoadingInfo, +payload: RawTextMessageInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_FAILED', +error: true, +payload: Error & { +localID: string, +threadID: string, }, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_SUCCESS', +payload: SendMessagePayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_STARTED', +loadingInfo: LoadingInfo, +payload: RawMultimediaMessageInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_FAILED', +error: true, +payload: Error & { +localID: string, +threadID: string, }, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', +payload: SendMessagePayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_SUCCESS', +payload: UserSearchResult, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_DRAFT', +payload: { +key: string, +draft: string, }, |} | {| +type: 'UPDATE_ACTIVITY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_ACTIVITY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_ACTIVITY_SUCCESS', +payload: ActivityUpdateSuccessPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_STARTED', +payload: string, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_SUCCESS', +payload: string, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_SUCCESS', +payload?: ClearDeliveredReportsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_SUCCESS', +payload?: ClearDeliveredReportsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'QUEUE_REPORTS', +payload: QueueReportsPayload, |} | {| +type: 'SET_URL_PREFIX', +payload: string, |} | {| +type: 'SAVE_MESSAGES', +payload: SaveMessagesPayload, |} | {| +type: 'UPDATE_CALENDAR_THREAD_FILTER', +payload: CalendarThreadFilter, |} | {| +type: 'CLEAR_CALENDAR_THREAD_FILTER', +payload?: void, |} | {| +type: 'SET_CALENDAR_DELETED_FILTER', +payload: SetCalendarDeletedFilterPayload, |} | {| +type: 'UPDATE_SUBSCRIPTION_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_SUBSCRIPTION_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_SUBSCRIPTION_SUCCESS', +payload: SubscriptionUpdateResult, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_CALENDAR_QUERY_STARTED', +loadingInfo: LoadingInfo, +payload?: CalendarQueryUpdateStartingPayload, |} | {| +type: 'UPDATE_CALENDAR_QUERY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_CALENDAR_QUERY_SUCCESS', +payload: CalendarQueryUpdateResult, +loadingInfo: LoadingInfo, |} | {| +type: 'FULL_STATE_SYNC', +payload: StateSyncFullActionPayload, |} | {| +type: 'INCREMENTAL_STATE_SYNC', +payload: StateSyncIncrementalActionPayload, |} | {| +type: 'PROCESS_SERVER_REQUESTS', +payload: ProcessServerRequestsPayload, |} | {| +type: 'UPDATE_CONNECTION_STATUS', +payload: UpdateConnectionStatusPayload, |} | {| +type: 'QUEUE_ACTIVITY_UPDATES', +payload: QueueActivityUpdatesPayload, |} | {| +type: 'UNSUPERVISED_BACKGROUND', +payload?: void, |} | {| +type: 'UPDATE_LIFECYCLE_STATE', +payload: LifecycleState, |} | {| +type: 'PROCESS_UPDATES', +payload: UpdatesResultWithUserInfos, |} | {| +type: 'PROCESS_MESSAGES', +payload: NewMessagesPayload, |} | {| +type: 'MESSAGE_STORE_PRUNE', +payload: MessageStorePrunePayload, |} | {| +type: 'SET_LATE_RESPONSE', +payload: SetLateResponsePayload, |} | {| +type: 'UPDATE_DISCONNECTED_BAR', +payload: UpdateDisconnectedBarPayload, |} | {| +type: 'REQUEST_ACCESS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'REQUEST_ACCESS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REQUEST_ACCESS_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', +payload: UpdateMultimediaMessageMediaPayload, |} | {| +type: 'CREATE_LOCAL_MESSAGE', +payload: LocallyComposedMessageInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_STARTED', +payload: {| +threadID: string, +unread: boolean, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_SUCCESS', +payload: SetThreadUnreadStatusPayload, |}; export type ActionPayload = ?(Object | Array<*> | $ReadOnlyArray<*> | string); export type SuperAction = { type: string, payload?: ActionPayload, loadingInfo?: LoadingInfo, error?: boolean, }; type ThunkedAction = (dispatch: Dispatch) => void; export type PromisedAction = (dispatch: Dispatch) => Promise; export type Dispatch = ((promisedAction: PromisedAction) => Promise) & ((thunkedAction: ThunkedAction) => void) & ((action: SuperAction) => boolean); // This is lifted from redux-persist/lib/constants.js // I don't want to add redux-persist to the web/server bundles... // import { REHYDRATE } from 'redux-persist'; export const rehydrateActionType = 'persist/REHYDRATE'; diff --git a/native/lifecycle/lifecycle-handler.react.js b/native/lifecycle/lifecycle-handler.react.js index c5abcbf55..f915b946d 100644 --- a/native/lifecycle/lifecycle-handler.react.js +++ b/native/lifecycle/lifecycle-handler.react.js @@ -1,45 +1,45 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; -import { updateLifecycleStateActionType } from 'lib/reducers/foreground-reducer'; +import { updateLifecycleStateActionType } from 'lib/reducers/lifecycle-state-reducer'; import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import { appBecameInactive } from '../redux/redux-setup'; import { addLifecycleListener } from './lifecycle'; const LifecycleHandler = React.memo<{||}>(() => { const dispatch = useDispatch(); const lastStateRef = React.useRef(); const onLifecycleChange = React.useCallback( (nextState: ?LifecycleState) => { if (!nextState || nextState === 'unknown') { return; } const lastState = lastStateRef.current; lastStateRef.current = nextState; if (lastState === 'background' && nextState === 'active') { dispatch({ type: updateLifecycleStateActionType, payload: 'active' }); } else if (lastState !== 'background' && nextState === 'background') { dispatch({ type: updateLifecycleStateActionType, payload: 'background', }); appBecameInactive(); } }, [lastStateRef, dispatch], ); React.useEffect(() => { const subscription = addLifecycleListener(onLifecycleChange); return () => subscription.remove(); }, [onLifecycleChange]); return null; }); LifecycleHandler.displayName = 'LifecycleHandler'; export default LifecycleHandler; diff --git a/native/redux/persist.js b/native/redux/persist.js index 1de2210ea..584ff06e7 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,234 +1,233 @@ // @flow import AsyncStorage from '@react-native-community/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createMigrate } from 'redux-persist'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils'; import { unshimMessageStore } from 'lib/shared/unshim-utils'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import { messageTypes } from 'lib/types/message-types'; import { defaultConnectionInfo } from 'lib/types/socket-types'; import { defaultNotifPermissionAlertInfo } from '../push/alerts'; import { defaultDeviceCameraInfo } from '../types/camera'; import { defaultGlobalThemeInfo } from '../types/themes'; import type { AppState } from './redux-setup'; const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: (state) => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, drafts: state.drafts, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, threadIDsToNotifIDs: state.threadIDsToNotifIDs, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: (state) => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: (state) => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], - foreground: true, entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: (state: AppState) => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: (state: AppState) => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [messageTypes.IMAGES]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.MULTIMEDIA, ]), }), [15]: (state) => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: (state) => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: (state) => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: (state) => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: (state) => { const threadInfos = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), }; const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: [ 'loadingStatuses', - 'foreground', + 'lifecycleState', 'dimensions', 'connectivity', 'deviceOrientation', 'frozen', ], debug: __DEV__, version: 21, migrate: createMigrate(migrations, { debug: __DEV__ }), timeout: __DEV__ ? 0 : undefined, }; const codeVersion = 81; // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor() { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index 7bdd75164..c02309d61 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,438 +1,438 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import type { Orientations } from 'react-native-orientation-locker'; import Orientation from 'react-native-orientation-locker'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import type { PersistState } from 'redux-persist/src/types'; import thunk from 'redux-thunk'; import { setDeviceTokenActionTypes } from 'lib/actions/device-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/account-utils'; import { type EntryStore } from 'lib/types/entry-types'; import { type CalendarFilter, defaultCalendarFilters, } from 'lib/types/filter-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import type { SetSessionPayload } from 'lib/types/session-types'; import { type ConnectionInfo, defaultConnectionInfo, incrementalStateSyncActionType, } from 'lib/types/socket-types'; import type { ThreadStore } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { type NavInfo, defaultNavInfo } from '../navigation/default-state'; import { getGlobalNavContext } from '../navigation/icky-global'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { type NotifPermissionAlertInfo, defaultNotifPermissionAlertInfo, } from '../push/alerts'; import { reduceThreadIDsToNotifIDs } from '../push/reducer'; import reactotron from '../reactotron'; import reduceDrafts from '../reducers/draft-reducer'; import { type DeviceCameraInfo, defaultDeviceCameraInfo, } from '../types/camera'; import { type ConnectivityInfo, defaultConnectivityInfo, } from '../types/connectivity'; import { type GlobalThemeInfo, defaultGlobalThemeInfo } from '../types/themes'; import { defaultURLPrefix, natServer, setCustomServer, } from '../utils/url-utils'; import { resetUserStateActionType, recordNotifPermissionAlertActionType, recordAndroidNotificationActionType, clearAndroidNotificationsActionType, rescindAndroidNotificationActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, } from './action-types'; import { defaultDimensionsInfo, type DimensionsInfo, } from './dimensions-updater.react'; import { persistConfig, setPersistor } from './persist'; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, drafts: { [key: string]: string }, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, cookie: ?string, deviceToken: ?string, dataLoaded: boolean, urlPrefix: string, customServer: ?string, threadIDsToNotifIDs: { [threadID: string]: string[] }, notifPermissionAlertInfo: NotifPermissionAlertInfo, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, - foreground: boolean, + lifecycleState: boolean, nextLocalID: number, queuedReports: $ReadOnlyArray, _persist: ?PersistState, sessionID?: void, dimensions: DimensionsInfo, connectivity: ConnectivityInfo, globalThemeInfo: GlobalThemeInfo, deviceCameraInfo: DeviceCameraInfo, deviceOrientation: Orientations, frozen: boolean, |}; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, inconsistencyReports: [], }, threadStore: { threadInfos: {}, inconsistencyReports: [], }, userStore: { userInfos: {}, inconsistencyReports: [], }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, drafts: {}, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, cookie: null, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix(), customServer: natServer, threadIDsToNotifIDs: {}, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], - foreground: true, + lifecycleState: true, nextLocalID: 0, queuedReports: [], _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, }: AppState); function reducer(state: AppState = defaultState, action: *) { if (action.type === setReduxStateActionType) { return action.state; } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return state; } if ( (action.type === setNewSessionActionType && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, action.payload.source, )) || (action.type === logInActionTypes.success && invalidSessionRecovery( state, action.payload.currentUserInfo, action.payload.source, )) ) { return state; } if ( action.type === recordAndroidNotificationActionType || action.type === clearAndroidNotificationsActionType || action.type === rescindAndroidNotificationActionType ) { return { ...state, threadIDsToNotifIDs: reduceThreadIDsToNotifIDs( state.threadIDsToNotifIDs, action, ), }; } else if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === recordNotifPermissionAlertActionType) { return { ...state, notifPermissionAlertInfo: { totalAlerts: state.notifPermissionAlertInfo.totalAlerts + 1, lastAlertTime: action.payload.time, }, }; } else if (action.type === resetUserStateActionType) { const cookie = state.cookie && state.cookie.startsWith('anonymous=') ? state.cookie : null; const currentUserInfo = state.currentUserInfo && state.currentUserInfo.anonymous ? state.currentUserInfo : null; return { ...state, currentUserInfo, cookie, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { return { ...state, globalThemeInfo: { ...state.globalThemeInfo, ...action.payload, }, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setDeviceTokenActionTypes.started) { return { ...state, deviceToken: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }, }, }; } } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } else if (action.type === incrementalStateSyncActionType) { let wipeDeviceToken = false; for (let update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.deviceToken ) { wipeDeviceToken = true; break; } } if (wipeDeviceToken) { state = { ...state, deviceToken: null, }; } } state = { ...baseReducer(state, action), drafts: reduceDrafts(state.drafts, action), }; return fixUnreadActiveThread(state, action); } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how to " + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], { cancelable: true }, ); } else { Alert.alert( 'Session invalidated', "We're sorry, but your session was invalidated by the server. " + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. function fixUnreadActiveThread(state: AppState, action: *): AppState { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( activeThread && (NativeAppState.currentState === 'active' || (appLastBecameInactive + 10000 < Date.now() && !backgroundActionTypes.has(action.type))) && state.threadStore.threadInfos[activeThread] && state.threadStore.threadInfos[activeThread].currentUser.unread ) { state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } return state; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middleware = applyMiddleware(thunk, reduxLoggerMiddleware); const composeFunc = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux' }) : compose; 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/socket.react.js b/native/socket.react.js index c2c819ae7..244260e11 100644 --- a/native/socket.react.js +++ b/native/socket.react.js @@ -1,103 +1,105 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { logOut } from 'lib/actions/user-actions'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import Socket, { type BaseSocketProps } from 'lib/socket/socket.react'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { InputStateContext } from './input/input-state'; import { activeMessageListSelector, nativeCalendarQuery, } from './navigation/nav-selectors'; import { NavContext } from './navigation/navigation-context'; import { useSelector } from './redux/redux-utils'; import { openSocketSelector, sessionIdentificationSelector, nativeGetClientResponsesSelector, nativeSessionStateFuncSelector, } from './selectors/socket-selectors'; export default React.memo(function NativeSocket( props: BaseSocketProps, ) { const inputState = React.useContext(InputStateContext); const navContext = React.useContext(NavContext); const cookie = useSelector((state) => state.cookie); const urlPrefix = useSelector((state) => state.urlPrefix); const connection = useSelector((state) => state.connection); const frozen = useSelector((state) => state.frozen); - const active = useSelector((state) => isLoggedIn(state) && state.foreground); + const active = useSelector( + (state) => isLoggedIn(state) && state.lifecycleState, + ); const openSocket = useSelector(openSocketSelector); const sessionIdentification = useSelector(sessionIdentificationSelector); const preRequestUserState = useSelector(preRequestUserStateSelector); const getClientResponses = useSelector((state) => nativeGetClientResponsesSelector({ redux: state, navContext, }), ); const sessionStateFunc = useSelector((state) => nativeSessionStateFuncSelector({ redux: state, navContext, }), ); const currentCalendarQuery = useSelector((state) => nativeCalendarQuery({ redux: state, navContext, }), ); const canSendReports = useSelector( (state) => !state.frozen && state.connectivity.hasWiFi && (!inputState || !inputState.uploadInProgress()), ); const activeThread = React.useMemo(() => { if (!active) { return null; } return activeMessageListSelector(navContext); }, [active, navContext]); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callLogOut = useServerCall(logOut); return ( ); }); diff --git a/server/src/responders/website-responders.js b/server/src/responders/website-responders.js index 9dfcaa8c1..700928006 100644 --- a/server/src/responders/website-responders.js +++ b/server/src/responders/website-responders.js @@ -1,331 +1,331 @@ // @flow import html from 'common-tags/lib/html'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import _keyBy from 'lodash/fp/keyBy'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; import { Provider } from 'react-redux'; import { Route, StaticRouter } from 'react-router'; import { createStore, type Store } from 'redux'; import { promisify } from 'util'; import { daysToEntriesFromEntryInfos } from 'lib/reducers/entry-reducer'; import { freshMessageStore } from 'lib/reducers/message-reducer'; import { mostRecentReadThread } from 'lib/selectors/thread-selectors'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils'; import { threadHasPermission } from 'lib/shared/thread-utils'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import { defaultConnectionInfo } from 'lib/types/socket-types'; import { threadPermissions } from 'lib/types/thread-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import { currentDateInTimeZone } from 'lib/utils/date-utils'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import App from 'web/dist/app.build.cjs'; import { reducer } from 'web/redux/redux-setup'; import type { AppState, Action } from 'web/redux/redux-setup'; import getTitle from 'web/title/getTitle'; import { navInfoFromURL } from 'web/url-utils'; import urlFacts from '../../facts/url'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchCurrentUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { handleCodeVerificationRequest } from '../models/verification'; import { setNewSession } from '../session/cookies'; import { Viewer } from '../session/viewer'; import { streamJSON, waitForStream } from '../utils/json-stream'; const { basePath, baseDomain } = urlFacts; const { renderToNodeStream } = ReactDOMServer; const baseURL = basePath.replace(/\/$/, ''); const baseHref = baseDomain + baseURL; const access = promisify(fs.access); const googleFontsURL = 'https://fonts.googleapis.com/css?family=Open+Sans:300,600%7CAnaheim'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } type AssetInfo = {| jsURL: string, fontsURL: string, cssInclude: string |}; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'dev') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', }; return assetInfo; } // $FlowFixMe compiled/assets.json doesn't always exist const { default: assets } = await import('../../compiled/assets'); assetInfo = { jsURL: `compiled/${assets.browser.js}`, fontsURL: googleFontsURL, cssInclude: html` `, }; return assetInfo; } async function websiteResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { let initialNavInfo; try { initialNavInfo = navInfoFromURL(req.url, { now: currentDateInTimeZone(viewer.timeZone), }); } catch (e) { throw new ServerError(e.message); } const calendarQuery = { startDate: initialNavInfo.startDate, endDate: initialNavInfo.endDate, filters: defaultCalendarFilters, }; const threadSelectionCriteria = { joinedThreads: true }; const initialTime = Date.now(); const assetInfoPromise = getAssetInfo(); const threadInfoPromise = fetchThreadInfos(viewer); const messageInfoPromise = fetchMessageInfos( viewer, threadSelectionCriteria, defaultNumberPerThread, ); const entryInfoPromise = fetchEntryInfos(viewer, [calendarQuery]); const currentUserInfoPromise = fetchCurrentUserInfo(viewer); const serverVerificationResultPromise = handleVerificationRequest( viewer, initialNavInfo.verify, ); const userInfoPromise = fetchKnownUserInfos(viewer); const sessionIDPromise = (async () => { if (viewer.loggedIn) { await setNewSession(viewer, calendarQuery, initialTime); } return viewer.sessionID; })(); const threadStorePromise = (async () => { const { threadInfos } = await threadInfoPromise; return { threadInfos, inconsistencyReports: [] }; })(); const messageStorePromise = (async () => { const [ { threadInfos }, { rawMessageInfos, truncationStatuses }, ] = await Promise.all([threadInfoPromise, messageInfoPromise]); return freshMessageStore( rawMessageInfos, truncationStatuses, mostRecentMessageTimestamp(rawMessageInfos, initialTime), threadInfos, ); })(); const entryStorePromise = (async () => { const { rawEntryInfos } = await entryInfoPromise; return { entryInfos: _keyBy('id')(rawEntryInfos), daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos), lastUserInteractionCalendar: initialTime, inconsistencyReports: [], }; })(); const userStorePromise = (async () => { const userInfos = await userInfoPromise; return { userInfos, inconsistencyReports: [] }; })(); const navInfoPromise = (async () => { const [{ threadInfos }, messageStore] = await Promise.all([ threadInfoPromise, messageStorePromise, ]); let finalNavInfo = initialNavInfo; const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID; if ( requestedActiveChatThreadID && !threadHasPermission( threadInfos[requestedActiveChatThreadID], threadPermissions.VISIBLE, ) ) { finalNavInfo.activeChatThreadID = null; } if (!finalNavInfo.activeChatThreadID) { const mostRecentThread = mostRecentReadThread(messageStore, threadInfos); if (mostRecentThread) { finalNavInfo.activeChatThreadID = mostRecentThread; } } return finalNavInfo; })(); const { jsURL, fontsURL, cssInclude } = await assetInfoPromise; // prettier-ignore res.write(html` ${getTitle(0)} ${cssInclude}
`); const statePromises = { navInfo: navInfoPromise, currentUserInfo: currentUserInfoPromise, sessionID: sessionIDPromise, serverVerificationResult: serverVerificationResultPromise, entryStore: entryStorePromise, threadStore: threadStorePromise, userStore: userStorePromise, messageStore: messageStorePromise, updatesCurrentAsOf: initialTime, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, // We can use paths local to the on web urlPrefix: '', windowDimensions: { width: 0, height: 0 }, baseHref, connection: { ...defaultConnectionInfo('web', viewer.timeZone), actualizedCalendarQuery: calendarQuery, }, watchedThreadIDs: [], - foreground: true, + lifecycleState: true, nextLocalID: 0, queuedReports: [], timeZone: viewer.timeZone, userAgent: viewer.userAgent, cookie: undefined, deviceToken: undefined, dataLoaded: viewer.loggedIn, windowActive: true, }; const stateResult = await promiseAll(statePromises); const state: AppState = { ...stateResult }; const store: Store = createStore(reducer, state); const routerContext = {}; const reactStream = renderToNodeStream( , ); if (routerContext.url) { throw new ServerError('URL modified during server render!'); } reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.write(html`
`); } async function handleVerificationRequest( viewer: Viewer, code: ?string, ): Promise { if (!code) { return null; } try { return await handleCodeVerificationRequest(viewer, code); } catch (e) { if (e instanceof ServerError && e.message === 'invalid_code') { return { success: false }; } throw e; } } export { websiteResponder }; diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index 7cff180d1..4de0f17b2 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,220 +1,220 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { mostRecentReadThreadSelector } from 'lib/selectors/thread-selectors'; import { invalidSessionDowngrade } from 'lib/shared/account-utils'; import type { Shape } from 'lib/types/core'; import type { EntryStore } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { BaseNavInfo } from 'lib/types/nav-types'; import type { BaseAction } from 'lib/types/redux-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import type { ConnectionInfo } from 'lib/types/socket-types'; import type { ThreadInfo, ThreadStore } from 'lib/types/thread-types'; import { threadInfoPropType } from 'lib/types/thread-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { activeThreadSelector } from '../selectors/nav-selectors'; import { updateWindowActiveActionType } from './action-types'; import { getVisibility } from './visibility'; export type NavInfo = {| ...$Exact, +tab: 'calendar' | 'chat', +verify: ?string, +activeChatThreadID: ?string, +pendingThread?: ThreadInfo, +sourceMessageID?: string, |}; export const navInfoPropType = PropTypes.shape({ startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, tab: PropTypes.oneOf(['calendar', 'chat']).isRequired, verify: PropTypes.string, activeChatThreadID: PropTypes.string, pendingThread: threadInfoPropType, sourceMessageID: PropTypes.string, }); export type WindowDimensions = {| width: number, height: number |}; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, sessionID: ?string, serverVerificationResult: ?ServerVerificationResult, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, windowDimensions: WindowDimensions, cookie?: void, deviceToken?: void, baseHref: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, - foreground: boolean, + lifecycleState: boolean, nextLocalID: number, queuedReports: $ReadOnlyArray, timeZone: ?string, userAgent: ?string, dataLoaded: boolean, windowActive: boolean, |}; export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; export const updateWindowDimensions = 'UPDATE_WINDOW_DIMENSIONS'; export type Action = | BaseAction | {| type: 'UPDATE_NAV_INFO', payload: Shape |} | {| type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, |} | {| type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, |}; export function reducer(oldState: AppState | void, action: Action) { invariant(oldState, 'should be set'); let state = oldState; if (action.type === updateNavInfoActionType) { return validateState(oldState, { ...state, navInfo: { ...state.navInfo, ...action.payload, }, }); } else if (action.type === updateWindowDimensions) { return validateState(oldState, { ...state, windowDimensions: action.payload, }); } else if (action.type === updateWindowActiveActionType) { return validateState(oldState, { ...state, windowActive: action.payload, }); } else if (action.type === setNewSessionActionType) { if ( invalidSessionDowngrade( oldState, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, ) ) { return oldState; } state = { ...state, sessionID: action.payload.sessionChange.sessionID, }; } else if ( (action.type === logOutActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return oldState; } return validateState(oldState, baseReducer(state, action)); } function validateState(oldState: AppState, state: AppState): AppState { if ( state.navInfo.activeChatThreadID && !state.navInfo.pendingThread && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID] ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !getVisibility().hidden() && typeof document !== 'undefined' && document && document.hasFocus && document.hasFocus() && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread ) { // Makes sure a currently focused thread is never unread state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { // Update messageStore.threads[activeThread].lastNavigatedTo state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [activeThread]: { ...state.messageStore.threads[activeThread], lastNavigatedTo: Date.now(), }, }, }, }; } return state; } diff --git a/web/redux/visibility-handler.react.js b/web/redux/visibility-handler.react.js index 73afa5a32..a6cf16533 100644 --- a/web/redux/visibility-handler.react.js +++ b/web/redux/visibility-handler.react.js @@ -1,57 +1,57 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; -import { updateLifecycleStateActionType } from 'lib/reducers/foreground-reducer'; +import { updateLifecycleStateActionType } from 'lib/reducers/lifecycle-state-reducer'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils'; import { useVisibility } from './visibility'; function VisibilityHandler() { const visibility = useVisibility(); const [visible, setVisible] = React.useState(!visibility.hidden()); const onVisibilityChange = React.useCallback((event, state: string) => { setVisible(state === 'visible'); }, []); React.useEffect(() => { const listener = visibility.change(onVisibilityChange); return () => { visibility.unbind(listener); }; }, [visibility, onVisibilityChange]); const dispatch = useDispatch(); const curForeground = useIsAppForegrounded(); const updateRedux = React.useCallback( (foreground) => { if (foreground === curForeground) { return; } if (foreground) { dispatch({ type: updateLifecycleStateActionType, payload: 'active' }); } else { dispatch({ type: updateLifecycleStateActionType, payload: 'background', }); } }, [dispatch, curForeground], ); const prevVisibleRef = React.useRef(curForeground); React.useEffect(() => { const prevVisible = prevVisibleRef.current; if (visible && !prevVisible) { updateRedux(true); } else if (!visible && prevVisible) { updateRedux(false); } prevVisibleRef.current = visible; }, [visible, updateRedux]); return null; } export default VisibilityHandler; diff --git a/web/socket.react.js b/web/socket.react.js index 8c68825ee..30457515d 100644 --- a/web/socket.react.js +++ b/web/socket.react.js @@ -1,80 +1,80 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { logOut } from 'lib/actions/user-actions'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import Socket, { type BaseSocketProps } from 'lib/socket/socket.react'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { useSelector } from './redux/redux-utils'; import { activeThreadSelector, webCalendarQuery, } from './selectors/nav-selectors'; import { openSocketSelector, sessionIdentificationSelector, webGetClientResponsesSelector, webSessionStateFuncSelector, } from './selectors/socket-selectors'; export default React.memo(function WebSocket( props: BaseSocketProps, ) { const cookie = useSelector((state) => state.cookie); const urlPrefix = useSelector((state) => state.urlPrefix); const connection = useSelector((state) => state.connection); const active = useSelector( (state) => !!state.currentUserInfo && !state.currentUserInfo.anonymous && - state.foreground, + state.lifecycleState, ); const openSocket = useSelector(openSocketSelector); const sessionIdentification = useSelector(sessionIdentificationSelector); const preRequestUserState = useSelector(preRequestUserStateSelector); const getClientResponses = useSelector(webGetClientResponsesSelector); const sessionStateFunc = useSelector(webSessionStateFuncSelector); const currentCalendarQuery = useSelector(webCalendarQuery); const reduxActiveThread = useSelector(activeThreadSelector); const windowActive = useSelector((state) => state.windowActive); const activeThread = React.useMemo(() => { if (!active || !windowActive) { return null; } return reduxActiveThread; }, [active, windowActive, reduxActiveThread]); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callLogOut = useServerCall(logOut); return ( ); });