diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index 91812d452..c35dce4dc 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,171 +1,172 @@ // @flow import invariant from 'invariant'; import type { ChangeThreadSettingsPayload, LeaveThreadPayload, UpdateThreadRequest, NewThreadRequest, NewThreadResult, ClientThreadJoinRequest, ThreadJoinPayload, } from '../types/thread-types'; import type { FetchJSON } from '../utils/fetch-json'; 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, + userInfos: response.userInfos, }; } 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, }, }; } 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/reducers/thread-reducer.js b/lib/reducers/thread-reducer.js index 8b1197e58..093cbe4c0 100644 --- a/lib/reducers/thread-reducer.js +++ b/lib/reducers/thread-reducer.js @@ -1,342 +1,342 @@ // @flow import _isEqual from 'lodash/fp/isEqual'; import { setThreadUnreadStatusActionTypes, updateActivityActionTypes, } from '../actions/activity-actions'; import { saveMessagesActionType } from '../actions/message-actions'; import { sendReportActionTypes, sendReportsActionTypes, } from '../actions/report-actions'; import { changeThreadSettingsActionTypes, deleteThreadActionTypes, newThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, joinThreadActionTypes, leaveThreadActionTypes, } from '../actions/thread-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, registerActionTypes, updateSubscriptionActionTypes, } from '../actions/user-actions'; import type { BaseAction } from '../types/redux-types'; import { type ClientThreadInconsistencyReportCreationRequest, reportTypes, } from '../types/report-types'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import type { RawThreadInfo, ThreadStore } from '../types/thread-types'; import { updateTypes, type UpdateInfo, processUpdatesActionType, } from '../types/update-types'; import { actionLogger } from '../utils/action-logger'; import { setNewSessionActionType } from '../utils/action-utils'; import { getConfig } from '../utils/config'; import { sanitizeAction } from '../utils/sanitization'; function reduceThreadUpdates( threadInfos: { [id: string]: RawThreadInfo }, - payload: { +updatesResult: { newUpdates: $ReadOnlyArray } }, + payload: { +updatesResult: { +newUpdates: $ReadOnlyArray } }, ): { [id: string]: RawThreadInfo } { const newState = { ...threadInfos }; let someThreadUpdated = false; for (let update of payload.updatesResult.newUpdates) { if ( (update.type === updateTypes.UPDATE_THREAD || update.type === updateTypes.JOIN_THREAD) && !_isEqual(threadInfos[update.threadInfo.id])(update.threadInfo) ) { someThreadUpdated = true; newState[update.threadInfo.id] = update.threadInfo; } else if ( update.type === updateTypes.UPDATE_THREAD_READ_STATUS && threadInfos[update.threadID] && threadInfos[update.threadID].currentUser.unread !== update.unread ) { someThreadUpdated = true; newState[update.threadID] = { ...threadInfos[update.threadID], currentUser: { ...threadInfos[update.threadID].currentUser, unread: update.unread, }, }; } else if ( update.type === updateTypes.DELETE_THREAD && threadInfos[update.threadID] ) { someThreadUpdated = true; delete newState[update.threadID]; } else if (update.type === updateTypes.DELETE_ACCOUNT) { for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; const newMembers = threadInfo.members.filter( (member) => member.id !== update.deletedUserID, ); if (newMembers.length < threadInfo.members.length) { someThreadUpdated = true; newState[threadID] = { ...threadInfo, members: newMembers, }; } } } } if (!someThreadUpdated) { return threadInfos; } return newState; } const emptyArray = []; function findInconsistencies( action: BaseAction, beforeStateCheck: { [id: string]: RawThreadInfo }, afterStateCheck: { [id: string]: RawThreadInfo }, ): ClientThreadInconsistencyReportCreationRequest[] { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return emptyArray; } return [ { type: reportTypes.THREAD_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeAction(action), pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } export default function reduceThreadInfos( state: ThreadStore, action: BaseAction, ): ThreadStore { if ( action.type === logInActionTypes.success || action.type === registerActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === fullStateSyncActionType ) { if (_isEqual(state.threadInfos)(action.payload.threadInfos)) { return state; } return { threadInfos: action.payload.threadInfos, inconsistencyReports: state.inconsistencyReports, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.threadInfos).length === 0) { return state; } return { threadInfos: {}, inconsistencyReports: state.inconsistencyReports, }; } else if ( action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType || action.type === newThreadActionTypes.success ) { if (action.payload.updatesResult.newUpdates.length === 0) { return state; } return { threadInfos: reduceThreadUpdates(state.threadInfos, action.payload), inconsistencyReports: state.inconsistencyReports, }; } else if (action.type === updateSubscriptionActionTypes.success) { const newThreadInfos = { ...state.threadInfos, [action.payload.threadID]: { ...state.threadInfos[action.payload.threadID], currentUser: { ...state.threadInfos[action.payload.threadID].currentUser, subscription: action.payload.subscription, }, }, }; return { threadInfos: newThreadInfos, inconsistencyReports: state.inconsistencyReports, }; } else if (action.type === saveMessagesActionType) { const threadIDToMostRecentTime = new Map(); for (let messageInfo of action.payload.rawMessageInfos) { const current = threadIDToMostRecentTime.get(messageInfo.threadID); if (!current || current < messageInfo.time) { threadIDToMostRecentTime.set(messageInfo.threadID, messageInfo.time); } } const changedThreadInfos = {}; for (let [threadID, mostRecentTime] of threadIDToMostRecentTime) { const threadInfo = state.threadInfos[threadID]; if ( !threadInfo || threadInfo.currentUser.unread || action.payload.updatesCurrentAsOf > mostRecentTime ) { continue; } changedThreadInfos[threadID] = { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread: true, }, }; } if (Object.keys(changedThreadInfos).length !== 0) { return { threadInfos: { ...state.threadInfos, ...changedThreadInfos, }, inconsistencyReports: state.inconsistencyReports, }; } } else if ( (action.type === sendReportActionTypes.success || action.type === sendReportsActionTypes.success) && action.payload ) { const { payload } = action; const updatedReports = state.inconsistencyReports.filter( (response) => !payload.reports.includes(response), ); if (updatedReports.length === state.inconsistencyReports.length) { return state; } return { threadInfos: state.threadInfos, inconsistencyReports: updatedReports, }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( (candidate) => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return state; } const { rawThreadInfos, deleteThreadIDs } = checkStateRequest.stateChanges; if (!rawThreadInfos && !deleteThreadIDs) { return state; } const newThreadInfos = { ...state.threadInfos }; if (rawThreadInfos) { for (let rawThreadInfo of rawThreadInfos) { newThreadInfos[rawThreadInfo.id] = rawThreadInfo; } } if (deleteThreadIDs) { for (let deleteThreadID of deleteThreadIDs) { delete newThreadInfos[deleteThreadID]; } } const newInconsistencies = findInconsistencies( action, state.threadInfos, newThreadInfos, ); return { threadInfos: newThreadInfos, inconsistencyReports: [ ...state.inconsistencyReports, ...newInconsistencies, ], }; } else if (action.type === updateActivityActionTypes.success) { const updatedThreadInfos = {}; for (let setToUnread of action.payload.result.unfocusedToUnread) { const threadInfo = state.threadInfos[setToUnread]; if (threadInfo && !threadInfo.currentUser.unread) { updatedThreadInfos[setToUnread] = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: true, }, }; } } if (Object.keys(updatedThreadInfos).length === 0) { return state; } return { threadInfos: { ...state.threadInfos, ...updatedThreadInfos }, inconsistencyReports: state.inconsistencyReports, }; } else if (action.type === setThreadUnreadStatusActionTypes.started) { const { threadID, unread } = action.payload; return { ...state, threadInfos: { ...state.threadInfos, [threadID]: { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread, }, }, }, }; } else if (action.type === setThreadUnreadStatusActionTypes.success) { const { threadID, resetToUnread } = action.payload; const currentUser = state.threadInfos[threadID].currentUser; if (!resetToUnread || currentUser.unread) { return state; } const updatedUser = { ...currentUser, unread: true, }; return { ...state, threadInfos: { ...state.threadInfos, [threadID]: { ...state.threadInfos[threadID], currentUser: updatedUser, }, }, }; } return state; } diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index 8cf4dfb07..8434af154 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,230 +1,236 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; import _keyBy from 'lodash/fp/keyBy'; -import { joinThreadActionTypes } from '../actions/thread-actions'; +import { + joinThreadActionTypes, + newThreadActionTypes, +} from '../actions/thread-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, resetPasswordActionTypes, changeUserSettingsActionTypes, } from '../actions/user-actions'; import type { BaseAction } from '../types/redux-types'; import { type UserInconsistencyReportCreationRequest, reportTypes, } from '../types/report-types'; import { serverRequestTypes, processServerRequestsActionType, } from '../types/request-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; import { updateTypes, processUpdatesActionType } from '../types/update-types'; import type { CurrentUserInfo, UserStore, UserInfos, } from '../types/user-types'; import { actionLogger } from '../utils/action-logger'; import { setNewSessionActionType } from '../utils/action-utils'; import { getConfig } from '../utils/config'; 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: UserInfos, afterStateCheck: UserInfos, ): 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) { + if ( + action.type === joinThreadActionTypes.success || + action.type === newThreadActionTypes.success + ) { const newUserInfos = _keyBy((userInfo) => userInfo.id)( action.payload.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; if (!_isEqual(state.userInfos)(updated)) { return { + ...state, 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, ); 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 === 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/types/thread-types.js b/lib/types/thread-types.js index 9c7168e63..027329b88 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,381 +1,383 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, } from './message-types'; import type { ClientThreadInconsistencyReportCreationRequest } from './report-types'; import type { ThreadSubscription } from './subscription-types'; import type { UpdateInfo } from './update-types'; import type { UserInfo, AccountUserInfo } from './user-types'; export const threadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) CHAT_NESTED_OPEN: 3, CHAT_SECRET: 4, SIDEBAR: 5, PERSONAL: 6, }); export type ThreadType = $Values; export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6, 'number is not ThreadType enum', ); return threadType; } export const threadPermissions = Object.freeze({ KNOW_OF: 'know_of', VISIBLE: 'visible', VOICED: 'voiced', EDIT_ENTRIES: 'edit_entries', EDIT_THREAD: 'edit_thread', DELETE_THREAD: 'delete_thread', CREATE_SUBTHREADS: 'create_subthreads', CREATE_SIDEBARS: 'create_sidebars', JOIN_THREAD: 'join_thread', EDIT_PERMISSIONS: 'edit_permissions', ADD_MEMBERS: 'add_members', REMOVE_MEMBERS: 'remove_members', CHANGE_ROLE: 'change_role', LEAVE_THREAD: 'leave_thread', }); export type ThreadPermission = $Values; export function assertThreadPermissions( ourThreadPermissions: string, ): ThreadPermission { invariant( ourThreadPermissions === 'know_of' || ourThreadPermissions === 'visible' || ourThreadPermissions === 'voiced' || ourThreadPermissions === 'edit_entries' || ourThreadPermissions === 'edit_thread' || ourThreadPermissions === 'delete_thread' || ourThreadPermissions === 'create_subthreads' || ourThreadPermissions === 'create_sidebars' || ourThreadPermissions === 'join_thread' || ourThreadPermissions === 'edit_permissions' || ourThreadPermissions === 'add_members' || ourThreadPermissions === 'remove_members' || ourThreadPermissions === 'change_role' || ourThreadPermissions === 'leave_thread', 'string is not threadPermissions enum', ); return ourThreadPermissions; } export const threadPermissionPrefixes = Object.freeze({ DESCENDANT: 'descendant_', CHILD: 'child_', OPEN: 'open_', OPEN_DESCENDANT: 'descendant_open_', }); export type ThreadPermissionInfo = | {| value: true, source: string |} | {| value: false, source: null |}; export type ThreadPermissionsBlob = { [permission: string]: ThreadPermissionInfo, }; export type ThreadRolePermissionsBlob = { [permission: string]: boolean }; export type ThreadPermissionsInfo = { [permission: ThreadPermission]: ThreadPermissionInfo, }; export const threadPermissionsInfoPropType = PropTypes.objectOf( PropTypes.oneOfType([ PropTypes.shape({ value: PropTypes.oneOf([true]), source: PropTypes.string.isRequired, }), PropTypes.shape({ value: PropTypes.oneOf([false]), source: PropTypes.oneOf([null]), }), ]), ); export type MemberInfo = {| id: string, role: ?string, permissions: ThreadPermissionsInfo, |}; export const memberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, }); export type RelativeMemberInfo = {| ...MemberInfo, username: ?string, isViewer: boolean, |}; export const relativeMemberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, username: PropTypes.string, isViewer: PropTypes.bool.isRequired, }); export type RoleInfo = {| id: string, name: string, permissions: ThreadRolePermissionsBlob, isDefault: boolean, |}; export type ThreadCurrentUserInfo = {| role: ?string, permissions: ThreadPermissionsInfo, subscription: ThreadSubscription, unread: ?boolean, |}; export type RawThreadInfo = {| id: string, type: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, |}; export type ThreadInfo = {| id: string, type: ThreadType, name: ?string, uiName: string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, |}; export const threadTypePropType = PropTypes.oneOf([ threadTypes.CHAT_NESTED_OPEN, threadTypes.CHAT_SECRET, threadTypes.SIDEBAR, threadTypes.PERSONAL, ]); const rolePropType = PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, permissions: PropTypes.objectOf(PropTypes.bool).isRequired, isDefault: PropTypes.bool.isRequired, }); const currentUserPropType = PropTypes.shape({ role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, subscription: PropTypes.shape({ pushNotifs: PropTypes.bool.isRequired, home: PropTypes.bool.isRequired, }).isRequired, unread: PropTypes.bool, }); export const rawThreadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, }); export const threadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, uiName: PropTypes.string.isRequired, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, }); export type ServerMemberInfo = {| id: string, role: ?string, permissions: ThreadPermissionsInfo, subscription: ThreadSubscription, unread: ?boolean, |}; export type ServerThreadInfo = {| id: string, type: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, |}; export type ThreadStore = {| threadInfos: { [id: string]: RawThreadInfo }, inconsistencyReports: $ReadOnlyArray, |}; export type ThreadDeletionRequest = {| threadID: string, accountPassword: string, |}; export type RemoveMembersRequest = {| threadID: string, memberIDs: $ReadOnlyArray, |}; export type RoleChangeRequest = {| threadID: string, memberIDs: $ReadOnlyArray, role: string, |}; export type ChangeThreadSettingsResult = {| threadInfo?: RawThreadInfo, threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type ChangeThreadSettingsPayload = {| threadID: string, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type LeaveThreadRequest = {| threadID: string, |}; export type LeaveThreadResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type LeaveThreadPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type ThreadChanges = $Shape<{| type: ThreadType, name: string, description: string, color: string, parentThreadID: string, newMemberIDs: $ReadOnlyArray, |}>; export type UpdateThreadRequest = {| threadID: string, changes: ThreadChanges, |}; export type NewThreadRequest = {| - type: ThreadType, - name?: ?string, - description?: ?string, - color?: ?string, - parentThreadID?: ?string, - initialMemberIDs?: ?$ReadOnlyArray, + +type: ThreadType, + +name?: ?string, + +description?: ?string, + +color?: ?string, + +parentThreadID?: ?string, + +initialMemberIDs?: ?$ReadOnlyArray, |}; export type NewThreadResponse = {| - updatesResult: { - newUpdates: $ReadOnlyArray, - }, - newMessageInfos: $ReadOnlyArray, - newThreadInfo?: RawThreadInfo, - newThreadID?: string, + +updatesResult: {| + +newUpdates: $ReadOnlyArray, + |}, + +newMessageInfos: $ReadOnlyArray, + +newThreadInfo?: RawThreadInfo, + +userInfos: { [string]: AccountUserInfo }, + +newThreadID?: string, |}; export type NewThreadResult = {| - updatesResult: { - newUpdates: $ReadOnlyArray, - }, - newMessageInfos: $ReadOnlyArray, - newThreadID: string, + +updatesResult: {| + +newUpdates: $ReadOnlyArray, + |}, + +newMessageInfos: $ReadOnlyArray, + +userInfos: { [string]: AccountUserInfo }, + +newThreadID: string, |}; export type ServerThreadJoinRequest = {| threadID: string, calendarQuery?: ?CalendarQuery, |}; export type ClientThreadJoinRequest = {| threadID: string, calendarQuery: CalendarQuery, |}; export type ThreadJoinResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: { [string]: AccountUserInfo }, rawEntryInfos?: ?$ReadOnlyArray, |}; export type ThreadJoinPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: $ReadOnlyArray, calendarResult: CalendarResult, |}; export type SidebarInfo = {| +threadInfo: ThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, |}; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; diff --git a/server/src/creators/thread-creator.js b/server/src/creators/thread-creator.js index 0dce1d00c..b053d0bf9 100644 --- a/server/src/creators/thread-creator.js +++ b/server/src/creators/thread-creator.js @@ -1,351 +1,356 @@ // @flow import invariant from 'invariant'; import { generateRandomColor } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import { messageTypes } from 'lib/types/message-types'; import { userRelationshipStatus } from 'lib/types/relationship-types'; import { type NewThreadRequest, type NewThreadResponse, threadTypes, threadPermissions, } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { dbQuery, SQL } from '../database/database'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; import { changeRole, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getRelationshipRowsForUsers, getParentThreadRelationshipRowsForNewUsers, } from '../updaters/thread-permission-updaters'; import createIDs from './id-creator'; import createMessages from './message-creator'; import { createInitialRolesForNewThread } from './role-creator'; import type { UpdatesForCurrentSession } from './update-creator'; // If forceAddMembers is set, we will allow the viewer to add random users who // they aren't friends with. We will only fail if the viewer is trying to add // somebody who they have blocked or has blocked them. On the other hand, if // forceAddMembers is not set, we will fail if the viewer tries to add somebody // who they aren't friends with and doesn't have a membership row with a // nonnegative role for the parent thread. async function createThread( viewer: Viewer, request: NewThreadRequest, forceAddMembers?: boolean = false, updatesForCurrentSession?: UpdatesForCurrentSession = 'return', ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const threadType = request.type; const shouldCreateRelationships = forceAddMembers || threadType === threadTypes.PERSONAL; const parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDs = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? request.initialMemberIDs : null; if ( threadType !== threadTypes.CHAT_SECRET && threadType !== threadTypes.PERSONAL && !parentThreadID ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && (request.initialMemberIDs?.length !== 1 || parentThreadID) ) { throw new ServerError('invalid_parameters'); } const checkPromises = {}; if (parentThreadID) { checkPromises.parentThreadFetch = fetchThreadInfos( viewer, SQL`t.id = ${parentThreadID}`, ); checkPromises.hasParentPermission = checkThreadPermission( viewer, parentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBTHREADS, ); } if (initialMemberIDs) { checkPromises.fetchInitialMembers = fetchKnownUserInfos( viewer, initialMemberIDs, ); } const { parentThreadFetch, hasParentPermission, fetchInitialMembers, } = await promiseAll(checkPromises); let parentThreadMembers; if (parentThreadID) { invariant(parentThreadFetch, 'parentThreadFetch should be set'); const parentThreadInfo = parentThreadFetch.threadInfos[parentThreadID]; if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } parentThreadMembers = parentThreadInfo.members.map( (userInfo) => userInfo.id, ); } const viewerNeedsRelationshipsWith = []; if (fetchInitialMembers) { invariant(initialMemberIDs, 'should be set'); for (const initialMemberID of initialMemberIDs) { const initialMember = fetchInitialMembers[initialMemberID]; if (!initialMember && shouldCreateRelationships) { viewerNeedsRelationshipsWith.push(initialMemberID); continue; } else if (!initialMember) { throw new ServerError('invalid_credentials'); } const { relationshipStatus } = initialMember; if (relationshipStatus === userRelationshipStatus.FRIEND) { continue; } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER || relationshipStatus === userRelationshipStatus.BOTH_BLOCKED ) { throw new ServerError('invalid_credentials'); } else if ( parentThreadMembers && parentThreadMembers.includes(initialMemberID) ) { continue; } else if (!shouldCreateRelationships) { throw new ServerError('invalid_credentials'); } } } 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, ]; if (threadType === threadTypes.PERSONAL) { const otherMemberID = initialMemberIDs?.[0]; invariant( otherMemberID, 'Other member id should be set for a PERSONAL thread', ); const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, default_role) SELECT ${row} WHERE NOT EXISTS ( SELECT * FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${viewer.userID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user = ${otherMemberID} WHERE t.type = ${threadTypes.PERSONAL} ) `; const [result] = await dbQuery(query); if (result.affectedRows === 0) { const personalThreadQuery = SQL` SELECT t.id FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id AND m1.user = ${viewer.userID} INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user = ${otherMemberID} WHERE t.type = ${threadTypes.PERSONAL} `; const deleteRoles = SQL` DELETE FROM roles WHERE id IN (${newRoles.default.id}, ${newRoles.creator.id}) `; const deleteIDs = SQL` DELETE FROM ids WHERE id IN (${id}, ${newRoles.default.id}, ${newRoles.creator.id}) `; const [[personalThreadResult]] = await Promise.all([ dbQuery(personalThreadQuery), dbQuery(deleteRoles), dbQuery(deleteIDs), ]); invariant( personalThreadResult.length > 0, 'PERSONAL thread should exist', ); const personalThreadID = personalThreadResult[0].id.toString(); return { newThreadID: personalThreadID, updatesResult: { newUpdates: [], }, + userInfos: {}, newMessageInfos: [], }; } } else { 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 changeset = { membershipRows, relationshipRows }; - const { threadInfos, viewerUpdates } = await commitMembershipChangeset( - viewer, - changeset, - { updatesForCurrentSession }, - ); + const { + threadInfos, + viewerUpdates, + userInfos, + } = await commitMembershipChangeset(viewer, changeset, { + updatesForCurrentSession, + }); 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 newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, + userInfos, newMessageInfos, }; } return { newThreadInfo: threadInfos[id], updatesResult: { newUpdates: viewerUpdates, }, + userInfos, newMessageInfos, }; } export default createThread;