diff --git a/keyserver/src/responders/thread-responders.js b/keyserver/src/responders/thread-responders.js --- a/keyserver/src/responders/thread-responders.js +++ b/keyserver/src/responders/thread-responders.js @@ -27,8 +27,7 @@ type RoleDeletionRequest, type RoleDeletionResult, } from 'lib/types/thread-types.js'; -import { updateUserAvatarRequestValidator } from 'lib/utils/avatar-utils.js'; -import { values } from 'lib/utils/objects.js'; +import { threadSettingsChangesValidator } from 'lib/types/validators/thread-validators.js'; import { tShape, tNumEnum, @@ -118,15 +117,7 @@ export const updateThreadRequestInputValidator: TInterface = tShape({ threadID: tID, - changes: tShape({ - type: t.maybe(tNumEnum(values(threadTypes))), - name: t.maybe(t.String), - description: t.maybe(t.String), - color: t.maybe(tColor), - parentThreadID: t.maybe(tID), - newMemberIDs: t.maybe(t.list(tUserID)), - avatar: t.maybe(updateUserAvatarRequestValidator), - }), + changes: threadSettingsChangesValidator, accountPassword: t.maybe(tPassword), }); diff --git a/lib/shared/dm-ops/add-members-spec.js b/lib/shared/dm-ops/add-members-spec.js --- a/lib/shared/dm-ops/add-members-spec.js +++ b/lib/shared/dm-ops/add-members-spec.js @@ -28,113 +28,132 @@ import { values } from '../../utils/objects.js'; import { roleIsDefaultRole, userIsMember } from '../thread-utils.js'; +function createAddNewMembersResults( + dmOperation: DMAddMembersOperation, + viewerID: string, + utilities: ProcessDMOperationUtilities, +): { + +rawMessageInfos: Array, + +updateInfos: Array, + +threadInfo: ?ThickRawThreadInfo, +} { + const { editorID, time, messageID, addedUserIDs, existingThreadDetails } = + dmOperation; + const addMembersMessage = { + type: messageTypes.ADD_MEMBERS, + id: messageID, + threadID: existingThreadDetails.threadID, + creatorID: editorID, + time, + addedUserIDs: [...addedUserIDs], + }; + + const viewerIsAdded = addedUserIDs.includes(viewerID); + const updateInfos: Array = []; + const rawMessageInfos: Array = []; + let resultThreadInfo: ?ThickRawThreadInfo; + if (viewerIsAdded) { + const newThread = createThickRawThreadInfo( + { + ...existingThreadDetails, + allMemberIDs: [...existingThreadDetails.allMemberIDs, ...addedUserIDs], + }, + viewerID, + ); + resultThreadInfo = newThread; + updateInfos.push( + { + type: updateTypes.JOIN_THREAD, + id: uuid.v4(), + time, + threadInfo: newThread, + rawMessageInfos: [addMembersMessage], + truncationStatus: messageTruncationStatus.EXHAUSTIVE, + rawEntryInfos: [], + }, + { + type: updateTypes.UPDATE_THREAD_READ_STATUS, + id: uuid.v4(), + time, + threadID: existingThreadDetails.threadID, + unread: true, + }, + ); + } else { + const currentThreadInfoOptional = + utilities.threadInfos[existingThreadDetails.threadID]; + if (!currentThreadInfoOptional || !currentThreadInfoOptional.thick) { + // We can't perform this operation now. It should be queued for later. + return { + rawMessageInfos: [], + updateInfos: [], + threadInfo: null, + }; + } + const currentThreadInfo: ThickRawThreadInfo = currentThreadInfoOptional; + const defaultRoleID = values(currentThreadInfo.roles).find(role => + roleIsDefaultRole(role), + )?.id; + invariant(defaultRoleID, 'Default role ID must exist'); + const { membershipPermissions } = createRoleAndPermissionForThickThreads( + currentThreadInfo.type, + currentThreadInfo.id, + defaultRoleID, + ); + const newMembers = addedUserIDs + .filter(userID => !userIsMember(currentThreadInfo, userID)) + .map(userID => + minimallyEncodeMemberInfo({ + id: userID, + role: defaultRoleID, + permissions: membershipPermissions, + isSender: editorID === viewerID, + subscription: joinThreadSubscription, + }), + ); + + const newThreadInfo = { + ...currentThreadInfo, + members: [...currentThreadInfo.members, ...newMembers], + }; + resultThreadInfo = newThreadInfo; + updateInfos.push( + { + type: updateTypes.UPDATE_THREAD, + id: uuid.v4(), + time, + threadInfo: newThreadInfo, + }, + { + type: updateTypes.UPDATE_THREAD_READ_STATUS, + id: uuid.v4(), + time, + threadID: existingThreadDetails.threadID, + unread: true, + }, + ); + rawMessageInfos.push(addMembersMessage); + } + return { + rawMessageInfos, + updateInfos, + threadInfo: resultThreadInfo, + }; +} + const addMembersSpec: DMOperationSpec = Object.freeze({ processDMOperation: async ( dmOperation: DMAddMembersOperation, viewerID: string, utilities: ProcessDMOperationUtilities, ) => { - const { editorID, time, messageID, addedUserIDs, existingThreadDetails } = - dmOperation; - const addMembersMessage = { - type: messageTypes.ADD_MEMBERS, - id: messageID, - threadID: existingThreadDetails.threadID, - creatorID: editorID, - time, - addedUserIDs: [...addedUserIDs], - }; - - const viewerIsAdded = addedUserIDs.includes(viewerID); - const updateInfos: Array = []; - const rawMessageInfos: Array = []; - if (viewerIsAdded) { - const newThread = createThickRawThreadInfo( - { - ...existingThreadDetails, - allMemberIDs: [ - ...existingThreadDetails.allMemberIDs, - ...addedUserIDs, - ], - }, - viewerID, - ); - updateInfos.push( - { - type: updateTypes.JOIN_THREAD, - id: uuid.v4(), - time, - threadInfo: newThread, - rawMessageInfos: [addMembersMessage], - truncationStatus: messageTruncationStatus.EXHAUSTIVE, - rawEntryInfos: [], - }, - { - type: updateTypes.UPDATE_THREAD_READ_STATUS, - id: uuid.v4(), - time, - threadID: existingThreadDetails.threadID, - unread: true, - }, - ); - } else { - const currentThreadInfoOptional = - utilities.threadInfos[existingThreadDetails.threadID]; - if (!currentThreadInfoOptional || !currentThreadInfoOptional.thick) { - // We can't perform this operation now. It should be queued for later. - return { - rawMessageInfos: [], - updateInfos: [], - }; - } - const currentThreadInfo: ThickRawThreadInfo = currentThreadInfoOptional; - const defaultRoleID = values(currentThreadInfo.roles).find(role => - roleIsDefaultRole(role), - )?.id; - invariant(defaultRoleID, 'Default role ID must exist'); - const { membershipPermissions } = createRoleAndPermissionForThickThreads( - currentThreadInfo.type, - currentThreadInfo.id, - defaultRoleID, - ); - const newMembers = addedUserIDs - .filter(userID => !userIsMember(currentThreadInfo, userID)) - .map(userID => - minimallyEncodeMemberInfo({ - id: userID, - role: defaultRoleID, - permissions: membershipPermissions, - isSender: editorID === viewerID, - subscription: joinThreadSubscription, - }), - ); - - const newThreadInfo = { - ...currentThreadInfo, - members: [...currentThreadInfo.members, ...newMembers], - }; - updateInfos.push( - { - type: updateTypes.UPDATE_THREAD, - id: uuid.v4(), - time, - threadInfo: newThreadInfo, - }, - { - type: updateTypes.UPDATE_THREAD_READ_STATUS, - id: uuid.v4(), - time, - threadID: existingThreadDetails.threadID, - unread: true, - }, - ); - rawMessageInfos.push(addMembersMessage); - } - return { - rawMessageInfos, - updateInfos, - }; + const { rawMessageInfos, updateInfos } = createAddNewMembersResults( + dmOperation, + viewerID, + utilities, + ); + return { rawMessageInfos, updateInfos }; }, }); -export { addMembersSpec }; +export { addMembersSpec, createAddNewMembersResults }; diff --git a/lib/shared/dm-ops/change-thread-settings-spec.js b/lib/shared/dm-ops/change-thread-settings-spec.js new file mode 100644 --- /dev/null +++ b/lib/shared/dm-ops/change-thread-settings-spec.js @@ -0,0 +1,166 @@ +// @flow + +import uuid from 'uuid'; + +import { createAddNewMembersResults } from './add-members-spec.js'; +import type { + DMOperationSpec, + ProcessDMOperationUtilities, +} from './dm-op-spec.js'; +import type { DMChangeThreadSettingsOperation } from '../../types/dm-ops.js'; +import { messageTypes } from '../../types/message-types-enum.js'; +import type { RawMessageInfo } from '../../types/message-types.js'; +import type { RawThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; +import type { LegacyRawThreadInfo } from '../../types/thread-types.js'; +import { updateTypes } from '../../types/update-types-enum.js'; +import type { ClientUpdateInfo } from '../../types/update-types.js'; +import { values } from '../../utils/objects.js'; + +const changeThreadSettingsSpec: DMOperationSpec = + Object.freeze({ + processDMOperation: async ( + dmOperation: DMChangeThreadSettingsOperation, + viewerID: string, + utilities: ProcessDMOperationUtilities, + ) => { + const { + editorID, + time, + changes, + messageIDsPrefix, + existingThreadDetails, + } = dmOperation; + const { + name, + description, + color, + parentThreadID, + avatar, + type: threadType, + } = changes; + const threadID = existingThreadDetails.threadID; + + const newMemberIDs = + changes.newMemberIDs && changes.newMemberIDs.length > 0 + ? [...new Set(changes.newMemberIDs)] + : null; + + let threadInfoToUpdate: ?(RawThreadInfo | LegacyRawThreadInfo) = + utilities.threadInfos[threadID]; + if (!threadInfoToUpdate && !newMemberIDs?.includes(viewerID)) { + // We can't perform this operation now. It should be queued for later. + return { + rawMessageInfos: [], + updateInfos: [], + }; + } + + const updateInfos: Array = []; + const rawMessageInfos: Array = []; + + if (newMemberIDs) { + const addMembersResult = createAddNewMembersResults( + { + type: 'add_members', + editorID, + time, + messageID: `${messageIDsPrefix}/add_members`, + addedUserIDs: newMemberIDs, + existingThreadDetails, + }, + viewerID, + utilities, + ); + if (addMembersResult.threadInfo) { + threadInfoToUpdate = addMembersResult.threadInfo; + } + updateInfos.push(...addMembersResult.updateInfos); + rawMessageInfos.push(...addMembersResult.rawMessageInfos); + } + + if (!threadInfoToUpdate || !threadInfoToUpdate.thick) { + // We can't perform this operation now. It should be queued for later. + return { + rawMessageInfos: [], + updateInfos: [], + }; + } + + const changedFields: { [string]: string | number } = {}; + + if (name !== undefined && name !== null) { + changedFields.name = name; + threadInfoToUpdate = { + ...threadInfoToUpdate, + name, + }; + } + + if (description !== undefined && description !== null) { + changedFields.description = description; + threadInfoToUpdate = { + ...threadInfoToUpdate, + description, + }; + } + + if (color) { + changedFields.color = color; + threadInfoToUpdate = { + ...threadInfoToUpdate, + color, + }; + } + + if (parentThreadID !== undefined) { + // TODO do we want to support this for thick threads? + return { + rawMessageInfos: [], + updateInfos: [], + }; + } + + if (avatar) { + changedFields.avatar = + avatar.type !== 'remove' ? JSON.stringify(avatar) : ''; + // TODO how to create an avatar? + } + + if (threadType !== null && threadType !== undefined) { + // TODO do we want to support this for thick threads? + return { + rawMessageInfos: [], + updateInfos: [], + }; + } + + for (const fieldName in changedFields) { + const newValue = changedFields[fieldName]; + rawMessageInfos.push({ + type: messageTypes.CHANGE_SETTINGS, + threadID, + creatorID: editorID, + time, + field: fieldName, + value: newValue, + id: `${messageIDsPrefix}/${fieldName}`, + }); + } + + if (values(changedFields).length > 0) { + updateInfos.push({ + type: updateTypes.UPDATE_THREAD, + id: uuid.v4(), + time, + threadInfo: threadInfoToUpdate, + }); + } + + return { + rawMessageInfos, + updateInfos, + }; + }, + }); + +export { changeThreadSettingsSpec }; diff --git a/lib/shared/dm-ops/dm-op-specs.js b/lib/shared/dm-ops/dm-op-specs.js --- a/lib/shared/dm-ops/dm-op-specs.js +++ b/lib/shared/dm-ops/dm-op-specs.js @@ -1,6 +1,7 @@ // @flow import { addMembersSpec } from './add-members-spec.js'; +import { changeThreadSettingsSpec } from './change-thread-settings-spec.js'; import { createSidebarSpec } from './create-sidebar-spec.js'; import { createThreadSpec } from './create-thread-spec.js'; import type { DMOperationSpec } from './dm-op-spec.js'; @@ -24,4 +25,5 @@ [dmOperationTypes.JOIN_THREAD]: joinThreadSpec, [dmOperationTypes.LEAVE_THREAD]: leaveThreadSpec, [dmOperationTypes.REMOVE_MEMBERS]: removeMembersSpec, + [dmOperationTypes.CHANGE_THREAD_SETTINGS]: changeThreadSettingsSpec, }); diff --git a/lib/types/dm-ops.js b/lib/types/dm-ops.js --- a/lib/types/dm-ops.js +++ b/lib/types/dm-ops.js @@ -11,7 +11,9 @@ type ThickThreadType, thickThreadTypeValidator, } from './thread-types-enum.js'; +import type { ThreadChanges } from './thread-types.js'; import type { ClientUpdateInfo } from './update-types.js'; +import { threadSettingsChangesValidator } from './validators/thread-validators.js'; import { values } from '../utils/objects.js'; import { tShape, tString, tUserID } from '../utils/validation-utils.js'; @@ -25,6 +27,7 @@ JOIN_THREAD: 'join_thread', LEAVE_THREAD: 'leave_thread', REMOVE_MEMBERS: 'remove_members', + CHANGE_THREAD_SETTINGS: 'change_thread_settings', }); export type DMOperationType = $Values; @@ -240,6 +243,24 @@ removedUserIDs: t.list(tUserID), }); +export type DMChangeThreadSettingsOperation = { + +type: 'change_thread_settings', + +editorID: string, + +time: number, + +changes: ThreadChanges, + +messageIDsPrefix: string, + +existingThreadDetails: CreateThickRawThreadInfoInput, +}; +export const dmChangeThreadSettingsOperationValidator: TInterface = + tShape({ + type: tString(dmOperationTypes.CHANGE_THREAD_SETTINGS), + editorID: tUserID, + time: t.Number, + changes: threadSettingsChangesValidator, + messageIDsPrefix: t.String, + existingThreadDetails: createThickRawThreadInfoInputValidator, + }); + export type DMOperation = | DMCreateThreadOperation | DMCreateSidebarOperation @@ -249,7 +270,8 @@ | DMAddMembersOperation | DMJoinThreadOperation | DMLeaveThreadOperation - | DMRemoveMembersOperation; + | DMRemoveMembersOperation + | DMChangeThreadSettingsOperation; export const dmOperationValidator: TUnion = t.union([ dmCreateThreadOperationValidator, dmCreateSidebarOperationValidator, @@ -260,6 +282,7 @@ dmJoinThreadOperationValidator, dmLeaveThreadOperationValidator, dmRemoveMembersOperationValidator, + dmChangeThreadSettingsOperationValidator, ]); export type DMOperationResult = { diff --git a/lib/types/validators/thread-validators.js b/lib/types/validators/thread-validators.js --- a/lib/types/validators/thread-validators.js +++ b/lib/types/validators/thread-validators.js @@ -4,12 +4,21 @@ import type { TInterface } from 'tcomb'; import { mixedRawThreadInfoValidator } from '../../permissions/minimally-encoded-raw-thread-info-validators.js'; -import { tShape, tID } from '../../utils/validation-utils.js'; +import { updateUserAvatarRequestValidator } from '../../utils/avatar-utils.js'; +import { values } from '../../utils/objects.js'; +import { + tShape, + tID, + tNumEnum, + tColor, + tUserID, +} from '../../utils/validation-utils.js'; import { mediaValidator } from '../media-types.js'; import { rawMessageInfoValidator, messageTruncationStatusesValidator, } from '../message-types.js'; +import { threadTypes } from '../thread-types-enum.js'; import { type ChangeThreadSettingsResult, type LeaveThreadResult, @@ -19,6 +28,7 @@ type ToggleMessagePinResult, type RoleModificationResult, type RoleDeletionResult, + type ThreadChanges, } from '../thread-types.js'; import { serverUpdateInfoValidator } from '../update-types.js'; import { userInfosValidator } from '../user-types.js'; @@ -30,6 +40,17 @@ }), }); +export const threadSettingsChangesValidator: TInterface = + tShape({ + type: t.maybe(tNumEnum(values(threadTypes))), + name: t.maybe(t.String), + description: t.maybe(t.String), + color: t.maybe(tColor), + parentThreadID: t.maybe(tID), + newMemberIDs: t.maybe(t.list(tUserID)), + avatar: t.maybe(updateUserAvatarRequestValidator), + }); + export const changeThreadSettingsResultValidator: TInterface = tShape({ newMessageInfos: t.list(rawMessageInfoValidator),