diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index b82ddc1ba..0e1f10d2f 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,342 +1,342 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import bcrypt from 'twin-bcrypt'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { ResetPasswordRequest, LogOutResponse, DeleteAccountRequest, RegisterResponse, RegisterRequest, LogInResponse, LogInRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, } from 'lib/types/account-types'; import { userSettingsTypes, notificationTypeValues, - loginActionSources, + logInActionSources, } from 'lib/types/account-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResponse, } from 'lib/types/subscription-types'; import type { PasswordUpdate } from 'lib/types/user-types'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import { promiseAll } from 'lib/utils/promises'; import { tShape, tPlatformDetails, tPassword, tEmail, tOldValidUsername, } from 'lib/utils/validation-utils'; import createAccount from '../creators/account-creator'; import { dbQuery, SQL } from '../database/database'; import { deleteAccount } from '../deleters/account-deleters'; import { deleteCookie } from '../deleters/cookie-deleters'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchKnownUserInfos, fetchLoggedInUserInfo, } from '../fetchers/user-fetchers'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies'; import type { Viewer } from '../session/viewer'; import { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, updateUserSettings, } from '../updaters/account-updaters'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters'; import { validateInput } from '../utils/validation-utils'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders'; const subscriptionUpdateRequestInputValidator = tShape({ threadID: t.String, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); async function userSubscriptionUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SubscriptionUpdateRequest = input; await validateInput(viewer, subscriptionUpdateRequestInputValidator, request); const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription }; } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function passwordUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: PasswordUpdate = input; await validateInput(viewer, accountUpdateInputValidator, request); await accountUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); await checkAndSendVerificationEmail(viewer); } const resetPasswordRequestInputValidator = tShape({ usernameOrEmail: t.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: any, ): Promise { const request: ResetPasswordRequest = input; await validateInput(viewer, resetPasswordRequestInputValidator, request); await checkAndSendPasswordResetEmail(request); } async function logOutResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { id: viewer.id, anonymous: true, }, }; } const deleteAccountRequestInputValidator = tShape({ password: tPassword, }); async function accountDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteAccountRequest = input; await validateInput(viewer, deleteAccountRequestInputValidator, request); const result = await deleteAccount(viewer, request); invariant(result, 'deleteAccount should return result if handed request'); return result; } const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); const registerRequestInputValidator = tShape({ username: t.String, email: t.maybe(tEmail), password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function accountCreationResponder( viewer: Viewer, input: any, ): Promise { const request: RegisterRequest = input; await validateInput(viewer, registerRequestInputValidator, request); return await createAccount(viewer, request); } const logInRequestInputValidator = tShape({ username: t.maybe(t.String), usernameOrEmail: t.maybe(t.union([tEmail, tOldValidUsername])), password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, - source: t.maybe(t.enums.of(values(loginActionSources))), + source: t.maybe(t.enums.of(values(logInActionSources))), }); async function logInResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, logInRequestInputValidator, input); const request: LogInRequest = input; const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs( calendarQuery, ); } const username = request.username ?? request.usernameOrEmail; if (!username) { throw new ServerError('invalid_parameters'); } const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${username}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { throw new ServerError('invalid_parameters'); } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { if (hasMinCodeVersion(viewer.platformDetails, 99999)) { throw new ServerError('invalid_parameters'); } else { throw new ServerError('invalid_credentials'); } } const id = userRow.id.toString(); const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const threadCursors = {}; for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true }; const [ threadsResult, messagesResult, entriesResult, userInfos, currentUserInfo, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: LogInResponse = { currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { response.rawEntryInfos = rawEntryInfos; } return response; } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function oldPasswordUpdateResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, updatePasswordRequestInputValidator, input); const request: UpdatePasswordRequest = input; if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } const updateUserSettingsInputValidator = tShape({ name: t.irreducible( userSettingsTypes.DEFAULT_NOTIFICATIONS, x => x === userSettingsTypes.DEFAULT_NOTIFICATIONS, ), data: t.enums.of(notificationTypeValues), }); async function updateUserSettingsResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateUserSettingsRequest = input; await validateInput(viewer, updateUserSettingsInputValidator, request); return await updateUserSettings(viewer, request); } export { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, }; diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index 3e2b2df49..01d69e9d4 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,241 +1,243 @@ // @flow import threadWatcher from '../shared/thread-watcher'; import type { LogOutResult, LogInInfo, LogInResult, RegisterResult, RegisterInfo, UpdateUserSettingsRequest, } from '../types/account-types'; import type { GetSessionPublicKeysArgs } from '../types/request-types'; import type { UserSearchResult } from '../types/search-types'; import type { SessionPublicKeys, PreRequestUserState, } from '../types/session-types'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types'; import type { UserInfo, PasswordUpdate } from '../types/user-types'; import type { CallServerEndpoint } from '../utils/call-server-endpoint'; import { getConfig } from '../utils/config'; import sleep from '../utils/sleep'; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); const logOut = ( callServerEndpoint: CallServerEndpoint, ): (( preRequestUserState: PreRequestUserState, ) => Promise) => async preRequestUserState => { let response = null; try { response = await Promise.race([ callServerEndpoint('log_out', {}), (async () => { await sleep(500); throw new Error('log_out took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? response.currentUserInfo : null; return { currentUserInfo, preRequestUserState }; }; const deleteAccountActionTypes = Object.freeze({ started: 'DELETE_ACCOUNT_STARTED', success: 'DELETE_ACCOUNT_SUCCESS', failed: 'DELETE_ACCOUNT_FAILED', }); const deleteAccount = ( callServerEndpoint: CallServerEndpoint, ): (( password: string, preRequestUserState: PreRequestUserState, ) => Promise) => async (password, preRequestUserState) => { const response = await callServerEndpoint('delete_account', { password }); return { currentUserInfo: response.currentUserInfo, preRequestUserState }; }; const registerActionTypes = Object.freeze({ started: 'REGISTER_STARTED', success: 'REGISTER_SUCCESS', failed: 'REGISTER_FAILED', }); const registerCallServerEndpointOptions = { timeout: 60000 }; const register = ( callServerEndpoint: CallServerEndpoint, ): (( registerInfo: RegisterInfo, ) => Promise) => async registerInfo => { const response = await callServerEndpoint( 'create_account', { ...registerInfo, platformDetails: getConfig().platformDetails, }, registerCallServerEndpointOptions, ); return { currentUserInfo: response.currentUserInfo, rawMessageInfos: response.rawMessageInfos, threadInfos: response.cookieChange.threadInfos, userInfos: response.cookieChange.userInfos, calendarQuery: registerInfo.calendarQuery, }; }; function mergeUserInfos(...userInfoArrays: UserInfo[][]): UserInfo[] { const merged = {}; for (const userInfoArray of userInfoArrays) { for (const userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; for (const id in merged) { flattened.push(merged[id]); } return flattened; } const logInActionTypes = Object.freeze({ started: 'LOG_IN_STARTED', success: 'LOG_IN_SUCCESS', failed: 'LOG_IN_FAILED', }); const logInCallServerEndpointOptions = { timeout: 60000 }; const logIn = ( callServerEndpoint: CallServerEndpoint, ): ((logInInfo: LogInInfo) => Promise) => async logInInfo => { const watchedIDs = threadWatcher.getWatchedIDs(); + const { logInActionSource, ...restLogInInfo } = logInInfo; const response = await callServerEndpoint( 'log_in', { - ...logInInfo, + ...restLogInInfo, + source: logInActionSource, watchedIDs, platformDetails: getConfig().platformDetails, }, logInCallServerEndpointOptions, ); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, - source: logInInfo.source, + logInActionSource: logInInfo.logInActionSource, }; }; const changeUserPasswordActionTypes = Object.freeze({ started: 'CHANGE_USER_PASSWORD_STARTED', success: 'CHANGE_USER_PASSWORD_SUCCESS', failed: 'CHANGE_USER_PASSWORD_FAILED', }); const changeUserPassword = ( callServerEndpoint: CallServerEndpoint, ): (( passwordUpdate: PasswordUpdate, ) => Promise) => async passwordUpdate => { await callServerEndpoint('update_account', passwordUpdate); }; const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); const searchUsers = ( callServerEndpoint: CallServerEndpoint, ): (( usernamePrefix: string, ) => Promise) => async usernamePrefix => { const response = await callServerEndpoint('search_users', { prefix: usernamePrefix, }); return { userInfos: response.userInfos, }; }; const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); const updateSubscription = ( callServerEndpoint: CallServerEndpoint, ): (( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise) => async subscriptionUpdate => { const response = await callServerEndpoint( 'update_user_subscription', subscriptionUpdate, ); return { threadID: subscriptionUpdate.threadID, subscription: response.threadSubscription, }; }; const setUserSettingsActionTypes = Object.freeze({ started: 'SET_USER_SETTINGS_STARTED', success: 'SET_USER_SETTINGS_SUCCESS', failed: 'SET_USER_SETTINGS_FAILED', }); const setUserSettings = ( callServerEndpoint: CallServerEndpoint, ): (( userSettingsRequest: UpdateUserSettingsRequest, ) => Promise) => async userSettingsRequest => { await callServerEndpoint('update_user_settings', userSettingsRequest); }; const getSessionPublicKeys = ( callServerEndpoint: CallServerEndpoint, ): (( data: GetSessionPublicKeysArgs, ) => Promise) => async data => { return await callServerEndpoint('get_session_public_keys', data); }; export { changeUserPasswordActionTypes, changeUserPassword, deleteAccount, deleteAccountActionTypes, getSessionPublicKeys, logIn, logInActionTypes, logOut, logOutActionTypes, register, registerActionTypes, searchUsers, searchUsersActionTypes, setUserSettings, setUserSettingsActionTypes, updateSubscription, updateSubscriptionActionTypes, }; diff --git a/lib/shared/account-utils.js b/lib/shared/account-utils.js index 73665f98b..fdab92422 100644 --- a/lib/shared/account-utils.js +++ b/lib/shared/account-utils.js @@ -1,85 +1,86 @@ // @flow import { - loginActionSources, + logInActionSources, type LogInActionSource, } from '../types/account-types'; import type { AppState } from '../types/redux-types'; import type { PreRequestUserState } from '../types/session-types'; import type { CurrentUserInfo } from '../types/user-types'; const usernameMaxLength = 191; const usernameMinLength = 1; const secondCharRange = `{${usernameMinLength - 1},${usernameMaxLength - 1}}`; const validUsernameRegexString = `^[a-zA-Z0-9][a-zA-Z0-9-_]${secondCharRange}$`; const validUsernameRegex: RegExp = new RegExp(validUsernameRegexString); // usernames used to be less restrictive (eg single chars were allowed) // use oldValidUsername when dealing with existing accounts const oldValidUsernameRegexString = '[a-zA-Z0-9-_]+'; const oldValidUsernameRegex: RegExp = new RegExp( `^${oldValidUsernameRegexString}$`, ); const validEmailRegex: RegExp = new RegExp( /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+/.source + /@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/.source + /(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.source, ); const validHexColorRegex: RegExp = /^[a-fA-F0-9]{6}$/; function invalidSessionDowngrade( currentReduxState: AppState, actionCurrentUserInfo: ?CurrentUserInfo, preRequestUserState: ?PreRequestUserState, ): boolean { // If this action represents a session downgrade - oldState has a loggedIn // currentUserInfo, but the action has an anonymous one - then it is only // valid if the currentUserInfo used for the request matches what oldState // currently has. If the currentUserInfo in Redux has changed since the // request, and is currently loggedIn, then the session downgrade does not // apply to it. In this case we will simply swallow the action. const currentCurrentUserInfo = currentReduxState.currentUserInfo; return !!( currentCurrentUserInfo && !currentCurrentUserInfo.anonymous && // Note that an undefined actionCurrentUserInfo represents an action that // doesn't affect currentUserInfo, whereas a null one represents an action // that sets it to null (actionCurrentUserInfo === null || (actionCurrentUserInfo && actionCurrentUserInfo.anonymous)) && preRequestUserState && (preRequestUserState.currentUserInfo?.id !== currentCurrentUserInfo.id || preRequestUserState.cookie !== currentReduxState.cookie || preRequestUserState.sessionID !== currentReduxState.sessionID) ); } function invalidSessionRecovery( currentReduxState: AppState, actionCurrentUserInfo: CurrentUserInfo, - source: ?LogInActionSource, + logInActionSource: ?LogInActionSource, ): boolean { if ( - source !== loginActionSources.cookieInvalidationResolutionAttempt && - source !== loginActionSources.socketAuthErrorResolutionAttempt + logInActionSource !== + logInActionSources.cookieInvalidationResolutionAttempt && + logInActionSource !== logInActionSources.socketAuthErrorResolutionAttempt ) { return false; } return ( !currentReduxState.dataLoaded || currentReduxState.currentUserInfo?.id !== actionCurrentUserInfo.id ); } export { usernameMaxLength, oldValidUsernameRegexString, validUsernameRegex, oldValidUsernameRegex, validEmailRegex, invalidSessionDowngrade, invalidSessionRecovery, validHexColorRegex, }; diff --git a/lib/socket/socket.react.js b/lib/socket/socket.react.js index 51e1254bb..998a9f48d 100644 --- a/lib/socket/socket.react.js +++ b/lib/socket/socket.react.js @@ -1,738 +1,739 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle'; import * as React from 'react'; import { updateActivityActionTypes } from '../actions/activity-actions'; import { logOutActionTypes } from '../actions/user-actions'; import { unsupervisedBackgroundActionType } from '../reducers/lifecycle-state-reducer'; import { pingFrequency, serverRequestSocketTimeout, clientRequestVisualTimeout, clientRequestSocketTimeout, } from '../shared/timeouts'; -import { loginActionSources, type LogOutResult } from '../types/account-types'; +import { logInActionSources, 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 ClientServerRequest, } from '../types/request-types'; import { type SessionState, type SessionIdentification, type PreRequestUserState, } from '../types/session-types'; import { clientSocketMessageTypes, type ClientClientSocketMessage, serverSocketMessageTypes, type ClientServerSocketMessage, stateSyncPayloadTypes, fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, type ConnectionInfo, type ClientInitialClientSocketMessage, type ClientResponsesClientSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionStatus, setLateResponseActionType, } from '../types/socket-types'; import type { CommTransportLayer } 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: () => CommTransportLayer, +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 { state: State = { inflightRequests: null, }; socket: ?CommTransportLayer; nextClientMessageID: number = 0; listeners: Set = new Set(); pingTimeoutID: ?TimeoutID; messageLastReceived: ?number; initialPlatformDetailsSent: boolean = getConfig().platformDetails.platform === 'web'; reopenConnectionAfterClosing: boolean = false; invalidationRecoveryInProgress: boolean = 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: $Call void, number> = _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(): React.Node { // 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, ) => number = message => { 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): ?ClientServerSocketMessage { 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: (event: MessageEvent) => Promise = async event => { 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 (const 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, - loginActionSources.socketAuthErrorResolutionAttempt, + logInActionSources.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: loginActionSources.socketAuthErrorResolutionAttempt, + logInActionSource: + logInActionSources.socketAuthErrorResolutionAttempt, }, }); } else if (!recoverySessionChange) { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } this.invalidationRecoveryInProgress = false; } }; addListener: (listener: SocketListener) => void = listener => { this.listeners.add(listener); }; removeListener: (listener: SocketListener) => void = listener => { this.listeners.delete(listener); }; onClose: () => void = () => { 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, + logInActionSource: 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: (retriesLeft?: number) => Promise = async ( retriesLeft = 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) => void = ( messageID, isLate, ) => { 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, ) => boolean = alreadyClosed => { // 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/account-types.js b/lib/types/account-types.js index 9f4668cf3..4ed2bd220 100644 --- a/lib/types/account-types.js +++ b/lib/types/account-types.js @@ -1,178 +1,178 @@ // @flow import { values } from '../utils/objects'; import type { PlatformDetails } from './device-types'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, GenericMessagesResult, } from './message-types'; import type { PreRequestUserState } from './session-types'; import type { RawThreadInfo } from './thread-types'; import type { UserInfo, LoggedOutUserInfo, LoggedInUserInfo, OldLoggedInUserInfo, } from './user-types'; export type ResetPasswordRequest = { +usernameOrEmail: string, }; export type LogOutResult = { +currentUserInfo: ?LoggedOutUserInfo, +preRequestUserState: PreRequestUserState, }; export type LogOutResponse = { +currentUserInfo: LoggedOutUserInfo, }; export type RegisterInfo = { ...LogInExtraInfo, +username: string, +password: string, }; type DeviceTokenUpdateRequest = { +deviceToken: string, }; export type RegisterRequest = { +username: string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, }; export type RegisterResponse = { id: string, rawMessageInfos: $ReadOnlyArray, currentUserInfo: OldLoggedInUserInfo | LoggedInUserInfo, cookieChange: { threadInfos: { +[id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, }, }; export type RegisterResult = { +currentUserInfo: LoggedInUserInfo, +rawMessageInfos: $ReadOnlyArray, +threadInfos: { +[id: string]: RawThreadInfo }, +userInfos: $ReadOnlyArray, +calendarQuery: CalendarQuery, }; export type DeleteAccountRequest = { +password: string, }; -export const loginActionSources = Object.freeze({ +export const logInActionSources = Object.freeze({ cookieInvalidationResolutionAttempt: 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT', appStartCookieLoggedInButInvalidRedux: 'APP_START_COOKIE_LOGGED_IN_BUT_INVALID_REDUX', appStartReduxLoggedInButInvalidCookie: 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE', socketAuthErrorResolutionAttempt: 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT', sqliteOpFailure: 'SQLITE_OP_FAILURE', sqliteLoadFailure: 'SQLITE_LOAD_FAILURE', logInFromWebForm: 'LOG_IN_FROM_WEB_FORM', logInFromNativeForm: 'LOG_IN_FROM_NATIVE_FORM', }); -export type LogInActionSource = $Values; +export type LogInActionSource = $Values; export type LogInStartingPayload = { +calendarQuery: CalendarQuery, - +source?: LogInActionSource, + +logInActionSource?: LogInActionSource, }; export type LogInExtraInfo = { +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, }; export type LogInInfo = { ...LogInExtraInfo, +username: string, +password: string, - +source: LogInActionSource, + +logInActionSource: LogInActionSource, }; export type LogInRequest = { +usernameOrEmail?: ?string, +username?: ?string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +watchedIDs: $ReadOnlyArray, +source?: LogInActionSource, }; export type LogInResponse = { currentUserInfo: LoggedInUserInfo | OldLoggedInUserInfo, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: $ReadOnlyArray, rawEntryInfos?: ?$ReadOnlyArray, serverTime: number, cookieChange: { threadInfos: { +[id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, }, }; export type LogInResult = { +threadInfos: { +[id: string]: RawThreadInfo }, +currentUserInfo: LoggedInUserInfo, +messagesResult: GenericMessagesResult, +userInfos: $ReadOnlyArray, +calendarResult: CalendarResult, +updatesCurrentAsOf: number, - +source: LogInActionSource, + +logInActionSource: LogInActionSource, }; export type UpdatePasswordRequest = { code: string, password: string, calendarQuery?: ?CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, platformDetails: PlatformDetails, watchedIDs: $ReadOnlyArray, }; export type EmailSubscriptionRequest = { +email: string, }; export type UpdateUserSettingsRequest = { +name: 'default_user_notifications', +data: NotificationTypes, }; export const userSettingsTypes = Object.freeze({ DEFAULT_NOTIFICATIONS: 'default_user_notifications', }); export type DefaultNotificationPayload = { +default_user_notifications: ?NotificationTypes, }; export const notificationTypes = Object.freeze({ FOCUSED: 'focused', BADGE_ONLY: 'badge_only', BACKGROUND: 'background', }); export type NotificationTypes = $Values; export const notificationTypeValues: $ReadOnlyArray = values( notificationTypes, ); diff --git a/lib/types/session-types.js b/lib/types/session-types.js index 710d8f636..6db23852f 100644 --- a/lib/types/session-types.js +++ b/lib/types/session-types.js @@ -1,109 +1,109 @@ // @flow import type { LogInActionSource } from './account-types'; import type { Shape } from './core'; import type { CalendarQuery } from './entry-types'; import type { RawThreadInfo } from './thread-types'; import { type UserInfo, type CurrentUserInfo, type LoggedOutUserInfo, } from './user-types'; export const cookieLifetime = 30 * 24 * 60 * 60 * 1000; // in milliseconds // Interval the server waits after a state check before starting a new one export const sessionCheckFrequency = 3 * 60 * 1000; // in milliseconds // How long the server debounces after activity before initiating a state check export const stateCheckInactivityActivationInterval = 3 * 1000; // in milliseconds // On native, we specify the cookie directly in the request and response body. // We do this because: // (1) We don't have the same XSS risks as we do on web, so there is no need to // prevent JavaScript from knowing the cookie password. // (2) In the past the internal cookie logic on Android has been buggy. // https://github.com/facebook/react-native/issues/12956 is an example // issue. By specifying the cookie in the body we retain full control of how // that data is passed, without necessitating any native modules like // react-native-cookies. export const cookieSources = Object.freeze({ BODY: 0, HEADER: 1, }); export type CookieSource = $Values; // On native, we use the cookieID as a unique session identifier. This is // because there is no way to have two instances of an app running. On the other // hand, on web it is possible to have two sessions open using the same cookie, // so we have a unique sessionID specified in the request body. export const sessionIdentifierTypes = Object.freeze({ COOKIE_ID: 0, BODY_SESSION_ID: 1, }); export type SessionIdentifierType = $Values; export const cookieTypes = Object.freeze({ USER: 'user', ANONYMOUS: 'anonymous', }); export type CookieType = $Values; export type ServerSessionChange = | { cookieInvalidated: false, threadInfos: { +[id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, sessionID?: null | string, cookie?: string, } | { cookieInvalidated: true, threadInfos: { +[id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, currentUserInfo: LoggedOutUserInfo, sessionID?: null | string, cookie?: string, }; export type ClientSessionChange = | { +cookieInvalidated: false, +currentUserInfo?: ?CurrentUserInfo, +sessionID?: null | string, +cookie?: string, } | { +cookieInvalidated: true, +currentUserInfo: LoggedOutUserInfo, +sessionID?: null | string, +cookie?: string, }; export type PreRequestUserState = { +currentUserInfo: ?CurrentUserInfo, +cookie: ?string, +sessionID: ?string, }; export type SetSessionPayload = { sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, - source: ?LogInActionSource, + logInActionSource: ?LogInActionSource, }; export type SessionState = { calendarQuery: CalendarQuery, messagesCurrentAsOf: number, updatesCurrentAsOf: number, watchedIDs: $ReadOnlyArray, }; export type SessionIdentification = Shape<{ cookie: ?string, sessionID: ?string, }>; export type SessionPublicKeys = { +identityKey: string, +oneTimeKey?: string, }; diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 55411f9b8..5350fc0ab 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,440 +1,440 @@ // @flow import _memoize from 'lodash/memoize'; import * as React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { serverCallStateSelector } from '../selectors/server-calls'; import { - loginActionSources, + logInActionSources, type LogInActionSource, type LogInStartingPayload, type LogInResult, } from '../types/account-types'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints'; import type { LoadingOptions, LoadingInfo } from '../types/loading-types'; import type { ActionPayload, Dispatch, PromisedAction, BaseAction, } from '../types/redux-types'; import type { ClientSessionChange, PreRequestUserState, } from '../types/session-types'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types'; import callServerEndpoint from './call-server-endpoint'; import type { CallServerEndpoint, CallServerEndpointOptions, } from './call-server-endpoint'; import { getConfig } from './config'; let nextPromiseIndex = 0; export type ActionTypes< STARTED_ACTION_TYPE: string, SUCCESS_ACTION_TYPE: string, FAILED_ACTION_TYPE: string, > = { started: STARTED_ACTION_TYPE, success: SUCCESS_ACTION_TYPE, failed: FAILED_ACTION_TYPE, }; function wrapActionPromise< STARTED_ACTION_TYPE: string, STARTED_PAYLOAD: ActionPayload, SUCCESS_ACTION_TYPE: string, SUCCESS_PAYLOAD: ActionPayload, FAILED_ACTION_TYPE: string, >( actionTypes: ActionTypes< STARTED_ACTION_TYPE, SUCCESS_ACTION_TYPE, FAILED_ACTION_TYPE, >, promise: Promise, loadingOptions: ?LoadingOptions, startingPayload: ?STARTED_PAYLOAD, ): PromisedAction { const loadingInfo: LoadingInfo = { fetchIndex: nextPromiseIndex++, trackMultipleRequests: !!loadingOptions?.trackMultipleRequests, customKeyName: loadingOptions?.customKeyName || null, }; return async (dispatch: Dispatch): Promise => { const startAction = startingPayload ? { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, payload: (startingPayload: STARTED_PAYLOAD), } : { type: (actionTypes.started: STARTED_ACTION_TYPE), loadingInfo, }; dispatch(startAction); try { const result = await promise; dispatch({ type: (actionTypes.success: SUCCESS_ACTION_TYPE), payload: (result: SUCCESS_PAYLOAD), loadingInfo, }); } catch (e) { console.log(e); dispatch({ type: (actionTypes.failed: FAILED_ACTION_TYPE), error: true, payload: (e: Error), loadingInfo, }); } }; } export type DispatchActionPromise = < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ) => Promise; function useDispatchActionPromise(): DispatchActionPromise { const dispatch = useDispatch(); return React.useMemo(() => createDispatchActionPromise(dispatch), [dispatch]); } function createDispatchActionPromise(dispatch: Dispatch) { const dispatchActionPromise = function < STARTED: BaseAction, SUCCESS: BaseAction, FAILED: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ): Promise { return dispatch( wrapActionPromise(actionTypes, promise, loadingOptions, startingPayload), ); }; return dispatchActionPromise; } export type DispatchFunctions = { +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, }; let currentlyWaitingForNewCookie = false; let serverEndpointCallsWaitingForNewCookie: (( callServerEndpoint: ?CallServerEndpoint, ) => void)[] = []; export type DispatchRecoveryAttempt = ( actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, promise: Promise, startingPayload: LogInStartingPayload, ) => Promise; const setNewSessionActionType = 'SET_NEW_SESSION'; function setNewSession( dispatch: Dispatch, sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, - source: ?LogInActionSource, + logInActionSource: ?LogInActionSource, ) { dispatch({ type: setNewSessionActionType, - payload: { sessionChange, preRequestUserState, error, source }, + payload: { sessionChange, preRequestUserState, error, logInActionSource }, }); } // This function calls resolveInvalidatedCookie, which dispatchs a log in action // using the native credentials. Note that we never actually specify a sessionID // here, on the assumption that only native clients will call this. (Native // clients don't specify a sessionID, indicating to the server that it should // use the cookieID as the sessionID.) async function fetchNewCookieFromNativeCredentials( dispatch: Dispatch, cookie: ?string, urlPrefix: string, - source: LogInActionSource, + logInActionSource: LogInActionSource, ): Promise { const resolveInvalidatedCookie = getConfig().resolveInvalidatedCookie; if (!resolveInvalidatedCookie) { return null; } let newSessionChange = null; let callServerEndpointCallback = null; const boundCallServerEndpoint = async ( endpoint: Endpoint, data: { [key: string]: mixed }, options?: ?CallServerEndpointOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; - setNewSession(dispatch, sessionChange, null, error, source); + setNewSession(dispatch, sessionChange, null, error, logInActionSource); }; try { const result = await callServerEndpoint( cookie, innerBoundSetNewSession, () => new Promise(r => r(null)), () => new Promise(r => r(null)), urlPrefix, null, 'disconnected', null, endpoint, data, options, ); if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } return result; } catch (e) { if (callServerEndpointCallback) { callServerEndpointCallback(!!newSessionChange); } throw e; } }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { - const startingPayload = { ...inputStartingPayload, source }; + const startingPayload = { ...inputStartingPayload, logInActionSource }; dispatch(wrapActionPromise(actionTypes, promise, null, startingPayload)); return new Promise(r => (callServerEndpointCallback = r)); }; await resolveInvalidatedCookie( boundCallServerEndpoint, dispatchRecoveryAttempt, - source, + logInActionSource, ); return newSessionChange; } // Third param is optional and gets called with newCookie if we get a new cookie // Necessary to propagate cookie in cookieInvalidationRecovery below function bindCookieAndUtilsIntoCallServerEndpoint( params: BindServerCallsParams, ): CallServerEndpoint { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookie, sessionID }, error, undefined, ); // This function gets called before callServerEndpoint sends a request, // to make sure that we're not in the middle of trying to recover // an invalidated cookie const waitIfCookieInvalidated = () => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // callServerEndpoint instance continue return Promise.resolve(null); } if (!currentlyWaitingForNewCookie) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r)); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await fetchNewCookieFromNativeCredentials( dispatch, newAnonymousCookie, urlPrefix, - loginActionSources.cookieInvalidationResolutionAttempt, + logInActionSources.cookieInvalidationResolutionAttempt, ); currentlyWaitingForNewCookie = false; const currentWaitingCalls = serverEndpointCallsWaitingForNewCookie; serverEndpointCallsWaitingForNewCookie = []; const newCallServerEndpoint = newSessionChange ? bindCookieAndUtilsIntoCallServerEndpoint({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newCallServerEndpoint); } return newCallServerEndpoint; }; // If this function is called, callServerEndpoint got a response invalidating // its cookie, and is wondering if it should just like... give up? // Or if there's a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // callServerEndpoint instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } if (currentlyWaitingForNewCookie) { return new Promise(r => serverEndpointCallsWaitingForNewCookie.push(r)); } currentlyWaitingForNewCookie = true; return attemptToResolveInvalidation(sessionChange); }; return ( endpoint: Endpoint, data: Object, options?: ?CallServerEndpointOptions, ) => callServerEndpoint( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, connectionStatus, socketAPIHandler, endpoint, data, options, ); } export type ActionFunc = (callServerEndpoint: CallServerEndpoint) => F; export type BindServerCall = (serverCall: ActionFunc) => F; type BindServerCallsParams = { dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, }; // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where callServerEndpoint is called. We don't want to bother // propagating the cookie (and any future config info that callServerEndpoint // needs) through to the server calls so they can pass it to callServerEndpoint. // Instead, we "curry" the cookie onto callServerEndpoint within react-redux's // connect's mapStateToProps function, and then pass that "bound" // callServerEndpoint that no longer needs the cookie as a parameter on to // the server call. const baseCreateBoundServerCallsSelector = ( actionFunc: ActionFunc, ): (BindServerCallsParams => F) => createSelector( (state: BindServerCallsParams) => state.dispatch, (state: BindServerCallsParams) => state.cookie, (state: BindServerCallsParams) => state.urlPrefix, (state: BindServerCallsParams) => state.sessionID, (state: BindServerCallsParams) => state.currentUserInfo, (state: BindServerCallsParams) => state.connectionStatus, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, ) => { const boundCallServerEndpoint = bindCookieAndUtilsIntoCallServerEndpoint({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); return actionFunc(boundCallServerEndpoint); }, ); type CreateBoundServerCallsSelectorType = ( ActionFunc, ) => BindServerCallsParams => F; const createBoundServerCallsSelector: CreateBoundServerCallsSelectorType = (_memoize( baseCreateBoundServerCallsSelector, ): any); function useServerCall(serverCall: ActionFunc): F { const dispatch = useDispatch(); const serverCallState = useSelector(serverCallStateSelector); return React.useMemo( () => createBoundServerCallsSelector(serverCall)({ ...serverCallState, dispatch, }), [serverCall, dispatch, serverCallState], ); } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { useDispatchActionPromise, setNewSessionActionType, fetchNewCookieFromNativeCredentials, createBoundServerCallsSelector, registerActiveSocket, useServerCall, }; diff --git a/lib/utils/config.js b/lib/utils/config.js index 7be386697..6f0978427 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -1,33 +1,33 @@ // @flow import invariant from 'invariant'; import type { LogInActionSource } from '../types/account-types'; import type { PlatformDetails } from '../types/device-types'; import type { DispatchRecoveryAttempt } from './action-utils'; import type { CallServerEndpoint } from './call-server-endpoint'; export type Config = { +resolveInvalidatedCookie: ?( callServerEndpoint: CallServerEndpoint, dispatchRecoveryAttempt: DispatchRecoveryAttempt, - source: LogInActionSource, + logInActionSource: LogInActionSource, ) => Promise, +setCookieOnRequest: boolean, +setSessionIDOnRequest: boolean, +calendarRangeInactivityLimit: ?number, +platformDetails: PlatformDetails, }; let registeredConfig: ?Config = null; const registerConfig = (config: $Shape) => { registeredConfig = { ...registeredConfig, ...config }; }; const getConfig = (): Config => { invariant(registeredConfig, 'config should be set'); return registeredConfig; }; export { registerConfig, getConfig }; diff --git a/native/account/log-in-panel.react.js b/native/account/log-in-panel.react.js index d082d0ebf..ac9ed8466 100644 --- a/native/account/log-in-panel.react.js +++ b/native/account/log-in-panel.react.js @@ -1,382 +1,382 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet, Alert, Keyboard, Platform } from 'react-native'; import Animated from 'react-native-reanimated'; import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { validEmailRegex, oldValidUsernameRegex, } from 'lib/shared/account-utils'; import { type LogInInfo, type LogInExtraInfo, type LogInResult, type LogInStartingPayload, - loginActionSources, + logInActionSources, } from 'lib/types/account-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; import SWMansionIcon from '../components/swmansion-icon.react'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import type { KeyPressEvent } from '../types/react-native'; import type { StateContainer } from '../utils/state-container'; import { TextInput } from './modal-components.react'; import { fetchNativeCredentials, setNativeCredentials, } from './native-credentials'; import { PanelButton, Panel } from './panel-components.react'; export type LogInState = { +usernameInputText: ?string, +passwordInputText: ?string, }; type BaseProps = { +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Node, +logInState: StateContainer, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logIn: (logInInfo: LogInInfo) => Promise, }; class LogInPanel extends React.PureComponent { usernameInput: ?TextInput; passwordInput: ?TextInput; componentDidMount() { this.attemptToFetchCredentials(); } get usernameInputText(): string { return this.props.logInState.state.usernameInputText || ''; } get passwordInputText(): string { return this.props.logInState.state.passwordInputText || ''; } async attemptToFetchCredentials() { if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } const credentials = await fetchNativeCredentials(); if (!credentials) { return; } if ( this.props.logInState.state.usernameInputText !== null && this.props.logInState.state.usernameInputText !== undefined ) { return; } this.props.logInState.setState({ usernameInputText: credentials.username, passwordInputText: credentials.password, }); } render(): React.Node { return ( ); } usernameInputRef: (usernameInput: ?TextInput) => void = usernameInput => { this.usernameInput = usernameInput; if (Platform.OS === 'ios' && usernameInput) { setTimeout(() => usernameInput.focus()); } }; focusUsernameInput: () => void = () => { invariant(this.usernameInput, 'ref should be set'); this.usernameInput.focus(); }; passwordInputRef: (passwordInput: ?TextInput) => void = passwordInput => { this.passwordInput = passwordInput; }; focusPasswordInput: () => void = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; onChangeUsernameInputText: (text: string) => void = text => { this.props.logInState.setState({ usernameInputText: text.trim() }); }; onUsernameKeyPress: (event: KeyPressEvent) => void = event => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.passwordInputText.length === 0 ) { this.focusPasswordInput(); } }; onChangePasswordInputText: (text: string) => void = text => { this.props.logInState.setState({ passwordInputText: text }); }; onSubmit: () => void = () => { this.props.setActiveAlert(true); if (this.usernameInputText.search(validEmailRegex) > -1) { Alert.alert( 'Can’t log in with email', 'You need to log in with your username now', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.usernameInputText.search(oldValidUsernameRegex) === -1) { Alert.alert( 'Invalid username', 'Alphanumeric usernames only', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); const extraInfo = this.props.logInExtraInfo(); this.props.dispatchActionPromise( logInActionTypes, this.logInAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }; async logInAction(extraInfo: LogInExtraInfo): Promise { try { const result = await this.props.logIn({ ...extraInfo, username: this.usernameInputText, password: this.passwordInputText, - source: loginActionSources.logInFromNativeForm, + logInActionSource: logInActionSources.logInFromNativeForm, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.passwordInputText, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect username or password', "Either that user doesn't exist, or the password is incorrect", [{ text: 'OK', onPress: this.onUnsuccessfulLoginAlertAckowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', 'Your app version is pretty old, and the server doesn’t know how ' + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onUnsuccessfulLoginAlertAckowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', passwordInputText: '', }, this.focusUsernameInput, ); }; onUsernameAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', }, this.focusUsernameInput, ); }; onPasswordAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { passwordInputText: '', }, this.focusPasswordInput, ); }; onUnknownErrorAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); this.props.logInState.setState( { usernameInputText: '', passwordInputText: '', }, this.focusUsernameInput, ); }; onAppOutOfDateAlertAcknowledged: () => void = () => { this.props.setActiveAlert(false); }; } export type InnerLogInPanel = LogInPanel; const styles = StyleSheet.create({ footer: { flexDirection: 'row', justifyContent: 'flex-end', }, icon: { bottom: 10, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, row: { marginHorizontal: 24, }, }); const loadingStatusSelector = createLoadingStatusSelector(logInActionTypes); const ConnectedLogInPanel: React.ComponentType = React.memo( function ConnectedLogInPanel(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector(state => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callLogIn = useServerCall(logIn); return ( ); }, ); export default ConnectedLogInPanel; diff --git a/native/account/logged-out-modal.react.js b/native/account/logged-out-modal.react.js index 1bec40950..e8c9fb1a7 100644 --- a/native/account/logged-out-modal.react.js +++ b/native/account/logged-out-modal.react.js @@ -1,679 +1,679 @@ // @flow import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { View, StyleSheet, Text, TouchableOpacity, Image, Keyboard, Platform, BackHandler, ActivityIndicator, } from 'react-native'; import Animated, { EasingNode } from 'react-native-reanimated'; import { SafeAreaView } from 'react-native-safe-area-context'; import Icon from 'react-native-vector-icons/FontAwesome'; import { useDispatch } from 'react-redux'; import { isLoggedIn } from 'lib/selectors/user-selectors'; -import { loginActionSources } from 'lib/types/account-types'; +import { logInActionSources } from 'lib/types/account-types'; import type { Dispatch } from 'lib/types/redux-types'; import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; import { createIsForegroundSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { LoggedOutModalRouteName } from '../navigation/route-names'; import { resetUserStateActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; import { usePersistedStateLoaded } from '../selectors/app-state-selectors'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; import { splashStyleSelector } from '../splash'; import type { EventSubscription, KeyboardEvent } from '../types/react-native'; import type { ImageStyle } from '../types/styles'; import { runTiming, ratchetAlongWithKeyboardHeight, } from '../utils/animation-utils'; import { type StateContainer, type StateChange, setStateForContainer, } from '../utils/state-container'; import { splashBackgroundURI } from './background-info'; import LogInPanel from './log-in-panel.react'; import type { LogInState } from './log-in-panel.react'; import RegisterPanel from './register-panel.react'; import type { RegisterState } from './register-panel.react'; import SIWEPanel from './siwe-panel.react'; let initialAppLoad = true; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Node, Clock, block, set, call, cond, not, and, eq, neq, lessThan, greaterOrEq, add, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ type LoggedOutMode = 'loading' | 'prompt' | 'log-in' | 'register' | 'siwe'; const modeNumbers: { [LoggedOutMode]: number } = { 'loading': 0, 'prompt': 1, 'log-in': 2, 'register': 3, 'siwe': 4, }; function isPastPrompt(modeValue: Node) { return and( neq(modeValue, modeNumbers['loading']), neq(modeValue, modeNumbers['prompt']), ); } type Props = { // Navigation state +isForeground: boolean, // Redux state +persistedStateLoaded: boolean, +rehydrateConcluded: boolean, +cookie: ?string, +urlPrefix: string, +loggedIn: boolean, +dimensions: DerivedDimensionsInfo, +splashStyle: ImageStyle, // Redux dispatch functions +dispatch: Dispatch, ... }; type State = { +mode: LoggedOutMode, +logInState: StateContainer, +registerState: StateContainer, }; class LoggedOutModal extends React.PureComponent { keyboardShowListener: ?EventSubscription; keyboardHideListener: ?EventSubscription; mounted = false; nextMode: LoggedOutMode = 'loading'; activeAlert = false; contentHeight: Value; keyboardHeightValue = new Value(0); modeValue: Value; buttonOpacity: Value; panelPaddingTopValue: Node; panelOpacityValue: Node; constructor(props: Props) { super(props); // Man, this is a lot of boilerplate just to containerize some state. // Mostly due to Flow typing requirements... const setLogInState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ logInState: { ...fullState.logInState, state: { ...fullState.logInState.state, ...change }, }, }), ); const setRegisterState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ registerState: { ...fullState.registerState, state: { ...fullState.registerState.state, ...change }, }, }), ); this.state = { mode: props.persistedStateLoaded ? 'prompt' : 'loading', logInState: { state: { usernameInputText: null, passwordInputText: null, }, setState: setLogInState, }, registerState: { state: { usernameInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, setState: setRegisterState, }, }; if (props.persistedStateLoaded) { this.nextMode = 'prompt'; } this.contentHeight = new Value(props.dimensions.safeAreaHeight); this.modeValue = new Value(modeNumbers[this.nextMode]); this.buttonOpacity = new Value(props.persistedStateLoaded ? 1 : 0); this.panelPaddingTopValue = this.panelPaddingTop(); this.panelOpacityValue = this.panelOpacity(); } guardedSetState = (change: StateChange, callback?: () => mixed) => { if (this.mounted) { this.setState(change, callback); } }; setMode(newMode: LoggedOutMode) { this.nextMode = newMode; this.guardedSetState({ mode: newMode }); this.modeValue.setValue(modeNumbers[newMode]); } proceedToNextMode = () => { this.guardedSetState({ mode: this.nextMode }); }; componentDidMount() { this.mounted = true; if (this.props.rehydrateConcluded) { this.onInitialAppLoad(); } if (this.props.isForeground) { this.onForeground(); } } componentWillUnmount() { this.mounted = false; if (this.props.isForeground) { this.onBackground(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (!prevProps.persistedStateLoaded && this.props.persistedStateLoaded) { this.setMode('prompt'); } if (!prevProps.rehydrateConcluded && this.props.rehydrateConcluded) { this.onInitialAppLoad(); } if (!prevProps.isForeground && this.props.isForeground) { this.onForeground(); } else if (prevProps.isForeground && !this.props.isForeground) { this.onBackground(); } if (this.state.mode === 'prompt' && prevState.mode !== 'prompt') { this.buttonOpacity.setValue(0); Animated.timing(this.buttonOpacity, { easing: EasingNode.out(EasingNode.ease), duration: 250, toValue: 1.0, }).start(); } const newContentHeight = this.props.dimensions.safeAreaHeight; const oldContentHeight = prevProps.dimensions.safeAreaHeight; if (newContentHeight !== oldContentHeight) { this.contentHeight.setValue(newContentHeight); } } onForeground() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardHideListener = addKeyboardDismissListener(this.keyboardHide); BackHandler.addEventListener('hardwareBackPress', this.hardwareBack); } onBackground() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } BackHandler.removeEventListener('hardwareBackPress', this.hardwareBack); } // This gets triggered when an app is killed and restarted // Not when it is returned from being backgrounded async onInitialAppLoad() { if (!initialAppLoad) { return; } initialAppLoad = false; const { loggedIn, cookie, urlPrefix, dispatch } = this.props; const hasUserCookie = cookie && cookie.startsWith('user='); if (loggedIn === !!hasUserCookie) { return; } if (!__DEV__) { const actionSource = loggedIn - ? loginActionSources.appStartReduxLoggedInButInvalidCookie - : loginActionSources.appStartCookieLoggedInButInvalidRedux; + ? logInActionSources.appStartReduxLoggedInButInvalidCookie + : logInActionSources.appStartCookieLoggedInButInvalidRedux; const sessionChange = await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, actionSource, ); if ( sessionChange && sessionChange.cookie && sessionChange.cookie.startsWith('user=') ) { // success! we can expect subsequent actions to fix up the state return; } } this.props.dispatch({ type: resetUserStateActionType }); } hardwareBack = () => { if (this.nextMode !== 'prompt') { this.goBackToPrompt(); return true; } return false; }; panelPaddingTop() { const headerHeight = Platform.OS === 'ios' ? 62.33 : 58.54; const promptButtonsSize = Platform.OS === 'ios' ? 40 : 61; const logInContainerSize = 140; const registerPanelSize = Platform.OS === 'ios' ? 181 : 180; const containerSize = add( headerHeight, cond(not(isPastPrompt(this.modeValue)), promptButtonsSize, 0), cond(eq(this.modeValue, modeNumbers['log-in']), logInContainerSize, 0), cond(eq(this.modeValue, modeNumbers['register']), registerPanelSize, 0), ); const potentialPanelPaddingTop = divide( max(sub(this.contentHeight, this.keyboardHeightValue, containerSize), 0), 2, ); const panelPaddingTop = new Value(-1); const targetPanelPaddingTop = new Value(-1); const prevModeValue = new Value(modeNumbers[this.nextMode]); const clock = new Clock(); const keyboardTimeoutClock = new Clock(); return block([ cond(lessThan(panelPaddingTop, 0), [ set(panelPaddingTop, potentialPanelPaddingTop), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( lessThan(this.keyboardHeightValue, 0), [ runTiming(keyboardTimeoutClock, 0, 1, true, { duration: 500 }), cond( not(clockRunning(keyboardTimeoutClock)), set(this.keyboardHeightValue, 0), ), ], stopClock(keyboardTimeoutClock), ), cond( and( greaterOrEq(this.keyboardHeightValue, 0), neq(prevModeValue, this.modeValue), ), [ stopClock(clock), cond( neq(isPastPrompt(prevModeValue), isPastPrompt(this.modeValue)), set(targetPanelPaddingTop, potentialPanelPaddingTop), ), set(prevModeValue, this.modeValue), ], ), ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ stopClock(clock), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( neq(panelPaddingTop, targetPanelPaddingTop), set( panelPaddingTop, runTiming(clock, panelPaddingTop, targetPanelPaddingTop), ), ), panelPaddingTop, ]); } panelOpacity() { const targetPanelOpacity = isPastPrompt(this.modeValue); const panelOpacity = new Value(-1); const prevPanelOpacity = new Value(-1); const prevTargetPanelOpacity = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(panelOpacity, 0), [ set(panelOpacity, targetPanelOpacity), set(prevPanelOpacity, targetPanelOpacity), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond(neq(targetPanelOpacity, prevTargetPanelOpacity), [ stopClock(clock), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond( neq(panelOpacity, targetPanelOpacity), set(panelOpacity, runTiming(clock, panelOpacity, targetPanelOpacity)), ), ]), cond( and(eq(panelOpacity, 0), neq(prevPanelOpacity, 0)), call([], this.proceedToNextMode), ), set(prevPanelOpacity, panelOpacity), panelOpacity, ]); } keyboardShow = (event: KeyboardEvent) => { if ( event.startCoordinates && _isEqual(event.startCoordinates)(event.endCoordinates) ) { return; } const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max( event.endCoordinates.height - this.props.dimensions.bottomInset, 0, ), }); this.keyboardHeightValue.setValue(keyboardHeight); }; keyboardHide = () => { if (!this.activeAlert) { this.keyboardHeightValue.setValue(0); } }; setActiveAlert = (activeAlert: boolean) => { this.activeAlert = activeAlert; }; goBackToPrompt = () => { this.nextMode = 'prompt'; this.keyboardHeightValue.setValue(0); this.modeValue.setValue(modeNumbers['prompt']); Keyboard.dismiss(); }; render() { let panel = null; let buttons = null; let siweButton = null; if (__DEV__) { siweButton = ( SIWE ); } if (this.state.mode === 'siwe') { panel = ( ); } else if (this.state.mode === 'log-in') { panel = ( ); } else if (this.state.mode === 'register') { panel = ( ); } else if (this.state.mode === 'prompt') { const opacityStyle = { opacity: this.buttonOpacity }; buttons = ( {siweButton} LOG IN SIGN UP ); } else if (this.state.mode === 'loading') { panel = ( ); } const windowWidth = this.props.dimensions.width; const buttonStyle = { opacity: this.panelOpacityValue, left: windowWidth < 360 ? 28 : 40, }; const padding = { paddingTop: this.panelPaddingTopValue }; const animatedContent = ( Comm {panel} ); const backgroundSource = { uri: splashBackgroundURI }; return ( {animatedContent} {buttons} ); } onPressSIWE = () => { this.setMode('siwe'); }; onPressLogIn = () => { if (Platform.OS !== 'ios') { // For some strange reason, iOS's password management logic doesn't // realize that the username and password fields in LogInPanel are related // if the username field gets focused on mount. To avoid this issue we // need the username and password fields to both appear on-screen before // we focus the username field. However, when we set keyboardHeightValue // to -1 here, we are telling our Reanimated logic to wait until the // keyboard appears before showing LogInPanel. Since we need LogInPanel // to appear before the username field is focused, we need to avoid this // behavior on iOS. this.keyboardHeightValue.setValue(-1); } this.setMode('log-in'); }; onPressRegister = () => { this.keyboardHeightValue.setValue(-1); this.setMode('register'); }; } const styles = StyleSheet.create({ animationContainer: { flex: 1, }, backButton: { position: 'absolute', top: 13, }, button: { backgroundColor: '#FFFFFFAA', borderRadius: 6, marginBottom: 10, marginLeft: 40, marginRight: 40, marginTop: 10, paddingBottom: 6, paddingLeft: 18, paddingRight: 18, paddingTop: 6, }, buttonContainer: { bottom: 0, left: 0, paddingBottom: 20, position: 'absolute', right: 0, }, buttonText: { color: '#000000FF', fontFamily: 'OpenSans-Semibold', fontSize: 22, textAlign: 'center', }, container: { backgroundColor: 'transparent', flex: 1, }, header: { color: 'white', fontFamily: Platform.OS === 'ios' ? 'IBMPlexSans' : 'IBMPlexSans-Medium', fontSize: 56, fontWeight: '500', lineHeight: 66, textAlign: 'center', }, loadingIndicator: { paddingTop: 15, }, modalBackground: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); const isForegroundSelector = createIsForegroundSelector( LoggedOutModalRouteName, ); const ConnectedLoggedOutModal: React.ComponentType<{ ... }> = React.memo<{ ... }>(function ConnectedLoggedOutModal(props: { ... }) { const navContext = React.useContext(NavContext); const isForeground = isForegroundSelector(navContext); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated && navContext), ); const persistedStateLoaded = usePersistedStateLoaded(); const cookie = useSelector(state => state.cookie); const urlPrefix = useSelector(state => state.urlPrefix); const loggedIn = useSelector(isLoggedIn); const dimensions = useSelector(derivedDimensionsInfoSelector); const splashStyle = useSelector(splashStyleSelector); const dispatch = useDispatch(); return ( ); }); export default ConnectedLoggedOutModal; diff --git a/native/account/resolve-invalidated-cookie.js b/native/account/resolve-invalidated-cookie.js index 3a0f8030e..c9369eff3 100644 --- a/native/account/resolve-invalidated-cookie.js +++ b/native/account/resolve-invalidated-cookie.js @@ -1,38 +1,38 @@ // @flow import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import type { LogInActionSource } from 'lib/types/account-types'; import type { DispatchRecoveryAttempt } from 'lib/utils/action-utils'; import type { CallServerEndpoint } from 'lib/utils/call-server-endpoint'; import { getGlobalNavContext } from '../navigation/icky-global'; import { store } from '../redux/redux-setup'; import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import { fetchNativeKeychainCredentials } from './native-credentials'; async function resolveInvalidatedCookie( callServerEndpoint: CallServerEndpoint, dispatchRecoveryAttempt: DispatchRecoveryAttempt, - source: LogInActionSource, + logInActionSource: LogInActionSource, ) { const keychainCredentials = await fetchNativeKeychainCredentials(); if (!keychainCredentials) { return; } const extraInfo = nativeLogInExtraInfoSelector({ redux: store.getState(), navContext: getGlobalNavContext(), })(); const { calendarQuery } = extraInfo; await dispatchRecoveryAttempt( logInActionTypes, logIn(callServerEndpoint)({ ...keychainCredentials, ...extraInfo, - source, + logInActionSource, }), { calendarQuery }, ); } export { resolveInvalidatedCookie }; diff --git a/native/data/sqlite-context-provider.js b/native/data/sqlite-context-provider.js index f03e3d1a2..6680e075a 100644 --- a/native/data/sqlite-context-provider.js +++ b/native/data/sqlite-context-provider.js @@ -1,109 +1,109 @@ // @flow import * as React from 'react'; import { Alert } from 'react-native'; import ExitApp from 'react-native-exit-app'; import { useDispatch } from 'react-redux'; import { setMessageStoreMessages } from 'lib/actions/message-actions.js'; import { setThreadStoreActionType } from 'lib/actions/thread-actions'; -import { loginActionSources } from 'lib/types/account-types'; +import { logInActionSources } from 'lib/types/account-types'; import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; import { getMessageForException } from 'lib/utils/errors'; import { convertClientDBThreadInfosToRawThreadInfos } from 'lib/utils/thread-ops-utils'; import { commCoreModule } from '../native-modules'; import { useSelector } from '../redux/redux-utils'; import { useStaffCanSee } from '../utils/staff-utils'; import { SQLiteContext } from './sqlite-context'; type Props = { +children: React.Node, }; function SQLiteContextProvider(props: Props): React.Node { const [storeLoaded, setStoreLoaded] = React.useState(false); const dispatch = useDispatch(); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated), ); const cookie = useSelector(state => state.cookie); const urlPrefix = useSelector(state => state.urlPrefix); const staffCanSee = useStaffCanSee(); React.useEffect(() => { if (storeLoaded || !rehydrateConcluded) { return; } (async () => { try { const threads = await commCoreModule.getAllThreads(); const threadInfosFromDB = convertClientDBThreadInfosToRawThreadInfos( threads, ); dispatch({ type: setThreadStoreActionType, payload: { threadInfos: threadInfosFromDB }, }); const messages = await commCoreModule.getAllMessages(); dispatch({ type: setMessageStoreMessages, payload: messages, }); setStoreLoaded(true); } catch (setStoreException) { if (staffCanSee) { Alert.alert( `Error setting threadStore or messageStore: ${ getMessageForException(setStoreException) ?? '{no exception message}' }`, ); } try { await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, - loginActionSources.sqliteLoadFailure, + logInActionSources.sqliteLoadFailure, ); setStoreLoaded(true); } catch (fetchCookieException) { if (staffCanSee) { Alert.alert( `Error fetching new cookie from native credentials: ${ getMessageForException(fetchCookieException) ?? '{no exception message}' }. Please kill the app.`, ); } else { ExitApp.exitApp(); } } } })(); }, [ cookie, dispatch, rehydrateConcluded, staffCanSee, storeLoaded, urlPrefix, ]); const contextValue = React.useMemo( () => ({ storeLoaded, }), [storeLoaded], ); return ( {props.children} ); } export { SQLiteContextProvider }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index 62f801c9b..f9ad1233d 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,494 +1,494 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import ExitApp from 'react-native-exit-app'; import Orientation from 'react-native-orientation-locker'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import thunk from 'redux-thunk'; import { setDeviceTokenActionTypes } from 'lib/actions/device-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { processThreadStoreOperations } from 'lib/reducers/thread-reducer'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/account-utils'; -import { loginActionSources } from 'lib/types/account-types'; +import { logInActionSources } from 'lib/types/account-types'; import { defaultEnabledApps } from 'lib/types/enabled-apps'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import type { Dispatch, BaseAction } from 'lib/types/redux-types'; import type { SetSessionPayload } from 'lib/types/session-types'; import { defaultConnectionInfo, incrementalStateSyncActionType, } from 'lib/types/socket-types'; import type { ThreadStoreOperation } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { convertMessageStoreOperationsToClientDBOperations } from 'lib/utils/message-ops-utils'; import { convertThreadStoreOperationsToClientDBOperations } from 'lib/utils/thread-ops-utils'; import { commCoreModule } from '../native-modules'; import { defaultNavInfo } from '../navigation/default-state'; import { getGlobalNavContext } from '../navigation/icky-global'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { defaultNotifPermissionAlertInfo } from '../push/alerts'; import { reduceThreadIDsToNotifIDs } from '../push/reducer'; import reactotron from '../reactotron'; import { defaultDeviceCameraInfo } from '../types/camera'; import { defaultConnectivityInfo } from '../types/connectivity'; import { defaultGlobalThemeInfo } from '../types/themes'; import { defaultURLPrefix, natNodeServer, setCustomServer, getDevServerHostname, } from '../utils/url-utils'; import { resetUserStateActionType, recordNotifPermissionAlertActionType, recordAndroidNotificationActionType, clearAndroidNotificationsActionType, rescindAndroidNotificationActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, type Action, } from './action-types'; import { remoteReduxDevServerConfig } from './dev-tools'; import { defaultDimensionsInfo } from './dimensions-updater.react'; import { persistConfig, setPersistor } from './persist'; import type { AppState } from './state-types'; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }, threadStore: { threadInfos: {}, }, userStore: { userInfos: {}, inconsistencyReports: [], }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, cookie: null, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix, customServer: natNodeServer, threadIDsToNotifIDs: {}, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], lifecycleState: 'active', enabledApps: defaultEnabledApps, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [], }, nextLocalID: 0, _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, }: AppState); function reducer(state: AppState = defaultState, action: Action) { if (action.type === setReduxStateActionType) { return action.payload.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 && action.payload.sessionChange.currentUserInfo && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, - action.payload.source, + action.payload.logInActionSource, )) || (action.type === logInActionTypes.success && invalidSessionRecovery( state, action.payload.currentUserInfo, - action.payload.source, + action.payload.logInActionSource, )) ) { return state; } const threadIDsToNotifIDs = reduceThreadIDsToNotifIDs( state.threadIDsToNotifIDs, action, ); state = { ...state, threadIDsToNotifIDs }; if ( action.type === recordAndroidNotificationActionType || action.type === clearAndroidNotificationsActionType || action.type === rescindAndroidNotificationActionType ) { return state; } if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === recordNotifPermissionAlertActionType) { return { ...state, notifPermissionAlertInfo: { totalAlerts: state.notifPermissionAlertInfo.totalAlerts + 1, lastAlertTime: action.payload.time, }, }; } else if (action.type === resetUserStateActionType) { const cookie = state.cookie && state.cookie.startsWith('anonymous=') ? state.cookie : null; const currentUserInfo = state.currentUserInfo && state.currentUserInfo.anonymous ? state.currentUserInfo : null; return { ...state, currentUserInfo, cookie, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { return { ...state, globalThemeInfo: { ...state.globalThemeInfo, ...action.payload, }, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setDeviceTokenActionTypes.success) { return { ...state, deviceToken: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }, }, }; } return state; } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } else if (action.type === incrementalStateSyncActionType) { let wipeDeviceToken = false; for (const update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.deviceToken ) { wipeDeviceToken = true; break; } } if (wipeDeviceToken) { state = { ...state, deviceToken: null, }; } } const baseReducerResult = baseReducer(state, (action: BaseAction)); state = baseReducerResult.state; const { storeOperations } = baseReducerResult; const { threadStoreOperations, messageStoreOperations } = storeOperations; const fixUnreadActiveThreadResult = fixUnreadActiveThread(state, action); state = fixUnreadActiveThreadResult.state; const threadStoreOperationsWithUnreadFix = [ ...threadStoreOperations, ...fixUnreadActiveThreadResult.threadStoreOperations, ]; const convertedThreadStoreOperations = convertThreadStoreOperationsToClientDBOperations( threadStoreOperationsWithUnreadFix, ); const convertedMessageStoreOperations = convertMessageStoreOperationsToClientDBOperations( messageStoreOperations, ); (async () => { try { const promises = []; if (convertedThreadStoreOperations.length > 0) { promises.push( commCoreModule.processThreadStoreOperations( convertedThreadStoreOperations, ), ); } if (convertedMessageStoreOperations.length > 0) { promises.push( commCoreModule.processMessageStoreOperations( convertedMessageStoreOperations, ), ); } await Promise.all(promises); } catch { dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookie: null, cookieInvalidated: false, currentUserInfo: state.currentUserInfo, }, preRequestUserState: { currentUserInfo: state.currentUserInfo, sessionID: undefined, cookie: state.cookie, }, error: null, - source: loginActionSources.sqliteOpFailure, + logInActionSource: logInActionSources.sqliteOpFailure, }, }); await persistor.flush(); ExitApp.exitApp(); } })(); return state; } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'App Store', android: 'Play Store', }); Alert.alert( 'App out of date', 'Your app version is pretty old, and the server doesn’t know how to ' + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], { cancelable: true }, ); } else { Alert.alert( 'Session invalidated', 'We’re sorry, but your session was invalidated by the server. ' + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. type FixUnreadActiveThreadResult = { +state: AppState, +threadStoreOperations: $ReadOnlyArray, }; function fixUnreadActiveThread( state: AppState, action: *, ): FixUnreadActiveThreadResult { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( !activeThread || !state.threadStore.threadInfos[activeThread]?.currentUser.unread || (NativeAppState.currentState !== 'active' && (appLastBecameInactive + 10000 >= Date.now() || backgroundActionTypes.has(action.type))) ) { return { state, threadStoreOperations: [] }; } const updatedActiveThreadInfo = { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }; const threadStoreOperations = [ { type: 'replace', payload: { id: activeThread, threadInfo: updatedActiveThreadInfo, }, }, ]; const updatedThreadStore = processThreadStoreOperations( state.threadStore, threadStoreOperations, ); return { state: { ...state, threadStore: updatedThreadStore }, threadStoreOperations, }; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middlewares = [thunk, reduxLoggerMiddleware]; if (__DEV__) { const createDebugger = require('redux-flipper').default; middlewares.push(createDebugger()); } const middleware = applyMiddleware(...middlewares); let composeFunc = compose; if (__DEV__ && global.HermesInternal) { const { composeWithDevTools } = require('remote-redux-devtools/src'); composeFunc = composeWithDevTools({ name: 'Redux', hostname: getDevServerHostname(), ...remoteReduxDevServerConfig, }); } else if (global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeFunc = global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux', }); } let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; export { store, dispatch, appBecameInactive }; diff --git a/web/account/log-in-form.react.js b/web/account/log-in-form.react.js index 5869a3427..a4fe90655 100644 --- a/web/account/log-in-form.react.js +++ b/web/account/log-in-form.react.js @@ -1,167 +1,167 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import { useModalContext } from 'lib/components/modal-provider.react'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { oldValidUsernameRegex, validEmailRegex, } from 'lib/shared/account-utils'; import { type LogInExtraInfo, type LogInStartingPayload, - loginActionSources, + logInActionSources, } from 'lib/types/account-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import LoadingIndicator from '../loading-indicator.react'; import Input from '../modals/input.react'; import { useSelector } from '../redux/redux-utils'; import { webLogInExtraInfoSelector } from '../selectors/account-selectors'; import css from './log-in-form.css'; const loadingStatusSelector = createLoadingStatusSelector(logInActionTypes); function LoginForm(): React.Node { const inputDisabled = useSelector(loadingStatusSelector) === 'loading'; const loginExtraInfo = useSelector(webLogInExtraInfoSelector); const callLogIn = useServerCall(logIn); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); const [username, setUsername] = React.useState(''); const [password, setPassword] = React.useState(''); const [errorMessage, setErrorMessage] = React.useState(''); const usernameInputRef = React.useRef(); React.useEffect(() => { usernameInputRef.current?.focus(); }, []); const onUsernameChange = React.useCallback(e => { invariant(e.target instanceof HTMLInputElement, 'target not input'); setUsername(e.target.value); }, []); const onUsernameBlur = React.useCallback(() => { setUsername(untrimmedUsername => untrimmedUsername.trim()); }, []); const onPasswordChange = React.useCallback(e => { invariant(e.target instanceof HTMLInputElement, 'target not input'); setPassword(e.target.value); }, []); const logInAction = React.useCallback( async (extraInfo: LogInExtraInfo) => { try { const result = await callLogIn({ ...extraInfo, username, password, - source: loginActionSources.logInFromWebForm, + logInActionSource: logInActionSources.logInFromWebForm, }); modalContext.popModal(); return result; } catch (e) { setUsername(''); setPassword(''); if (e.message === 'invalid_credentials') { setErrorMessage('incorrect username or password'); } else { setErrorMessage('unknown error'); } usernameInputRef.current?.focus(); throw e; } }, [callLogIn, modalContext, password, username], ); const onSubmit = React.useCallback( (event: SyntheticEvent) => { event.preventDefault(); if (username.search(validEmailRegex) > -1) { setUsername(''); setErrorMessage('usernames only, not emails'); usernameInputRef.current?.focus(); return; } else if (username.search(oldValidUsernameRegex) === -1) { setUsername(''); setErrorMessage('alphanumeric usernames only'); usernameInputRef.current?.focus(); return; } const extraInfo = loginExtraInfo(); dispatchActionPromise( logInActionTypes, logInAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }, [dispatchActionPromise, logInAction, loginExtraInfo, username], ); const loginButtonContent = React.useMemo(() => { if (inputDisabled) { return ; } return 'Log in'; }, [inputDisabled]); return (
Username
Password
{errorMessage}
); } export default LoginForm;