diff --git a/lib/actions/entry-actions.js b/lib/actions/entry-actions.js index 342afdd7b..dfdc6f522 100644 --- a/lib/actions/entry-actions.js +++ b/lib/actions/entry-actions.js @@ -1,189 +1,186 @@ // @flow import type { RawEntryInfo, CalendarQuery, SaveEntryInfo, SaveEntryResponse, CreateEntryInfo, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResponse, RestoreEntryInfo, RestoreEntryResponse, FetchEntryInfosResult, CalendarQueryUpdateResult, } from '../types/entry-types'; import type { FetchJSON } from '../utils/fetch-json'; import type { HistoryRevisionInfo } from '../types/history-types'; import { dateFromString } from '../utils/date-utils'; -import { values } from '../utils/objects'; const fetchEntriesActionTypes = Object.freeze({ started: 'FETCH_ENTRIES_STARTED', success: 'FETCH_ENTRIES_SUCCESS', failed: 'FETCH_ENTRIES_FAILED', }); async function fetchEntries( fetchJSON: FetchJSON, calendarQuery: CalendarQuery, ): Promise { const response = await fetchJSON('fetch_entries', calendarQuery); return { rawEntryInfos: response.rawEntryInfos, - userInfos: values(response.userInfos), }; } const updateCalendarQueryActionTypes = Object.freeze({ started: 'UPDATE_CALENDAR_QUERY_STARTED', success: 'UPDATE_CALENDAR_QUERY_SUCCESS', failed: 'UPDATE_CALENDAR_QUERY_FAILED', }); async function updateCalendarQuery( fetchJSON: FetchJSON, calendarQuery: CalendarQuery, reduxAlreadyUpdated: boolean = false, ): Promise { const response = await fetchJSON('update_calendar_query', calendarQuery); - const { rawEntryInfos, deletedEntryIDs, userInfos } = response; + const { rawEntryInfos, deletedEntryIDs } = response; return { rawEntryInfos, deletedEntryIDs, - userInfos, calendarQuery, calendarQueryAlreadyUpdated: reduxAlreadyUpdated, }; } const createLocalEntryActionType = 'CREATE_LOCAL_ENTRY'; function createLocalEntry( threadID: string, localID: number, dateString: string, creatorID: string, ): RawEntryInfo { const date = dateFromString(dateString); const newEntryInfo: RawEntryInfo = { localID: `local${localID}`, threadID, text: '', year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), creationTime: Date.now(), creatorID, deleted: false, }; return newEntryInfo; } const createEntryActionTypes = Object.freeze({ started: 'CREATE_ENTRY_STARTED', success: 'CREATE_ENTRY_SUCCESS', failed: 'CREATE_ENTRY_FAILED', }); async function createEntry( fetchJSON: FetchJSON, request: CreateEntryInfo, ): Promise { const result = await fetchJSON('create_entry', request); return { entryID: result.entryID, newMessageInfos: result.newMessageInfos, threadID: request.threadID, localID: request.localID, updatesResult: result.updatesResult, }; } const saveEntryActionTypes = Object.freeze({ started: 'SAVE_ENTRY_STARTED', success: 'SAVE_ENTRY_SUCCESS', failed: 'SAVE_ENTRY_FAILED', }); const concurrentModificationResetActionType = 'CONCURRENT_MODIFICATION_RESET'; async function saveEntry( fetchJSON: FetchJSON, request: SaveEntryInfo, ): Promise { const result = await fetchJSON('update_entry', request); return { entryID: result.entryID, newMessageInfos: result.newMessageInfos, updatesResult: result.updatesResult, }; } const deleteEntryActionTypes = Object.freeze({ started: 'DELETE_ENTRY_STARTED', success: 'DELETE_ENTRY_SUCCESS', failed: 'DELETE_ENTRY_FAILED', }); async function deleteEntry( fetchJSON: FetchJSON, info: DeleteEntryInfo, ): Promise { const response = await fetchJSON('delete_entry', { ...info, timestamp: Date.now(), }); return { newMessageInfos: response.newMessageInfos, threadID: response.threadID, updatesResult: response.updatesResult, }; } const fetchRevisionsForEntryActionTypes = Object.freeze({ started: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', success: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', failed: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', }); async function fetchRevisionsForEntry( fetchJSON: FetchJSON, entryID: string, ): Promise<$ReadOnlyArray> { const response = await fetchJSON('fetch_entry_revisions', { id: entryID }); return response.result; } const restoreEntryActionTypes = Object.freeze({ started: 'RESTORE_ENTRY_STARTED', success: 'RESTORE_ENTRY_SUCCESS', failed: 'RESTORE_ENTRY_FAILED', }); async function restoreEntry( fetchJSON: FetchJSON, info: RestoreEntryInfo, ): Promise { const response = await fetchJSON('restore_entry', { ...info, timestamp: Date.now(), }); return { newMessageInfos: response.newMessageInfos, updatesResult: response.updatesResult, }; } export { fetchEntriesActionTypes, fetchEntries, updateCalendarQueryActionTypes, updateCalendarQuery, createLocalEntryActionType, createLocalEntry, createEntryActionTypes, createEntry, saveEntryActionTypes, concurrentModificationResetActionType, saveEntry, deleteEntryActionTypes, deleteEntry, fetchRevisionsForEntryActionTypes, fetchRevisionsForEntry, restoreEntryActionTypes, restoreEntry, }; diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js index b899e9fd1..d5f57e78e 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,119 +1,115 @@ // @flow import type { FetchJSON } from '../utils/fetch-json'; import type { FetchMessageInfosPayload, SendMessageResult, } from '../types/message-types'; -import { values } from '../utils/objects'; - const fetchMessagesBeforeCursorActionTypes = Object.freeze({ started: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', success: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', failed: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', }); async function fetchMessagesBeforeCursor( fetchJSON: FetchJSON, threadID: string, beforeMessageID: string, ): Promise { const response = await fetchJSON('fetch_messages', { cursors: { [threadID]: beforeMessageID, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], - userInfos: values(response.userInfos), }; } const fetchMostRecentMessagesActionTypes = Object.freeze({ started: 'FETCH_MOST_RECENT_MESSAGES_STARTED', success: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', failed: 'FETCH_MOST_RECENT_MESSAGES_FAILED', }); async function fetchMostRecentMessages( fetchJSON: FetchJSON, threadID: string, ): Promise { const response = await fetchJSON('fetch_messages', { cursors: { [threadID]: null, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], - userInfos: values(response.userInfos), }; } const sendTextMessageActionTypes = Object.freeze({ started: 'SEND_TEXT_MESSAGE_STARTED', success: 'SEND_TEXT_MESSAGE_SUCCESS', failed: 'SEND_TEXT_MESSAGE_FAILED', }); async function sendTextMessage( fetchJSON: FetchJSON, threadID: string, localID: string, text: string, ): Promise { const response = await fetchJSON('create_text_message', { threadID, localID, text, }); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, }; } const createLocalMessageActionType = 'CREATE_LOCAL_MESSAGE'; const sendMultimediaMessageActionTypes = Object.freeze({ started: 'SEND_MULTIMEDIA_MESSAGE_STARTED', success: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', failed: 'SEND_MULTIMEDIA_MESSAGE_FAILED', }); async function sendMultimediaMessage( fetchJSON: FetchJSON, threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ): Promise { const response = await fetchJSON('create_multimedia_message', { threadID, localID, mediaIDs, }); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, }; } const saveMessagesActionType = 'SAVE_MESSAGES'; const processMessagesActionType = 'PROCESS_MESSAGES'; const messageStorePruneActionType = 'MESSAGE_STORE_PRUNE'; export { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, sendTextMessageActionTypes, sendTextMessage, createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, }; diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index 76bc4e31e..50c068281 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,173 +1,172 @@ // @flow import type { ChangeThreadSettingsPayload, LeaveThreadPayload, UpdateThreadRequest, NewThreadRequest, NewThreadResult, ClientThreadJoinRequest, ThreadJoinPayload, } from '../types/thread-types'; import type { FetchJSON } from '../utils/fetch-json'; import invariant from 'invariant'; import { values } from '../utils/objects'; const deleteThreadActionTypes = Object.freeze({ started: 'DELETE_THREAD_STARTED', success: 'DELETE_THREAD_SUCCESS', failed: 'DELETE_THREAD_FAILED', }); async function deleteThread( fetchJSON: FetchJSON, threadID: string, currentAccountPassword: string, ): Promise { const response = await fetchJSON('delete_thread', { threadID, accountPassword: currentAccountPassword, }); return { updatesResult: response.updatesResult, }; } const changeThreadSettingsActionTypes = Object.freeze({ started: 'CHANGE_THREAD_SETTINGS_STARTED', success: 'CHANGE_THREAD_SETTINGS_SUCCESS', failed: 'CHANGE_THREAD_SETTINGS_FAILED', }); async function changeThreadSettings( fetchJSON: FetchJSON, request: UpdateThreadRequest, ): Promise { const response = await fetchJSON('update_thread', request); invariant( Object.keys(request.changes).length > 0, 'No changes provided to changeThreadSettings!', ); return { threadID: request.threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; } const removeUsersFromThreadActionTypes = Object.freeze({ started: 'REMOVE_USERS_FROM_THREAD_STARTED', success: 'REMOVE_USERS_FROM_THREAD_SUCCESS', failed: 'REMOVE_USERS_FROM_THREAD_FAILED', }); async function removeUsersFromThread( fetchJSON: FetchJSON, threadID: string, memberIDs: string[], ): Promise { const response = await fetchJSON('remove_members', { threadID, memberIDs, }); return { threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; } const changeThreadMemberRolesActionTypes = Object.freeze({ started: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', success: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', failed: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', }); async function changeThreadMemberRoles( fetchJSON: FetchJSON, threadID: string, memberIDs: string[], newRole: string, ): Promise { const response = await fetchJSON('update_role', { threadID, memberIDs, role: newRole, }); return { threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; } const newThreadActionTypes = Object.freeze({ started: 'NEW_THREAD_STARTED', success: 'NEW_THREAD_SUCCESS', failed: 'NEW_THREAD_FAILED', }); async function newThread( fetchJSON: FetchJSON, request: NewThreadRequest, ): Promise { const response = await fetchJSON('create_thread', request); return { newThreadID: response.newThreadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; } const joinThreadActionTypes = Object.freeze({ started: 'JOIN_THREAD_STARTED', success: 'JOIN_THREAD_SUCCESS', failed: 'JOIN_THREAD_FAILED', }); async function joinThread( fetchJSON: FetchJSON, request: ClientThreadJoinRequest, ): Promise { const response = await fetchJSON('join_thread', request); const userInfos = values(response.userInfos); return { updatesResult: response.updatesResult, rawMessageInfos: response.rawMessageInfos, truncationStatuses: response.truncationStatuses, userInfos, calendarResult: { calendarQuery: request.calendarQuery, rawEntryInfos: response.rawEntryInfos, - userInfos, }, }; } const leaveThreadActionTypes = Object.freeze({ started: 'LEAVE_THREAD_STARTED', success: 'LEAVE_THREAD_SUCCESS', failed: 'LEAVE_THREAD_FAILED', }); async function leaveThread( fetchJSON: FetchJSON, threadID: string, ): Promise { const response = await fetchJSON('leave_thread', { threadID }); return { updatesResult: response.updatesResult, }; } export { deleteThreadActionTypes, deleteThread, changeThreadSettingsActionTypes, changeThreadSettings, removeUsersFromThreadActionTypes, removeUsersFromThread, changeThreadMemberRolesActionTypes, changeThreadMemberRoles, newThreadActionTypes, newThread, joinThreadActionTypes, joinThread, leaveThreadActionTypes, leaveThread, }; diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index bedf6ff43..c43f34881 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,322 +1,320 @@ // @flow import type { FetchJSON } from '../utils/fetch-json'; import type { HandleVerificationCodeResult } from '../types/verify-types'; import type { UserInfo, AccountUpdate } from '../types/user-types'; import type { ChangeUserSettingsResult, LogOutResult, LogInInfo, LogInResult, RegisterResult, UpdatePasswordInfo, RegisterInfo, AccessRequest, } from '../types/account-types'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types'; import type { UserSearchResult } from '../types/search-types'; import type { PreRequestUserState } from '../types/session-types'; import threadWatcher from '../shared/thread-watcher'; import { getConfig } from '../utils/config'; import sleep from '../utils/sleep'; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); async function logOut( fetchJSON: FetchJSON, preRequestUserState: PreRequestUserState, ): Promise { let response = null; try { response = await Promise.race([ fetchJSON('log_out', {}), (async () => { await sleep(500); throw new Error('log_out took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? response.currentUserInfo : null; return { currentUserInfo, preRequestUserState }; } const deleteAccountActionTypes = Object.freeze({ started: 'DELETE_ACCOUNT_STARTED', success: 'DELETE_ACCOUNT_SUCCESS', failed: 'DELETE_ACCOUNT_FAILED', }); async function deleteAccount( fetchJSON: FetchJSON, password: string, preRequestUserState: PreRequestUserState, ): Promise { let response = null; try { response = await Promise.race([ fetchJSON('delete_account', { password }), (async () => { await sleep(500); throw new Error('delete_account took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? response.currentUserInfo : null; return { currentUserInfo, preRequestUserState }; } const registerActionTypes = Object.freeze({ started: 'REGISTER_STARTED', success: 'REGISTER_SUCCESS', failed: 'REGISTER_FAILED', }); async function register( fetchJSON: FetchJSON, registerInfo: RegisterInfo, ): Promise { const response = await fetchJSON('create_account', { ...registerInfo, platformDetails: getConfig().platformDetails, }); return { currentUserInfo: { id: response.id, username: registerInfo.username, email: registerInfo.email, emailVerified: false, }, rawMessageInfos: response.rawMessageInfos, threadInfos: response.cookieChange.threadInfos, userInfos: response.cookieChange.userInfos, calendarQuery: registerInfo.calendarQuery, }; } function mergeUserInfos(...userInfoArrays: UserInfo[][]): UserInfo[] { const merged = {}; for (let userInfoArray of userInfoArrays) { for (let userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; for (let id in merged) { flattened.push(merged[id]); } return flattened; } const cookieInvalidationResolutionAttempt = 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT'; const appStartNativeCredentialsAutoLogIn = 'APP_START_NATIVE_CREDENTIALS_AUTO_LOG_IN'; const appStartReduxLoggedInButInvalidCookie = 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE'; const socketAuthErrorResolutionAttempt = 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT'; const logInActionTypes = Object.freeze({ started: 'LOG_IN_STARTED', success: 'LOG_IN_SUCCESS', failed: 'LOG_IN_FAILED', }); async function logIn( fetchJSON: FetchJSON, logInInfo: LogInInfo, ): Promise { const watchedIDs = threadWatcher.getWatchedIDs(); const response = await fetchJSON('log_in', { ...logInInfo, watchedIDs, platformDetails: getConfig().platformDetails, }); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, - userInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, }; } const resetPasswordActionTypes = Object.freeze({ started: 'RESET_PASSWORD_STARTED', success: 'RESET_PASSWORD_SUCCESS', failed: 'RESET_PASSWORD_FAILED', }); async function resetPassword( fetchJSON: FetchJSON, updatePasswordInfo: UpdatePasswordInfo, ): Promise { const watchedIDs = threadWatcher.getWatchedIDs(); const response = await fetchJSON('update_password', { ...updatePasswordInfo, watchedIDs, platformDetails: getConfig().platformDetails, }); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: updatePasswordInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, - userInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, }; } const forgotPasswordActionTypes = Object.freeze({ started: 'FORGOT_PASSWORD_STARTED', success: 'FORGOT_PASSWORD_SUCCESS', failed: 'FORGOT_PASSWORD_FAILED', }); async function forgotPassword( fetchJSON: FetchJSON, usernameOrEmail: string, ): Promise { await fetchJSON('send_password_reset_email', { usernameOrEmail }); } const changeUserSettingsActionTypes = Object.freeze({ started: 'CHANGE_USER_SETTINGS_STARTED', success: 'CHANGE_USER_SETTINGS_SUCCESS', failed: 'CHANGE_USER_SETTINGS_FAILED', }); async function changeUserSettings( fetchJSON: FetchJSON, accountUpdate: AccountUpdate, ): Promise { await fetchJSON('update_account', accountUpdate); return { email: accountUpdate.updatedFields.email }; } const resendVerificationEmailActionTypes = Object.freeze({ started: 'RESEND_VERIFICATION_EMAIL_STARTED', success: 'RESEND_VERIFICATION_EMAIL_SUCCESS', failed: 'RESEND_VERIFICATION_EMAIL_FAILED', }); async function resendVerificationEmail(fetchJSON: FetchJSON): Promise { await fetchJSON('send_verification_email', {}); } const handleVerificationCodeActionTypes = Object.freeze({ started: 'HANDLE_VERIFICATION_CODE_STARTED', success: 'HANDLE_VERIFICATION_CODE_SUCCESS', failed: 'HANDLE_VERIFICATION_CODE_FAILED', }); async function handleVerificationCode( fetchJSON: FetchJSON, code: string, ): Promise { const result = await fetchJSON('verify_code', { code }); const { verifyField, resetPasswordUsername } = result; return { verifyField, resetPasswordUsername }; } const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); async function searchUsers( fetchJSON: FetchJSON, usernamePrefix: string, ): Promise { const response = await fetchJSON('search_users', { prefix: usernamePrefix }); return { userInfos: response.userInfos, }; } const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); async function updateSubscription( fetchJSON: FetchJSON, subscriptionUpdate: SubscriptionUpdateRequest, ): Promise { const response = await fetchJSON( 'update_user_subscription', subscriptionUpdate, ); return { threadID: subscriptionUpdate.threadID, subscription: response.threadSubscription, }; } const requestAccessActionTypes = Object.freeze({ started: 'REQUEST_ACCESS_STARTED', success: 'REQUEST_ACCESS_SUCCESS', failed: 'REQUEST_ACCESS_FAILED', }); async function requestAccess( fetchJSON: FetchJSON, accessRequest: AccessRequest, ): Promise { await fetchJSON('request_access', accessRequest); } export { logOutActionTypes, logOut, deleteAccountActionTypes, deleteAccount, registerActionTypes, register, cookieInvalidationResolutionAttempt, appStartNativeCredentialsAutoLogIn, appStartReduxLoggedInButInvalidCookie, socketAuthErrorResolutionAttempt, logInActionTypes, logIn, resetPasswordActionTypes, resetPassword, forgotPasswordActionTypes, forgotPassword, changeUserSettingsActionTypes, changeUserSettings, resendVerificationEmailActionTypes, resendVerificationEmail, handleVerificationCodeActionTypes, handleVerificationCode, searchUsersActionTypes, searchUsers, updateSubscriptionActionTypes, updateSubscription, requestAccessActionTypes, requestAccess, }; diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index 9b396ad9f..7b7565cd4 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,278 +1,264 @@ // @flow import type { BaseAction } from '../types/redux-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 findInconsistencies( action: BaseAction, 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 - ) { + if (action.type === joinThreadActionTypes.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.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.userInfos).length === 0) { return state; } 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.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.userInfos, ...newUserInfos }; for (let update of action.payload.updatesResult.newUpdates) { if (update.type === updateTypes.DELETE_ACCOUNT) { delete updated[update.deletedUserID]; } } 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.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.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.userInfos }; if (userInfos) { for (const userInfo of userInfos) { newUserInfos[userInfo.id] = userInfo; } } if (deleteUserInfoIDs) { for (const deleteUserInfoID of deleteUserInfoIDs) { delete newUserInfos[deleteUserInfoID]; } } const newInconsistencies = findInconsistencies( action, state.userInfos, newUserInfos, ); return { userInfos: newUserInfos, inconsistencyReports: [ ...state.inconsistencyReports, ...newInconsistencies, ], }; } return state; } export { reduceCurrentUserInfo, reduceUserInfos }; diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index 92072cdb8..e5f79beb0 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,178 +1,179 @@ // @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.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.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, + searchIndexFromUserInfos, userSearchIndexForOtherMembersOfThread, isLoggedIn, }; diff --git a/lib/types/entry-types.js b/lib/types/entry-types.js index 5986aa1ec..c826a1b80 100644 --- a/lib/types/entry-types.js +++ b/lib/types/entry-types.js @@ -1,235 +1,231 @@ // @flow import type { RawMessageInfo } from './message-types'; -import type { UserInfo, AccountUserInfo } from './user-types'; +import type { AccountUserInfo } from './user-types'; import { type CalendarFilter, calendarFilterPropType, defaultCalendarFilters, } from './filter-types'; import type { CreateUpdatesResponse } from './update-types'; import type { Platform } from './device-types'; import type { ClientEntryInconsistencyReportCreationRequest } from './report-types'; import PropTypes from 'prop-types'; import { fifteenDaysEarlier, fifteenDaysLater, thisMonthDates, } from '../utils/date-utils'; export type RawEntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creatorID: string, deleted: boolean, |}; export const rawEntryInfoPropType = PropTypes.shape({ id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, text: PropTypes.string.isRequired, year: PropTypes.number.isRequired, month: PropTypes.number.isRequired, day: PropTypes.number.isRequired, creationTime: PropTypes.number.isRequired, creatorID: PropTypes.string.isRequired, deleted: PropTypes.bool.isRequired, }); export type EntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creator: ?string, deleted: boolean, |}; export const entryInfoPropType = PropTypes.shape({ id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, text: PropTypes.string.isRequired, year: PropTypes.number.isRequired, month: PropTypes.number.isRequired, day: PropTypes.number.isRequired, creationTime: PropTypes.number.isRequired, creator: PropTypes.string, deleted: PropTypes.bool.isRequired, }); export type EntryStore = {| entryInfos: { [id: string]: RawEntryInfo }, daysToEntries: { [day: string]: string[] }, lastUserInteractionCalendar: number, inconsistencyReports: $ReadOnlyArray, |}; export type CalendarQuery = {| startDate: string, endDate: string, filters: $ReadOnlyArray, |}; export const defaultCalendarQuery = ( platform: ?Platform, timeZone?: ?string, ) => { if (platform === 'web') { return { ...thisMonthDates(timeZone), filters: defaultCalendarFilters, }; } else { return { startDate: fifteenDaysEarlier(timeZone).valueOf(), endDate: fifteenDaysLater(timeZone).valueOf(), filters: defaultCalendarFilters, }; } }; export const calendarQueryPropType = PropTypes.shape({ startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, filters: PropTypes.arrayOf(calendarFilterPropType).isRequired, }); export type SaveEntryInfo = {| entryID: string, text: string, prevText: string, timestamp: number, calendarQuery: CalendarQuery, |}; export type SaveEntryRequest = {| entryID: string, text: string, prevText: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type SaveEntryResponse = {| entryID: string, newMessageInfos: $ReadOnlyArray, updatesResult: CreateUpdatesResponse, |}; export type SaveEntryPayload = {| ...SaveEntryResponse, threadID: string, |}; export type CreateEntryInfo = {| text: string, timestamp: number, date: string, threadID: string, localID: string, calendarQuery: CalendarQuery, |}; export type CreateEntryRequest = {| text: string, timestamp: number, date: string, threadID: string, localID?: string, calendarQuery?: CalendarQuery, |}; export type CreateEntryPayload = {| ...SaveEntryPayload, localID: string, |}; export type DeleteEntryInfo = {| entryID: string, prevText: string, calendarQuery: CalendarQuery, |}; export type DeleteEntryRequest = {| entryID: string, prevText: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type RestoreEntryInfo = {| entryID: string, calendarQuery: CalendarQuery, |}; export type RestoreEntryRequest = {| entryID: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type DeleteEntryResponse = {| newMessageInfos: $ReadOnlyArray, threadID: string, updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryResponse = {| newMessageInfos: $ReadOnlyArray, updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryPayload = {| ...RestoreEntryResponse, threadID: string, |}; export type FetchEntryInfosResponse = {| rawEntryInfos: $ReadOnlyArray, userInfos: { [id: string]: AccountUserInfo }, |}; export type FetchEntryInfosResult = {| rawEntryInfos: $ReadOnlyArray, - userInfos: $ReadOnlyArray, |}; export type DeltaEntryInfosResponse = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, - userInfos: { [id: string]: AccountUserInfo }, |}; export type DeltaEntryInfosResult = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, userInfos: $ReadOnlyArray, |}; export type CalendarResult = {| rawEntryInfos: $ReadOnlyArray, calendarQuery: CalendarQuery, - userInfos: $ReadOnlyArray, |}; export type CalendarQueryUpdateStartingPayload = {| calendarQuery?: CalendarQuery, |}; export type CalendarQueryUpdateResult = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, - userInfos: $ReadOnlyArray, calendarQuery: CalendarQuery, calendarQueryAlreadyUpdated: boolean, |}; diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 7e46bc65d..a1d812558 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,786 +1,784 @@ // @flow import { type ThreadInfo, threadInfoPropType, type ThreadType, threadTypePropType, } from './thread-types'; import { type RelativeUserInfo, relativeUserInfoPropType, - type UserInfo, type UserInfos, } from './user-types'; import { type Media, type Image, mediaPropType } from './media-types'; import invariant from 'invariant'; import PropTypes from 'prop-types'; export const messageTypes = Object.freeze({ TEXT: 0, CREATE_THREAD: 1, ADD_MEMBERS: 2, CREATE_SUB_THREAD: 3, CHANGE_SETTINGS: 4, REMOVE_MEMBERS: 5, CHANGE_ROLE: 6, LEAVE_THREAD: 7, JOIN_THREAD: 8, CREATE_ENTRY: 9, EDIT_ENTRY: 10, DELETE_ENTRY: 11, RESTORE_ENTRY: 12, // When the server has a message to deliver that the client can't properly // render because the client is too old, the server will send this message // type instead. Consequently, there is no MessageData for UNSUPPORTED - just // a RawMessageInfo and a MessageInfo. Note that native/persist.js handles // converting these MessageInfos when the client is upgraded. UNSUPPORTED: 13, IMAGES: 14, MULTIMEDIA: 15, }); export type MessageType = $Values; export function assertMessageType(ourMessageType: number): MessageType { invariant( ourMessageType === 0 || ourMessageType === 1 || ourMessageType === 2 || ourMessageType === 3 || ourMessageType === 4 || ourMessageType === 5 || ourMessageType === 6 || ourMessageType === 7 || ourMessageType === 8 || ourMessageType === 9 || ourMessageType === 10 || ourMessageType === 11 || ourMessageType === 12 || ourMessageType === 13 || ourMessageType === 14 || ourMessageType === 15, 'number is not MessageType enum', ); return ourMessageType; } const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function messageDataLocalID(messageData: MessageData) { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assetMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type TextMessageData = {| type: 0, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, text: string, |}; type CreateThreadMessageData = {| type: 1, threadID: string, creatorID: string, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadID: ?string, color: string, memberIDs: string[], |}, |}; type AddMembersMessageData = {| type: 2, threadID: string, creatorID: string, time: number, addedUserIDs: string[], |}; type CreateSubthreadMessageData = {| type: 3, threadID: string, creatorID: string, time: number, childThreadID: string, |}; type ChangeSettingsMessageData = {| type: 4, threadID: string, creatorID: string, time: number, field: string, value: string | number, |}; type RemoveMembersMessageData = {| type: 5, threadID: string, creatorID: string, time: number, removedUserIDs: string[], |}; type ChangeRoleMessageData = {| type: 6, threadID: string, creatorID: string, time: number, userIDs: string[], newRole: string, |}; type LeaveThreadMessageData = {| type: 7, threadID: string, creatorID: string, time: number, |}; type JoinThreadMessageData = {| type: 8, threadID: string, creatorID: string, time: number, |}; type CreateEntryMessageData = {| type: 9, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type EditEntryMessageData = {| type: 10, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type DeleteEntryMessageData = {| type: 11, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type RestoreEntryMessageData = {| type: 12, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; export type ImagesMessageData = {| type: 14, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type MediaMessageData = {| type: 15, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawTextMessageInfo = {| ...TextMessageData, id?: string, // null if local copy without ID yet |}; export type RawImagesMessageInfo = {| ...ImagesMessageData, id?: string, // null if local copy without ID yet |}; export type RawMediaMessageInfo = {| ...MediaMessageData, id?: string, // null if local copy without ID yet |}; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; type RawRobotextMessageInfo = | {| ...CreateThreadMessageData, id: string, |} | {| ...AddMembersMessageData, id: string, |} | {| ...CreateSubthreadMessageData, id: string, |} | {| ...ChangeSettingsMessageData, id: string, |} | {| ...RemoveMembersMessageData, id: string, |} | {| ...ChangeRoleMessageData, id: string, |} | {| ...LeaveThreadMessageData, id: string, |} | {| ...JoinThreadMessageData, id: string, |} | {| ...CreateEntryMessageData, id: string, |} | {| ...EditEntryMessageData, id: string, |} | {| ...DeleteEntryMessageData, id: string, |} | {| ...RestoreEntryMessageData, id: string, |} | {| type: 13, id: string, threadID: string, creatorID: string, time: number, robotext: string, unsupportedMessageInfo: Object, |}; export type RawMessageInfo = RawComposableMessageInfo | RawRobotextMessageInfo; export type LocallyComposedMessageInfo = { localID: string, threadID: string, ... }; export type TextMessageInfo = {| type: 0, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp text: string, |}; export type ImagesMessageInfo = {| type: 14, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MediaMessageInfo = {| type: 15, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | {| type: 1, id: string, threadID: string, creator: RelativeUserInfo, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadInfo: ?ThreadInfo, color: string, otherMembers: RelativeUserInfo[], |}, |} | {| type: 2, id: string, threadID: string, creator: RelativeUserInfo, time: number, addedMembers: RelativeUserInfo[], |} | {| type: 3, id: string, threadID: string, creator: RelativeUserInfo, time: number, childThreadInfo: ThreadInfo, |} | {| type: 4, id: string, threadID: string, creator: RelativeUserInfo, time: number, field: string, value: string | number, |} | {| type: 5, id: string, threadID: string, creator: RelativeUserInfo, time: number, removedMembers: RelativeUserInfo[], |} | {| type: 6, id: string, threadID: string, creator: RelativeUserInfo, time: number, members: RelativeUserInfo[], newRole: string, |} | {| type: 7, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 8, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 9, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 10, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 11, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 12, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 13, id: string, threadID: string, creator: RelativeUserInfo, time: number, robotext: string, unsupportedMessageInfo: Object, |}; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo; export type MessageInfo = ComposableMessageInfo | RobotextMessageInfo; export const messageInfoPropType = PropTypes.oneOfType([ PropTypes.shape({ type: PropTypes.oneOf([messageTypes.TEXT]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, initialThreadState: PropTypes.shape({ type: threadTypePropType.isRequired, name: PropTypes.string, parentThreadInfo: threadInfoPropType, color: PropTypes.string.isRequired, otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.ADD_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, addedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_SUB_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, childThreadInfo: threadInfoPropType.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_SETTINGS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, field: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.REMOVE_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, removedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_ROLE]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, members: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, newRole: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.LEAVE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.JOIN_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.EDIT_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.DELETE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.RESTORE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.UNSUPPORTED]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, robotext: PropTypes.string.isRequired, unsupportedMessageInfo: PropTypes.object.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.IMAGES]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.MULTIMEDIA]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), ]); export type ThreadMessageInfo = {| messageIDs: string[], startReached: boolean, lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp |}; // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = {| sendFailed?: boolean, |}; export const localMessageInfoPropType = PropTypes.shape({ sendFailed: PropTypes.bool, }); export type MessageStore = {| messages: { [id: string]: RawMessageInfo }, threads: { [threadID: string]: ThreadMessageInfo }, local: { [id: string]: LocalMessageInfo }, currentAsOf: number, |}; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: ThreadSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export type ThreadCursors = { [threadID: string]: ?string }; export type ThreadSelectionCriteria = {| threadCursors?: ?ThreadCursors, joinedThreads?: ?boolean, |}; export type FetchMessageInfosRequest = {| cursors: ThreadCursors, numberPerThread?: ?number, |}; export type FetchMessageInfosResult = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: UserInfos, |}; export type FetchMessageInfosPayload = {| threadID: string, rawMessageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatus, - userInfos: UserInfo[], |}; export type MessagesResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, currentAsOf: number, |}; export const defaultNumberPerThread = 20; export type SendMessageResponse = {| newMessageInfo: RawMessageInfo, |}; export type SendMessageResult = {| id: string, time: number, |}; export type SendMessagePayload = {| localID: string, serverID: string, threadID: string, time: number, |}; export type SendTextMessageRequest = {| threadID: string, localID?: string, text: string, |}; export type SendMultimediaMessageRequest = {| threadID: string, localID: string, mediaIDs: $ReadOnlyArray, |}; // Used for the message info included in log-in type actions export type GenericMessagesResult = {| messageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatuses, watchedIDsAtRequestTime: $ReadOnlyArray, currentAsOf: number, |}; export type SaveMessagesPayload = {| rawMessageInfos: $ReadOnlyArray, updatesCurrentAsOf: number, |}; export type NewMessagesPayload = {| messagesResult: MessagesResponse, |}; export type MessageStorePrunePayload = {| threadIDs: $ReadOnlyArray, |}; diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js index 2cee528fa..431f6e43a 100644 --- a/native/chat/compose-thread.react.js +++ b/native/chat/compose-thread.react.js @@ -1,531 +1,513 @@ // @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 }, + { newThread }, )(ComposeThread); diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index 8329cf1ce..1f911c699 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,371 +1,353 @@ // @flow import type { AppState } from '../../redux/redux-setup'; import { type ThreadInfo, threadInfoPropType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } 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 { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { RootNavigationProp } from '../../navigation/root-navigator.react'; import type { NavigationRoute } from '../../navigation/route-names'; import * as React from 'react'; import { View, Text, ActivityIndicator, Alert } from 'react-native'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import { createSelector } from 'reselect'; import { userInfoSelectorForOtherMembersOfThread, userSearchIndexForOtherMembersOfThread, } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { getUserSearchResults } from 'lib/shared/search-utils'; import { connect } from 'lib/utils/redux-utils'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; -import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { threadActualMembers } from 'lib/shared/thread-utils'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import UserList from '../../components/user-list.react'; import TagInput from '../../components/tag-input.react'; import Button from '../../components/button.react'; import Modal from '../../components/modal.react'; import { styleSelector } from '../../themes/colors'; const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; export type AddUsersModalParams = {| presentedFrom: string, threadInfo: ThreadInfo, |}; type Props = {| navigation: RootNavigationProp<'AddUsersModal'>, route: NavigationRoute<'AddUsersModal'>, // Redux state parentThreadInfo: ?ThreadInfo, otherUserInfos: { [id: string]: AccountUserInfo }, userSearchIndex: SearchIndex, changeThreadSettingsLoadingStatus: LoadingStatus, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, - searchUsers: (usernamePrefix: string) => Promise, |}; type State = {| usernameInputText: string, userInfoInputArray: $ReadOnlyArray, |}; type PropsAndState = {| ...Props, ...State |}; class AddUsersModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ threadInfo: threadInfoPropType.isRequired, }).isRequired, }).isRequired, parentThreadInfo: threadInfoPropType, otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired, userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired, changeThreadSettingsLoadingStatus: loadingStatusPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeThreadSettings: PropTypes.func.isRequired, - searchUsers: PropTypes.func.isRequired, }; state = { usernameInputText: '', userInfoInputArray: [], }; tagInput: ?TagInput = null; - componentDidMount() { - this.searchUsers(''); - } - - searchUsers(usernamePrefix: string) { - this.props.dispatchActionPromise( - searchUsersActionTypes, - this.props.searchUsers(usernamePrefix), - ); - } - userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, (propsAndState: PropsAndState) => propsAndState.otherUserInfos, (propsAndState: PropsAndState) => propsAndState.userSearchIndex, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, (propsAndState: PropsAndState) => propsAndState.route.params.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { const excludeUserIDs = userInfoInputArray .map(userInfo => userInfo.id) .concat(threadActualMembers(threadInfo.members)); return getUserSearchResults( text, userInfos, searchIndex, excludeUserIDs, parentThreadInfo, ); }, ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } render() { let addButton = null; const inputLength = this.state.userInfoInputArray.length; if (inputLength > 0) { let activityIndicator = null; if (this.props.changeThreadSettingsLoadingStatus === 'loading') { activityIndicator = ( ); } const addButtonText = `Add (${inputLength})`; addButton = ( ); } let cancelButton; if (this.props.changeThreadSettingsLoadingStatus !== 'loading') { cancelButton = ( ); } else { cancelButton = ; } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( {cancelButton} {addButton} ); } close = () => { this.props.navigation.goBackOnce(); }; tagInputRef = (tagInput: ?TagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } - this.searchUsers(text); this.setState({ usernameInputText: text }); }; onUserSelect = (userID: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } 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: '', }); }; onPressAdd = () => { if (this.state.userInfoInputArray.length === 0) { return; } this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.addUsersToThread(), ); }; async addUsersToThread() { try { const newMemberIDs = this.state.userInfoInputArray.map( userInfo => userInfo.id, ); const result = await this.props.changeThreadSettings({ threadID: this.props.route.params.threadInfo.id, changes: { newMemberIDs }, }); this.close(); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'nameInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { userInfoInputArray: [], usernameInputText: '', }, this.onErrorAcknowledged, ); }; } const styles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'greenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; const stylesSelector = styleSelector(styles); const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); -registerFetchKey(searchUsersActionTypes); export default connect( ( state: AppState, ownProps: { route: NavigationRoute<'AddUsersModal'>, }, ) => { let parentThreadInfo = null; const { parentThreadID } = ownProps.route.params.threadInfo; if (parentThreadID) { parentThreadInfo = threadInfoSelector(state)[parentThreadID]; } return { parentThreadInfo, otherUserInfos: userInfoSelectorForOtherMembersOfThread((null: ?string))( state, ), userSearchIndex: userSearchIndexForOtherMembersOfThread(null)(state), changeThreadSettingsLoadingStatus: changeThreadSettingsLoadingStatusSelector( state, ), styles: stylesSelector(state), }; }, - { changeThreadSettings, searchUsers }, + { changeThreadSettings }, )(AddUsersModal); diff --git a/native/more/add-friends-modal.react.js b/native/more/add-friends-modal.react.js index 94e1c1f10..74865c0b1 100644 --- a/native/more/add-friends-modal.react.js +++ b/native/more/add-friends-modal.react.js @@ -1,233 +1,230 @@ // @flow import type { AccountUserInfo } from 'lib/types/user-types'; import type { UserSearchResult } from 'lib/types/search-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import type { AppState } from '../redux/redux-setup'; import * as React from 'react'; import { Text, View } from 'react-native'; import { CommonActions } from '@react-navigation/native'; import { createSelector } from 'reselect'; +import _keyBy from 'lodash/fp/keyBy'; -import { - userInfoSelectorForOtherMembersOfThread, - userSearchIndexForOtherMembersOfThread, -} from 'lib/selectors/user-selectors'; +import { searchIndexFromUserInfos } from 'lib/selectors/user-selectors'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { getUserSearchResults } from 'lib/shared/search-utils'; -import SearchIndex from 'lib/shared/search-index'; import { connect } from 'lib/utils/redux-utils'; import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions'; import UserList from '../components/user-list.react'; import Modal from '../components/modal.react'; import Button from '../components/button.react'; import TagInput from '../components/tag-input.react'; import { styleSelector } from '../themes/colors'; const tagInputProps = { placeholder: 'Select users to invite', autoFocus: true, returnKeyType: 'go', }; type Props = {| navigation: RootNavigationProp<'AddFriendsModal'>, route: NavigationRoute<'AddFriendsModal'>, // Redux state - otherUserInfos: { [id: string]: AccountUserInfo }, - userSearchIndex: SearchIndex, + viewerID: ?string, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs searchUsers: (usernamePrefix: string) => Promise, sendFriendRequest: (userIDs: string[]) => Promise, |}; type State = {| usernameInputText: string, userInfoInputArray: $ReadOnlyArray, + userInfos: { [id: string]: AccountUserInfo }, |}; type PropsAndState = {| ...Props, ...State |}; class AddFriendsModal extends React.PureComponent { state = { usernameInputText: '', userInfoInputArray: [], + userInfos: {}, }; tagInput: ?TagInput = null; componentDidMount() { this.searchUsers(''); } - searchUsers(usernamePrefix: string) { - this.props.dispatchActionPromise( - searchUsersActionTypes, - this.props.searchUsers(usernamePrefix), - ); + async searchUsers(usernamePrefix: string) { + const { userInfos } = await this.props.searchUsers(usernamePrefix); + this.setState({ userInfos: _keyBy(userInfo => userInfo.id)(userInfos) }); } userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, - (propsAndState: PropsAndState) => propsAndState.otherUserInfos, - (propsAndState: PropsAndState) => propsAndState.userSearchIndex, + (propsAndState: PropsAndState) => propsAndState.userInfos, + (propsAndState: PropsAndState) => propsAndState.viewerID, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, ( text: string, userInfos: { [id: string]: AccountUserInfo }, - searchIndex: SearchIndex, + viewerID: ?string, userInfoInputArray: $ReadOnlyArray, ) => { - // TODO: exclude current and blocked friends - const excludeUserIDs = userInfoInputArray.map(userInfo => userInfo.id); + const excludeUserIDs = userInfoInputArray + .map(userInfo => userInfo.id) + .concat(viewerID || []); + const searchIndex = searchIndexFromUserInfos(userInfos); return getUserSearchResults(text, userInfos, searchIndex, excludeUserIDs); }, ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } render() { let addButton = null; const inputLength = this.state.userInfoInputArray.length; if (inputLength > 0) { const addButtonText = `Add (${inputLength})`; addButton = ( ); } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( {addButton} ); } tagInputRef = (tagInput: ?TagInput) => { this.tagInput = tagInput; }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { this.searchUsers(text); this.setState({ usernameInputText: text }); }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { this.setState({ userInfoInputArray }); }; onUserSelect = (userID: string) => { if (this.state.userInfoInputArray.find(o => o.id === userID)) { return; } - const selectedUserInfo = this.props.otherUserInfos[userID]; + const selectedUserInfo = this.state.userInfos[userID]; this.setState(state => ({ userInfoInputArray: state.userInfoInputArray.concat(selectedUserInfo), usernameInputText: '', })); }; onPressAdd = () => { if (this.state.userInfoInputArray.length === 0) { return; } this.props.navigation.goBack(); }; goBackOnce() { this.props.navigation.dispatch(state => ({ ...CommonActions.goBack(), target: state.key, })); } close = () => { this.goBackOnce(); }; } const styles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'greenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; const stylesSelector = styleSelector(styles); registerFetchKey(searchUsersActionTypes); export default connect( (state: AppState) => { return { - otherUserInfos: userInfoSelectorForOtherMembersOfThread(null)(state), - userSearchIndex: userSearchIndexForOtherMembersOfThread(null)(state), + viewerID: state.currentUserInfo && state.currentUserInfo.id, styles: stylesSelector(state), }; }, { searchUsers }, )(AddFriendsModal); diff --git a/server/src/creators/thread-creator.js b/server/src/creators/thread-creator.js index e465154e3..8598f9e0a 100644 --- a/server/src/creators/thread-creator.js +++ b/server/src/creators/thread-creator.js @@ -1,228 +1,228 @@ // @flow import { type NewThreadRequest, type NewThreadResponse, threadTypes, threadPermissions, } from 'lib/types/thread-types'; import { messageTypes } from 'lib/types/message-types'; import type { Viewer } from '../session/viewer'; import invariant from 'invariant'; import { generateRandomColor } from 'lib/shared/thread-utils'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import { dbQuery, SQL } from '../database'; import { checkThreadPermission } from '../fetchers/thread-fetchers'; import createIDs from './id-creator'; import { createInitialRolesForNewThread } from './role-creator'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; import { changeRole, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getRelationshipRowsForUsers, getParentThreadRelationshipRowsForNewUsers, } from '../updaters/thread-permission-updaters'; import createMessages from './message-creator'; async function createThread( viewer: Viewer, request: NewThreadRequest, createRelationships?: boolean = false, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const threadType = request.type; const parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDs = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? request.initialMemberIDs - : []; + : null; if (threadType !== threadTypes.CHAT_SECRET && !parentThreadID) { throw new ServerError('invalid_parameters'); } const checkPromises = {}; if (parentThreadID) { checkPromises.hasParentPermission = checkThreadPermission( viewer, parentThreadID, threadPermissions.CREATE_SUBTHREADS, ); } if (initialMemberIDs) { checkPromises.fetchInitialMembers = fetchKnownUserInfos( viewer, initialMemberIDs, ); } const checkResults = await promiseAll(checkPromises); if (checkResults.hasParentPermission === false) { throw new ServerError('invalid_credentials'); } const viewerNeedsRelationshipsWith = []; if (checkResults.fetchInitialMembers) { invariant(initialMemberIDs, 'should be set'); for (const initialMemberID of initialMemberIDs) { if (checkResults.fetchInitialMembers[initialMemberID]) { continue; } if (!createRelationships) { throw new ServerError('invalid_credentials'); } viewerNeedsRelationshipsWith.push(initialMemberID); } } const [id] = await createIDs('threads', 1); const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? request.name : null; const description = request.description ? request.description : null; const color = request.color ? request.color.toLowerCase() : generateRandomColor(); const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, newRoles.default.id, ]; const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, default_role) VALUES ${[row]} `; await dbQuery(query); const [ creatorChangeset, initialMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberIDs ? changeRole(id, initialMemberIDs, null) : undefined, recalculateAllPermissions(id, threadType), ]); if (!creatorChangeset) { throw new ServerError('unknown_error'); } const { membershipRows: creatorMembershipRows, relationshipRows: creatorRelationshipRows, } = creatorChangeset; const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipRows = [ ...creatorRelationshipRows, ...recalculateRelationshipRows, ]; if (initialMemberIDs) { if (!initialMembersChangeset) { throw new ServerError('unknown_error'); } relationshipRows.push( ...getRelationshipRowsForUsers( viewer.userID, viewerNeedsRelationshipsWith, ), ); const { membershipRows: initialMembersMembershipRows, relationshipRows: initialMembersRelationshipRows, } = initialMembersChangeset; const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( id, recalculateMembershipRows, initialMemberAndCreatorIDs, ); membershipRows.push(...initialMembersMembershipRows); relationshipRows.push( ...initialMembersRelationshipRows, ...parentRelationshipRows, ); } setJoinsToUnread(membershipRows, viewer.userID, id); const messageDatas = [ { type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, ]; if (parentThreadID) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const changeset = { membershipRows, relationshipRows }; const [newMessageInfos, commitResult] = await Promise.all([ createMessages(viewer, messageDatas), commitMembershipChangeset(viewer, changeset), ]); const { threadInfos, viewerUpdates } = commitResult; if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } return { newThreadInfo: threadInfos[id], updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } export default createThread; diff --git a/server/src/fetchers/entry-fetchers.js b/server/src/fetchers/entry-fetchers.js index 745c688ac..6ccae138f 100644 --- a/server/src/fetchers/entry-fetchers.js +++ b/server/src/fetchers/entry-fetchers.js @@ -1,339 +1,319 @@ // @flow import type { CalendarQuery, FetchEntryInfosResponse, DeltaEntryInfosResponse, RawEntryInfo, } from 'lib/types/entry-types'; import type { HistoryRevisionInfo } from 'lib/types/history-types'; import type { Viewer } from '../session/viewer'; import { threadPermissions, type ThreadPermission, } from 'lib/types/thread-types'; import { calendarThreadFilterTypes } from 'lib/types/filter-types'; import invariant from 'invariant'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { ServerError } from 'lib/utils/errors'; import { filteredThreadIDs, filterExists, nonExcludeDeletedCalendarFilters, } from 'lib/selectors/calendar-filter-selectors'; -import { - rawEntryInfoWithinCalendarQuery, - usersInRawEntryInfos, -} from 'lib/shared/entry-utils'; +import { rawEntryInfoWithinCalendarQuery } from 'lib/shared/entry-utils'; import { dbQuery, SQL, SQLStatement, mergeAndConditions, mergeOrConditions, } from '../database'; import { creationString } from '../utils/idempotent'; async function fetchEntryInfo( viewer: Viewer, entryID: string, ): Promise { const results = await fetchEntryInfosByID(viewer, [entryID]); if (results.length === 0) { return null; } return results[0]; } function rawEntryInfoFromRow(row: Object): RawEntryInfo { return { id: row.id.toString(), threadID: row.threadID.toString(), text: row.text, year: row.year, month: row.month, day: row.day, creationTime: row.creationTime, creatorID: row.creatorID.toString(), deleted: !!row.deleted, }; } const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; async function fetchEntryInfosByID( viewer: Viewer, entryIDs: $ReadOnlyArray, ): Promise { if (entryIDs.length === 0) { return []; } const viewerID = viewer.id; const query = SQL` SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year, e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID, e.deleted, e.creator AS creatorID FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID} WHERE e.id IN (${entryIDs}) AND JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE `; const [result] = await dbQuery(query); return result.map(rawEntryInfoFromRow); } function sqlConditionForCalendarQuery( calendarQuery: CalendarQuery, ): ?SQLStatement { const { filters, startDate, endDate } = calendarQuery; const conditions = []; conditions.push(SQL`d.date BETWEEN ${startDate} AND ${endDate}`); const filterToThreadIDs = filteredThreadIDs(filters); if (filterToThreadIDs && filterToThreadIDs.size > 0) { conditions.push(SQL`d.thread IN (${[...filterToThreadIDs]})`); } else if (filterToThreadIDs) { // Filter to empty set means the result is empty return null; } else { conditions.push(SQL`m.role != 0`); } if (filterExists(filters, calendarThreadFilterTypes.NOT_DELETED)) { conditions.push(SQL`e.deleted = 0`); } return mergeAndConditions(conditions); } async function fetchEntryInfos( viewer: Viewer, calendarQueries: $ReadOnlyArray, ): Promise { const queryConditions = calendarQueries .map(sqlConditionForCalendarQuery) .filter(condition => condition); if (queryConditions.length === 0) { return { rawEntryInfos: [], userInfos: {} }; } const queryCondition = mergeOrConditions(queryConditions); const viewerID = viewer.id; const query = SQL` SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year, e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID, e.deleted, e.creator AS creatorID, u.username AS creator FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID} LEFT JOIN users u ON u.id = e.creator WHERE JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE AND `; query.append(queryCondition); query.append(SQL`ORDER BY e.creation_time DESC`); const [result] = await dbQuery(query); const rawEntryInfos = []; - const userInfos = {}; for (let row of result) { rawEntryInfos.push(rawEntryInfoFromRow(row)); - if (row.creator) { - const creatorID = row.creatorID.toString(); - userInfos[creatorID] = { - id: creatorID, - username: row.creator, - }; - } } - return { rawEntryInfos, userInfos }; + return { rawEntryInfos, userInfos: {} }; } async function checkThreadPermissionForEntry( viewer: Viewer, entryID: string, permission: ThreadPermission, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.permissions, t.id FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN threads t ON t.id = d.thread LEFT JOIN memberships m ON m.thread = t.id AND m.user = ${viewerID} WHERE e.id = ${entryID} `; const [result] = await dbQuery(query); if (result.length === 0) { return false; } const row = result[0]; if (row.id === null) { return false; } return permissionLookup(row.permissions, permission); } async function fetchEntryRevisionInfo( viewer: Viewer, entryID: string, ): Promise<$ReadOnlyArray> { const hasPermission = await checkThreadPermissionForEntry( viewer, entryID, threadPermissions.VISIBLE, ); if (!hasPermission) { throw new ServerError('invalid_credentials'); } const query = SQL` SELECT r.id, u.username AS author, r.text, r.last_update AS lastUpdate, r.deleted, d.thread AS threadID, r.entry AS entryID FROM revisions r LEFT JOIN users u ON u.id = r.author LEFT JOIN entries e ON e.id = r.entry LEFT JOIN days d ON d.id = e.day WHERE r.entry = ${entryID} ORDER BY r.last_update DESC `; const [result] = await dbQuery(query); const revisions = []; for (let row of result) { revisions.push({ id: row.id.toString(), author: row.author, text: row.text, lastUpdate: row.lastUpdate, deleted: !!row.deleted, threadID: row.threadID.toString(), entryID: row.entryID.toString(), }); } return revisions; } // calendarQueries are the "difference" queries we get from subtracting the old // CalendarQuery from the new one. See calendarQueryDifference. // oldCalendarQuery is the old CalendarQuery. We make sure none of the returned // RawEntryInfos match the old CalendarQuery, so that only the difference is // returned. async function fetchEntriesForSession( viewer: Viewer, calendarQueries: $ReadOnlyArray, oldCalendarQuery: CalendarQuery, ): Promise { // If we're not including deleted entries, we will try and set deletedEntryIDs // so that the client can catch possibly stale deleted entryInfos let filterDeleted = null; for (let calendarQuery of calendarQueries) { const notDeletedFilterExists = filterExists( calendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED, ); if (filterDeleted === null) { filterDeleted = notDeletedFilterExists; } else { invariant( filterDeleted === notDeletedFilterExists, 'one of the CalendarQueries returned by calendarQueryDifference has ' + 'a NOT_DELETED filter but another does not: ' + JSON.stringify(calendarQueries), ); } } let calendarQueriesForFetch = calendarQueries; if (filterDeleted) { // Because in the filterDeleted case we still need the deleted RawEntryInfos // in order to construct deletedEntryIDs, we get rid of the NOT_DELETED // filters before passing the CalendarQueries to fetchEntryInfos. We will // filter out the deleted RawEntryInfos in a later step. calendarQueriesForFetch = calendarQueriesForFetch.map(calendarQuery => ({ ...calendarQuery, filters: nonExcludeDeletedCalendarFilters(calendarQuery.filters), })); } - const { rawEntryInfos, userInfos } = await fetchEntryInfos( + const { rawEntryInfos } = await fetchEntryInfos( viewer, calendarQueriesForFetch, ); const entryInfosNotInOldQuery = rawEntryInfos.filter( rawEntryInfo => !rawEntryInfoWithinCalendarQuery(rawEntryInfo, oldCalendarQuery), ); let filteredRawEntryInfos = entryInfosNotInOldQuery; let deletedEntryIDs = []; if (filterDeleted) { filteredRawEntryInfos = entryInfosNotInOldQuery.filter( rawEntryInfo => !rawEntryInfo.deleted, ); deletedEntryIDs = entryInfosNotInOldQuery .filter(rawEntryInfo => rawEntryInfo.deleted) .map(rawEntryInfo => { const { id } = rawEntryInfo; invariant( id !== null && id !== undefined, 'serverID should be set in fetchEntryInfos result', ); return id; }); } - const userIDs = new Set(usersInRawEntryInfos(filteredRawEntryInfos)); - const filteredUserInfos = {}; - for (let userID in userInfos) { - if (!userIDs.has(userID)) { - continue; - } - filteredUserInfos[userID] = userInfos[userID]; - } return { rawEntryInfos: filteredRawEntryInfos, deletedEntryIDs, - userInfos: filteredUserInfos, }; } async function fetchEntryInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT DAY(d.date) AS day, MONTH(d.date) AS month, YEAR(d.date) AS year, e.id, e.text, e.creation_time AS creationTime, d.thread AS threadID, e.deleted, e.creator AS creatorID FROM entries e LEFT JOIN days d ON d.id = e.day LEFT JOIN memberships m ON m.thread = d.thread AND m.user = ${viewerID} WHERE e.creator = ${viewerID} AND e.creation = ${creation} AND JSON_EXTRACT(m.permissions, ${visPermissionExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return rawEntryInfoFromRow(result[0]); } export { fetchEntryInfo, fetchEntryInfosByID, fetchEntryInfos, checkThreadPermissionForEntry, fetchEntryRevisionInfo, fetchEntriesForSession, fetchEntryInfoForLocalID, }; diff --git a/server/src/fetchers/message-fetchers.js b/server/src/fetchers/message-fetchers.js index 4a1e31c68..78a05d596 100644 --- a/server/src/fetchers/message-fetchers.js +++ b/server/src/fetchers/message-fetchers.js @@ -1,730 +1,729 @@ // @flow import type { PushInfo } from '../push/send'; import type { UserInfos } from 'lib/types/user-types'; import { type RawMessageInfo, messageTypes, type MessageType, assertMessageType, type ThreadSelectionCriteria, type MessageTruncationStatus, messageTruncationStatus, type FetchMessageInfosResult, type RawTextMessageInfo, } from 'lib/types/message-types'; import { threadPermissions } from 'lib/types/thread-types'; import type { Viewer } from '../session/viewer'; import invariant from 'invariant'; import { notifCollapseKeyForRawMessageInfo } from 'lib/shared/notif-utils'; import { sortMessageInfoList, shimUnsupportedRawMessageInfos, createMediaMessageInfo, } from 'lib/shared/message-utils'; import { permissionLookup } from 'lib/permissions/thread-permissions'; import { ServerError } from 'lib/utils/errors'; import { dbQuery, SQL, mergeOrConditions } from '../database'; import { fetchUserInfos } from './user-fetchers'; import { creationString, localIDFromCreationString } from '../utils/idempotent'; import { mediaFromRow } from './upload-fetchers'; export type CollapsableNotifInfo = {| collapseKey: ?string, existingMessageInfos: RawMessageInfo[], newMessageInfos: RawMessageInfo[], |}; export type FetchCollapsableNotifsResult = {| usersToCollapsableNotifInfo: { [userID: string]: CollapsableNotifInfo[] }, userInfos: UserInfos, |}; // This function doesn't filter RawMessageInfos based on what messageTypes the // client supports, since each user can have multiple clients. The caller must // handle this filtering. async function fetchCollapsableNotifs( pushInfo: PushInfo, ): Promise { // First, we need to fetch any notifications that should be collapsed const usersToCollapseKeysToInfo = {}; const usersToCollapsableNotifInfo = {}; for (let userID in pushInfo) { usersToCollapseKeysToInfo[userID] = {}; usersToCollapsableNotifInfo[userID] = []; for (let rawMessageInfo of pushInfo[userID].messageInfos) { const collapseKey = notifCollapseKeyForRawMessageInfo(rawMessageInfo); if (!collapseKey) { const collapsableNotifInfo = { collapseKey, existingMessageInfos: [], newMessageInfos: [rawMessageInfo], }; usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo); continue; } if (!usersToCollapseKeysToInfo[userID][collapseKey]) { usersToCollapseKeysToInfo[userID][collapseKey] = { collapseKey, existingMessageInfos: [], newMessageInfos: [], }; } usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push( rawMessageInfo, ); } } const sqlTuples = []; for (let userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (let collapseKey in collapseKeysToInfo) { sqlTuples.push( SQL`(n.user = ${userID} AND n.collapse_key = ${collapseKey})`, ); } } if (sqlTuples.length === 0) { return { usersToCollapsableNotifInfo, userInfos: {} }; } const visPermissionExtractString = `$.${threadPermissions.VISIBLE}.value`; const collapseQuery = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, u.username AS creator, m.user AS creatorID, stm.permissions AS subthread_permissions, n.user, n.collapse_key, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM notifications n LEFT JOIN messages m ON m.id = n.message LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = n.user LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = n.user LEFT JOIN users u ON u.id = m.user WHERE n.rescinded = 0 AND JSON_EXTRACT(mm.permissions, ${visPermissionExtractString}) IS TRUE AND `; collapseQuery.append(mergeOrConditions(sqlTuples)); collapseQuery.append(SQL`ORDER BY m.time DESC`); const [collapseResult] = await dbQuery(collapseQuery); const { userInfos, messages } = parseMessageSQLResult(collapseResult); for (let message of messages) { const { rawMessageInfo, rows } = message; const [row] = rows; const info = usersToCollapseKeysToInfo[row.user][row.collapse_key]; info.existingMessageInfos.push(rawMessageInfo); } for (let userID in usersToCollapseKeysToInfo) { const collapseKeysToInfo = usersToCollapseKeysToInfo[userID]; for (let collapseKey in collapseKeysToInfo) { const info = collapseKeysToInfo[collapseKey]; usersToCollapsableNotifInfo[userID].push({ collapseKey: info.collapseKey, existingMessageInfos: sortMessageInfoList(info.existingMessageInfos), newMessageInfos: sortMessageInfoList(info.newMessageInfos), }); } } return { usersToCollapsableNotifInfo, userInfos }; } type MessageSQLResult = {| messages: $ReadOnlyArray<{| rawMessageInfo: RawMessageInfo, rows: $ReadOnlyArray, |}>, userInfos: UserInfos, |}; function parseMessageSQLResult( rows: $ReadOnlyArray, viewer?: Viewer, ): MessageSQLResult { const userInfos = {}, rowsByID = new Map(); for (let row of rows) { const creatorID = row.creatorID.toString(); userInfos[creatorID] = { id: creatorID, username: row.creator, }; const id = row.id.toString(); const currentRowsForID = rowsByID.get(id); if (currentRowsForID) { currentRowsForID.push(row); } else { rowsByID.set(id, [row]); } } const messages = []; for (let messageRows of rowsByID.values()) { const rawMessageInfo = rawMessageInfoFromRows(messageRows, viewer); if (rawMessageInfo) { messages.push({ rawMessageInfo, rows: messageRows }); } } return { messages, userInfos }; } function assertSingleRow(rows: $ReadOnlyArray): Object { if (rows.length === 0) { throw new Error('expected single row, but none present!'); } else if (rows.length !== 1) { const messageIDs = rows.map(row => row.id.toString()); console.log( `expected single row, but there are multiple! ${messageIDs.join(', ')}`, ); } return rows[0]; } function mostRecentRowType(rows: $ReadOnlyArray): MessageType { if (rows.length === 0) { throw new Error('expected row, but none present!'); } return assertMessageType(rows[0].type); } function rawMessageInfoFromRows( rows: $ReadOnlyArray, viewer?: ?Viewer, ): ?RawMessageInfo { const type = mostRecentRowType(rows); if (type === messageTypes.TEXT) { const row = assertSingleRow(rows); const rawTextMessageInfo: RawTextMessageInfo = { type: messageTypes.TEXT, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), text: row.content, }; const localID = localIDFromCreationString(viewer, row.creation); if (localID) { rawTextMessageInfo.localID = localID; } return rawTextMessageInfo; } else if (type === messageTypes.CREATE_THREAD) { const row = assertSingleRow(rows); const dbInitialThreadState = JSON.parse(row.content); // For legacy clients before the rename const initialThreadState = { ...dbInitialThreadState, visibilityRules: dbInitialThreadState.type, }; return { type: messageTypes.CREATE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), initialThreadState, }; } else if (type === messageTypes.ADD_MEMBERS) { const row = assertSingleRow(rows); return { type: messageTypes.ADD_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), addedUserIDs: JSON.parse(row.content), }; } else if (type === messageTypes.CREATE_SUB_THREAD) { const row = assertSingleRow(rows); const subthreadPermissions = row.subthread_permissions; if (!permissionLookup(subthreadPermissions, threadPermissions.KNOW_OF)) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), childThreadID: row.content, }; } else if (type === messageTypes.CHANGE_SETTINGS) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); const field = Object.keys(content)[0]; return { type: messageTypes.CHANGE_SETTINGS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), field, value: content[field], }; } else if (type === messageTypes.REMOVE_MEMBERS) { const row = assertSingleRow(rows); return { type: messageTypes.REMOVE_MEMBERS, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), removedUserIDs: JSON.parse(row.content), }; } else if (type === messageTypes.CHANGE_ROLE) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.CHANGE_ROLE, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), userIDs: content.userIDs, newRole: content.newRole, }; } else if (type === messageTypes.LEAVE_THREAD) { const row = assertSingleRow(rows); return { type: messageTypes.LEAVE_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; } else if (type === messageTypes.JOIN_THREAD) { const row = assertSingleRow(rows); return { type: messageTypes.JOIN_THREAD, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), }; } else if (type === messageTypes.CREATE_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.CREATE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.EDIT_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.EDIT_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.DELETE_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.DELETE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.RESTORE_ENTRY) { const row = assertSingleRow(rows); const content = JSON.parse(row.content); return { type: messageTypes.RESTORE_ENTRY, id: row.id.toString(), threadID: row.threadID.toString(), time: row.time, creatorID: row.creatorID.toString(), entryID: content.entryID, date: content.date, text: content.text, }; } else if (type === messageTypes.IMAGES || type === messageTypes.MULTIMEDIA) { const media = rows.filter(row => row.uploadID).map(mediaFromRow); const [row] = rows; return createMediaMessageInfo({ threadID: row.threadID.toString(), creatorID: row.creatorID.toString(), media, id: row.id.toString(), localID: localIDFromCreationString(viewer, row.creation), time: row.time, }); } else { invariant(false, `unrecognized messageType ${type}`); } } const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; async function fetchMessageInfos( viewer: Viewer, criteria: ThreadSelectionCriteria, numberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = {}; const viewerID = viewer.id; const query = SQL` SELECT * FROM ( SELECT x.id, x.content, x.time, x.type, x.user AS creatorID, x.creation, u.username AS creator, x.subthread_permissions, x.uploadID, x.uploadType, x.uploadSecret, x.uploadExtra, @num := if( @thread = x.thread, if(@message = x.id, @num, @num + 1), 1 ) AS number, @message := x.id AS messageID, @thread := x.thread AS threadID FROM (SELECT @num := 0, @thread := '', @message := '') init JOIN ( SELECT m.id, m.thread, m.user, m.content, m.time, m.type, m.creation, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC ) x LEFT JOIN users u ON u.id = x.user ) y WHERE y.number <= ${numberPerThread} `); const [result] = await dbQuery(query); - const { userInfos, messages } = parseMessageSQLResult(result, viewer); + const { messages } = parseMessageSQLResult(result, viewer); const rawMessageInfos = []; const threadToMessageCount = new Map(); for (let message of messages) { const { rawMessageInfo } = message; rawMessageInfos.push(rawMessageInfo); const { threadID } = rawMessageInfo; const currentCountValue = threadToMessageCount.get(threadID); const currentCount = currentCountValue ? currentCountValue : 0; threadToMessageCount.set(threadID, currentCount + 1); } for (let [threadID, messageCount] of threadToMessageCount) { // If there are fewer messages returned than the max for a given thread, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageCount < numberPerThread ? messageTruncationStatus.EXHAUSTIVE : messageTruncationStatus.TRUNCATED; } for (let rawMessageInfo of rawMessageInfos) { if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { // If a CREATE_THREAD message for a given thread is in the result set, // then our result set includes all messages in the query range for that // thread truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } } for (let threadID in criteria.threadCursors) { const truncationStatus = truncationStatuses[threadID]; if (truncationStatus === null || truncationStatus === undefined) { // If nothing was returned for a thread that was explicitly queried for, // then our result set includes all messages in the query range for that // thread truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } else if (truncationStatus === messageTruncationStatus.TRUNCATED) { // If a cursor was specified for a given thread, then the result is // guaranteed to be contiguous with what the client has, and as such the // result should never be TRUNCATED truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } } - const allUserInfos = await fetchAllUsers(rawMessageInfos, userInfos); const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, - userInfos: allUserInfos, + userInfos: {}, }; } function threadSelectionCriteriaToSQLClause(criteria: ThreadSelectionCriteria) { const conditions = []; if (criteria.joinedThreads === true) { conditions.push(SQL`mm.role != 0`); } if (criteria.threadCursors) { for (let threadID in criteria.threadCursors) { const cursor = criteria.threadCursors[threadID]; if (cursor) { conditions.push(SQL`(m.thread = ${threadID} AND m.id < ${cursor})`); } else { conditions.push(SQL`m.thread = ${threadID}`); } } } if (conditions.length === 0) { throw new ServerError('internal_error'); } return mergeOrConditions(conditions); } function threadSelectionCriteriaToInitialTruncationStatuses( criteria: ThreadSelectionCriteria, defaultTruncationStatus: MessageTruncationStatus, ) { const truncationStatuses = {}; if (criteria.threadCursors) { for (let threadID in criteria.threadCursors) { truncationStatuses[threadID] = defaultTruncationStatus; } } return truncationStatuses; } async function fetchAllUsers( rawMessageInfos: $ReadOnlyArray, userInfos: UserInfos, ): Promise { const allAddedUserIDs = []; for (let rawMessageInfo of rawMessageInfos) { let newUsers = []; if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { newUsers = rawMessageInfo.addedUserIDs; } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { newUsers = rawMessageInfo.initialThreadState.memberIDs; } for (let userID of newUsers) { if (!userInfos[userID]) { allAddedUserIDs.push(userID); } } } if (allAddedUserIDs.length === 0) { return userInfos; } const newUserInfos = await fetchUserInfos(allAddedUserIDs); // $FlowFixMe should be fixed in flow-bin@0.115 / react-native@0.63 return { ...userInfos, ...newUserInfos, }; } async function fetchMessageInfosSince( viewer: Viewer, criteria: ThreadSelectionCriteria, currentAsOf: number, maxNumberPerThread: number, ): Promise { const threadSelectionClause = threadSelectionCriteriaToSQLClause(criteria); const truncationStatuses = threadSelectionCriteriaToInitialTruncationStatuses( criteria, messageTruncationStatus.UNCHANGED, ); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, u.username AS creator, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} LEFT JOIN users u ON u.id = m.user WHERE m.time > ${currentAsOf} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE AND `; query.append(threadSelectionClause); query.append(SQL` ORDER BY m.thread, m.time DESC `); const [result] = await dbQuery(query); const { userInfos: allCreatorUserInfos, messages } = parseMessageSQLResult( result, viewer, ); const rawMessageInfos = []; const userInfos = {}; let currentThreadID = null; let numMessagesForCurrentThreadID = 0; for (let message of messages) { const { rawMessageInfo } = message; const { threadID } = rawMessageInfo; if (threadID !== currentThreadID) { currentThreadID = threadID; numMessagesForCurrentThreadID = 1; truncationStatuses[threadID] = messageTruncationStatus.UNCHANGED; } else { numMessagesForCurrentThreadID++; } if (numMessagesForCurrentThreadID <= maxNumberPerThread) { if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { // If a CREATE_THREAD message is here, then we have all messages truncationStatuses[threadID] = messageTruncationStatus.EXHAUSTIVE; } const { creatorID } = rawMessageInfo; const userInfo = allCreatorUserInfos[creatorID]; if (userInfo) { userInfos[creatorID] = userInfo; } rawMessageInfos.push(rawMessageInfo); } else if (numMessagesForCurrentThreadID === maxNumberPerThread + 1) { truncationStatuses[threadID] = messageTruncationStatus.TRUNCATED; } } const allUserInfos = await fetchAllUsers(rawMessageInfos, userInfos); const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, userInfos: allUserInfos, }; } async function getMessageFetchResultFromRedisMessages( viewer: Viewer, rawMessageInfos: $ReadOnlyArray, ): Promise { const truncationStatuses = {}; for (let rawMessageInfo of rawMessageInfos) { truncationStatuses[rawMessageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const userInfos = await fetchAllUsers(rawMessageInfos, {}); const shimmedRawMessageInfos = shimUnsupportedRawMessageInfos( rawMessageInfos, viewer.platformDetails, ); return { rawMessageInfos: shimmedRawMessageInfos, truncationStatuses, userInfos, }; } async function fetchMessageInfoForLocalID( viewer: Viewer, localID: ?string, ): Promise { if (!localID || !viewer.hasSessionInfo) { return null; } const creation = creationString(viewer, localID); const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, stm.permissions AS subthread_permissions, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} LEFT JOIN memberships stm ON m.type = ${messageTypes.CREATE_SUB_THREAD} AND stm.thread = m.content AND stm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.creation = ${creation} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return rawMessageInfoFromRows(result, viewer); } const entryIDExtractString = '$.entryID'; async function fetchMessageInfoForEntryAction( viewer: Viewer, messageType: MessageType, entryID: string, threadID: string, ): Promise { const viewerID = viewer.id; const query = SQL` SELECT m.id, m.thread AS threadID, m.content, m.time, m.type, m.creation, m.user AS creatorID, up.id AS uploadID, up.type AS uploadType, up.secret AS uploadSecret, up.extra AS uploadExtra FROM messages m LEFT JOIN uploads up ON m.type IN (${[messageTypes.IMAGES, messageTypes.MULTIMEDIA]}) AND JSON_CONTAINS(m.content, CAST(up.id as JSON), '$') LEFT JOIN memberships mm ON mm.thread = m.thread AND mm.user = ${viewerID} WHERE m.user = ${viewerID} AND m.thread = ${threadID} AND m.type = ${messageType} AND JSON_EXTRACT(m.content, ${entryIDExtractString}) = ${entryID} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE `; const [result] = await dbQuery(query); if (result.length === 0) { return null; } return rawMessageInfoFromRows(result, viewer); } export { fetchCollapsableNotifs, fetchMessageInfos, fetchMessageInfosSince, getMessageFetchResultFromRedisMessages, fetchMessageInfoForLocalID, fetchMessageInfoForEntryAction, }; diff --git a/server/src/responders/entry-responders.js b/server/src/responders/entry-responders.js index 8e6613f13..96f020e73 100644 --- a/server/src/responders/entry-responders.js +++ b/server/src/responders/entry-responders.js @@ -1,258 +1,258 @@ // @flow import type { Viewer } from '../session/viewer'; import type { CalendarQuery, SaveEntryRequest, CreateEntryRequest, DeleteEntryRequest, DeleteEntryResponse, RestoreEntryRequest, RestoreEntryResponse, FetchEntryInfosResponse, DeltaEntryInfosResult, SaveEntryResponse, } from 'lib/types/entry-types'; import type { FetchEntryRevisionInfosResult, FetchEntryRevisionInfosRequest, } from 'lib/types/history-types'; import { calendarThreadFilterTypes } from 'lib/types/filter-types'; import t from 'tcomb'; import { ServerError } from 'lib/utils/errors'; import { filteredThreadIDs } from 'lib/selectors/calendar-filter-selectors'; -import { values } from 'lib/utils/objects'; import { validateInput, tString, tShape, tDate, } from '../utils/validation-utils'; import { verifyThreadIDs } from '../fetchers/thread-fetchers'; import { fetchEntryInfos, fetchEntryRevisionInfo, fetchEntriesForSession, } from '../fetchers/entry-fetchers'; import createEntry from '../creators/entry-creator'; import { updateEntry, compareNewCalendarQuery, } from '../updaters/entry-updaters'; import { deleteEntry, restoreEntry } from '../deleters/entry-deleters'; import { commitSessionUpdate } from '../updaters/session-updaters'; const entryQueryInputValidator = tShape({ navID: t.maybe(t.String), startDate: tDate, endDate: tDate, includeDeleted: t.maybe(t.Boolean), filters: t.maybe( t.list( t.union([ tShape({ type: tString(calendarThreadFilterTypes.NOT_DELETED), }), tShape({ type: tString(calendarThreadFilterTypes.THREAD_LIST), threadIDs: t.list(t.String), }), ]), ), ), }); const newEntryQueryInputValidator = tShape({ startDate: tDate, endDate: tDate, filters: t.list( t.union([ tShape({ type: tString(calendarThreadFilterTypes.NOT_DELETED), }), tShape({ type: tString(calendarThreadFilterTypes.THREAD_LIST), threadIDs: t.list(t.String), }), ]), ), }); function normalizeCalendarQuery(input: any): CalendarQuery { if (input.filters) { return { startDate: input.startDate, endDate: input.endDate, filters: input.filters, }; } const filters = []; if (!input.includeDeleted) { filters.push({ type: calendarThreadFilterTypes.NOT_DELETED }); } if (input.navID !== 'home') { filters.push({ type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: [input.navID], }); } return { startDate: input.startDate, endDate: input.endDate, filters, }; } async function verifyCalendarQueryThreadIDs( request: CalendarQuery, ): Promise { const threadIDsToFilterTo = filteredThreadIDs(request.filters); if (threadIDsToFilterTo && threadIDsToFilterTo.size > 0) { const verifiedThreadIDs = await verifyThreadIDs([...threadIDsToFilterTo]); if (verifiedThreadIDs.length !== threadIDsToFilterTo.size) { throw new ServerError('invalid_parameters'); } } } async function entryFetchResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, entryQueryInputValidator, input); const request = normalizeCalendarQuery(input); await verifyCalendarQueryThreadIDs(request); return await fetchEntryInfos(viewer, [request]); } const entryRevisionHistoryFetchInputValidator = tShape({ id: t.String, }); async function entryRevisionFetchResponder( viewer: Viewer, input: any, ): Promise { const request: FetchEntryRevisionInfosRequest = input; await validateInput(viewer, entryRevisionHistoryFetchInputValidator, request); const entryHistory = await fetchEntryRevisionInfo(viewer, request.id); return { result: entryHistory }; } const createEntryRequestInputValidator = tShape({ text: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, date: tDate, threadID: t.String, localID: t.maybe(t.String), calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryCreationResponder( viewer: Viewer, input: any, ): Promise { const request: CreateEntryRequest = input; await validateInput(viewer, createEntryRequestInputValidator, request); return await createEntry(viewer, request); } const saveEntryRequestInputValidator = tShape({ entryID: t.String, text: t.String, prevText: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SaveEntryRequest = input; await validateInput(viewer, saveEntryRequestInputValidator, request); return await updateEntry(viewer, request); } const deleteEntryRequestInputValidator = tShape({ entryID: t.String, prevText: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteEntryRequest = input; await validateInput(viewer, deleteEntryRequestInputValidator, request); return await deleteEntry(viewer, request); } const restoreEntryRequestInputValidator = tShape({ entryID: t.String, sessionID: t.maybe(t.String), timestamp: t.Number, calendarQuery: t.maybe(newEntryQueryInputValidator), }); async function entryRestorationResponder( viewer: Viewer, input: any, ): Promise { const request: RestoreEntryRequest = input; await validateInput(viewer, restoreEntryRequestInputValidator, request); return await restoreEntry(viewer, request); } async function calendarQueryUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: CalendarQuery = input; await validateInput(viewer, newEntryQueryInputValidator, input); await verifyCalendarQueryThreadIDs(request); if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const { difference, oldCalendarQuery, sessionUpdate, } = compareNewCalendarQuery(viewer, request); const [response] = await Promise.all([ fetchEntriesForSession(viewer, difference, oldCalendarQuery), commitSessionUpdate(viewer, sessionUpdate), ]); return { rawEntryInfos: response.rawEntryInfos, deletedEntryIDs: response.deletedEntryIDs, - userInfos: values(response.userInfos), + // Old clients expect userInfos object + userInfos: [], }; } export { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, };