diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js index 29eeac2af..45b03754b 100644 --- a/lib/reducers/master-reducer.js +++ b/lib/reducers/master-reducer.js @@ -1,78 +1,78 @@ // @flow import type { BaseAppState, BaseAction } from '../types/redux-types'; import type { BaseNavInfo } from '../types/nav-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import { reduceLoadingStatuses } from './loading-reducer'; import { reduceEntryInfos } from './entry-reducer'; import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer'; import reduceThreadInfos from './thread-reducer'; import reduceBaseNavInfo from './nav-reducer'; import { reduceMessageStore } from './message-reducer'; import reduceUpdatesCurrentAsOf from './updates-reducer'; import reduceURLPrefix from './url-prefix-reducer'; import reduceCalendarFilters from './calendar-filters-reducer'; import reduceConnectionInfo from './connection-reducer'; import reduceForeground from './foreground-reducer'; import reduceNextLocalID from './local-id-reducer'; import reduceQueuedReports from './report-reducer'; import reduceDataLoaded from './data-loaded-reducer'; export default function baseReducer>( state: T, action: BaseAction, ): T { const threadStore = reduceThreadInfos(state.threadStore, action); const { threadInfos } = threadStore; // Only allow checkpoints to increase if we are connected // or if the action is a STATE_SYNC let messageStore = reduceMessageStore( state.messageStore, action, threadInfos, ); let updatesCurrentAsOf = reduceUpdatesCurrentAsOf( state.updatesCurrentAsOf, action, ); const connection = reduceConnectionInfo(state.connection, action); if ( connection.status !== 'connected' && action.type !== incrementalStateSyncActionType && action.type !== fullStateSyncActionType ) { if (messageStore.currentAsOf !== state.messageStore.currentAsOf) { messageStore = { ...messageStore, currentAsOf: state.messageStore.currentAsOf, }; } if (updatesCurrentAsOf !== state.updatesCurrentAsOf) { updatesCurrentAsOf = state.updatesCurrentAsOf; } } return { ...state, navInfo: reduceBaseNavInfo(state.navInfo, action), entryStore: reduceEntryInfos(state.entryStore, action, threadInfos), loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), currentUserInfo: reduceCurrentUserInfo(state.currentUserInfo, action), threadStore, - userInfos: reduceUserInfos(state.userInfos, action), + userStore: reduceUserInfos(state.userStore, action), messageStore: reduceMessageStore(state.messageStore, action, threadInfos), updatesCurrentAsOf, urlPrefix: reduceURLPrefix(state.urlPrefix, action), calendarFilters: reduceCalendarFilters(state.calendarFilters, action), connection, foreground: reduceForeground(state.foreground, action), nextLocalID: reduceNextLocalID(state.nextLocalID, action), queuedReports: reduceQueuedReports(state.queuedReports, action), dataLoaded: reduceDataLoaded(state.dataLoaded, action), }; } diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index d71d65661..9b396ad9f 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,223 +1,278 @@ // @flow import type { BaseAction } from '../types/redux-types'; -import type { CurrentUserInfo, UserInfo, UserInfos } from '../types/user-types'; +import type { CurrentUserInfo, UserStore, UserInfo } from '../types/user-types'; import { updateTypes, processUpdatesActionType } from '../types/update-types'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; +import { + type UserInconsistencyReportCreationRequest, + reportTypes, +} from '../types/report-types'; import invariant from 'invariant'; import _keyBy from 'lodash/fp/keyBy'; import _isEqual from 'lodash/fp/isEqual'; import { setNewSessionActionType } from '../utils/action-utils'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, createEntryActionTypes, saveEntryActionTypes, deleteEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, resetPasswordActionTypes, changeUserSettingsActionTypes, searchUsersActionTypes, } from '../actions/user-actions'; import { joinThreadActionTypes } from '../actions/thread-actions'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, } from '../actions/message-actions'; +import { getConfig } from '../utils/config'; +import { actionLogger } from '../utils/action-logger'; +import { sanitizeAction } from '../utils/sanitization'; function reduceCurrentUserInfo( state: ?CurrentUserInfo, action: BaseAction, ): ?CurrentUserInfo { if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === registerActionTypes.success || action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { if (!_isEqual(action.payload.currentUserInfo)(state)) { return action.payload.currentUserInfo; } } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo ) { const { sessionChange } = action.payload; if (!_isEqual(sessionChange.currentUserInfo)(state)) { return sessionChange.currentUserInfo; } } else if (action.type === fullStateSyncActionType) { const { currentUserInfo } = action.payload; if (!_isEqual(currentUserInfo)(state)) { return currentUserInfo; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { for (let update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.UPDATE_CURRENT_USER && !_isEqual(update.currentUserInfo)(state) ) { return update.currentUserInfo; } } } else if (action.type === changeUserSettingsActionTypes.success) { invariant( state && !state.anonymous, "can't change settings if not logged in", ); const email = action.payload.email; if (!email) { return state; } return { id: state.id, username: state.username, email: email, emailVerified: false, }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if ( checkStateRequest && checkStateRequest.stateChanges && checkStateRequest.stateChanges.currentUserInfo && !_isEqual(checkStateRequest.stateChanges.currentUserInfo)(state) ) { return checkStateRequest.stateChanges.currentUserInfo; } } return state; } -function reduceUserInfos( - state: { [id: string]: UserInfo }, +function findInconsistencies( action: BaseAction, -): UserInfos { + beforeStateCheck: { [id: string]: UserInfo }, + afterStateCheck: { [id: string]: UserInfo }, +): UserInconsistencyReportCreationRequest[] { + if (_isEqual(beforeStateCheck)(afterStateCheck)) { + return []; + } + return [ + { + type: reportTypes.USER_INCONSISTENCY, + platformDetails: getConfig().platformDetails, + action: sanitizeAction(action), + beforeStateCheck, + afterStateCheck, + lastActions: actionLogger.interestingActionSummaries, + time: Date.now(), + }, + ]; +} + +function reduceUserInfos(state: UserStore, action: BaseAction): UserStore { if ( action.type === joinThreadActionTypes.success || action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success || action.type === fetchEntriesActionTypes.success || action.type === updateCalendarQueryActionTypes.success || action.type === searchUsersActionTypes.success ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - const updated = { ...state, ...newUserInfos }; - if (!_isEqual(state)(updated)) { - return updated; + const updated = { ...state.userInfos, ...newUserInfos }; + if (!_isEqual(state.userInfos)(updated)) { + return { + userInfos: updated, + inconsistencyReports: state.inconsistencyReports, + }; } } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { - if (Object.keys(state).length === 0) { + if (Object.keys(state.userInfos).length === 0) { return state; } - return {}; + return { + userInfos: {}, + inconsistencyReports: state.inconsistencyReports, + }; } else if ( action.type === logInActionTypes.success || action.type === registerActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === fullStateSyncActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); - if (!_isEqual(state)(newUserInfos)) { - return newUserInfos; + if (!_isEqual(state.userInfos)(newUserInfos)) { + return { + userInfos: newUserInfos, + inconsistencyReports: state.inconsistencyReports, + }; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.userInfos, ); // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - const updated = { ...state, ...newUserInfos }; + const updated = { ...state.userInfos, ...newUserInfos }; for (let update of action.payload.updatesResult.newUpdates) { if (update.type === updateTypes.DELETE_ACCOUNT) { delete updated[update.deletedUserID]; } } - if (!_isEqual(state)(updated)) { - return updated; + if (!_isEqual(state.userInfos)(updated)) { + return { + userInfos: updated, + inconsistencyReports: state.inconsistencyReports, + }; } } else if ( action.type === createEntryActionTypes.success || action.type === saveEntryActionTypes.success || action.type === restoreEntryActionTypes.success ) { const newUserInfos = _keyBy(userInfo => userInfo.id)( action.payload.updatesResult.userInfos, ); // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - const updated = { ...state, ...newUserInfos }; - if (!_isEqual(state)(updated)) { - return updated; + const updated = { ...state.userInfos, ...newUserInfos }; + if (!_isEqual(state.userInfos)(updated)) { + return { + userInfos: updated, + inconsistencyReports: state.inconsistencyReports, + }; } } else if (action.type === deleteEntryActionTypes.success && action.payload) { const { updatesResult } = action.payload; const newUserInfos = _keyBy(userInfo => userInfo.id)( updatesResult.userInfos, ); // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - const updated = { ...state, ...newUserInfos }; - if (!_isEqual(state)(updated)) { - return updated; + const updated = { ...state.userInfos, ...newUserInfos }; + if (!_isEqual(state.userInfos)(updated)) { + return { + userInfos: updated, + inconsistencyReports: state.inconsistencyReports, + }; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( candidate => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return state; } const { userInfos, deleteUserInfoIDs } = checkStateRequest.stateChanges; if (!userInfos && !deleteUserInfoIDs) { return state; } - const newUserInfos = { ...state }; + const newUserInfos = { ...state.userInfos }; if (userInfos) { for (const userInfo of userInfos) { newUserInfos[userInfo.id] = userInfo; } } if (deleteUserInfoIDs) { for (const deleteUserInfoID of deleteUserInfoIDs) { delete newUserInfos[deleteUserInfoID]; } } - return newUserInfos; + + const newInconsistencies = findInconsistencies( + action, + state.userInfos, + newUserInfos, + ); + return { + userInfos: newUserInfos, + inconsistencyReports: [ + ...state.inconsistencyReports, + ...newInconsistencies, + ], + }; } return state; } export { reduceCurrentUserInfo, reduceUserInfos }; diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 05ff65ea1..3bd950737 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,348 +1,348 @@ // @flow import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, threadInfoPropType, threadTypes, } from '../types/thread-types'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, messageInfoPropType, localMessageInfoPropType, messageTypes, isComposableMessageType, } from '../types/message-types'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import _flow from 'lodash/fp/flow'; import _filter from 'lodash/fp/filter'; import _map from 'lodash/fp/map'; import _orderBy from 'lodash/fp/orderBy'; import _memoize from 'lodash/memoize'; import { messageKey, robotextForMessageInfo, createMessageInfo, } from '../shared/message-utils'; import { threadInfoSelector, childThreadInfos } from './thread-selectors'; import { threadInChatList } from '../shared/thread-utils'; export type ChatThreadItem = {| +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray<{| +threadInfo: ThreadInfo, +lastUpdatedTime: number, |}>, |}; const chatThreadItemPropType = PropTypes.exact({ type: PropTypes.oneOf(['chatThreadItem']), threadInfo: threadInfoPropType.isRequired, mostRecentMessageInfo: messageInfoPropType, lastUpdatedTime: PropTypes.number.isRequired, lastUpdatedTimeIncludingSidebars: PropTypes.number.isRequired, sidebars: PropTypes.arrayOf( PropTypes.exact({ threadInfo: threadInfoPropType.isRequired, lastUpdatedTime: PropTypes.number.isRequired, }), ).isRequired, }); const messageInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: MessageInfo } = createObjectSelector( (state: BaseAppState<*>) => state.messageStore.messages, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, - (state: BaseAppState<*>) => state.userInfos, + (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function getMostRecentMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (let messageID of thread.messageIDs) { return messages[messageID]; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, childThreads: ?(ThreadInfo[]), ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = []; let lastUpdatedTimeIncludingSidebars = lastUpdatedTime; if (childThreads) { for (const childThreadInfo of childThreads) { if (childThreadInfo.type !== threadTypes.SIDEBAR) { continue; } const sidebarLastUpdatedTime = getLastUpdatedTime( childThreadInfo, getMostRecentMessageInfo(childThreadInfo, messageStore, messages), ); if (sidebarLastUpdatedTime > lastUpdatedTimeIncludingSidebars) { lastUpdatedTimeIncludingSidebars = sidebarLastUpdatedTime; } sidebars.push({ threadInfo: childThreadInfo, lastUpdatedTime: sidebarLastUpdatedTime, }); } } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars, }; } const chatListData: ( state: BaseAppState<*>, ) => ChatThreadItem[] = createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, childThreadInfos, ( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, childThreads: { [id: string]: ThreadInfo[] }, ): ChatThreadItem[] => _flow( _filter(threadInChatList), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, childThreads[threadInfo.id], ), ), _orderBy('lastUpdatedTime')('desc'), )(threadInfos), ); // Requires UI that supports displaying sidebars inline const chatListDataWithNestedSidebars: ( state: BaseAppState<*>, ) => ChatThreadItem[] = createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, childThreadInfos, ( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, childThreads: { [id: string]: ThreadInfo[] }, ): ChatThreadItem[] => _flow( _filter(threadInChatList), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, childThreads[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos), ); export type RobotextChatMessageInfoItem = {| itemType: 'message', messageInfo: RobotextMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, robotext: string, |}; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | {| itemType: 'message', messageInfo: ComposableMessageInfo, localMessageInfo: ?LocalMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, |}; export type ChatMessageItem = {| itemType: 'loader' |} | ChatMessageInfoItem; const chatMessageItemPropType = PropTypes.oneOfType([ PropTypes.shape({ itemType: PropTypes.oneOf(['loader']).isRequired, }), PropTypes.shape({ itemType: PropTypes.oneOf(['message']).isRequired, messageInfo: messageInfoPropType.isRequired, localMessageInfo: localMessageInfoPropType, startsConversation: PropTypes.bool.isRequired, startsCluster: PropTypes.bool.isRequired, endsCluster: PropTypes.bool.isRequired, robotext: PropTypes.string, }), ]); const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; if (!thread) { return []; } const threadMessageInfos = thread.messageIDs .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const chatMessageItems = []; let lastMessageInfo = null; for (let i = threadMessageInfos.length - 1; i >= 0; i--) { const messageInfo = threadMessageInfos[i]; let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > messageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(messageInfo.type) && lastMessageInfo.creator.id === messageInfo.creator.id ) { startsCluster = false; } } if (startsCluster && chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } if (isComposableMessageType(messageInfo.type)) { // We use these invariants instead of just checking the messageInfo.type // directly in the conditional above so that isComposableMessageType can // be the source of truth invariant( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(messageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, }); } else { invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( messageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfo, startsConversation, startsCluster, endsCluster: false, robotext, }); } lastMessageInfo = messageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); if (thread.startReached) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, threadInfoSelector, ( messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] => createChatMessageItems(threadID, messageStore, messageInfos, threadInfos), ); const messageListData: ( threadID: string, ) => (state: BaseAppState<*>) => ChatMessageItem[] = _memoize( baseMessageListData, ); export { messageInfoSelector, createChatThreadItem, chatThreadItemPropType, chatListData, chatListDataWithNestedSidebars, chatMessageItemPropType, createChatMessageItems, messageListData, }; diff --git a/lib/selectors/nav-selectors.js b/lib/selectors/nav-selectors.js index 273750cf0..c807472f8 100644 --- a/lib/selectors/nav-selectors.js +++ b/lib/selectors/nav-selectors.js @@ -1,110 +1,110 @@ // @flow import type { BaseAppState } from '../types/redux-types'; import type { BaseNavInfo } from '../types/nav-types'; import type { RawThreadInfo } from '../types/thread-types'; import { type CalendarQuery, defaultCalendarQuery } from '../types/entry-types'; import type { UserInfo } from '../types/user-types'; import type { CalendarFilter } from '../types/filter-types'; import type { Platform } from '../types/device-types'; import { createSelector } from 'reselect'; import { getConfig } from '../utils/config'; import SearchIndex from '../shared/search-index'; function timeUntilCalendarRangeExpiration( lastUserInteractionCalendar: number, ): ?number { const inactivityLimit = getConfig().calendarRangeInactivityLimit; if (inactivityLimit === null || inactivityLimit === undefined) { return null; } return lastUserInteractionCalendar + inactivityLimit - Date.now(); } function calendarRangeExpired(lastUserInteractionCalendar: number): boolean { const timeUntil = timeUntilCalendarRangeExpiration( lastUserInteractionCalendar, ); if (timeUntil === null || timeUntil === undefined) { return false; } return timeUntil <= 0; } const currentCalendarQuery: ( state: BaseAppState<*>, ) => (calendarActive: boolean) => CalendarQuery = createSelector( (state: BaseAppState<*>) => state.entryStore.lastUserInteractionCalendar, (state: BaseAppState<*>) => state.navInfo, (state: BaseAppState<*>) => state.calendarFilters, ( lastUserInteractionCalendar: number, navInfo: BaseNavInfo, calendarFilters: $ReadOnlyArray, ) => { // Return a function since we depend on the time of evaluation return (calendarActive: boolean, platform: ?Platform): CalendarQuery => { if (calendarActive) { return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; } if (calendarRangeExpired(lastUserInteractionCalendar)) { return defaultCalendarQuery(platform); } return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; }; }, ); const threadSearchText = ( threadInfo: RawThreadInfo, userInfos: { [id: string]: UserInfo }, ): string => { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (let member of threadInfo.members) { const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; const threadSearchIndex: ( state: BaseAppState<*>, ) => SearchIndex = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, - (state: BaseAppState<*>) => state.userInfos, + (state: BaseAppState<*>) => state.userStore.userInfos, ( threadInfos: { [id: string]: RawThreadInfo }, userInfos: { [id: string]: UserInfo }, ) => { const searchIndex = new SearchIndex(); for (const threadID in threadInfos) { const thread = threadInfos[threadID]; searchIndex.addEntry(threadID, threadSearchText(thread, userInfos)); } return searchIndex; }, ); export { timeUntilCalendarRangeExpiration, currentCalendarQuery, threadSearchIndex, }; diff --git a/lib/selectors/socket-selectors.js b/lib/selectors/socket-selectors.js index 0361e8b9c..2fb749ebb 100644 --- a/lib/selectors/socket-selectors.js +++ b/lib/selectors/socket-selectors.js @@ -1,180 +1,180 @@ // @flow import type { AppState } from '../types/redux-types'; import { serverRequestTypes, type ServerRequest, type ClientClientResponse, } from '../types/request-types'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types'; import type { CurrentUserInfo, UserInfo } from '../types/user-types'; import type { RawThreadInfo } from '../types/thread-types'; import type { SessionState } from '../types/session-types'; import type { ClientThreadInconsistencyReportCreationRequest, ClientEntryInconsistencyReportCreationRequest, ClientReportCreationRequest, } from '../types/report-types'; import { createSelector } from 'reselect'; import { getConfig } from '../utils/config'; import { serverEntryInfo, serverEntryInfosObject, filterRawEntryInfosByCalendarQuery, } from '../shared/entry-utils'; import { values, hash } from '../utils/objects'; import { currentCalendarQuery } from './nav-selectors'; import threadWatcher from '../shared/thread-watcher'; const queuedReports: ( state: AppState, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.inconsistencyReports, (state: AppState) => state.entryStore.inconsistencyReports, (state: AppState) => state.queuedReports, ( threadInconsistencyReports: $ReadOnlyArray, entryInconsistencyReports: $ReadOnlyArray, mainQueuedReports: $ReadOnlyArray, ): $ReadOnlyArray => [ ...threadInconsistencyReports, ...entryInconsistencyReports, ...mainQueuedReports, ], ); const getClientResponsesSelector: ( state: AppState, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.entryStore.entryInfos, - (state: AppState) => state.userInfos, + (state: AppState) => state.userStore.userInfos, (state: AppState) => state.currentUserInfo, currentCalendarQuery, ( threadInfos: { [id: string]: RawThreadInfo }, entryInfos: { [id: string]: RawEntryInfo }, userInfos: { [id: string]: UserInfo }, currentUserInfo: ?CurrentUserInfo, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ): $ReadOnlyArray => { const clientResponses = []; const serverRequestedPlatformDetails = serverRequests.some( request => request.type === serverRequestTypes.PLATFORM_DETAILS, ); for (let serverRequest of serverRequests) { if ( serverRequest.type === serverRequestTypes.PLATFORM && !serverRequestedPlatformDetails ) { clientResponses.push({ type: serverRequestTypes.PLATFORM, platform: getConfig().platformDetails.platform, }); } else if (serverRequest.type === serverRequestTypes.PLATFORM_DETAILS) { clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } else if (serverRequest.type === serverRequestTypes.CHECK_STATE) { const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(entryInfos)), calendarQuery(calendarActive), ); const hashResults = {}; for (let key in serverRequest.hashesToCheck) { const expectedHashValue = serverRequest.hashesToCheck[key]; let hashValue; if (key === 'threadInfos') { hashValue = hash(threadInfos); } else if (key === 'entryInfos') { hashValue = hash(filteredEntryInfos); } else if (key === 'userInfos') { hashValue = hash(userInfos); } else if (key === 'currentUserInfo') { hashValue = hash(currentUserInfo); } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); hashValue = hash(threadInfos[threadID]); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); let rawEntryInfo = filteredEntryInfos[entryID]; if (rawEntryInfo) { rawEntryInfo = serverEntryInfo(rawEntryInfo); } hashValue = hash(rawEntryInfo); } else if (key.startsWith('userInfo|')) { const [, userID] = key.split('|'); hashValue = hash(userInfos[userID]); } else { continue; } hashResults[key] = expectedHashValue === hashValue; } const { failUnmentioned } = serverRequest; if (failUnmentioned && failUnmentioned.threadInfos) { for (let threadID in threadInfos) { const key = `threadInfo|${threadID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.entryInfos) { for (let entryID in filteredEntryInfos) { const key = `entryInfo|${entryID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.userInfos) { for (let userID in userInfos) { const key = `userInfo|${userID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } clientResponses.push({ type: serverRequestTypes.CHECK_STATE, hashResults, }); } } return clientResponses; }, ); const sessionStateFuncSelector: ( state: AppState, ) => (calendarActive: boolean) => SessionState = createSelector( (state: AppState) => state.messageStore.currentAsOf, (state: AppState) => state.updatesCurrentAsOf, currentCalendarQuery, ( messagesCurrentAsOf: number, updatesCurrentAsOf: number, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => (calendarActive: boolean): SessionState => ({ calendarQuery: calendarQuery(calendarActive), messagesCurrentAsOf, updatesCurrentAsOf, watchedIDs: threadWatcher.getWatchedIDs(), }), ); export { queuedReports, getClientResponsesSelector, sessionStateFuncSelector }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index 8a2976115..558feade7 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,266 +1,266 @@ // @flow import type { BaseAppState } from '../types/redux-types'; import { type ThreadInfo, type RawThreadInfo, type RelativeMemberInfo, threadPermissions, } from '../types/thread-types'; import type { EntryInfo } from '../types/entry-types'; import type { MessageStore } from '../types/message-types'; import { createSelector } from 'reselect'; import { createObjectSelector } from 'reselect-map'; import _flow from 'lodash/fp/flow'; import _some from 'lodash/fp/some'; import _mapValues from 'lodash/fp/mapValues'; import _map from 'lodash/fp/map'; import _compact from 'lodash/fp/compact'; import _filter from 'lodash/fp/filter'; import _sortBy from 'lodash/fp/sortBy'; import _memoize from 'lodash/memoize'; import _find from 'lodash/fp/find'; const _mapValuesWithKeys = _mapValues.convert({ cap: false }); import { dateString, dateFromString } from '../utils/date-utils'; import { values } from '../utils/objects'; import { createEntryInfo } from '../shared/entry-utils'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasPermission, } from '../shared/thread-utils'; import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors'; import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors'; type ThreadInfoSelectorType = ( state: BaseAppState<*>, ) => { [id: string]: ThreadInfo }; const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, - (state: BaseAppState<*>) => state.userInfos, + (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoFromRawThreadInfo, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = []; for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, (inputThreadIDs: ?Set, threadInfos: ThreadInfo[]) => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter(threadInfo => threadIDs.has(threadInfo.id)); }, ); const onScreenEntryEditableThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( onScreenThreadInfos, (threadInfos: ThreadInfo[]) => threadInfos.filter(threadInfo => threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES), ), ); const entryInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: EntryInfo } = createObjectSelector( (state: BaseAppState<*>) => state.entryStore.entryInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, - (state: BaseAppState<*>) => state.userInfos, + (state: BaseAppState<*>) => state.userStore.userInfos, createEntryInfo, ); // "current" means within startDate/endDate range, not deleted, and in // onScreenThreadInfos const currentDaysToEntries: ( state: BaseAppState<*>, ) => { [dayString: string]: EntryInfo[] } = createSelector( entryInfoSelector, (state: BaseAppState<*>) => state.entryStore.daysToEntries, (state: BaseAppState<*>) => state.navInfo.startDate, (state: BaseAppState<*>) => state.navInfo.endDate, onScreenThreadInfos, includeDeletedSelector, ( entryInfos: { [id: string]: EntryInfo }, daysToEntries: { [day: string]: string[] }, startDateString: string, endDateString: string, onScreen: ThreadInfo[], includeDeleted: boolean, ) => { const allDaysWithinRange = {}, startDate = dateFromString(startDateString), endDate = dateFromString(endDateString); for ( const curDate = startDate; curDate <= endDate; curDate.setDate(curDate.getDate() + 1) ) { allDaysWithinRange[dateString(curDate)] = []; } return _mapValuesWithKeys((_: string[], dayString: string) => _flow( _map((entryID: string) => entryInfos[entryID]), _compact, _filter( (entryInfo: EntryInfo) => (includeDeleted || !entryInfo.deleted) && _some(['id', entryInfo.threadID])(onScreen), ), _sortBy('creationTime'), )(daysToEntries[dayString] ? daysToEntries[dayString] : []), )(allDaysWithinRange); }, ); const childThreadInfos: ( state: BaseAppState<*>, ) => { [id: string]: ThreadInfo[] } = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = {}; for (let id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = []; } result[parentThreadID].push(threadInfo); } return result; }, ); const unreadCount: (state: BaseAppState<*>) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( threadInfo => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const unreadBackgroundCount: ( state: BaseAppState<*>, ) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( threadInfo => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseOtherUsersButNoOtherAdmins = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), (threadInfo: ?RawThreadInfo, members: RelativeMemberInfo[]): boolean => { if (!threadInfo) { return false; } if (!_find({ name: 'Admins' })(threadInfo.roles)) { return false; } let otherUsersExist = false; let otherAdminsExist = false; for (let member of members) { const role = member.role; if (role === undefined || role === null || member.isViewer) { continue; } otherUsersExist = true; if (threadInfo.roles[role].name === 'Admins') { otherAdminsExist = true; break; } } return otherUsersExist && !otherAdminsExist; }, ); const otherUsersButNoOtherAdmins: ( threadID: string, ) => (state: BaseAppState<*>) => boolean = _memoize( baseOtherUsersButNoOtherAdmins, ); function mostRecentReadThread( messageStore: MessageStore, threadInfos: { [id: string]: RawThreadInfo }, ): ?string { let mostRecent = null; for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.currentUser.unread) { continue; } const threadMessageInfo = messageStore.threads[threadID]; if (!threadMessageInfo) { continue; } const mostRecentMessageTime = threadMessageInfo.messageIDs.length === 0 ? threadInfo.creationTime : messageStore.messages[threadMessageInfo.messageIDs[0]].time; if (!mostRecent || mostRecent.time < mostRecentMessageTime) { mostRecent = { threadID, time: mostRecentMessageTime }; } } return mostRecent ? mostRecent.threadID : null; } const mostRecentReadThreadSelector: ( state: BaseAppState<*>, ) => ?string = createSelector( (state: BaseAppState<*>) => state.messageStore, (state: BaseAppState<*>) => state.threadStore.threadInfos, mostRecentReadThread, ); export { threadInfoSelector, onScreenThreadInfos, onScreenEntryEditableThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, unreadCount, unreadBackgroundCount, otherUsersButNoOtherAdmins, mostRecentReadThread, mostRecentReadThreadSelector, }; diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index 48f6fbc06..92072cdb8 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,178 +1,178 @@ // @flow import type { BaseAppState } from '../types/redux-types'; import type { UserInfo, RelativeUserInfo, AccountUserInfo, } from '../types/user-types'; import { type RawThreadInfo, type MemberInfo, type RelativeMemberInfo, threadPermissions, } from '../types/thread-types'; import { createSelector } from 'reselect'; import _memoize from 'lodash/memoize'; import _keys from 'lodash/keys'; import SearchIndex from '../shared/search-index'; import { threadActualMembers } from '../shared/thread-utils'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: string[], viewerID: ?string, userInfos: { [id: string]: UserInfo }, ): RelativeUserInfo[] { const relativeUserInfos = []; for (let userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; if (userID === viewerID) { relativeUserInfos.unshift({ id: userID, username, isViewer: true, }); } else { relativeUserInfos.push({ id: userID, username, isViewer: false, }); } } return relativeUserInfos; } // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, - (state: BaseAppState<*>) => state.userInfos, + (state: BaseAppState<*>) => state.userStore.userInfos, ( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: { [id: string]: UserInfo }, ): RelativeMemberInfo[] => { const relativeMemberInfos = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (let memberInfo of memberInfos) { const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; const canChangeRoles = memberInfo.permissions[threadPermissions.CHANGE_ROLE] && memberInfo.permissions[threadPermissions.CHANGE_ROLE].value; if ( memberInfo.id === currentUserID && (memberInfo.role || canChangeRoles) ) { relativeMemberInfos.unshift({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: true, }); } else if (memberInfo.id !== currentUserID) { relativeMemberInfos.push({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: false, }); } } return relativeMemberInfos; }, ); const relativeMemberInfoSelectorForMembersOfThread: ( threadID: string, ) => (state: BaseAppState<*>) => RelativeMemberInfo[] = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); // If threadID is null, then all users except the logged-in user are returned const baseUserInfoSelectorForOtherMembersOfThread = (threadID: ?string) => createSelector( - (state: BaseAppState<*>) => state.userInfos, + (state: BaseAppState<*>) => state.userStore.userInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => threadID && state.threadStore.threadInfos[threadID] ? state.threadStore.threadInfos[threadID].members : null, ( userInfos: { [id: string]: UserInfo }, currentUserID: ?string, members: ?$ReadOnlyArray, ): { [id: string]: AccountUserInfo } => { const others = {}; const memberUserIDs = members ? threadActualMembers(members) : _keys(userInfos); for (let memberID of memberUserIDs) { if ( memberID !== currentUserID && userInfos[memberID] && userInfos[memberID].username ) { others[memberID] = userInfos[memberID]; } } return others; }, ); const userInfoSelectorForOtherMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<*>) => { [id: string]: AccountUserInfo } = _memoize( baseUserInfoSelectorForOtherMembersOfThread, ); function searchIndexFromUserInfos(userInfos: { [id: string]: AccountUserInfo, }) { const searchIndex = new SearchIndex(); for (const id in userInfos) { searchIndex.addEntry(id, userInfos[id].username); } return searchIndex; } const baseUserSearchIndexForOtherMembersOfThread = (threadID: ?string) => createSelector( userInfoSelectorForOtherMembersOfThread(threadID), searchIndexFromUserInfos, ); const userSearchIndexForOtherMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<*>) => SearchIndex = _memoize( baseUserSearchIndexForOtherMembersOfThread, ); const isLoggedIn = (state: BaseAppState<*>) => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); export { userIDsToRelativeUserInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForOtherMembersOfThread, userSearchIndexForOtherMembersOfThread, isLoggedIn, }; diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js index 98c92beb7..c2bcf3644 100644 --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -1,812 +1,812 @@ // @flow import type { ThreadStore, ChangeThreadSettingsPayload, LeaveThreadPayload, NewThreadResult, ThreadJoinPayload, } from './thread-types'; import type { RawEntryInfo, EntryStore, CalendarQuery, SaveEntryPayload, CreateEntryPayload, DeleteEntryResponse, RestoreEntryPayload, FetchEntryInfosResult, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from './entry-types'; import type { LoadingStatus, LoadingInfo } from './loading-types'; import type { BaseNavInfo } from './nav-types'; -import type { CurrentUserInfo, UserInfo } from './user-types'; +import type { CurrentUserInfo, UserStore } from './user-types'; import type { LogOutResult, LogInStartingPayload, LogInResult, RegisterResult, } from './account-types'; import type { UserSearchResult } from '../types/search-types'; import type { MessageStore, RawTextMessageInfo, RawMultimediaMessageInfo, FetchMessageInfosPayload, SendMessagePayload, SaveMessagesPayload, MessagesResultWithUserInfos, MessageStorePrunePayload, LocallyComposedMessageInfo, } from './message-types'; import type { SetSessionPayload } from './session-types'; import type { ProcessServerRequestsPayload } from './request-types'; import type { ClearDeliveredReportsPayload, ClientReportCreationRequest, QueueReportsPayload, } from './report-types'; import type { CalendarFilter, CalendarThreadFilter, SetCalendarDeletedFilterPayload, } from './filter-types'; import type { SubscriptionUpdateResult } from './subscription-types'; import type { ConnectionInfo, StateSyncFullActionPayload, StateSyncIncrementalActionPayload, UpdateConnectionStatusPayload, SetLateResponsePayload, UpdateDisconnectedBarPayload, } from './socket-types'; import type { UpdatesResultWithUserInfos } from './update-types'; import type { ActivityUpdateSuccessPayload, QueueActivityUpdatesPayload, } from './activity-types'; import type { UpdateMultimediaMessageMediaPayload } from './media-types'; export type BaseAppState = { navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, - userInfos: { [id: string]: UserInfo }, + userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, // millisecond timestamp loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, foreground: boolean, nextLocalID: number, queuedReports: $ReadOnlyArray, dataLoaded: boolean, }; // Web JS runtime doesn't have access to the cookie for security reasons. // Native JS doesn't have a sessionID because the cookieID is used instead. // Web JS doesn't have a device token because it's not a device... export type NativeAppState = BaseAppState<*> & { sessionID?: void, deviceToken: ?string, cookie: ?string, }; export type WebAppState = BaseAppState<*> & { sessionID: ?string, deviceToken?: void, cookie?: void, }; export type AppState = NativeAppState | WebAppState; export type BaseAction = | {| type: '@@redux/INIT', payload?: void, |} | {| type: 'FETCH_ENTRIES_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_ENTRIES_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_ENTRIES_SUCCESS', payload: FetchEntryInfosResult, loadingInfo: LoadingInfo, |} | {| type: 'LOG_OUT_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'LOG_OUT_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'LOG_OUT_SUCCESS', payload: LogOutResult, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ACCOUNT_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ACCOUNT_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ACCOUNT_SUCCESS', payload: LogOutResult, loadingInfo: LoadingInfo, |} | {| type: 'CREATE_LOCAL_ENTRY', payload: RawEntryInfo, |} | {| type: 'CREATE_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CREATE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CREATE_ENTRY_SUCCESS', payload: CreateEntryPayload, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_ENTRY_SUCCESS', payload: SaveEntryPayload, loadingInfo: LoadingInfo, |} | {| type: 'CONCURRENT_MODIFICATION_RESET', payload: {| id: string, dbText: string, |}, |} | {| type: 'DELETE_ENTRY_STARTED', loadingInfo: LoadingInfo, payload: {| localID: ?string, serverID: ?string, |}, |} | {| type: 'DELETE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ENTRY_SUCCESS', payload: ?DeleteEntryResponse, loadingInfo: LoadingInfo, |} | {| type: 'LOG_IN_STARTED', loadingInfo: LoadingInfo, payload: LogInStartingPayload, |} | {| type: 'LOG_IN_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'LOG_IN_SUCCESS', payload: LogInResult, loadingInfo: LoadingInfo, |} | {| type: 'REGISTER_STARTED', loadingInfo: LoadingInfo, payload: LogInStartingPayload, |} | {| type: 'REGISTER_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'REGISTER_SUCCESS', payload: RegisterResult, loadingInfo: LoadingInfo, |} | {| type: 'RESET_PASSWORD_STARTED', payload: {| calendarQuery: CalendarQuery |}, loadingInfo: LoadingInfo, |} | {| type: 'RESET_PASSWORD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'RESET_PASSWORD_SUCCESS', payload: LogInResult, loadingInfo: LoadingInfo, |} | {| type: 'FORGOT_PASSWORD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FORGOT_PASSWORD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FORGOT_PASSWORD_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_USER_SETTINGS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_USER_SETTINGS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_USER_SETTINGS_SUCCESS', payload: {| email: string, |}, loadingInfo: LoadingInfo, |} | {| type: 'RESEND_VERIFICATION_EMAIL_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'RESEND_VERIFICATION_EMAIL_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'RESEND_VERIFICATION_EMAIL_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_SETTINGS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_SETTINGS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_SETTINGS_SUCCESS', payload: ChangeThreadSettingsPayload, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_THREAD_SUCCESS', payload: LeaveThreadPayload, loadingInfo: LoadingInfo, |} | {| type: 'NEW_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'NEW_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'NEW_THREAD_SUCCESS', payload: NewThreadResult, loadingInfo: LoadingInfo, |} | {| type: 'REMOVE_USERS_FROM_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'REMOVE_USERS_FROM_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'REMOVE_USERS_FROM_THREAD_SUCCESS', payload: ChangeThreadSettingsPayload, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', payload: ChangeThreadSettingsPayload, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', payload: {| entryID: string, text: string, deleted: boolean, |}, loadingInfo: LoadingInfo, |} | {| type: 'RESTORE_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'RESTORE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'RESTORE_ENTRY_SUCCESS', payload: RestoreEntryPayload, loadingInfo: LoadingInfo, |} | {| type: 'JOIN_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'JOIN_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'JOIN_THREAD_SUCCESS', payload: ThreadJoinPayload, loadingInfo: LoadingInfo, |} | {| type: 'LEAVE_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'LEAVE_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'LEAVE_THREAD_SUCCESS', payload: LeaveThreadPayload, loadingInfo: LoadingInfo, |} | {| type: 'SET_NEW_SESSION', payload: SetSessionPayload, |} | {| type: 'persist/REHYDRATE', payload: ?BaseAppState<*>, |} | {| type: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', payload: FetchMessageInfosPayload, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MOST_RECENT_MESSAGES_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MOST_RECENT_MESSAGES_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', payload: FetchMessageInfosPayload, loadingInfo: LoadingInfo, |} | {| type: 'SEND_TEXT_MESSAGE_STARTED', loadingInfo: LoadingInfo, payload: RawTextMessageInfo, |} | {| type: 'SEND_TEXT_MESSAGE_FAILED', error: true, payload: Error & { localID: string, threadID: string, }, loadingInfo: LoadingInfo, |} | {| type: 'SEND_TEXT_MESSAGE_SUCCESS', payload: SendMessagePayload, loadingInfo: LoadingInfo, |} | {| type: 'SEND_MULTIMEDIA_MESSAGE_STARTED', loadingInfo: LoadingInfo, payload: RawMultimediaMessageInfo, |} | {| type: 'SEND_MULTIMEDIA_MESSAGE_FAILED', error: true, payload: Error & { localID: string, threadID: string, }, loadingInfo: LoadingInfo, |} | {| type: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', payload: SendMessagePayload, loadingInfo: LoadingInfo, |} | {| type: 'SEARCH_USERS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEARCH_USERS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SEARCH_USERS_SUCCESS', payload: UserSearchResult, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_DRAFT', payload: { key: string, draft: string, }, |} | {| type: 'UPDATE_ACTIVITY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_ACTIVITY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_ACTIVITY_SUCCESS', payload: ActivityUpdateSuccessPayload, loadingInfo: LoadingInfo, |} | {| type: 'SET_DEVICE_TOKEN_STARTED', payload: string, loadingInfo: LoadingInfo, |} | {| type: 'SET_DEVICE_TOKEN_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SET_DEVICE_TOKEN_SUCCESS', payload: string, loadingInfo: LoadingInfo, |} | {| type: 'HANDLE_VERIFICATION_CODE_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'HANDLE_VERIFICATION_CODE_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'HANDLE_VERIFICATION_CODE_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORT_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORT_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORT_SUCCESS', payload?: ClearDeliveredReportsPayload, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORTS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORTS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORTS_SUCCESS', payload?: ClearDeliveredReportsPayload, loadingInfo: LoadingInfo, |} | {| type: 'QUEUE_REPORTS', payload: QueueReportsPayload, |} | {| type: 'SET_URL_PREFIX', payload: string, |} | {| type: 'SAVE_MESSAGES', payload: SaveMessagesPayload, |} | {| type: 'UPDATE_CALENDAR_THREAD_FILTER', payload: CalendarThreadFilter, |} | {| type: 'CLEAR_CALENDAR_THREAD_FILTER', payload?: void, |} | {| type: 'SET_CALENDAR_DELETED_FILTER', payload: SetCalendarDeletedFilterPayload, |} | {| type: 'UPDATE_SUBSCRIPTION_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_SUBSCRIPTION_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_SUBSCRIPTION_SUCCESS', payload: SubscriptionUpdateResult, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_CALENDAR_QUERY_STARTED', loadingInfo: LoadingInfo, payload?: CalendarQueryUpdateStartingPayload, |} | {| type: 'UPDATE_CALENDAR_QUERY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_CALENDAR_QUERY_SUCCESS', payload: CalendarQueryUpdateResult, loadingInfo: LoadingInfo, |} | {| type: 'FULL_STATE_SYNC', payload: StateSyncFullActionPayload, |} | {| type: 'INCREMENTAL_STATE_SYNC', payload: StateSyncIncrementalActionPayload, |} | {| type: 'PROCESS_SERVER_REQUESTS', payload: ProcessServerRequestsPayload, |} | {| type: 'UPDATE_CONNECTION_STATUS', payload: UpdateConnectionStatusPayload, |} | {| type: 'QUEUE_ACTIVITY_UPDATES', payload: QueueActivityUpdatesPayload, |} | {| type: 'FOREGROUND', payload?: void, |} | {| type: 'BACKGROUND', payload?: void, |} | {| type: 'UNSUPERVISED_BACKGROUND', payload?: void, |} | {| type: 'PROCESS_UPDATES', payload: UpdatesResultWithUserInfos, |} | {| type: 'PROCESS_MESSAGES', payload: MessagesResultWithUserInfos, |} | {| type: 'MESSAGE_STORE_PRUNE', payload: MessageStorePrunePayload, |} | {| type: 'SET_LATE_RESPONSE', payload: SetLateResponsePayload, |} | {| type: 'UPDATE_DISCONNECTED_BAR', payload: UpdateDisconnectedBarPayload, |} | {| type: 'REQUEST_ACCESS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'REQUEST_ACCESS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'REQUEST_ACCESS_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', payload: UpdateMultimediaMessageMediaPayload, |} | {| type: 'CREATE_LOCAL_MESSAGE', payload: LocallyComposedMessageInfo, |} | {| type: 'UPDATE_RELATIONSHIPS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_RELATIONSHIPS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_RELATIONSHIPS_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |}; export type ActionPayload = ?(Object | Array<*> | $ReadOnlyArray<*> | string); export type SuperAction = { type: string, payload?: ActionPayload, loadingInfo?: LoadingInfo, error?: boolean, }; type ThunkedAction = (dispatch: Dispatch) => void; export type PromisedAction = (dispatch: Dispatch) => Promise; export type Dispatch = ((promisedAction: PromisedAction) => Promise) & ((thunkedAction: ThunkedAction) => void) & ((action: SuperAction) => boolean); // This is lifted from redux-persist/lib/constants.js // I don't want to add redux-persist to the web/server bundles... // import { REHYDRATE } from 'redux-persist'; export const rehydrateActionType = 'persist/REHYDRATE'; diff --git a/lib/types/report-types.js b/lib/types/report-types.js index 1085e0b22..6da01d93b 100644 --- a/lib/types/report-types.js +++ b/lib/types/report-types.js @@ -1,208 +1,233 @@ // @flow import type { AppState, BaseAction } from './redux-types'; import type { UserInfo } from './user-types'; import { type PlatformDetails, platformDetailsPropType } from './device-types'; import { type RawThreadInfo, rawThreadInfoPropType } from './thread-types'; import { type RawEntryInfo, type CalendarQuery, rawEntryInfoPropType, calendarQueryPropType, } from './entry-types'; import { type MediaMission, mediaMissionPropType } from './media-types'; import invariant from 'invariant'; import PropTypes from 'prop-types'; export const reportTypes = Object.freeze({ ERROR: 0, THREAD_INCONSISTENCY: 1, ENTRY_INCONSISTENCY: 2, MEDIA_MISSION: 3, + USER_INCONSISTENCY: 4, }); type ReportType = $Values; export function assertReportType(reportType: number): ReportType { invariant( reportType === 0 || reportType === 1 || reportType === 2 || - reportType === 3, + reportType === 3 || + reportType === 4, 'number is not ReportType enum', ); return reportType; } export type ErrorInfo = { componentStack: string }; export type ErrorData = {| error: Error, info?: ErrorInfo |}; export type FlatErrorData = {| errorMessage: string, stack?: string, componentStack?: ?string, |}; export type ActionSummary = {| type: $PropertyType, time: number, summary: string, |}; export type ThreadInconsistencyReportShape = {| platformDetails: PlatformDetails, beforeAction: { [id: string]: RawThreadInfo }, action: BaseAction, pollResult?: { [id: string]: RawThreadInfo }, pushResult: { [id: string]: RawThreadInfo }, lastActionTypes?: $ReadOnlyArray<$PropertyType>, lastActions?: $ReadOnlyArray, time?: number, |}; export type EntryInconsistencyReportShape = {| platformDetails: PlatformDetails, beforeAction: { [id: string]: RawEntryInfo }, action: BaseAction, calendarQuery: CalendarQuery, pollResult?: { [id: string]: RawEntryInfo }, pushResult: { [id: string]: RawEntryInfo }, lastActionTypes?: $ReadOnlyArray<$PropertyType>, lastActions?: $ReadOnlyArray, time: number, |}; +export type UserInconsistencyReportShape = {| + platformDetails: PlatformDetails, + action: BaseAction, + beforeStateCheck: { [id: string]: UserInfo }, + afterStateCheck: { [id: string]: UserInfo }, + lastActions: $ReadOnlyArray, + time: number, +|}; type ErrorReportCreationRequest = {| type: 0, platformDetails: PlatformDetails, errors: $ReadOnlyArray, preloadedState: AppState, currentState: AppState, actions: $ReadOnlyArray, |}; export type ThreadInconsistencyReportCreationRequest = {| ...ThreadInconsistencyReportShape, type: 1, |}; export type EntryInconsistencyReportCreationRequest = {| ...EntryInconsistencyReportShape, type: 2, |}; export type MediaMissionReportCreationRequest = {| type: 3, platformDetails: PlatformDetails, time: number, // ms mediaMission: MediaMission, uploadServerID?: ?string, uploadLocalID?: ?string, mediaLocalID?: ?string, // deprecated messageServerID?: ?string, messageLocalID?: ?string, |}; +export type UserInconsistencyReportCreationRequest = {| + ...UserInconsistencyReportShape, + type: 4, +|}; export type ReportCreationRequest = | ErrorReportCreationRequest | ThreadInconsistencyReportCreationRequest | EntryInconsistencyReportCreationRequest - | MediaMissionReportCreationRequest; + | MediaMissionReportCreationRequest + | UserInconsistencyReportCreationRequest; export type ClientThreadInconsistencyReportShape = {| platformDetails: PlatformDetails, beforeAction: { [id: string]: RawThreadInfo }, action: BaseAction, pushResult: { [id: string]: RawThreadInfo }, lastActions: $ReadOnlyArray, time: number, |}; export type ClientEntryInconsistencyReportShape = {| platformDetails: PlatformDetails, beforeAction: { [id: string]: RawEntryInfo }, action: BaseAction, calendarQuery: CalendarQuery, pushResult: { [id: string]: RawEntryInfo }, lastActions: $ReadOnlyArray, time: number, |}; export type ClientThreadInconsistencyReportCreationRequest = {| ...ClientThreadInconsistencyReportShape, type: 1, |}; export type ClientEntryInconsistencyReportCreationRequest = {| ...ClientEntryInconsistencyReportShape, type: 2, |}; export type ClientReportCreationRequest = | ErrorReportCreationRequest | ClientThreadInconsistencyReportCreationRequest | ClientEntryInconsistencyReportCreationRequest - | MediaMissionReportCreationRequest; + | MediaMissionReportCreationRequest + | UserInconsistencyReportCreationRequest; export type QueueReportsPayload = {| reports: $ReadOnlyArray, |}; export type ClearDeliveredReportsPayload = {| reports: $ReadOnlyArray, |}; const actionSummaryPropType = PropTypes.shape({ type: PropTypes.string.isRequired, time: PropTypes.number.isRequired, summary: PropTypes.string.isRequired, }); export const queuedClientReportCreationRequestPropType = PropTypes.oneOfType([ PropTypes.shape({ type: PropTypes.oneOf([reportTypes.THREAD_INCONSISTENCY]).isRequired, platformDetails: platformDetailsPropType.isRequired, beforeAction: PropTypes.objectOf(rawThreadInfoPropType).isRequired, action: PropTypes.object.isRequired, pollResult: PropTypes.objectOf(rawThreadInfoPropType), pushResult: PropTypes.objectOf(rawThreadInfoPropType).isRequired, lastActions: PropTypes.arrayOf(actionSummaryPropType).isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([reportTypes.ENTRY_INCONSISTENCY]).isRequired, platformDetails: platformDetailsPropType.isRequired, beforeAction: PropTypes.objectOf(rawEntryInfoPropType).isRequired, action: PropTypes.object.isRequired, calendarQuery: calendarQueryPropType.isRequired, pollResult: PropTypes.objectOf(rawEntryInfoPropType), pushResult: PropTypes.objectOf(rawEntryInfoPropType).isRequired, lastActions: PropTypes.arrayOf(actionSummaryPropType).isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([reportTypes.MEDIA_MISSION]).isRequired, platformDetails: platformDetailsPropType.isRequired, time: PropTypes.number.isRequired, mediaMission: mediaMissionPropType.isRequired, uploadServerID: PropTypes.string, uploadLocalID: PropTypes.string, mediaLocalID: PropTypes.string, messageServerID: PropTypes.string, messageLocalID: PropTypes.string, }), + PropTypes.shape({ + type: PropTypes.oneOf([reportTypes.USER_INCONSISTENCY]).isRequired, + platformDetails: platformDetailsPropType.isRequired, + action: PropTypes.object.isRequired, + beforeStateCheck: PropTypes.objectOf(rawThreadInfoPropType).isRequired, + afterStateCheck: PropTypes.objectOf(rawThreadInfoPropType).isRequired, + lastActions: PropTypes.arrayOf(actionSummaryPropType).isRequired, + time: PropTypes.number.isRequired, + }), ]); export type ReportCreationResponse = {| id: string, |}; type ReportInfo = {| id: string, viewerID: string, platformDetails: PlatformDetails, creationTime: number, |}; export type FetchErrorReportInfosRequest = {| cursor: ?string, |}; export type FetchErrorReportInfosResponse = {| reports: $ReadOnlyArray, userInfos: $ReadOnlyArray, |}; export type ReduxToolsImport = {| preloadedState: AppState, payload: $ReadOnlyArray, |}; diff --git a/lib/types/user-types.js b/lib/types/user-types.js index b33dfe467..f6eb40fc3 100644 --- a/lib/types/user-types.js +++ b/lib/types/user-types.js @@ -1,83 +1,90 @@ // @flow +import type { UserInconsistencyReportCreationRequest } from './report-types'; + import PropTypes from 'prop-types'; export type UserInfo = {| id: string, username: ?string, |}; export type UserInfos = { [id: string]: UserInfo }; export const userInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string, }); export type AccountUserInfo = {| id: string, username: string, |} & UserInfo; export const accountUserInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, }); +export type UserStore = {| + userInfos: { [id: string]: UserInfo }, + inconsistencyReports: $ReadOnlyArray, +|}; + export type RelativeUserInfo = {| id: string, username: ?string, isViewer: boolean, |}; export const relativeUserInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string, isViewer: PropTypes.bool.isRequired, }); export type LoggedInUserInfo = {| id: string, username: string, email: string, emailVerified: boolean, |}; export type LoggedOutUserInfo = {| id: string, anonymous: true, |}; export type CurrentUserInfo = LoggedInUserInfo | LoggedOutUserInfo; export const currentUserPropType = PropTypes.oneOfType([ PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, email: PropTypes.string.isRequired, emailVerified: PropTypes.bool.isRequired, }), PropTypes.shape({ id: PropTypes.string.isRequired, anonymous: PropTypes.oneOf([true]).isRequired, }), ]); export type AccountUpdate = {| updatedFields: {| email?: ?string, password?: ?string, |}, currentPassword: string, |}; export type UserListItem = {| id: string, username: string, memberOfParentThread: boolean, |}; export const userListItemPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, memberOfParentThread: PropTypes.bool.isRequired, }); diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js index 1d4d50ddb..2cee528fa 100644 --- a/native/chat/compose-thread.react.js +++ b/native/chat/compose-thread.react.js @@ -1,530 +1,531 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import { type ThreadInfo, threadInfoPropType, type ThreadType, threadTypes, threadTypePropType, type NewThreadRequest, type NewThreadResult, } from 'lib/types/thread-types'; import { type AccountUserInfo, accountUserInfoPropType, } from 'lib/types/user-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import type { UserSearchResult } from 'lib/types/search-types'; import type { ChatNavigationProp } from './chat.react'; import type { NavigationRoute } from '../navigation/route-names'; import * as React from 'react'; import PropTypes from 'prop-types'; import { View, Text, Alert } from 'react-native'; import invariant from 'invariant'; import _flow from 'lodash/fp/flow'; import _filter from 'lodash/fp/filter'; import _sortBy from 'lodash/fp/sortBy'; import { createSelector } from 'reselect'; import { connect } from 'lib/utils/redux-utils'; import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions'; import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { userInfoSelectorForOtherMembersOfThread, userSearchIndexForOtherMembersOfThread, } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils'; import { getUserSearchResults } from 'lib/shared/search-utils'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import TagInput from '../components/tag-input.react'; import UserList from '../components/user-list.react'; import ThreadList from '../components/thread-list.react'; import LinkButton from '../components/link-button.react'; import { MessageListRouteName } from '../navigation/route-names'; import ThreadVisibility from '../components/thread-visibility.react'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; export type ComposeThreadParams = {| threadType?: ThreadType, parentThreadInfo?: ThreadInfo, |}; type Props = {| navigation: ChatNavigationProp<'ComposeThread'>, route: NavigationRoute<'ComposeThread'>, // Redux state parentThreadInfo: ?ThreadInfo, loadingStatus: LoadingStatus, otherUserInfos: { [id: string]: AccountUserInfo }, userSearchIndex: SearchIndex, threadInfos: { [id: string]: ThreadInfo }, colors: Colors, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs newThread: (request: NewThreadRequest) => Promise, searchUsers: (usernamePrefix: string) => Promise, |}; type State = {| usernameInputText: string, userInfoInputArray: $ReadOnlyArray, |}; type PropsAndState = {| ...Props, ...State |}; class ComposeThread extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ setParams: PropTypes.func.isRequired, setOptions: PropTypes.func.isRequired, navigate: PropTypes.func.isRequired, pushNewThread: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ key: PropTypes.string.isRequired, params: PropTypes.shape({ threadType: threadTypePropType, parentThreadInfo: threadInfoPropType, }).isRequired, }).isRequired, parentThreadInfo: threadInfoPropType, loadingStatus: loadingStatusPropType.isRequired, otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired, userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired, threadInfos: PropTypes.objectOf(threadInfoPropType).isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, newThread: PropTypes.func.isRequired, searchUsers: PropTypes.func.isRequired, }; state = { usernameInputText: '', userInfoInputArray: [], }; tagInput: ?TagInput; createThreadPressed = false; waitingOnThreadID: ?string; constructor(props: Props) { super(props); this.setLinkButton(true); } setLinkButton(enabled: boolean) { this.props.navigation.setOptions({ headerRight: () => ( ), }); } componentDidMount() { this.searchUsers(''); } componentDidUpdate(prevProps: Props) { const oldReduxParentThreadInfo = prevProps.parentThreadInfo; const newReduxParentThreadInfo = this.props.parentThreadInfo; if ( newReduxParentThreadInfo && newReduxParentThreadInfo !== oldReduxParentThreadInfo ) { this.props.navigation.setParams({ parentThreadInfo: newReduxParentThreadInfo, }); } if ( this.waitingOnThreadID && this.props.threadInfos[this.waitingOnThreadID] && !prevProps.threadInfos[this.waitingOnThreadID] ) { const threadInfo = this.props.threadInfos[this.waitingOnThreadID]; this.props.navigation.pushNewThread(threadInfo); } } static getParentThreadInfo(props: { route: NavigationRoute<'ComposeThread'>, }): ?ThreadInfo { return props.route.params.parentThreadInfo; } userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, (propsAndState: PropsAndState) => propsAndState.otherUserInfos, (propsAndState: PropsAndState) => propsAndState.userSearchIndex, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, ) => getUserSearchResults( text, userInfos, searchIndex, userInfoInputArray.map(userInfo => userInfo.id), parentThreadInfo, ), ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } existingThreadsSelector = createSelector( (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.threadInfos, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, ( parentThreadInfo: ?ThreadInfo, threadInfos: { [id: string]: ThreadInfo }, userInfoInputArray: $ReadOnlyArray, ) => { const userIDs = userInfoInputArray.map(userInfo => userInfo.id); if (userIDs.length === 0) { return []; } return _flow( _filter( (threadInfo: ThreadInfo) => threadInFilterList(threadInfo) && (!parentThreadInfo || threadInfo.parentThreadID === parentThreadInfo.id) && userIDs.every(userID => userIsMember(threadInfo, userID)), ), _sortBy( ([ 'members.length', (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), ]: $ReadOnlyArray mixed)>), ), )(threadInfos); }, ); get existingThreads() { return this.existingThreadsSelector({ ...this.props, ...this.state }); } render() { let existingThreadsSection = null; const { existingThreads, userSearchResults } = this; if (existingThreads.length > 0) { existingThreadsSection = ( Existing threads ); } let parentThreadRow = null; const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); if (parentThreadInfo) { const threadType = this.props.route.params.threadType; invariant( threadType !== undefined && threadType !== null, `no threadType provided for ${parentThreadInfo.id}`, ); const threadVisibilityColor = this.props.colors.modalForegroundLabel; parentThreadRow = ( within {parentThreadInfo.uiName} ); } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressCreateThread, }; return ( {parentThreadRow} To: {existingThreadsSection} ); } tagInputRef = (tagInput: ?TagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { this.searchUsers(text); this.setState({ usernameInputText: text }); }; searchUsers(usernamePrefix: string) { this.props.dispatchActionPromise( searchUsersActionTypes, this.props.searchUsers(usernamePrefix), ); } onUserSelect = (userID: string) => { for (let existingUserInfo of this.state.userInfoInputArray) { if (userID === existingUserInfo.id) { return; } } const userInfoInputArray = [ ...this.state.userInfoInputArray, this.props.otherUserInfos[userID], ]; this.setState({ userInfoInputArray, usernameInputText: '', }); }; onPressCreateThread = () => { if (this.createThreadPressed) { return; } if (this.state.userInfoInputArray.length === 0) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a thread containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: this.dispatchNewChatThreadAction }, ], ); } else { this.dispatchNewChatThreadAction(); } }; dispatchNewChatThreadAction = async () => { this.createThreadPressed = true; this.props.dispatchActionPromise( newThreadActionTypes, this.newChatThreadAction(), ); }; async newChatThreadAction() { this.setLinkButton(false); try { const threadTypeParam = this.props.route.params.threadType; const threadType = threadTypeParam ? threadTypeParam : threadTypes.CHAT_SECRET; const initialMemberIDs = this.state.userInfoInputArray.map( (userInfo: AccountUserInfo) => userInfo.id, ); const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); const result = await this.props.newThread({ type: threadType, parentThreadID: parentThreadInfo ? parentThreadInfo.id : null, initialMemberIDs, color: parentThreadInfo ? parentThreadInfo.color : null, }); this.waitingOnThreadID = result.newThreadID; return result; } catch (e) { this.createThreadPressed = false; this.setLinkButton(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'tagInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState({ usernameInputText: '' }, this.onErrorAcknowledged); }; onSelectExistingThread = (threadID: string) => { const threadInfo = this.props.threadInfos[threadID]; this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; } const styles = { container: { flex: 1, }, existingThreadList: { backgroundColor: 'modalBackground', flex: 1, paddingRight: 12, }, existingThreads: { flex: 1, }, existingThreadsLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, textAlign: 'center', }, existingThreadsRow: { backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', borderTopWidth: 1, paddingVertical: 6, }, listItem: { color: 'modalForegroundLabel', }, parentThreadLabel: { color: 'modalSubtextLabel', fontSize: 16, paddingLeft: 6, }, parentThreadName: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 6, }, parentThreadRow: { alignItems: 'center', backgroundColor: 'modalSubtext', flexDirection: 'row', paddingLeft: 12, paddingVertical: 6, }, tagInputContainer: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, userList: { backgroundColor: 'modalBackground', flex: 1, paddingLeft: 35, paddingRight: 12, }, userSelectionRow: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); registerFetchKey(searchUsersActionTypes); export default connect( ( state: AppState, ownProps: { route: NavigationRoute<'ComposeThread'>, }, ) => { let reduxParentThreadInfo = null; const parentThreadInfo = ownProps.route.params.parentThreadInfo; if (parentThreadInfo) { reduxParentThreadInfo = threadInfoSelector(state)[parentThreadInfo.id]; } return { parentThreadInfo: reduxParentThreadInfo, loadingStatus: loadingStatusSelector(state), otherUserInfos: userInfoSelectorForOtherMembersOfThread((null: ?string))( state, ), userSearchIndex: userSearchIndexForOtherMembersOfThread(null)(state), threadInfos: threadInfoSelector(state), colors: colorsSelector(state), styles: stylesSelector(state), + viewerID: state.currentUserInfo && state.currentUserInfo.id, }; }, { newThread, searchUsers }, )(ComposeThread); diff --git a/native/redux/persist.js b/native/redux/persist.js index a313478ae..f3235b97e 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,192 +1,200 @@ // @flow import type { AppState } from './redux-setup'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import { defaultConnectionInfo } from 'lib/types/socket-types'; import { messageTypes } from 'lib/types/message-types'; import { defaultGlobalThemeInfo } from '../types/themes'; import { defaultDeviceCameraInfo } from '../types/camera'; import { createMigrate } from 'redux-persist'; import invariant from 'invariant'; import { Platform } from 'react-native'; import AsyncStorage from '@react-native-community/async-storage'; import Orientation from 'react-native-orientation-locker'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors'; import { unshimMessageStore } from 'lib/shared/unshim-utils'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils'; import { defaultNotifPermissionAlertInfo } from '../push/alerts'; const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: state => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, drafts: state.drafts, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, threadIDsToNotifIDs: state.threadIDsToNotifIDs, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: state => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: state => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], foreground: true, entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: (state: AppState) => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: (state: AppState) => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [messageTypes.IMAGES]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.MULTIMEDIA, ]), }), [15]: state => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: state => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, + [17]: state => ({ + ...state, + userInfos: undefined, + userStore: { + userInfos: state.userInfos, + inconsistencyResponses: [], + }, + }), }; const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: [ 'loadingStatuses', 'foreground', 'dimensions', 'connectivity', 'deviceOrientation', 'frozen', ], debug: __DEV__, - version: 16, + version: 17, migrate: createMigrate(migrations, { debug: __DEV__ }), timeout: __DEV__ ? 0 : undefined, }; const codeVersion = 64; // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor() { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index 4fddc1a65..1d5b3c0f1 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,414 +1,417 @@ // @flow import type { ThreadStore } from 'lib/types/thread-types'; import { type EntryStore } from 'lib/types/entry-types'; import type { LoadingStatus } from 'lib/types/loading-types'; -import type { CurrentUserInfo, UserInfo } from 'lib/types/user-types'; +import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import type { MessageStore } from 'lib/types/message-types'; import type { PersistState } from 'redux-persist/src/types'; import { type NotifPermissionAlertInfo, defaultNotifPermissionAlertInfo, } from '../push/alerts'; import { type CalendarFilter, defaultCalendarFilters, } from 'lib/types/filter-types'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { updateTypes } from 'lib/types/update-types'; import { setDeviceTokenActionTypes } from 'lib/actions/device-actions'; import { type ConnectionInfo, defaultConnectionInfo, incrementalStateSyncActionType, } from 'lib/types/socket-types'; import { type ConnectivityInfo, defaultConnectivityInfo, } from '../types/connectivity'; import type { Dispatch } from 'lib/types/redux-types'; import { type GlobalThemeInfo, defaultGlobalThemeInfo } from '../types/themes'; import { type DeviceCameraInfo, defaultDeviceCameraInfo, } from '../types/camera'; import type { Orientations } from 'react-native-orientation-locker'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import type { SetSessionPayload } from 'lib/types/session-types'; import thunk from 'redux-thunk'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import baseReducer from 'lib/reducers/master-reducer'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger'; import { invalidSessionDowngrade } from 'lib/shared/account-utils'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { resetUserStateActionType, recordNotifPermissionAlertActionType, recordAndroidNotificationActionType, clearAndroidNotificationsActionType, rescindAndroidNotificationActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, } from './action-types'; import { type NavInfo, defaultNavInfo } from '../navigation/default-state'; import { reduceThreadIDsToNotifIDs } from '../push/reducer'; import { persistConfig, setPersistor } from './persist'; import { defaultURLPrefix, natServer, setCustomServer, } from '../utils/url-utils'; import reactotron from '../reactotron'; import reduceDrafts from '../reducers/draft-reducer'; import { getGlobalNavContext } from '../navigation/icky-global'; import { defaultDimensionsInfo, type DimensionsInfo, } from './dimensions-updater.react'; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, - userInfos: { [id: string]: UserInfo }, + userStore: UserStore, messageStore: MessageStore, drafts: { [key: string]: string }, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, cookie: ?string, deviceToken: ?string, dataLoaded: boolean, urlPrefix: string, customServer: ?string, threadIDsToNotifIDs: { [threadID: string]: string[] }, notifPermissionAlertInfo: NotifPermissionAlertInfo, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, foreground: boolean, nextLocalID: number, queuedReports: $ReadOnlyArray, _persist: ?PersistState, sessionID?: void, dimensions: DimensionsInfo, connectivity: ConnectivityInfo, globalThemeInfo: GlobalThemeInfo, deviceCameraInfo: DeviceCameraInfo, deviceOrientation: Orientations, frozen: boolean, |}; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, inconsistencyReports: [], }, threadStore: { threadInfos: {}, inconsistencyReports: [], }, - userInfos: {}, + userStore: { + userInfos: {}, + inconsistencyReports: [], + }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, drafts: {}, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, cookie: null, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix(), customServer: natServer, threadIDsToNotifIDs: {}, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], foreground: true, nextLocalID: 0, queuedReports: [], _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, }: AppState); function reducer(state: AppState = defaultState, action: *) { if (action.type === setReduxStateActionType) { return action.state; } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return state; } if ( action.type === recordAndroidNotificationActionType || action.type === clearAndroidNotificationsActionType || action.type === rescindAndroidNotificationActionType ) { return { ...state, threadIDsToNotifIDs: reduceThreadIDsToNotifIDs( state.threadIDsToNotifIDs, action, ), }; } else if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === recordNotifPermissionAlertActionType) { return { ...state, notifPermissionAlertInfo: { totalAlerts: state.notifPermissionAlertInfo.totalAlerts + 1, lastAlertTime: action.payload.time, }, }; } else if (action.type === resetUserStateActionType) { const cookie = state.cookie && state.cookie.startsWith('anonymous=') ? state.cookie : null; const currentUserInfo = state.currentUserInfo && state.currentUserInfo.anonymous ? state.currentUserInfo : null; return { ...state, currentUserInfo, cookie, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { return { ...state, globalThemeInfo: { ...state.globalThemeInfo, ...action.payload, }, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setDeviceTokenActionTypes.started) { return { ...state, deviceToken: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }, }, }; } } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } else if (action.type === incrementalStateSyncActionType) { let wipeDeviceToken = false; for (let update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.deviceToken ) { wipeDeviceToken = true; break; } } if (wipeDeviceToken) { state = { ...state, deviceToken: null, }; } } state = { ...baseReducer(state, action), drafts: reduceDrafts(state.drafts, action), }; return fixUnreadActiveThread(state, action); } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how to " + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], ); } else { Alert.alert( 'Session invalidated', "We're sorry, but your session was invalidated by the server. " + 'Please log in again.', [{ text: 'OK' }], ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. function fixUnreadActiveThread(state: AppState, action: *): AppState { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( activeThread && (NativeAppState.currentState === 'active' || (appLastBecameInactive + 10000 < Date.now() && !backgroundActionTypes.has(action.type))) && state.threadStore.threadInfos[activeThread] && state.threadStore.threadInfos[activeThread].currentUser.unread ) { state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } return state; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middleware = applyMiddleware(thunk, reduxLoggerMiddleware); const composeFunc = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux' }) : compose; let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; export { store, dispatch, appBecameInactive }; diff --git a/server/src/creators/report-creator.js b/server/src/creators/report-creator.js index d49226982..f573840b8 100644 --- a/server/src/creators/report-creator.js +++ b/server/src/creators/report-creator.js @@ -1,214 +1,233 @@ // @flow import type { Viewer } from '../session/viewer'; import { type ReportCreationRequest, type ReportCreationResponse, type ThreadInconsistencyReportCreationRequest, type EntryInconsistencyReportCreationRequest, + type UserInconsistencyReportCreationRequest, reportTypes, } from 'lib/types/report-types'; import { messageTypes } from 'lib/types/message-types'; import bots from 'lib/facts/bots'; import _isEqual from 'lodash/fp/isEqual'; import { filterRawEntryInfosByCalendarQuery, serverEntryInfosObject, } from 'lib/shared/entry-utils'; import { sanitizeAction, sanitizeState } from 'lib/utils/sanitization'; import { values } from 'lib/utils/objects'; import { dbQuery, SQL } from '../database'; import createIDs from './id-creator'; import { fetchUsername } from '../fetchers/user-fetchers'; import urlFacts from '../../facts/url'; import createMessages from './message-creator'; import { handleAsyncPromise } from '../responders/handlers'; import { createBotViewer } from '../session/bots'; const { baseDomain, basePath } = urlFacts; const { squadbot } = bots; async function createReport( viewer: Viewer, request: ReportCreationRequest, ): Promise { const shouldIgnore = await ignoreReport(viewer, request); if (shouldIgnore) { return null; } const [id] = await createIDs('reports', 1); let type, report, time; if (request.type === reportTypes.THREAD_INCONSISTENCY) { ({ type, time, ...report } = request); time = time ? time : Date.now(); } else if (request.type === reportTypes.ENTRY_INCONSISTENCY) { ({ type, time, ...report } = request); } else if (request.type === reportTypes.MEDIA_MISSION) { ({ type, time, ...report } = request); + } else if (request.type === reportTypes.USER_INCONSISTENCY) { + ({ type, time, ...report } = request); } else { ({ type, ...report } = request); time = Date.now(); report = { ...report, preloadedState: sanitizeState(report.preloadedState), currentState: sanitizeState(report.currentState), actions: report.actions.map(sanitizeAction), }; } const row = [ id, viewer.id, type, request.platformDetails.platform, JSON.stringify(report), time, ]; const query = SQL` INSERT INTO reports (id, user, type, platform, report, creation_time) VALUES ${[row]} `; await dbQuery(query); handleAsyncPromise(sendSquadbotMessage(viewer, request, id)); return { id }; } async function sendSquadbotMessage( viewer: Viewer, request: ReportCreationRequest, reportID: string, ): Promise { const canGenerateMessage = getSquadbotMessage(request, reportID, null); if (!canGenerateMessage) { return; } const username = await fetchUsername(viewer.id); const message = getSquadbotMessage(request, reportID, username); if (!message) { return; } const time = Date.now(); await createMessages(createBotViewer(squadbot.userID), [ { type: messageTypes.TEXT, threadID: squadbot.staffThreadID, creatorID: squadbot.userID, time, text: message, }, ]); } async function ignoreReport( viewer: Viewer, request: ReportCreationRequest, ): Promise { // The below logic is to avoid duplicate inconsistency reports if ( request.type !== reportTypes.THREAD_INCONSISTENCY && request.type !== reportTypes.ENTRY_INCONSISTENCY ) { return false; } const { type, platformDetails, time } = request; if (!time) { return false; } const { platform } = platformDetails; const query = SQL` SELECT id FROM reports WHERE user = ${viewer.id} AND type = ${type} AND platform = ${platform} AND creation_time = ${time} `; const [result] = await dbQuery(query); return result.length !== 0; } function getSquadbotMessage( request: ReportCreationRequest, reportID: string, username: ?string, ): ?string { const name = username ? username : '[null]'; const { platformDetails } = request; const { platform, codeVersion } = platformDetails; const platformString = codeVersion ? `${platform} v${codeVersion}` : platform; if (request.type === reportTypes.ERROR) { return ( `${name} got an error :(\n` + `using ${platformString}\n` + `${baseDomain}${basePath}download_error_report/${reportID}` ); } else if (request.type === reportTypes.THREAD_INCONSISTENCY) { const nonMatchingThreadIDs = getInconsistentThreadIDsFromReport(request); const nonMatchingString = [...nonMatchingThreadIDs].join(', '); return ( `system detected inconsistency for ${name}!\n` + `using ${platformString}\n` + `occurred during ${request.action.type}\n` + `thread IDs that are inconsistent: ${nonMatchingString}` ); } else if (request.type === reportTypes.ENTRY_INCONSISTENCY) { const nonMatchingEntryIDs = getInconsistentEntryIDsFromReport(request); const nonMatchingString = [...nonMatchingEntryIDs].join(', '); return ( `system detected inconsistency for ${name}!\n` + `using ${platformString}\n` + `occurred during ${request.action.type}\n` + `entry IDs that are inconsistent: ${nonMatchingString}` ); + } else if (request.type === reportTypes.USER_INCONSISTENCY) { + const nonMatchingUserIDs = getInconsistentUserIDsFromReport(request); + const nonMatchingString = [...nonMatchingUserIDs].join(', '); + return ( + `system detected inconsistency for ${name}!\n` + + `using ${platformString}\n` + + `occurred during ${request.action.type}\n` + + `user IDs that are inconsistent: ${nonMatchingString}` + ); } else if (request.type === reportTypes.MEDIA_MISSION) { const mediaMissionJSON = JSON.stringify(request.mediaMission); const success = request.mediaMission.result.success ? 'media mission success!' : 'media mission failed :('; return `${name} ${success}\n` + mediaMissionJSON; } else { return null; } } function findInconsistentObjectKeys( first: { [id: string]: Object }, second: { [id: string]: Object }, ): Set { const nonMatchingIDs = new Set(); for (let id in first) { if (!_isEqual(first[id])(second[id])) { nonMatchingIDs.add(id); } } for (let id in second) { if (!first[id]) { nonMatchingIDs.add(id); } } return nonMatchingIDs; } function getInconsistentThreadIDsFromReport( request: ThreadInconsistencyReportCreationRequest, ): Set { const { pushResult, beforeAction } = request; return findInconsistentObjectKeys(beforeAction, pushResult); } function getInconsistentEntryIDsFromReport( request: EntryInconsistencyReportCreationRequest, ): Set { const { pushResult, beforeAction, calendarQuery } = request; const filteredBeforeAction = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(beforeAction)), calendarQuery, ); const filteredAfterAction = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(pushResult)), calendarQuery, ); return findInconsistentObjectKeys(filteredBeforeAction, filteredAfterAction); } +function getInconsistentUserIDsFromReport( + request: UserInconsistencyReportCreationRequest, +): Set { + const { beforeStateCheck, afterStateCheck } = request; + return findInconsistentObjectKeys(beforeStateCheck, afterStateCheck); +} + export default createReport; diff --git a/server/src/responders/report-responders.js b/server/src/responders/report-responders.js index 3ffbd382c..491b19e3d 100644 --- a/server/src/responders/report-responders.js +++ b/server/src/responders/report-responders.js @@ -1,221 +1,239 @@ // @flow import { type ReportCreationResponse, type ReportCreationRequest, type FetchErrorReportInfosResponse, type FetchErrorReportInfosRequest, reportTypes, } from 'lib/types/report-types'; import type { Viewer } from '../session/viewer'; import type { $Response, $Request } from 'express'; import t from 'tcomb'; import { ServerError } from 'lib/utils/errors'; import { validateInput, tShape, tPlatform, tPlatformDetails, } from '../utils/validation-utils'; import createReport from '../creators/report-creator'; import { fetchErrorReportInfos, fetchReduxToolsImport, } from '../fetchers/report-fetchers'; import { newEntryQueryInputValidator } from './entry-responders'; const tActionSummary = tShape({ type: t.String, time: t.Number, summary: t.String, }); const threadInconsistencyReportValidatorShape = { platformDetails: tPlatformDetails, beforeAction: t.Object, action: t.Object, pollResult: t.maybe(t.Object), pushResult: t.Object, lastActionTypes: t.maybe(t.list(t.String)), lastActions: t.maybe(t.list(tActionSummary)), time: t.maybe(t.Number), }; const entryInconsistencyReportValidatorShape = { platformDetails: tPlatformDetails, beforeAction: t.Object, action: t.Object, calendarQuery: newEntryQueryInputValidator, pollResult: t.maybe(t.Object), pushResult: t.Object, lastActionTypes: t.maybe(t.list(t.String)), lastActions: t.maybe(t.list(tActionSummary)), time: t.Number, }; +const userInconsistencyReportValidatorShape = { + platformDetails: tPlatformDetails, + action: t.Object, + beforeStateCheck: t.Object, + afterStateCheck: t.Object, + lastActions: t.list(tActionSummary), + time: t.Number, +}; const threadInconsistencyReportCreationRequest = tShape({ ...threadInconsistencyReportValidatorShape, type: t.irreducible( 'reportTypes.THREAD_INCONSISTENCY', x => x === reportTypes.THREAD_INCONSISTENCY, ), }); const entryInconsistencyReportCreationRquest = tShape({ ...entryInconsistencyReportValidatorShape, type: t.irreducible( 'reportTypes.ENTRY_INCONSISTENCY', x => x === reportTypes.ENTRY_INCONSISTENCY, ), }); const mediaMissionReportCreationRequest = tShape({ type: t.irreducible( 'reportTypes.MEDIA_MISSION', x => x === reportTypes.MEDIA_MISSION, ), platformDetails: tPlatformDetails, time: t.Number, mediaMission: t.Object, uploadServerID: t.maybe(t.String), uploadLocalID: t.maybe(t.String), mediaLocalID: t.maybe(t.String), messageServerID: t.maybe(t.String), messageLocalID: t.maybe(t.String), }); +const userInconsistencyReportCreationRequest = tShape({ + ...userInconsistencyReportValidatorShape, + type: t.irreducible( + 'reportTypes.USER_INCONSISTENCY', + x => x === reportTypes.USER_INCONSISTENCY, + ), +}); + const reportCreationRequestInputValidator = t.union([ tShape({ type: t.maybe( t.irreducible('reportTypes.ERROR', x => x === reportTypes.ERROR), ), platformDetails: t.maybe(tPlatformDetails), deviceType: t.maybe(tPlatform), codeVersion: t.maybe(t.Number), stateVersion: t.maybe(t.Number), errors: t.list( tShape({ errorMessage: t.String, stack: t.maybe(t.String), componentStack: t.maybe(t.String), }), ), preloadedState: t.Object, currentState: t.Object, actions: t.list(t.union([t.Object, t.String])), }), threadInconsistencyReportCreationRequest, entryInconsistencyReportCreationRquest, mediaMissionReportCreationRequest, + userInconsistencyReportCreationRequest, ]); async function reportCreationResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, reportCreationRequestInputValidator, input); if (input.type === null || input.type === undefined) { input.type = reportTypes.ERROR; } if (!input.platformDetails && input.deviceType) { const { deviceType, codeVersion, stateVersion, ...rest } = input; input = { ...rest, platformDetails: { platform: deviceType, codeVersion, stateVersion }, }; } const request: ReportCreationRequest = input; const response = await createReport(viewer, request); if (!response) { throw new ServerError('ignored_report'); } return response; } const reportMultiCreationRequestInputValidator = tShape({ reports: t.list( t.union([ tShape({ type: t.irreducible('reportTypes.ERROR', x => x === reportTypes.ERROR), platformDetails: tPlatformDetails, errors: t.list( tShape({ errorMessage: t.String, stack: t.maybe(t.String), componentStack: t.maybe(t.String), }), ), preloadedState: t.Object, currentState: t.Object, actions: t.list(t.union([t.Object, t.String])), }), threadInconsistencyReportCreationRequest, entryInconsistencyReportCreationRquest, mediaMissionReportCreationRequest, + userInconsistencyReportCreationRequest, ]), ), }); type ReportMultiCreationRequest = {| reports: $ReadOnlyArray, |}; async function reportMultiCreationResponder( viewer: Viewer, input: any, ): Promise { const request: ReportMultiCreationRequest = input; await validateInput( viewer, reportMultiCreationRequestInputValidator, request, ); await Promise.all( request.reports.map(reportCreationRequest => createReport(viewer, reportCreationRequest), ), ); } const fetchErrorReportInfosRequestInputValidator = tShape({ cursor: t.maybe(t.String), }); async function errorReportFetchInfosResponder( viewer: Viewer, input: any, ): Promise { const request: FetchErrorReportInfosRequest = input; await validateInput( viewer, fetchErrorReportInfosRequestInputValidator, request, ); return await fetchErrorReportInfos(viewer, request); } async function errorReportDownloadResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { const id = req.params.reportID; if (!id) { throw new ServerError('invalid_parameters'); } const result = await fetchReduxToolsImport(viewer, id); res.set('Content-Disposition', `attachment; filename=report-${id}.json`); res.json({ preloadedState: JSON.stringify(result.preloadedState), payload: JSON.stringify(result.payload), }); } export { threadInconsistencyReportValidatorShape, entryInconsistencyReportValidatorShape, reportCreationResponder, reportMultiCreationResponder, errorReportFetchInfosResponder, errorReportDownloadResponder, }; diff --git a/server/src/responders/website-responders.js b/server/src/responders/website-responders.js index 2ac48fffe..5b897e2a3 100644 --- a/server/src/responders/website-responders.js +++ b/server/src/responders/website-responders.js @@ -1,358 +1,349 @@ // @flow import type { $Response, $Request } from 'express'; import type { AppState, Action } from 'web/redux/redux-setup'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import { threadPermissions } from 'lib/types/thread-types'; import { defaultConnectionInfo } from 'lib/types/socket-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import html from 'common-tags/lib/html'; import { createStore, type Store } from 'redux'; import ReactDOMServer from 'react-dom/server'; import ReactRedux from 'react-redux'; import ReactRouter from 'react-router'; import React from 'react'; import _keyBy from 'lodash/fp/keyBy'; import fs from 'fs'; import { promisify } from 'util'; import { ServerError } from 'lib/utils/errors'; import { currentDateInTimeZone } from 'lib/utils/date-utils'; import { defaultNumberPerThread } from 'lib/types/message-types'; import { daysToEntriesFromEntryInfos } from 'lib/reducers/entry-reducer'; import { freshMessageStore } from 'lib/reducers/message-reducer'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils'; import { mostRecentReadThread } from 'lib/selectors/thread-selectors'; import { threadHasPermission } from 'lib/shared/thread-utils'; import { promiseAll } from 'lib/utils/promises'; import 'web/server-rendering'; import * as ReduxSetup from 'web/redux/redux-setup'; import App from 'web/dist/app.build.cjs'; import { navInfoFromURL } from 'web/url-utils'; import { activeThreadFromNavInfo } from 'web/selectors/nav-selectors'; import { Viewer } from '../session/viewer'; import { handleCodeVerificationRequest } from '../models/verification'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; -import { fetchCurrentUserInfo } from '../fetchers/user-fetchers'; +import { + fetchCurrentUserInfo, + fetchKnownUserInfos, +} from '../fetchers/user-fetchers'; import { setNewSession } from '../session/cookies'; import { activityUpdater } from '../updaters/activity-updaters'; import urlFacts from '../../facts/url'; import { streamJSON, waitForStream } from '../utils/json-stream'; import { handleAsyncPromise } from '../responders/handlers'; const { basePath, baseDomain } = urlFacts; const { renderToNodeStream } = ReactDOMServer; const { Provider } = ReactRedux; const { reducer } = ReduxSetup; const { Route, StaticRouter } = ReactRouter; const baseURL = basePath.replace(/\/$/, ''); const baseHref = baseDomain + baseURL; const access = promisify(fs.access); const googleFontsURL = 'https://fonts.googleapis.com/css?family=Open+Sans:300,600%7CAnaheim'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } type AssetInfo = {| jsURL: string, fontsURL: string, cssInclude: string |}; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'dev') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', }; return assetInfo; } // $FlowFixMe compiled/assets.json doesn't always exist const { default: assets } = await import('../../compiled/assets'); assetInfo = { jsURL: `compiled/${assets.browser.js}`, fontsURL: googleFontsURL, cssInclude: html` `, }; return assetInfo; } async function websiteResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { let initialNavInfo; try { initialNavInfo = navInfoFromURL(req.url, { now: currentDateInTimeZone(viewer.timeZone), }); } catch (e) { throw new ServerError(e.message); } const calendarQuery = { startDate: initialNavInfo.startDate, endDate: initialNavInfo.endDate, filters: defaultCalendarFilters, }; const threadSelectionCriteria = { joinedThreads: true }; const initialTime = Date.now(); const assetInfoPromise = getAssetInfo(); const threadInfoPromise = fetchThreadInfos(viewer); const messageInfoPromise = fetchMessageInfos( viewer, threadSelectionCriteria, defaultNumberPerThread, ); const entryInfoPromise = fetchEntryInfos(viewer, [calendarQuery]); const currentUserInfoPromise = fetchCurrentUserInfo(viewer); const serverVerificationResultPromise = handleVerificationRequest( viewer, initialNavInfo.verify, ); + const userInfoPromise = fetchKnownUserInfos(viewer); const sessionIDPromise = (async () => { if (viewer.loggedIn) { await setNewSession(viewer, calendarQuery, initialTime); } return viewer.sessionID; })(); const threadStorePromise = (async () => { const { threadInfos } = await threadInfoPromise; return { threadInfos, inconsistencyReports: [] }; })(); const messageStorePromise = (async () => { const [ { threadInfos }, { rawMessageInfos, truncationStatuses }, ] = await Promise.all([threadInfoPromise, messageInfoPromise]); return freshMessageStore( rawMessageInfos, truncationStatuses, mostRecentMessageTimestamp(rawMessageInfos, initialTime), threadInfos, ); })(); const entryStorePromise = (async () => { const { rawEntryInfos } = await entryInfoPromise; return { entryInfos: _keyBy('id')(rawEntryInfos), daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos), lastUserInteractionCalendar: initialTime, inconsistencyReports: [], }; })(); - const userInfoPromise = (async () => { - const [ - { userInfos: threadUserInfos }, - { userInfos: messageUserInfos }, - { userInfos: entryUserInfos }, - ] = await Promise.all([ - threadInfoPromise, - messageInfoPromise, - entryInfoPromise, - ]); - // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 - return { - ...messageUserInfos, - ...entryUserInfos, - ...threadUserInfos, - }; + const userStorePromise = (async () => { + const userInfos = await userInfoPromise; + return { userInfos, inconsistencyReports: [] }; })(); const navInfoPromise = (async () => { const [{ threadInfos }, messageStore] = await Promise.all([ threadInfoPromise, messageStorePromise, ]); let finalNavInfo = initialNavInfo; const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID; if ( requestedActiveChatThreadID && !threadHasPermission( threadInfos[requestedActiveChatThreadID], threadPermissions.VISIBLE, ) ) { finalNavInfo.activeChatThreadID = null; } if (!finalNavInfo.activeChatThreadID) { const mostRecentThread = mostRecentReadThread(messageStore, threadInfos); if (mostRecentThread) { finalNavInfo.activeChatThreadID = mostRecentThread; } } return finalNavInfo; })(); const updateActivityPromise = (async () => { const [navInfo] = await Promise.all([navInfoPromise, sessionIDPromise]); const activeThread = activeThreadFromNavInfo(navInfo); if (activeThread) { await activityUpdater(viewer, { updates: [{ focus: true, threadID: activeThread }], }); } })(); const { jsURL, fontsURL, cssInclude } = await assetInfoPromise; // prettier-ignore res.write(html` SquadCal ${cssInclude}
`); const statePromises = { navInfo: navInfoPromise, currentUserInfo: currentUserInfoPromise, sessionID: sessionIDPromise, serverVerificationResult: serverVerificationResultPromise, entryStore: entryStorePromise, threadStore: threadStorePromise, - userInfos: userInfoPromise, + userStore: userStorePromise, messageStore: messageStorePromise, updatesCurrentAsOf: initialTime, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, // We can use paths local to the on web urlPrefix: '', windowDimensions: { width: 0, height: 0 }, baseHref, connection: { ...defaultConnectionInfo('web', viewer.timeZone), actualizedCalendarQuery: calendarQuery, }, watchedThreadIDs: [], foreground: true, nextLocalID: 0, queuedReports: [], timeZone: viewer.timeZone, userAgent: viewer.userAgent, cookie: undefined, deviceToken: undefined, dataLoaded: viewer.loggedIn, windowActive: true, }; const state = await promiseAll(statePromises); const store: Store = createStore(reducer, state); const routerContext = {}; const reactStream = renderToNodeStream( , ); if (routerContext.url) { throw new ServerError('URL modified during server render!'); } reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.write(html`
`); handleAsyncPromise(updateActivityPromise); } async function handleVerificationRequest( viewer: Viewer, code: ?string, ): Promise { if (!code) { return null; } try { return await handleCodeVerificationRequest(viewer, code); } catch (e) { if (e instanceof ServerError && e.message === 'invalid_code') { return { success: false }; } throw e; } } export { websiteResponder }; diff --git a/web/modals/threads/thread-settings-modal.react.js b/web/modals/threads/thread-settings-modal.react.js index 28996e508..71b0f81ff 100644 --- a/web/modals/threads/thread-settings-modal.react.js +++ b/web/modals/threads/thread-settings-modal.react.js @@ -1,540 +1,540 @@ // @flow import { type ThreadInfo, threadInfoPropType, threadTypes, assertThreadType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, type LeaveThreadPayload, threadPermissions, type ThreadChanges, } from 'lib/types/thread-types'; import type { AppState } from '../../redux/redux-setup'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { type UserInfo, userInfoPropType } from 'lib/types/user-types'; import * as React from 'react'; import classNames from 'classnames'; import invariant from 'invariant'; import PropTypes from 'prop-types'; import _pickBy from 'lodash/fp/pickBy'; import { connect } from 'lib/utils/redux-utils'; import { deleteThreadActionTypes, deleteThread, changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadHasPermission, threadTypeDescriptions, robotextName, } from 'lib/shared/thread-utils'; import css from '../../style.css'; import Modal from '../modal.react'; import ColorPicker from './color-picker.react'; type TabType = 'general' | 'privacy' | 'delete'; type TabProps = { name: string, tabType: TabType, selected: boolean, onClick: (tabType: TabType) => void, }; class Tab extends React.PureComponent { render() { const classNamesForTab = classNames({ [css['current-tab']]: this.props.selected, [css['delete-tab']]: this.props.selected && this.props.tabType === 'delete', }); return (
  • {this.props.name}
  • ); } onClick = () => { return this.props.onClick(this.props.tabType); }; } type Props = { threadInfo: ThreadInfo, onClose: () => void, // Redux state inputDisabled: boolean, viewerID: ?string, userInfos: { [id: string]: UserInfo }, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs deleteThread: ( threadID: string, currentAccountPassword: string, ) => Promise, changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, }; type State = {| queuedChanges: ThreadChanges, errorMessage: string, accountPassword: string, currentTabType: TabType, |}; class ThreadSettingsModal extends React.PureComponent { nameInput: ?HTMLInputElement; newThreadPasswordInput: ?HTMLInputElement; accountPasswordInput: ?HTMLInputElement; constructor(props: Props) { super(props); this.state = { queuedChanges: {}, errorMessage: '', accountPassword: '', currentTabType: 'general', }; } componentDidMount() { invariant(this.nameInput, 'nameInput ref unset'); this.nameInput.focus(); } possiblyChangedValue(key: string) { const valueChanged = this.state.queuedChanges[key] !== null && this.state.queuedChanges[key] !== undefined; return valueChanged ? this.state.queuedChanges[key] : this.props.threadInfo[key]; } namePlaceholder() { return robotextName( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ); } changeQueued() { return ( Object.keys( _pickBy( value => value !== null && value !== undefined, // the lodash/fp libdef coerces the returned object's properties to the // same type, which means it only works for object-as-maps $FlowFixMe )(this.state.queuedChanges), ).length > 0 ); } render() { let mainContent = null; if (this.state.currentTabType === 'general') { mainContent = (
    Thread name
    Description
    Color
    ); } else if (this.state.currentTabType === 'privacy') { let threadTypeSection = null; if (this.possiblyChangedValue('parentThreadID')) { threadTypeSection = (
    Thread type
    ); } mainContent = (
    {threadTypeSection}
    ); } else if (this.state.currentTabType === 'delete') { mainContent = (

    Your thread will be permanently deleted. There is no way to reverse this.

    ); } let buttons = null; if (this.state.currentTabType === 'delete') { buttons = ( ); } else { buttons = ( ); } const canDeleteThread = threadHasPermission( this.props.threadInfo, threadPermissions.DELETE_THREAD, ); let deleteTab = null; if (canDeleteThread) { deleteTab = ( ); } return (
      {deleteTab}
    {mainContent}

    Please enter your account password to confirm your identity

    Account password
    {this.state.errorMessage} {buttons}
    ); } setTab = (tabType: TabType) => { this.setState({ currentTabType: tabType }); }; nameInputRef = (nameInput: ?HTMLInputElement) => { this.nameInput = nameInput; }; newThreadPasswordInputRef = (newThreadPasswordInput: ?HTMLInputElement) => { this.newThreadPasswordInput = newThreadPasswordInput; }; accountPasswordInputRef = (accountPasswordInput: ?HTMLInputElement) => { this.accountPasswordInput = accountPasswordInput; }; onChangeName = (event: SyntheticEvent) => { const target = event.currentTarget; const newValue = target.value !== this.props.threadInfo.name ? target.value : undefined; this.setState((prevState: State) => ({ ...prevState, queuedChanges: { ...prevState.queuedChanges, name: newValue, }, })); }; onChangeDescription = (event: SyntheticEvent) => { const target = event.currentTarget; const newValue = target.value !== this.props.threadInfo.description ? target.value : undefined; this.setState((prevState: State) => ({ ...prevState, queuedChanges: { ...prevState.queuedChanges, description: newValue, }, })); }; onChangeColor = (color: string) => { const newValue = color !== this.props.threadInfo.color ? color : undefined; this.setState((prevState: State) => ({ ...prevState, queuedChanges: { ...prevState.queuedChanges, color: newValue, }, })); }; onChangeThreadType = (event: SyntheticEvent) => { const uiValue = assertThreadType(parseInt(event.currentTarget.value, 10)); const newValue = uiValue !== this.props.threadInfo.type ? uiValue : undefined; this.setState((prevState: State) => ({ ...prevState, queuedChanges: { ...prevState.queuedChanges, type: newValue, }, })); }; onChangeAccountPassword = (event: SyntheticEvent) => { const target = event.currentTarget; this.setState({ accountPassword: target.value }); }; onSubmit = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.changeThreadSettingsAction(), ); }; async changeThreadSettingsAction() { try { const response = await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: this.state.queuedChanges, accountPassword: this.state.accountPassword ? this.state.accountPassword : null, }); this.props.onClose(); return response; } catch (e) { if (e.message === 'invalid_credentials') { this.setState( { accountPassword: '', errorMessage: 'wrong password', }, () => { invariant( this.accountPasswordInput, 'accountPasswordInput ref unset', ); this.accountPasswordInput.focus(); }, ); } else { this.setState( prevState => ({ ...prevState, queuedChanges: {}, accountPassword: '', errorMessage: 'unknown error', currentTabType: 'general', }), () => { invariant(this.nameInput, 'nameInput ref unset'); this.nameInput.focus(); }, ); } throw e; } } onDelete = (event: SyntheticEvent) => { event.preventDefault(); this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThreadAction(), ); }; async deleteThreadAction() { try { const response = await this.props.deleteThread( this.props.threadInfo.id, this.state.accountPassword, ); this.props.onClose(); return response; } catch (e) { const errorMessage = e.message === 'invalid_credentials' ? 'wrong password' : 'unknown error'; this.setState( { accountPassword: '', errorMessage: errorMessage, }, () => { invariant( this.accountPasswordInput, 'accountPasswordInput ref unset', ); this.accountPasswordInput.focus(); }, ); throw e; } } } ThreadSettingsModal.propTypes = { threadInfo: threadInfoPropType.isRequired, onClose: PropTypes.func.isRequired, inputDisabled: PropTypes.bool.isRequired, viewerID: PropTypes.string, userInfos: PropTypes.objectOf(userInfoPropType).isRequired, dispatchActionPromise: PropTypes.func.isRequired, deleteThread: PropTypes.func.isRequired, changeThreadSettings: PropTypes.func.isRequired, }; const deleteThreadLoadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); export default connect( (state: AppState) => ({ inputDisabled: deleteThreadLoadingStatusSelector(state) === 'loading' || changeThreadSettingsLoadingStatusSelector(state) === 'loading', viewerID: state.currentUserInfo && state.currentUserInfo.id, - userInfos: state.userInfos, + userInfos: state.userStore.userInfos, }), { deleteThread, changeThreadSettings }, )(ThreadSettingsModal); diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index 344eef1e1..326a9c350 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,209 +1,209 @@ // @flow import type { BaseNavInfo } from 'lib/types/nav-types'; import type { ThreadStore } from 'lib/types/thread-types'; import type { EntryStore } from 'lib/types/entry-types'; import type { BaseAction } from 'lib/types/redux-types'; import type { LoadingStatus } from 'lib/types/loading-types'; -import type { CurrentUserInfo, UserInfo } from 'lib/types/user-types'; +import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import type { MessageStore } from 'lib/types/message-types'; import type { CalendarFilter } from 'lib/types/filter-types'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import type { ConnectionInfo } from 'lib/types/socket-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import Visibility from 'visibilityjs'; import baseReducer from 'lib/reducers/master-reducer'; import { mostRecentReadThreadSelector } from 'lib/selectors/thread-selectors'; import { invalidSessionDowngrade } from 'lib/shared/account-utils'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions'; import { activeThreadSelector } from '../selectors/nav-selectors'; import { updateWindowActiveActionType } from './action-types'; export type NavInfo = {| ...$Exact, tab: 'calendar' | 'chat', verify: ?string, activeChatThreadID: ?string, |}; export const navInfoPropType = PropTypes.shape({ startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, tab: PropTypes.oneOf(['calendar', 'chat']).isRequired, verify: PropTypes.string, activeChatThreadID: PropTypes.string, }); export type WindowDimensions = {| width: number, height: number |}; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, sessionID: ?string, serverVerificationResult: ?ServerVerificationResult, entryStore: EntryStore, threadStore: ThreadStore, - userInfos: { [id: string]: UserInfo }, + userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, windowDimensions: WindowDimensions, cookie?: void, deviceToken?: void, baseHref: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, foreground: boolean, nextLocalID: number, queuedReports: $ReadOnlyArray, timeZone: ?string, userAgent: ?string, dataLoaded: boolean, windowActive: boolean, |}; export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; export const updateWindowDimensions = 'UPDATE_WINDOW_DIMENSIONS'; export type Action = | BaseAction | {| type: 'UPDATE_NAV_INFO', payload: NavInfo |} | {| type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, |} | {| type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, |}; export function reducer(oldState: AppState | void, action: Action) { invariant(oldState, 'should be set'); let state = oldState; if (action.type === updateNavInfoActionType) { return validateState(oldState, { ...state, navInfo: action.payload, }); } else if (action.type === updateWindowDimensions) { return validateState(oldState, { ...state, windowDimensions: action.payload, }); } else if (action.type === updateWindowActiveActionType) { return validateState(oldState, { ...state, windowActive: action.payload, }); } else if (action.type === setNewSessionActionType) { if ( invalidSessionDowngrade( oldState, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, ) ) { return oldState; } state = { ...state, sessionID: action.payload.sessionChange.sessionID, }; } else if ( (action.type === logOutActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return oldState; } return validateState(oldState, baseReducer(state, action)); } function validateState(oldState: AppState, state: AppState): AppState { if ( state.navInfo.activeChatThreadID && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID] ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !Visibility.hidden() && document && document.hasFocus && document.hasFocus() && state.threadStore.threadInfos[activeThread].currentUser.unread ) { // Makes sure a currently focused thread is never unread state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { // Update messageStore.threads[activeThread].lastNavigatedTo state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [activeThread]: { ...state.messageStore.threads[activeThread], lastNavigatedTo: Date.now(), }, }, }, }; } return state; }