diff --git a/lib/shared/dm-ops/add-members-spec.js b/lib/shared/dm-ops/add-members-spec.js new file mode 100644 index 000000000..40161bca9 --- /dev/null +++ b/lib/shared/dm-ops/add-members-spec.js @@ -0,0 +1,140 @@ +// @flow + +import invariant from 'invariant'; +import uuid from 'uuid'; + +import { + createRoleAndPermissionForThickThreads, + createThickRawThreadInfo, +} from './create-thread-spec.js'; +import type { + DMOperationSpec, + ProcessDMOperationUtilities, +} from './dm-op-spec.js'; +import type { DMAddMembersOperation } from '../../types/dm-ops.js'; +import { messageTypes } from '../../types/message-types-enum.js'; +import { + messageTruncationStatus, + type RawMessageInfo, +} from '../../types/message-types.js'; +import { + minimallyEncodeMemberInfo, + type ThickRawThreadInfo, +} from '../../types/minimally-encoded-thread-permissions-types.js'; +import { joinThreadSubscription } from '../../types/subscription-types.js'; +import type { ThickMemberInfo } 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'; +import { roleIsDefaultRole, userIsMember } from '../thread-utils.js'; + +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, + }; + }, +}); + +export { addMembersSpec }; diff --git a/lib/shared/dm-ops/create-thread-spec.js b/lib/shared/dm-ops/create-thread-spec.js index d0daba92c..8307e6667 100644 --- a/lib/shared/dm-ops/create-thread-spec.js +++ b/lib/shared/dm-ops/create-thread-spec.js @@ -1,177 +1,196 @@ // @flow import uuid from 'uuid'; import type { DMOperationSpec } from './dm-op-spec.js'; import { specialRoles } from '../../permissions/special-roles.js'; import { getAllThreadPermissions, makePermissionsBlob, getThickThreadRolePermissionsBlob, } from '../../permissions/thread-permissions.js'; import type { CreateThickRawThreadInfoInput, DMCreateThreadOperation, } from '../../types/dm-ops.js'; import { messageTypes } from '../../types/message-types-enum.js'; import { type RawMessageInfo, messageTruncationStatus, } from '../../types/message-types.js'; import { type ThickRawThreadInfo, type RoleInfo, minimallyEncodeMemberInfo, minimallyEncodeRoleInfo, minimallyEncodeThreadCurrentUserInfo, } from '../../types/minimally-encoded-thread-permissions-types.js'; import { joinThreadSubscription } from '../../types/subscription-types.js'; +import type { ThreadPermissionsInfo } from '../../types/thread-permission-types.js'; +import type { ThickThreadType } from '../../types/thread-types-enum.js'; import type { ThickMemberInfo } from '../../types/thread-types.js'; import { updateTypes } from '../../types/update-types-enum.js'; import { generatePendingThreadColor } from '../color-utils.js'; +function createRoleAndPermissionForThickThreads( + threadType: ThickThreadType, + threadID: string, + roleID: string, +): { +role: RoleInfo, +membershipPermissions: ThreadPermissionsInfo } { + const rolePermissions = getThickThreadRolePermissionsBlob(threadType); + const membershipPermissions = getAllThreadPermissions( + makePermissionsBlob(rolePermissions, null, threadID, threadType), + threadID, + ); + const role: RoleInfo = { + ...minimallyEncodeRoleInfo({ + id: roleID, + name: 'Members', + permissions: rolePermissions, + isDefault: true, + }), + specialRole: specialRoles.DEFAULT_ROLE, + }; + return { + membershipPermissions, + role, + }; +} + type MutableThickRawThreadInfo = { ...ThickRawThreadInfo }; function createThickRawThreadInfo( input: CreateThickRawThreadInfoInput, viewerID: string, ): MutableThickRawThreadInfo { const { threadID, threadType, creationTime, parentThreadID, allMemberIDs, roleID, creatorID, name, avatar, description, color, containingThreadID, community, sourceMessageID, repliesCount, pinnedCount, } = input; const threadColor = color ?? generatePendingThreadColor(allMemberIDs); - const rolePermissions = getThickThreadRolePermissionsBlob(threadType); - const membershipPermissions = getAllThreadPermissions( - makePermissionsBlob(rolePermissions, null, threadID, threadType), - threadID, - ); - const role: RoleInfo = { - ...minimallyEncodeRoleInfo({ - id: roleID, - name: 'Members', - permissions: rolePermissions, - isDefault: true, - }), - specialRole: specialRoles.DEFAULT_ROLE, - }; + const { membershipPermissions, role } = + createRoleAndPermissionForThickThreads(threadType, threadID, roleID); const newThread: MutableThickRawThreadInfo = { thick: true, minimallyEncoded: true, id: threadID, type: threadType, color: threadColor, creationTime, parentThreadID, members: allMemberIDs.map(memberID => minimallyEncodeMemberInfo({ id: memberID, role: role.id, permissions: membershipPermissions, isSender: memberID === viewerID, subscription: joinThreadSubscription, }), ), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: role.id, permissions: membershipPermissions, subscription: joinThreadSubscription, unread: creatorID !== viewerID, }), repliesCount: repliesCount ?? 0, name, avatar, description, containingThreadID, community, }; if (sourceMessageID) { newThread.sourceMessageID = sourceMessageID; } if (pinnedCount) { newThread.pinnedCount = pinnedCount; } return newThread; } const createThreadSpec: DMOperationSpec = Object.freeze({ processDMOperation: async ( dmOperation: DMCreateThreadOperation, viewerID: string, ) => { const { threadID, creatorID, time, threadType, memberIDs, roleID, newMessageID, } = dmOperation; const allMemberIDs = [creatorID, ...memberIDs]; const rawThreadInfo = createThickRawThreadInfo( { threadID, threadType, creationTime: time, allMemberIDs, roleID, creatorID, }, viewerID, ); const rawMessageInfos: Array = [ { type: messageTypes.CREATE_THREAD, id: newMessageID, threadID, creatorID, time, initialThreadState: { type: threadType, color: rawThreadInfo.color, memberIDs: allMemberIDs, }, }, ]; const threadJoinUpdateInfo = { type: updateTypes.JOIN_THREAD, id: uuid.v4(), time, threadInfo: rawThreadInfo, rawMessageInfos, truncationStatus: messageTruncationStatus.EXHAUSTIVE, rawEntryInfos: [], }; return { rawMessageInfos: [], // included in updateInfos below updateInfos: [threadJoinUpdateInfo], }; }, }); -export { createThickRawThreadInfo, createThreadSpec }; +export { + createThickRawThreadInfo, + createThreadSpec, + createRoleAndPermissionForThickThreads, +}; diff --git a/lib/shared/dm-ops/dm-op-spec.js b/lib/shared/dm-ops/dm-op-spec.js index 9d1e0f8e5..ba327c253 100644 --- a/lib/shared/dm-ops/dm-op-spec.js +++ b/lib/shared/dm-ops/dm-op-spec.js @@ -1,17 +1,19 @@ // @flow import type { DMOperation, DMOperationResult } from '../../types/dm-ops.js'; import type { RawMessageInfo } from '../../types/message-types.js'; +import type { RawThreadInfos } from '../../types/thread-types.js'; export type ProcessDMOperationUtilities = { // Needed to fetch sidebar source messages +fetchMessage: (messageID: string) => Promise, + +threadInfos: RawThreadInfos, }; export type DMOperationSpec = { +processDMOperation: ( dmOp: DMOp, viewerID: string, utilities: ProcessDMOperationUtilities, ) => Promise, }; diff --git a/lib/shared/dm-ops/dm-op-specs.js b/lib/shared/dm-ops/dm-op-specs.js index 75af1f0dc..7a6c32b1c 100644 --- a/lib/shared/dm-ops/dm-op-specs.js +++ b/lib/shared/dm-ops/dm-op-specs.js @@ -1,19 +1,21 @@ // @flow +import { addMembersSpec } from './add-members-spec.js'; import { createSidebarSpec } from './create-sidebar-spec.js'; import { createThreadSpec } from './create-thread-spec.js'; import type { DMOperationSpec } from './dm-op-spec.js'; import { sendEditMessageSpec } from './send-edit-message-spec.js'; import { sendReactionMessageSpec } from './send-reaction-message-spec.js'; import { sendTextMessageSpec } from './send-text-message-spec.js'; import { type DMOperationType, dmOperationTypes } from '../../types/dm-ops.js'; export const dmOpSpecs: { +[DMOperationType]: DMOperationSpec, } = Object.freeze({ [dmOperationTypes.CREATE_THREAD]: createThreadSpec, [dmOperationTypes.CREATE_SIDEBAR]: createSidebarSpec, [dmOperationTypes.SEND_TEXT_MESSAGE]: sendTextMessageSpec, [dmOperationTypes.SEND_REACTION_MESSAGE]: sendReactionMessageSpec, [dmOperationTypes.SEND_EDIT_MESSAGE]: sendEditMessageSpec, + [dmOperationTypes.ADD_MEMBERS]: addMembersSpec, }); diff --git a/lib/shared/dm-ops/process-dm-ops.js b/lib/shared/dm-ops/process-dm-ops.js index bc9cd4a59..dbe228d01 100644 --- a/lib/shared/dm-ops/process-dm-ops.js +++ b/lib/shared/dm-ops/process-dm-ops.js @@ -1,47 +1,50 @@ // @flow import * as React from 'react'; import { dmOpSpecs } from './dm-op-specs.js'; import { useLoggedInUserInfo } from '../../hooks/account-hooks.js'; import { useGetLatestMessageEdit } from '../../hooks/latest-message-edit.js'; import { type DMOperation, processDMOpsActionType, } from '../../types/dm-ops.js'; -import { useDispatch } from '../../utils/redux-utils.js'; +import { useDispatch, useSelector } from '../../utils/redux-utils.js'; function useProcessDMOperation(): (dmOp: DMOperation) => Promise { const fetchMessage = useGetLatestMessageEdit(); + const threadInfos = useSelector(state => state.threadStore.threadInfos); + const utilities = React.useMemo( () => ({ fetchMessage, + threadInfos, }), - [fetchMessage], + [fetchMessage, threadInfos], ); const dispatch = useDispatch(); const loggedInUserInfo = useLoggedInUserInfo(); const viewerID = loggedInUserInfo?.id; return React.useCallback( async (dmOp: DMOperation) => { if (!viewerID) { console.log('ignored DMOperation because logged out'); return; } const { rawMessageInfos, updateInfos } = await dmOpSpecs[ dmOp.type ].processDMOperation(dmOp, viewerID, utilities); dispatch({ type: processDMOpsActionType, payload: { rawMessageInfos, updateInfos, }, }); }, [dispatch, viewerID, utilities], ); } export { useProcessDMOperation }; diff --git a/lib/types/dm-ops.js b/lib/types/dm-ops.js index cdb78a14f..69af340df 100644 --- a/lib/types/dm-ops.js +++ b/lib/types/dm-ops.js @@ -1,204 +1,224 @@ // @flow -import type { TInterface, TUnion } from 'tcomb'; -import t from 'tcomb'; +import t, { type TInterface, type TUnion } from 'tcomb'; import { clientAvatarValidator, type ClientAvatar } from './avatar-types.js'; import type { RawMessageInfo } from './message-types.js'; import type { OutboundP2PMessage } from './sqlite-types.js'; import { type NonSidebarThickThreadType, nonSidebarThickThreadTypes, type ThickThreadType, thickThreadTypeValidator, } from './thread-types-enum.js'; import type { ClientUpdateInfo } from './update-types.js'; import { values } from '../utils/objects.js'; import { tShape, tString, tUserID } from '../utils/validation-utils.js'; export const dmOperationTypes = Object.freeze({ CREATE_THREAD: 'create_thread', CREATE_SIDEBAR: 'create_sidebar', SEND_TEXT_MESSAGE: 'send_text_message', SEND_REACTION_MESSAGE: 'send_reaction_message', SEND_EDIT_MESSAGE: 'send_edit_message', + ADD_MEMBERS: 'add_members', }); export type DMOperationType = $Values; export type CreateThickRawThreadInfoInput = { +threadID: string, +threadType: ThickThreadType, +creationTime: number, +parentThreadID?: ?string, +allMemberIDs: $ReadOnlyArray, +roleID: string, +creatorID: string, +name?: ?string, +avatar?: ?ClientAvatar, +description?: ?string, +color?: ?string, +containingThreadID?: ?string, +community?: ?string, +sourceMessageID?: ?string, +repliesCount?: ?number, +pinnedCount?: ?number, }; export const createThickRawThreadInfoInputValidator: TInterface = tShape({ threadID: t.String, threadType: thickThreadTypeValidator, creationTime: t.Number, parentThreadID: t.maybe(t.String), allMemberIDs: t.list(tUserID), roleID: t.String, creatorID: tUserID, name: t.maybe(t.String), avatar: t.maybe(clientAvatarValidator), description: t.maybe(t.String), color: t.maybe(t.String), containingThreadID: t.maybe(t.String), community: t.maybe(t.String), sourceMessageID: t.maybe(t.String), repliesCount: t.maybe(t.Number), pinnedCount: t.maybe(t.Number), }); export type DMCreateThreadOperation = { +type: 'create_thread', +threadID: string, +creatorID: string, +time: number, +threadType: NonSidebarThickThreadType, +memberIDs: $ReadOnlyArray, +roleID: string, +newMessageID: string, }; export const dmCreateThreadOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CREATE_THREAD), threadID: t.String, creatorID: tUserID, time: t.Number, threadType: t.enums.of(values(nonSidebarThickThreadTypes)), memberIDs: t.list(tUserID), roleID: t.String, newMessageID: t.String, }); export type DMCreateSidebarOperation = { +type: 'create_sidebar', +threadID: string, +creatorID: string, +time: number, +parentThreadID: string, +memberIDs: $ReadOnlyArray, +sourceMessageID: string, +roleID: string, +newSidebarSourceMessageID: string, +newCreateSidebarMessageID: string, }; export const dmCreateSidebarOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.CREATE_SIDEBAR), threadID: t.String, creatorID: tUserID, time: t.Number, parentThreadID: t.String, memberIDs: t.list(tUserID), sourceMessageID: t.String, roleID: t.String, newSidebarSourceMessageID: t.String, newCreateSidebarMessageID: t.String, }); export type DMSendTextMessageOperation = { +type: 'send_text_message', +threadID: string, +creatorID: string, +time: number, +messageID: string, +text: string, }; export const dmSendTextMessageOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.SEND_TEXT_MESSAGE), threadID: t.String, creatorID: tUserID, time: t.Number, messageID: t.String, text: t.String, }); export type DMSendReactionMessageOperation = { +type: 'send_reaction_message', +threadID: string, +creatorID: string, +time: number, +messageID: string, +targetMessageID: string, +reaction: string, +action: 'add_reaction' | 'remove_reaction', }; export const dmSendReactionMessageOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.SEND_REACTION_MESSAGE), threadID: t.String, creatorID: tUserID, time: t.Number, messageID: t.String, targetMessageID: t.String, reaction: t.String, action: t.enums.of(['add_reaction', 'remove_reaction']), }); export type DMSendEditMessageOperation = { +type: 'send_edit_message', +threadID: string, +creatorID: string, +time: number, +messageID: string, +targetMessageID: string, +text: string, }; export const dmSendEditMessageOperationValidator: TInterface = tShape({ type: tString(dmOperationTypes.SEND_EDIT_MESSAGE), threadID: t.String, creatorID: tUserID, time: t.Number, messageID: t.String, targetMessageID: t.String, text: t.String, }); +export type DMAddMembersOperation = { + +type: 'add_members', + +editorID: string, + +time: number, + +messageID: string, + +addedUserIDs: $ReadOnlyArray, + +existingThreadDetails: CreateThickRawThreadInfoInput, +}; +export const dmAddMembersOperationValidator: TInterface = + tShape({ + type: tString(dmOperationTypes.ADD_MEMBERS), + editorID: tUserID, + time: t.Number, + messageID: t.String, + addedUserIDs: t.list(tUserID), + existingThreadDetails: createThickRawThreadInfoInputValidator, + }); + export type DMOperation = | DMCreateThreadOperation | DMCreateSidebarOperation | DMSendTextMessageOperation | DMSendReactionMessageOperation - | DMSendEditMessageOperation; + | DMSendEditMessageOperation + | DMAddMembersOperation; export const dmOperationValidator: TUnion = t.union([ dmCreateThreadOperationValidator, dmCreateSidebarOperationValidator, dmSendTextMessageOperationValidator, dmSendReactionMessageOperationValidator, dmSendEditMessageOperationValidator, + dmAddMembersOperationValidator, ]); export type DMOperationResult = { rawMessageInfos: Array, updateInfos: Array, }; export const processDMOpsActionType = 'PROCESS_DM_OPS'; export type ProcessDMOpsPayload = { +rawMessageInfos: $ReadOnlyArray, +updateInfos: $ReadOnlyArray, }; export const scheduleP2PMessagesActionType = 'SCHEDULE_P2P_MESSAGES'; export type ScheduleP2PMessagesPayload = { +dmOpID: string, +messages: $ReadOnlyArray, };