diff --git a/lib/shared/dm-ops/add-members-spec.js b/lib/shared/dm-ops/add-members-spec.js index e7ccb5d1a..c375f048d 100644 --- a/lib/shared/dm-ops/add-members-spec.js +++ b/lib/shared/dm-ops/add-members-spec.js @@ -1,199 +1,188 @@ // @flow import invariant from 'invariant'; import uuid from 'uuid'; import { createRoleAndPermissionForThickThreads } from './create-thread-spec.js'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import { type DMAddMembersOperation, dmAddMembersOperationValidator, } from '../../types/dm-ops.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { RawMessageInfo } from '../../types/message-types.js'; import type { AddMembersMessageData } from '../../types/messages/add-members.js'; import { minimallyEncodeMemberInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { ThickRawThreadInfo } 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 { ThickMemberInfo } from '../../types/thread-types.js'; import { updateTypes } from '../../types/update-types-enum.js'; import { values } from '../../utils/objects.js'; import { rawMessageInfoFromMessageData } from '../message-utils.js'; import { roleIsDefaultRole, userIsMember } from '../thread-utils.js'; function createAddNewMembersMessageDataWithInfoFromDMOperation( dmOperation: DMAddMembersOperation, ): { +messageData: AddMembersMessageData, +rawMessageInfo: RawMessageInfo, } { const { editorID, time, addedUserIDs, threadID, messageID } = dmOperation; const messageData = { type: messageTypes.ADD_MEMBERS, threadID, creatorID: editorID, time, addedUserIDs: [...addedUserIDs], }; const rawMessageInfo = rawMessageInfoFromMessageData(messageData, messageID); return { messageData, rawMessageInfo }; } function createPermissionsForNewMembers( threadInfo: ThickRawThreadInfo, utilities: ProcessDMOperationUtilities, ): { +membershipPermissions: ThreadPermissionsInfo, +roleID: string, } { const defaultRoleID = values(threadInfo.roles).find(role => roleIsDefaultRole(role), )?.id; invariant(defaultRoleID, 'Default role ID must exist'); const { parentThreadID } = threadInfo; const parentThreadInfo = parentThreadID ? utilities.threadInfos[parentThreadID] : null; if (parentThreadID && !parentThreadInfo) { console.log( `Parent thread with ID ${parentThreadID} was expected while adding ` + 'thread members but is missing from the store', ); } - invariant( - !parentThreadInfo || parentThreadInfo.thick, - 'Parent thread should be thick', - ); const { membershipPermissions } = createRoleAndPermissionForThickThreads( threadInfo.type, threadInfo.id, defaultRoleID, parentThreadInfo, ); return { membershipPermissions, roleID: defaultRoleID, }; } const addMembersSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async (dmOperation: DMAddMembersOperation) => { return { messageDatasWithMessageInfos: [ createAddNewMembersMessageDataWithInfoFromDMOperation(dmOperation), ], }; }, processDMOperation: async ( dmOperation: DMAddMembersOperation, utilities: ProcessDMOperationUtilities, ) => { const { editorID, time, addedUserIDs, threadID } = dmOperation; const { viewerID, threadInfos } = utilities; const { rawMessageInfo } = createAddNewMembersMessageDataWithInfoFromDMOperation(dmOperation); const rawMessageInfos = [rawMessageInfo]; const currentThreadInfo = threadInfos[threadID]; - if (!currentThreadInfo.thick) { - return { - rawMessageInfos: [], - updateInfos: [], - blobOps: [], - }; - } const { membershipPermissions, roleID } = createPermissionsForNewMembers( currentThreadInfo, utilities, ); const memberTimestamps = { ...currentThreadInfo.timestamps.members }; const newMembers = []; for (const userID of addedUserIDs) { if (!memberTimestamps[userID]) { memberTimestamps[userID] = { isMember: time, subscription: currentThreadInfo.creationTime, }; } if (memberTimestamps[userID].isMember > time) { continue; } memberTimestamps[userID] = { ...memberTimestamps[userID], isMember: time, }; if (userIsMember(currentThreadInfo, userID)) { continue; } newMembers.push( minimallyEncodeMemberInfo({ id: userID, role: roleID, permissions: membershipPermissions, isSender: editorID === viewerID, subscription: joinThreadSubscription, }), ); } const resultThreadInfo = { ...currentThreadInfo, members: [...currentThreadInfo.members, ...newMembers], timestamps: { ...currentThreadInfo.timestamps, members: memberTimestamps, }, }; const updateInfos = [ { type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time, threadInfo: resultThreadInfo, }, ]; return { rawMessageInfos, updateInfos, blobOps: [], }; }, canBeProcessed: async ( dmOperation: DMAddMembersOperation, utilities: ProcessDMOperationUtilities, ) => { if (utilities.threadInfos[dmOperation.threadID]) { return { isProcessingPossible: true }; } return { isProcessingPossible: false, reason: { type: 'missing_thread', threadID: dmOperation.threadID, }, }; }, supportsAutoRetry: true, operationValidator: dmAddMembersOperationValidator, }); export { addMembersSpec, createAddNewMembersMessageDataWithInfoFromDMOperation, createPermissionsForNewMembers, }; diff --git a/lib/shared/dm-ops/add-viewer-to-thread-members-spec.js b/lib/shared/dm-ops/add-viewer-to-thread-members-spec.js index cd032b23b..99323de4c 100644 --- a/lib/shared/dm-ops/add-viewer-to-thread-members-spec.js +++ b/lib/shared/dm-ops/add-viewer-to-thread-members-spec.js @@ -1,223 +1,216 @@ // @flow import uuid from 'uuid'; import { createPermissionsForNewMembers } from './add-members-spec.js'; import { createThickRawThreadInfo } from './create-thread-spec.js'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import { type DMAddViewerToThreadMembersOperation, dmAddViewerToThreadMembersValidator, } from '../../types/dm-ops.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { RawMessageInfo } from '../../types/message-types.js'; import { messageTruncationStatus } from '../../types/message-types.js'; import type { AddMembersMessageData } from '../../types/messages/add-members.js'; import { minimallyEncodeMemberInfo, minimallyEncodeThreadCurrentUserInfo, } 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 { rawMessageInfoFromMessageData } from '../message-utils.js'; import { userIsMember } from '../thread-utils.js'; function createAddViewerToThreadMembersMessageDataWithInfoFromDMOp( dmOperation: DMAddViewerToThreadMembersOperation, ): { +messageData: AddMembersMessageData, +rawMessageInfo: RawMessageInfo, } { const { editorID, time, addedUserIDs, existingThreadDetails, messageID } = dmOperation; const messageData = { type: messageTypes.ADD_MEMBERS, threadID: existingThreadDetails.threadID, creatorID: editorID, time, addedUserIDs: [...addedUserIDs], }; const rawMessageInfo = rawMessageInfoFromMessageData(messageData, messageID); return { messageData, rawMessageInfo }; } const addViewerToThreadMembersSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async ( dmOperation: DMAddViewerToThreadMembersOperation, ) => { return { messageDatasWithMessageInfos: [ createAddViewerToThreadMembersMessageDataWithInfoFromDMOp( dmOperation, ), ], }; }, processDMOperation: async ( dmOperation: DMAddViewerToThreadMembersOperation, utilities: ProcessDMOperationUtilities, ) => { const { time, messageID, addedUserIDs, existingThreadDetails, editorID } = dmOperation; const { threadInfos } = utilities; const { rawMessageInfo } = createAddViewerToThreadMembersMessageDataWithInfoFromDMOp(dmOperation); const rawMessageInfos = messageID ? [rawMessageInfo] : []; const threadID = existingThreadDetails.threadID; const currentThreadInfo = threadInfos[threadID]; - if (currentThreadInfo && !currentThreadInfo.thick) { - return { - rawMessageInfos: [], - updateInfos: [], - blobOps: [], - }; - } const memberTimestamps = { ...currentThreadInfo?.timestamps?.members, }; const newMembers = []; for (const userID of addedUserIDs) { if (!memberTimestamps[userID]) { memberTimestamps[userID] = { isMember: time, subscription: existingThreadDetails.creationTime, }; } if (memberTimestamps[userID].isMember > time) { continue; } memberTimestamps[userID] = { ...memberTimestamps[userID], isMember: time, }; if (!userIsMember(currentThreadInfo, userID)) { newMembers.push(userID); } } if (currentThreadInfo) { const { membershipPermissions, roleID } = createPermissionsForNewMembers(currentThreadInfo, utilities); const newMemberInfos = newMembers.map(userID => minimallyEncodeMemberInfo({ id: userID, role: roleID, permissions: membershipPermissions, isSender: editorID === utilities.viewerID, subscription: joinThreadSubscription, }), ); const resultThreadInfo = { ...currentThreadInfo, members: [...currentThreadInfo.members, ...newMemberInfos], currentUser: minimallyEncodeThreadCurrentUserInfo({ role: roleID, permissions: membershipPermissions, subscription: joinThreadSubscription, unread: true, }), timestamps: { ...currentThreadInfo.timestamps, members: { ...currentThreadInfo.timestamps.members, ...memberTimestamps, }, }, }; const updateInfos = [ { type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time, threadInfo: resultThreadInfo, }, ]; return { rawMessageInfos, updateInfos, blobOps: [], }; } const resultThreadInfo = createThickRawThreadInfo( { ...existingThreadDetails, allMemberIDsWithSubscriptions: [ ...existingThreadDetails.allMemberIDsWithSubscriptions, ...newMembers.map(id => ({ id, subscription: joinThreadSubscription, })), ], timestamps: { ...existingThreadDetails.timestamps, members: { ...existingThreadDetails.timestamps.members, ...memberTimestamps, }, }, }, utilities, ); const updateInfos = [ { type: updateTypes.JOIN_THREAD, id: uuid.v4(), time, threadInfo: resultThreadInfo, rawMessageInfos, truncationStatus: messageTruncationStatus.EXHAUSTIVE, rawEntryInfos: [], }, ]; return { rawMessageInfos: [], updateInfos, blobOps: [], }; }, canBeProcessed: async ( dmOperation: DMAddViewerToThreadMembersOperation, utilities: ProcessDMOperationUtilities, ) => { const { viewerID } = utilities; // We expect the viewer to be in the added users when the DM op // is processed. An exception is for ops generated // by InitialStateSharingHandler, which won't contain a messageID if ( dmOperation.addedUserIDs.includes(viewerID) || !dmOperation.messageID ) { return { isProcessingPossible: true }; } console.log('Invalid DM operation', dmOperation); return { isProcessingPossible: false, reason: { type: 'invalid', }, }; }, supportsAutoRetry: true, operationValidator: dmAddViewerToThreadMembersValidator, }); export { addViewerToThreadMembersSpec, createAddViewerToThreadMembersMessageDataWithInfoFromDMOp, }; diff --git a/lib/shared/dm-ops/change-thread-read-status-spec.js b/lib/shared/dm-ops/change-thread-read-status-spec.js index a092034f9..221e70e16 100644 --- a/lib/shared/dm-ops/change-thread-read-status-spec.js +++ b/lib/shared/dm-ops/change-thread-read-status-spec.js @@ -1,81 +1,79 @@ // @flow -import invariant from 'invariant'; import uuid from 'uuid'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import { type DMChangeThreadReadStatusOperation, dmChangeThreadReadStatusOperationValidator, } from '../../types/dm-ops.js'; import { updateTypes } from '../../types/update-types-enum.js'; const changeThreadReadStatusSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async ( dmOperation: DMChangeThreadReadStatusOperation, ) => { const { threadID, unread } = dmOperation; if (unread) { return { badgeUpdateData: { threadID } }; } return { rescindData: { threadID } }; }, processDMOperation: async ( dmOperation: DMChangeThreadReadStatusOperation, utilities: ProcessDMOperationUtilities, ) => { const { threadID, unread, time } = dmOperation; const threadInfo = utilities.threadInfos[threadID]; - invariant(threadInfo.thick, 'Thread should be thick'); if (threadInfo.timestamps.currentUser.unread > time) { return { rawMessageInfos: [], updateInfos: [], blobOps: [], }; } const updateInfos = [ { type: updateTypes.UPDATE_THREAD_READ_STATUS, id: uuid.v4(), time, threadID: threadInfo.id, unread, }, ]; return { rawMessageInfos: [], updateInfos, blobOps: [], }; }, canBeProcessed: async ( dmOperation: DMChangeThreadReadStatusOperation, utilities: ProcessDMOperationUtilities, ) => { const { creatorID, threadID } = dmOperation; const { threadInfos, viewerID } = utilities; if (viewerID !== creatorID) { return { isProcessingPossible: false, reason: { type: 'invalid' } }; } if (!threadInfos[threadID]) { return { isProcessingPossible: false, reason: { type: 'missing_thread', threadID }, }; } return { isProcessingPossible: true }; }, supportsAutoRetry: true, operationValidator: dmChangeThreadReadStatusOperationValidator, }); export { changeThreadReadStatusSpec }; diff --git a/lib/shared/dm-ops/change-thread-settings-spec.js b/lib/shared/dm-ops/change-thread-settings-spec.js index 951363105..6d2fe7e03 100644 --- a/lib/shared/dm-ops/change-thread-settings-spec.js +++ b/lib/shared/dm-ops/change-thread-settings-spec.js @@ -1,217 +1,215 @@ // @flow -import invariant from 'invariant'; import uuid from 'uuid'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import { type DMBlobOperation, type DMChangeThreadSettingsOperation, type DMThreadSettingsChanges, dmChangeThreadSettingsOperationValidator, } from '../../types/dm-ops.js'; import type { RawMessageInfo } from '../../types/message-types'; import { messageTypes } from '../../types/message-types-enum.js'; import type { ChangeSettingsMessageData } from '../../types/messages/change-settings.js'; import type { RawThreadInfo, ThickRawThreadInfo, } from '../../types/minimally-encoded-thread-permissions-types.js'; import { updateTypes } from '../../types/update-types-enum.js'; import type { ClientUpdateInfo } from '../../types/update-types.js'; import { blobHashFromBlobServiceURI } from '../../utils/blob-service.js'; import { values } from '../../utils/objects.js'; import { rawMessageInfoFromMessageData } from '../message-utils.js'; function getThreadIDFromChangeThreadSettingsDMOp( dmOperation: DMChangeThreadSettingsOperation, ): string { return dmOperation.type === 'change_thread_settings' ? dmOperation.threadID : dmOperation.existingThreadDetails.threadID; } function createChangeSettingsMessageDatasAndUpdate( dmOperation: DMChangeThreadSettingsOperation, ): { +fieldNameToMessageData: { +[fieldName: string]: { +messageData: ChangeSettingsMessageData, +rawMessageInfo: RawMessageInfo, }, }, +threadInfoUpdate: DMThreadSettingsChanges, } { const { changes, editorID, time, messageIDsPrefix } = dmOperation; const { name, description, color, avatar } = changes; const threadID = getThreadIDFromChangeThreadSettingsDMOp(dmOperation); const threadInfoUpdate: { ...DMThreadSettingsChanges } = {}; if (name !== undefined && name !== null) { threadInfoUpdate.name = name; } if (description !== undefined && description !== null) { threadInfoUpdate.description = description; } if (color) { threadInfoUpdate.color = color; } if (avatar || avatar === null) { threadInfoUpdate.avatar = avatar; } const fieldNameToMessageData: { [fieldName: string]: { +messageData: ChangeSettingsMessageData, +rawMessageInfo: RawMessageInfo, }, } = {}; const { avatar: avatarObject, ...rest } = threadInfoUpdate; let normalizedThreadInfoUpdate; if (avatarObject) { normalizedThreadInfoUpdate = { ...rest, avatar: JSON.stringify(avatarObject), }; } else if (avatarObject === null) { // clear thread avatar normalizedThreadInfoUpdate = { ...rest, avatar: '' }; } else { normalizedThreadInfoUpdate = { ...rest }; } for (const fieldName in normalizedThreadInfoUpdate) { const value = normalizedThreadInfoUpdate[fieldName]; const messageData: ChangeSettingsMessageData = { type: messageTypes.CHANGE_SETTINGS, threadID, creatorID: editorID, time, field: fieldName, value: value, }; const rawMessageInfo = rawMessageInfoFromMessageData( messageData, `${messageIDsPrefix}/${fieldName}`, ); fieldNameToMessageData[fieldName] = { messageData, rawMessageInfo }; } return { fieldNameToMessageData, threadInfoUpdate }; } function getBlobOpsFromOperation( dmOperation: DMChangeThreadSettingsOperation, threadInfo: ?RawThreadInfo, ): Array { const ops: Array = []; const prevAvatar = threadInfo?.avatar; if (prevAvatar && prevAvatar.type === 'encrypted_image') { ops.push({ type: 'remove_holder', blobHash: blobHashFromBlobServiceURI(prevAvatar.blobURI), dmOpType: 'inbound_and_outbound', }); } const { avatar } = dmOperation.changes; if (avatar && avatar?.type === 'encrypted_image') { ops.push({ type: 'establish_holder', blobHash: blobHashFromBlobServiceURI(avatar.blobURI), dmOpType: 'inbound_only', }); } return ops; } const changeThreadSettingsSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async ( dmOperation: DMChangeThreadSettingsOperation, ) => { const { fieldNameToMessageData } = createChangeSettingsMessageDatasAndUpdate(dmOperation); return { messageDatasWithMessageInfos: values(fieldNameToMessageData) }; }, processDMOperation: async ( dmOperation: DMChangeThreadSettingsOperation, utilities: ProcessDMOperationUtilities, ) => { const { time } = dmOperation; const threadID = getThreadIDFromChangeThreadSettingsDMOp(dmOperation); - const threadInfo: ?RawThreadInfo = utilities.threadInfos[threadID]; + const threadInfo = utilities.threadInfos[threadID]; const updateInfos: Array = []; const { fieldNameToMessageData, threadInfoUpdate } = createChangeSettingsMessageDatasAndUpdate(dmOperation); const blobOps = getBlobOpsFromOperation(dmOperation, threadInfo); const messageDataWithMessageInfoPairs = values(fieldNameToMessageData); const rawMessageInfos = messageDataWithMessageInfoPairs.map( ({ rawMessageInfo }) => rawMessageInfo, ); - invariant(threadInfo?.thick, 'Thread should be thick'); let threadInfoToUpdate: ThickRawThreadInfo = threadInfo; for (const fieldName in threadInfoUpdate) { const timestamp = threadInfoToUpdate.timestamps[fieldName]; if (timestamp < time) { threadInfoToUpdate = { ...threadInfoToUpdate, [fieldName]: threadInfoUpdate[fieldName], timestamps: { ...threadInfoToUpdate.timestamps, [fieldName]: time, }, }; } } if (messageDataWithMessageInfoPairs.length > 0) { updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time, threadInfo: threadInfoToUpdate, }); } return { rawMessageInfos, updateInfos, blobOps, }; }, canBeProcessed: async ( dmOperation: DMChangeThreadSettingsOperation, utilities: ProcessDMOperationUtilities, ) => { if (!utilities.threadInfos[dmOperation.threadID]) { return { isProcessingPossible: false, reason: { type: 'missing_thread', threadID: dmOperation.threadID, }, }; } return { isProcessingPossible: true }; }, supportsAutoRetry: true, operationValidator: dmChangeThreadSettingsOperationValidator, }); export { changeThreadSettingsSpec, createChangeSettingsMessageDatasAndUpdate }; diff --git a/lib/shared/dm-ops/change-thread-subscription.js b/lib/shared/dm-ops/change-thread-subscription.js index 5df0c4549..98aced38a 100644 --- a/lib/shared/dm-ops/change-thread-subscription.js +++ b/lib/shared/dm-ops/change-thread-subscription.js @@ -1,117 +1,116 @@ // @flow import invariant from 'invariant'; import uuid from 'uuid'; import type { ProcessDMOperationUtilities, DMOperationSpec, } from './dm-op-spec.js'; import { type DMChangeThreadSubscriptionOperation, dmChangeThreadSubscriptionOperationValidator, } from '../../types/dm-ops.js'; import { updateTypes } from '../../types/update-types-enum.js'; import type { ClientUpdateInfo } from '../../types/update-types.js'; const changeThreadSubscriptionSpec: DMOperationSpec = Object.freeze({ processDMOperation: async ( dmOperation: DMChangeThreadSubscriptionOperation, utilities: ProcessDMOperationUtilities, ) => { const { creatorID, threadID, subscription, time } = dmOperation; const { viewerID, threadInfos } = utilities; const threadInfo = threadInfos[threadID]; - invariant(threadInfo.thick, 'Thread should be thick'); if (threadInfo.timestamps.members[creatorID].subscription > time) { return { updateInfos: [], rawMessageInfos: [], blobOps: [], }; } const creatorMemberInfo = threadInfo.members.find( member => member.id === creatorID, ); invariant(creatorMemberInfo, 'operation creator missing in thread'); const updatedCreatorMemberInfo = { ...creatorMemberInfo, subscription, }; const otherMemberInfos = threadInfo.members.filter( member => member.id !== creatorID, ); const membersUpdate = [...otherMemberInfos, updatedCreatorMemberInfo]; const currentUserUpdate = viewerID === creatorID ? { ...threadInfo.currentUser, subscription, } : threadInfo.currentUser; const threadInfoUpdate = { ...threadInfo, members: membersUpdate, currentUser: currentUserUpdate, timestamps: { ...threadInfo.timestamps, members: { ...threadInfo.timestamps.members, [creatorID]: { ...threadInfo.timestamps.members[creatorID], subscription: time, }, }, }, }; const updateInfos: Array = [ { type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time, threadInfo: threadInfoUpdate, }, ]; return { updateInfos, rawMessageInfos: [], blobOps: [], }; }, canBeProcessed: async ( dmOperation: DMChangeThreadSubscriptionOperation, utilities: ProcessDMOperationUtilities, ) => { const { threadID, creatorID } = dmOperation; if (!utilities.threadInfos[threadID]) { return { isProcessingPossible: false, reason: { type: 'missing_thread', threadID }, }; } if ( !utilities.threadInfos[threadID].members.find( memberInfo => memberInfo.id === creatorID, ) ) { return { isProcessingPossible: false, reason: { type: 'missing_membership', threadID, userID: creatorID }, }; } return { isProcessingPossible: true }; }, supportsAutoRetry: true, operationValidator: dmChangeThreadSubscriptionOperationValidator, }); export { changeThreadSubscriptionSpec }; diff --git a/lib/shared/dm-ops/create-thread-spec.js b/lib/shared/dm-ops/create-thread-spec.js index ce938ac7c..1d86d1e94 100644 --- a/lib/shared/dm-ops/create-thread-spec.js +++ b/lib/shared/dm-ops/create-thread-spec.js @@ -1,321 +1,316 @@ // @flow -import invariant from 'invariant'; import uuid from 'uuid'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import { specialRoles } from '../../permissions/special-roles.js'; import { getAllThreadPermissions, makePermissionsBlob, getThickThreadRolePermissionsBlob, makePermissionsForChildrenBlob, } from '../../permissions/thread-permissions.js'; import { type CreateThickRawThreadInfoInput, type DMCreateThreadOperation, dmCreateThreadOperationValidator, } from '../../types/dm-ops.js'; import { messageTypes } from '../../types/message-types-enum.js'; import { 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'; import { rawMessageInfoFromMessageData } from '../message-utils.js'; import { createThreadTimestamps } from '../thread-utils.js'; function createPermissionsInfo( threadID: string, threadType: ThickThreadType, isMember: boolean, parentThreadInfo: ?ThickRawThreadInfo, ): ThreadPermissionsInfo { let rolePermissions = null; if (isMember) { rolePermissions = getThickThreadRolePermissionsBlob(threadType); } let permissionsFromParent = null; if (parentThreadInfo) { const parentThreadRolePermissions = getThickThreadRolePermissionsBlob( parentThreadInfo.type, ); const parentPermissionsBlob = makePermissionsBlob( parentThreadRolePermissions, null, parentThreadInfo.id, parentThreadInfo.type, ); permissionsFromParent = makePermissionsForChildrenBlob( parentPermissionsBlob, ); } return getAllThreadPermissions( makePermissionsBlob( rolePermissions, permissionsFromParent, threadID, threadType, ), threadID, ); } function createRoleAndPermissionForThickThreads( threadType: ThickThreadType, threadID: string, roleID: string, parentThreadInfo: ?ThickRawThreadInfo, ): { +role: RoleInfo, +membershipPermissions: ThreadPermissionsInfo } { const rolePermissions = getThickThreadRolePermissionsBlob(threadType); const membershipPermissions = createPermissionsInfo( threadID, threadType, true, parentThreadInfo, ); 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, utilities: ProcessDMOperationUtilities, ): MutableThickRawThreadInfo { const { threadID, threadType, creationTime, parentThreadID, allMemberIDsWithSubscriptions, roleID, unread, name, avatar, description, color, containingThreadID, sourceMessageID, repliesCount, pinnedCount, timestamps, } = input; const memberIDs = allMemberIDsWithSubscriptions.map(({ id }) => id); const threadColor = color ?? generatePendingThreadColor(memberIDs); const parentThreadInfo = parentThreadID ? utilities.threadInfos[parentThreadID] : null; if (parentThreadID && !parentThreadInfo) { console.log( `Parent thread with ID ${parentThreadID} was expected while creating ` + 'thick thread but is missing from the store', ); } - invariant( - !parentThreadInfo || parentThreadInfo.thick, - 'Parent thread should be thick', - ); const { membershipPermissions, role } = createRoleAndPermissionForThickThreads( threadType, threadID, roleID, parentThreadInfo, ); const viewerIsMember = allMemberIDsWithSubscriptions.some( member => member.id === utilities.viewerID, ); const viewerRoleID = viewerIsMember ? role.id : null; const viewerMembershipPermissions = createPermissionsInfo( threadID, threadType, viewerIsMember, parentThreadInfo, ); const newThread: MutableThickRawThreadInfo = { thick: true, minimallyEncoded: true, id: threadID, type: threadType, color: threadColor, creationTime, parentThreadID, members: allMemberIDsWithSubscriptions.map( ({ id: memberID, subscription }) => minimallyEncodeMemberInfo({ id: memberID, role: memberID === utilities.viewerID ? viewerRoleID : role.id, permissions: memberID === utilities.viewerID ? viewerMembershipPermissions : membershipPermissions, isSender: memberID === utilities.viewerID, subscription, }), ), roles: { [role.id]: role, }, currentUser: minimallyEncodeThreadCurrentUserInfo({ role: viewerRoleID, permissions: viewerMembershipPermissions, subscription: joinThreadSubscription, unread, }), repliesCount: repliesCount ?? 0, name, avatar, description: description ?? '', containingThreadID, timestamps, }; if (sourceMessageID) { newThread.sourceMessageID = sourceMessageID; } if (pinnedCount) { newThread.pinnedCount = pinnedCount; } return newThread; } function createMessageDataWithInfoFromDMOperation( dmOperation: DMCreateThreadOperation, ) { const { threadID, creatorID, time, threadType, memberIDs, newMessageID } = dmOperation; const allMemberIDs = [creatorID, ...memberIDs]; const color = generatePendingThreadColor(allMemberIDs); const messageData = { type: messageTypes.CREATE_THREAD, threadID, creatorID, time, initialThreadState: { type: threadType, color, memberIDs: allMemberIDs, }, }; const rawMessageInfo = rawMessageInfoFromMessageData( messageData, newMessageID, ); return { messageData, rawMessageInfo }; } const createThreadSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async (dmOperation: DMCreateThreadOperation) => { return { messageDatasWithMessageInfos: [ createMessageDataWithInfoFromDMOperation(dmOperation), ], }; }, processDMOperation: async ( dmOperation: DMCreateThreadOperation, utilities: ProcessDMOperationUtilities, ) => { const { threadID, creatorID, time, threadType, memberIDs, roleID } = dmOperation; const { viewerID } = utilities; const allMemberIDs = [creatorID, ...memberIDs]; const allMemberIDsWithSubscriptions = allMemberIDs.map(id => ({ id, subscription: joinThreadSubscription, })); const rawThreadInfo = createThickRawThreadInfo( { threadID, threadType, creationTime: time, allMemberIDsWithSubscriptions, roleID, unread: creatorID !== viewerID, timestamps: createThreadTimestamps(time, allMemberIDs), }, utilities, ); const { rawMessageInfo } = createMessageDataWithInfoFromDMOperation(dmOperation); const rawMessageInfos = [rawMessageInfo]; 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], blobOps: [], }; }, canBeProcessed: async ( dmOperation: DMCreateThreadOperation, utilities: ProcessDMOperationUtilities, ) => { if (utilities.threadInfos[dmOperation.threadID]) { console.log( 'Discarded a CREATE_THREAD operation because thread ' + `with the same ID ${dmOperation.threadID} already exists ` + 'in the store', ); return { isProcessingPossible: false, reason: { type: 'invalid', }, }; } return { isProcessingPossible: true }; }, supportsAutoRetry: true, operationValidator: dmCreateThreadOperationValidator, }); export { createThickRawThreadInfo, createThreadSpec, createRoleAndPermissionForThickThreads, createPermissionsInfo, }; diff --git a/lib/shared/dm-ops/dm-op-spec.js b/lib/shared/dm-ops/dm-op-spec.js index b1deae235..af6c83233 100644 --- a/lib/shared/dm-ops/dm-op-spec.js +++ b/lib/shared/dm-ops/dm-op-spec.js @@ -1,50 +1,50 @@ // @flow import type { TInterface } from 'tcomb'; import type { DMOperation, DMOperationResult } from '../../types/dm-ops.js'; import type { RawEntryInfos } from '../../types/entry-types.js'; import type { UserIdentitiesResponse } from '../../types/identity-service-types.js'; import type { RawMessageInfo } from '../../types/message-types.js'; import type { NotificationsCreationData } from '../../types/notif-types.js'; -import type { RawThreadInfos } from '../../types/thread-types.js'; +import type { ThickRawThreadInfos } from '../../types/thread-types.js'; export type ProcessDMOperationUtilities = { +viewerID: string, // Needed to fetch sidebar source messages +fetchMessage: (messageID: string) => Promise, - +threadInfos: RawThreadInfos, + +threadInfos: ThickRawThreadInfos, +entryInfos: RawEntryInfos, +findUserIdentities: ( userIDs: $ReadOnlyArray, ) => Promise, }; export type ProcessingPossibilityCheckResult = | { +isProcessingPossible: true } | { +isProcessingPossible: false, +reason: | { +type: 'missing_thread', +threadID: string } | { +type: 'missing_entry', +entryID: string } | { +type: 'missing_message', +messageID: string } | { +type: 'missing_membership', +threadID: string, +userID: string } | { +type: 'invalid' }, }; export type DMOperationSpec = { +notificationsCreationData?: ( dmOp: DMOp, utilities: ProcessDMOperationUtilities, ) => Promise, +processDMOperation: ( dmOp: DMOp, utilities: ProcessDMOperationUtilities, ) => Promise, +canBeProcessed: ( dmOp: DMOp, utilities: ProcessDMOperationUtilities, ) => Promise, +supportsAutoRetry: boolean, +operationValidator: TInterface, }; diff --git a/lib/shared/dm-ops/dm-op-utils.js b/lib/shared/dm-ops/dm-op-utils.js index 77b328728..e488a3e35 100644 --- a/lib/shared/dm-ops/dm-op-utils.js +++ b/lib/shared/dm-ops/dm-op-utils.js @@ -1,504 +1,505 @@ // @flow import invariant from 'invariant'; import _groupBy from 'lodash/fp/groupBy.js'; import * as React from 'react'; import uuid from 'uuid'; import { type ProcessDMOperationUtilities } from './dm-op-spec.js'; import { dmOpSpecs } from './dm-op-specs.js'; import { useProcessAndSendDMOperation } from './process-dm-ops.js'; import { setMissingDeviceListsActionType, removePeerUsersActionType, } from '../../actions/aux-user-actions.js'; import { useFindUserIdentities } from '../../actions/find-user-identities-actions.js'; import { useLoggedInUserInfo } from '../../hooks/account-hooks.js'; import { useGetLatestMessageEdit } from '../../hooks/latest-message-edit.js'; import { useGetAndUpdateDeviceListsForUsers } from '../../hooks/peer-list-hooks.js'; import { mergeUpdatesWithMessageInfos } from '../../reducers/message-reducer.js'; +import { thickRawThreadInfosSelector } from '../../selectors/thread-selectors.js'; import { getAllPeerUserIDAndDeviceIDs } from '../../selectors/user-selectors.js'; import { type P2PMessageRecipient } from '../../tunnelbroker/peer-to-peer-context.js'; import type { CreateThickRawThreadInfoInput, DMAddMembersOperation, DMAddViewerToThreadMembersOperation, DMOperation, ComposableDMOperation, } from '../../types/dm-ops.js'; import type { RawMessageInfo } from '../../types/message-types.js'; import type { ThickRawThreadInfo, ThreadInfo, } from '../../types/minimally-encoded-thread-permissions-types.js'; import type { InboundActionMetadata } from '../../types/redux-types.js'; import { outboundP2PMessageStatuses, type OutboundP2PMessage, } from '../../types/sqlite-types.js'; import { assertThickThreadType, thickThreadTypes, } from '../../types/thread-types-enum.js'; import type { RawThreadInfos } from '../../types/thread-types.js'; import { type DMOperationP2PMessage, userActionsP2PMessageTypes, } from '../../types/tunnelbroker/user-actions-peer-to-peer-message-types.js'; import { updateTypes } from '../../types/update-types-enum.js'; import type { AccountDeletionUpdateInfo, ClientUpdateInfo, } from '../../types/update-types.js'; import { getContentSigningKey } from '../../utils/crypto-utils.js'; import { useSelector, useDispatch } from '../../utils/redux-utils.js'; import { messageSpecs } from '../messages/message-specs.js'; import { expectedAccountDeletionUpdateTimeout, userHasDeviceList, deviceListCanBeRequestedForUser, } from '../thread-utils.js'; function generateMessagesToPeers( message: DMOperation, peers: $ReadOnlyArray<{ +userID: string, +deviceID: string, }>, ): $ReadOnlyArray { const opMessage: DMOperationP2PMessage = { type: userActionsP2PMessageTypes.DM_OPERATION, op: message, }; const plaintext = JSON.stringify(opMessage); const outboundP2PMessages = []; for (const peer of peers) { const messageToPeer: OutboundP2PMessage = { messageID: uuid.v4(), deviceID: peer.deviceID, userID: peer.userID, timestamp: new Date().getTime().toString(), plaintext, ciphertext: '', status: outboundP2PMessageStatuses.persisted, supportsAutoRetry: dmOpSpecs[message.type].supportsAutoRetry, }; outboundP2PMessages.push(messageToPeer); } return outboundP2PMessages; } export const dmOperationSpecificationTypes = Object.freeze({ OUTBOUND: 'OutboundDMOperationSpecification', INBOUND: 'InboundDMOperationSpecification', }); type OutboundDMOperationSpecificationRecipients = | { +type: 'all_peer_devices' | 'self_devices' } | { +type: 'some_users', +userIDs: $ReadOnlyArray } | { +type: 'all_thread_members', +threadID: string } | { +type: 'some_devices', +deviceIDs: $ReadOnlyArray }; // The operation generated on the sending client, causes changes to // the state and broadcasting information to peers. export type OutboundDMOperationSpecification = { +type: 'OutboundDMOperationSpecification', +op: DMOperation, +recipients: OutboundDMOperationSpecificationRecipients, +sendOnly?: boolean, }; export type OutboundComposableDMOperationSpecification = { +type: 'OutboundDMOperationSpecification', +op: ComposableDMOperation, +recipients: OutboundDMOperationSpecificationRecipients, // Composable DM Ops are created only to be sent, locally we use // dedicated mechanism for updating the store. +sendOnly: true, +composableMessageID: string, }; // The operation received from other peers, causes changes to // the state and after processing, sends confirmation to the sender. export type InboundDMOperationSpecification = { +type: 'InboundDMOperationSpecification', +op: DMOperation, +metadata: ?InboundActionMetadata, }; export type DMOperationSpecification = | OutboundDMOperationSpecification | InboundDMOperationSpecification; function useCreateMessagesToPeersFromDMOp(): ( operation: DMOperation, recipients: OutboundDMOperationSpecificationRecipients, ) => Promise<$ReadOnlyArray> { const allPeerUserIDAndDeviceIDs = useSelector(getAllPeerUserIDAndDeviceIDs); const utilities = useSendDMOperationUtils(); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const getAndUpdateDeviceListsForUsers = useGetAndUpdateDeviceListsForUsers(); const dispatch = useDispatch(); const getUsersWithoutDeviceList = React.useCallback( (userIDs: $ReadOnlyArray) => { const missingDeviceListsUserIDs: Array = []; for (const userID of userIDs) { const supportsThickThreads = userHasDeviceList(userID, auxUserInfos); const deviceListCanBeRequested = deviceListCanBeRequestedForUser( userID, auxUserInfos, ); if (!supportsThickThreads && deviceListCanBeRequested) { missingDeviceListsUserIDs.push(userID); } } return missingDeviceListsUserIDs; }, [auxUserInfos], ); const getMissingPeers = React.useCallback( async ( userIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> => { const missingDeviceListsUserIDs = getUsersWithoutDeviceList(userIDs); if (missingDeviceListsUserIDs.length === 0) { return []; } const deviceLists = await getAndUpdateDeviceListsForUsers( missingDeviceListsUserIDs, true, ); if (!deviceLists) { return []; } const missingUsers: $ReadOnlyArray = missingDeviceListsUserIDs.filter(id => !deviceLists[id]); const time = Date.now(); const shouldDeleteUser = (userID: string) => !!auxUserInfos[userID]?.accountMissingStatus && auxUserInfos[userID].accountMissingStatus.missingSince < time - expectedAccountDeletionUpdateTimeout; const nonDeletedUsers = missingUsers.filter( userID => !shouldDeleteUser(userID), ); if (nonDeletedUsers.length > 0) { dispatch({ type: setMissingDeviceListsActionType, payload: { usersMissingFromIdentity: { userIDs: nonDeletedUsers, time, }, }, }); } const deletedUsers = missingUsers.filter(shouldDeleteUser); if (deletedUsers.length > 0) { const deleteUserUpdates: $ReadOnlyArray = deletedUsers.map(deletedUserID => ({ type: updateTypes.DELETE_ACCOUNT, time, id: uuid.v4(), deletedUserID, })); dispatch({ type: removePeerUsersActionType, payload: { updatesResult: { newUpdates: deleteUserUpdates } }, }); } const updatedPeers: Array = []; for (const userID of missingDeviceListsUserIDs) { if (deviceLists[userID] && deviceLists[userID].devices.length > 0) { updatedPeers.push( ...deviceLists[userID].devices.map(deviceID => ({ deviceID, userID, })), ); } } return updatedPeers; }, [ auxUserInfos, dispatch, getAndUpdateDeviceListsForUsers, getUsersWithoutDeviceList, ], ); return React.useCallback( async ( operation: DMOperation, recipients: OutboundDMOperationSpecificationRecipients, ): Promise<$ReadOnlyArray> => { const { viewerID, threadInfos } = utilities; if (!viewerID) { return []; } let peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs; if (recipients.type === 'self_devices') { peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs.filter( peer => peer.userID === viewerID, ); } else if (recipients.type === 'some_users') { const missingPeers = await getMissingPeers(recipients.userIDs); const updatedPeers = [...allPeerUserIDAndDeviceIDs, ...missingPeers]; const userIDs = new Set(recipients.userIDs); peerUserIDAndDeviceIDs = updatedPeers.filter(peer => userIDs.has(peer.userID), ); } else if (recipients.type === 'all_thread_members') { const { threadID } = recipients; if (!threadInfos[threadID]) { console.log( `all_thread_members called for threadID ${threadID}, which is ` + 'missing from the ThreadStore. if sending a message soon after ' + 'thread creation, consider some_users instead', ); } const members = threadInfos[recipients.threadID]?.members ?? []; const memberIDs = members.map(member => member.id); const missingPeers = await getMissingPeers(memberIDs); const updatedPeers = [...allPeerUserIDAndDeviceIDs, ...missingPeers]; const userIDs = new Set(memberIDs); peerUserIDAndDeviceIDs = updatedPeers.filter(peer => userIDs.has(peer.userID), ); } else if (recipients.type === 'some_devices') { const deviceIDs = new Set(recipients.deviceIDs); peerUserIDAndDeviceIDs = allPeerUserIDAndDeviceIDs.filter(peer => deviceIDs.has(peer.deviceID), ); } const thisDeviceID = await getContentSigningKey(); const targetPeers = peerUserIDAndDeviceIDs.filter( peer => peer.deviceID !== thisDeviceID, ); return generateMessagesToPeers(operation, targetPeers); }, [allPeerUserIDAndDeviceIDs, getMissingPeers, utilities], ); } function getCreateThickRawThreadInfoInputFromThreadInfo( threadInfo: ThickRawThreadInfo, ): CreateThickRawThreadInfoInput { const roleID = Object.keys(threadInfo.roles).pop(); const thickThreadType = assertThickThreadType(threadInfo.type); return { threadID: threadInfo.id, threadType: thickThreadType, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, allMemberIDsWithSubscriptions: threadInfo.members.map( ({ id, subscription }) => ({ id, subscription, }), ), roleID, unread: !!threadInfo.currentUser.unread, name: threadInfo.name, avatar: threadInfo.avatar, description: threadInfo.description, color: threadInfo.color, containingThreadID: threadInfo.containingThreadID, sourceMessageID: threadInfo.sourceMessageID, repliesCount: threadInfo.repliesCount, pinnedCount: threadInfo.pinnedCount, timestamps: threadInfo.timestamps, }; } function useAddDMThreadMembers(): ( newMemberIDs: $ReadOnlyArray, threadInfo: ThreadInfo, ) => Promise { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const processAndSendDMOperation = useProcessAndSendDMOperation(); const threadInfos = useSelector(state => state.threadStore.threadInfos); return React.useCallback( async (newMemberIDs: $ReadOnlyArray, threadInfo: ThreadInfo) => { const rawThreadInfo = threadInfos[threadInfo.id]; invariant(rawThreadInfo.thick, 'thread should be thick'); const existingThreadDetails = getCreateThickRawThreadInfoInputFromThreadInfo(rawThreadInfo); const messageID = uuid.v4(); invariant(viewerID, 'viewerID should be set'); const addViewerToThreadMembersOperation: DMAddViewerToThreadMembersOperation = { type: 'add_viewer_to_thread_members', existingThreadDetails, editorID: viewerID, time: Date.now(), messageID, addedUserIDs: newMemberIDs, }; const viewerOperationSpecification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op: addViewerToThreadMembersOperation, recipients: { type: 'some_users', userIDs: newMemberIDs, }, sendOnly: true, }; invariant(viewerID, 'viewerID should be set'); const addMembersOperation: DMAddMembersOperation = { type: 'add_members', threadID: threadInfo.id, editorID: viewerID, time: Date.now(), messageID, addedUserIDs: newMemberIDs, }; const newMemberIDsSet = new Set(newMemberIDs); const recipientsThreadID = threadInfo.type === thickThreadTypes.THICK_SIDEBAR && threadInfo.parentThreadID ? threadInfo.parentThreadID : threadInfo.id; const existingMembers = threadInfos[recipientsThreadID]?.members ?.map(member => member.id) ?.filter(memberID => !newMemberIDsSet.has(memberID)) ?? []; const addMembersOperationSpecification: OutboundDMOperationSpecification = { type: dmOperationSpecificationTypes.OUTBOUND, op: addMembersOperation, recipients: { type: 'some_users', userIDs: existingMembers, }, }; await Promise.all([ processAndSendDMOperation(viewerOperationSpecification), processAndSendDMOperation(addMembersOperationSpecification), ]); }, [processAndSendDMOperation, threadInfos, viewerID], ); } function getThreadUpdatesForNewMessages( rawMessageInfos: $ReadOnlyArray, updateInfos: $ReadOnlyArray, threadInfos: RawThreadInfos, viewerID: ?string, ): Array { if (!viewerID) { return []; } const { rawMessageInfos: allNewMessageInfos } = mergeUpdatesWithMessageInfos( rawMessageInfos, updateInfos, ); const messagesByThreadID = _groupBy(message => message.threadID)( allNewMessageInfos, ); const newUpdateInfos: Array = []; for (const threadID in messagesByThreadID) { const repliesCountIncreasingMessages = messagesByThreadID[threadID].filter( message => messageSpecs[message.type].includedInRepliesCount, ); let threadInfo = threadInfos[threadID]; if (repliesCountIncreasingMessages.length > 0) { const repliesCountIncreaseTime = Math.max( repliesCountIncreasingMessages.map(message => message.time), ); const oldRepliesCount = threadInfo?.repliesCount ?? 0; const newThreadInfo = { ...threadInfo, repliesCount: oldRepliesCount + repliesCountIncreasingMessages.length, }; newUpdateInfos.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time: repliesCountIncreaseTime, threadInfo: newThreadInfo, }); threadInfo = newThreadInfo; } const messagesFromOtherPeers = messagesByThreadID[threadID].filter( message => message.creatorID !== viewerID, ); if (messagesFromOtherPeers.length === 0) { continue; } // We take the most recent timestamp to make sure that // change_thread_read_status operation older // than it won't flip the status to read. const time = Math.max(messagesFromOtherPeers.map(message => message.time)); invariant(threadInfo.thick, 'Thread should be thick'); if (threadInfo.timestamps.currentUser.unread < time) { newUpdateInfos.push({ type: updateTypes.UPDATE_THREAD_READ_STATUS, id: uuid.v4(), time, threadID: threadInfo.id, unread: true, }); } } return newUpdateInfos; } function useSendDMOperationUtils(): $ReadOnly<{ ...ProcessDMOperationUtilities, viewerID: ?string, }> { const fetchMessage = useGetLatestMessageEdit(); - const threadInfos = useSelector(state => state.threadStore.threadInfos); + const threadInfos = useSelector(thickRawThreadInfosSelector); const entryInfos = useSelector(state => state.entryStore.entryInfos); const findUserIdentities = useFindUserIdentities(); const loggedInUserInfo = useLoggedInUserInfo(); const viewerID = loggedInUserInfo?.id; return React.useMemo( () => ({ viewerID, fetchMessage, threadInfos, entryInfos, findUserIdentities, }), [viewerID, fetchMessage, threadInfos, entryInfos, findUserIdentities], ); } export { useCreateMessagesToPeersFromDMOp, useAddDMThreadMembers, getCreateThickRawThreadInfoInputFromThreadInfo, getThreadUpdatesForNewMessages, useSendDMOperationUtils, }; diff --git a/lib/shared/dm-ops/edit-entry-spec.js b/lib/shared/dm-ops/edit-entry-spec.js index 166c5b63b..357dd97df 100644 --- a/lib/shared/dm-ops/edit-entry-spec.js +++ b/lib/shared/dm-ops/edit-entry-spec.js @@ -1,126 +1,126 @@ // @flow import invariant from 'invariant'; import uuid from 'uuid'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import { type DMEditEntryOperation, dmEditEntryOperationValidator, } from '../../types/dm-ops.js'; import { messageTypes } from '../../types/message-types-enum.js'; import { updateTypes } from '../../types/update-types-enum.js'; import type { EntryUpdateInfo } from '../../types/update-types.js'; import { dateFromString } from '../../utils/date-utils.js'; import { rawMessageInfoFromMessageData } from '../message-utils.js'; function createMessageDataWithInfoFromDMOperation( dmOperation: DMEditEntryOperation, ) { const { threadID, creatorID, time, entryID, entryDate, text, messageID } = dmOperation; const messageData = { type: messageTypes.EDIT_ENTRY, threadID, creatorID, entryID, time, date: entryDate, text, }; const rawMessageInfo = rawMessageInfoFromMessageData(messageData, messageID); return { messageData, rawMessageInfo }; } const editEntrySpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async (dmOperation: DMEditEntryOperation) => { return { messageDatasWithMessageInfos: [ createMessageDataWithInfoFromDMOperation(dmOperation), ], }; }, processDMOperation: async ( dmOperation: DMEditEntryOperation, utilities: ProcessDMOperationUtilities, ) => { const { threadID, creatorID, creationTime, time, entryID, entryDate: dateString, text, } = dmOperation; const rawEntryInfo = utilities.entryInfos[entryID]; const { rawMessageInfo } = createMessageDataWithInfoFromDMOperation(dmOperation); const rawMessageInfos = [rawMessageInfo]; - invariant(rawEntryInfo?.thick, 'Entry thread should be thick'); + invariant(rawEntryInfo?.thick, 'Entry should be thick'); const timestamp = rawEntryInfo.lastUpdatedTime; if (timestamp > time) { return { rawMessageInfos, updateInfos: [], blobOps: [], }; } const date = dateFromString(dateString); const rawEntryInfoToUpdate = { id: entryID, threadID, text, year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), creationTime, creatorID, thick: true, deleted: false, lastUpdatedTime: time, }; const entryUpdateInfo: EntryUpdateInfo = { entryInfo: rawEntryInfoToUpdate, type: updateTypes.UPDATE_ENTRY, id: uuid.v4(), time, }; return { rawMessageInfos, updateInfos: [entryUpdateInfo], blobOps: [], }; }, canBeProcessed: async ( dmOperation: DMEditEntryOperation, utilities: ProcessDMOperationUtilities, ) => { if (!utilities.entryInfos[dmOperation.entryID]) { return { isProcessingPossible: false, reason: { type: 'missing_entry', entryID: dmOperation.entryID, }, }; } return { isProcessingPossible: true, }; }, supportsAutoRetry: true, operationValidator: dmEditEntryOperationValidator, }); export { editEntrySpec }; diff --git a/lib/shared/dm-ops/join-thread-spec.js b/lib/shared/dm-ops/join-thread-spec.js index fe160b1ba..839f4c4d9 100644 --- a/lib/shared/dm-ops/join-thread-spec.js +++ b/lib/shared/dm-ops/join-thread-spec.js @@ -1,215 +1,204 @@ // @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 DMJoinThreadOperation, dmJoinThreadOperationValidator, } from '../../types/dm-ops.js'; import { messageTypes } from '../../types/message-types-enum.js'; import { messageTruncationStatus, type RawMessageInfo, } from '../../types/message-types.js'; import { minimallyEncodeMemberInfo } 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 { rawMessageInfoFromMessageData } from '../message-utils.js'; import { roleIsDefaultRole, userIsMember } from '../thread-utils.js'; function createMessageDataWithInfoFromDMOperation( dmOperation: DMJoinThreadOperation, ) { const { joinerID, time, existingThreadDetails, messageID } = dmOperation; const messageData = { type: messageTypes.JOIN_THREAD, threadID: existingThreadDetails.threadID, creatorID: joinerID, time, }; const rawMessageInfo = rawMessageInfoFromMessageData(messageData, messageID); return { messageData, rawMessageInfo }; } const joinThreadSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async (dmOperation: DMJoinThreadOperation) => { return { messageDatasWithMessageInfos: [ createMessageDataWithInfoFromDMOperation(dmOperation), ], }; }, processDMOperation: async ( dmOperation: DMJoinThreadOperation, utilities: ProcessDMOperationUtilities, ) => { const { joinerID, time, existingThreadDetails } = dmOperation; const { viewerID, threadInfos } = utilities; const currentThreadInfo = threadInfos[existingThreadDetails.threadID]; - if (currentThreadInfo && !currentThreadInfo.thick) { - return { - rawMessageInfos: [], - updateInfos: [], - blobOps: [], - }; - } const { rawMessageInfo } = createMessageDataWithInfoFromDMOperation(dmOperation); const joinThreadMessageInfos = [rawMessageInfo]; const memberTimestamps = { ...currentThreadInfo?.timestamps?.members }; if (!memberTimestamps[joinerID]) { memberTimestamps[joinerID] = { isMember: time, subscription: existingThreadDetails.creationTime, }; } if (memberTimestamps[joinerID].isMember > time) { return { rawMessageInfos: joinThreadMessageInfos, updateInfos: [], blobOps: [], }; } memberTimestamps[joinerID] = { ...memberTimestamps[joinerID], isMember: time, }; const updateInfos: Array = []; const rawMessageInfos: Array = []; if (userIsMember(currentThreadInfo, joinerID)) { rawMessageInfos.push(...joinThreadMessageInfos); updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time, threadInfo: { ...currentThreadInfo, timestamps: { ...currentThreadInfo.timestamps, members: memberTimestamps, }, }, }); } else if (viewerID === joinerID) { const newThreadInfo = createThickRawThreadInfo( { ...existingThreadDetails, allMemberIDsWithSubscriptions: [ ...existingThreadDetails.allMemberIDsWithSubscriptions, { id: joinerID, subscription: joinThreadSubscription }, ], timestamps: { ...existingThreadDetails.timestamps, members: memberTimestamps, }, }, utilities, ); updateInfos.push({ type: updateTypes.JOIN_THREAD, id: uuid.v4(), time, threadInfo: newThreadInfo, rawMessageInfos: joinThreadMessageInfos, truncationStatus: messageTruncationStatus.EXHAUSTIVE, rawEntryInfos: [], }); } else { rawMessageInfos.push(...joinThreadMessageInfos); const defaultRoleID = values(currentThreadInfo.roles).find(role => roleIsDefaultRole(role), )?.id; invariant(defaultRoleID, 'Default role ID must exist'); const parentThreadID = existingThreadDetails.parentThreadID; const parentThreadInfo = parentThreadID ? utilities.threadInfos[parentThreadID] : null; if (parentThreadID && !parentThreadInfo) { console.log( `Parent thread with ID ${parentThreadID} was expected while joining ` + 'thick thread but is missing from the store', ); } - invariant( - !parentThreadInfo || parentThreadInfo.thick, - 'Parent thread should be thick', - ); const { membershipPermissions } = createRoleAndPermissionForThickThreads( currentThreadInfo.type, currentThreadInfo.id, defaultRoleID, parentThreadInfo, ); const member = minimallyEncodeMemberInfo({ id: joinerID, role: defaultRoleID, permissions: membershipPermissions, isSender: joinerID === viewerID, subscription: joinThreadSubscription, }); const updatedThreadInfo = { ...currentThreadInfo, members: [...currentThreadInfo.members, member], timestamps: { ...currentThreadInfo.timestamps, members: memberTimestamps, }, }; updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time, threadInfo: updatedThreadInfo, }); } return { rawMessageInfos, updateInfos, blobOps: [], }; }, canBeProcessed: async ( dmOperation: DMJoinThreadOperation, utilities: ProcessDMOperationUtilities, ) => { const { viewerID, threadInfos } = utilities; if ( threadInfos[dmOperation.existingThreadDetails.threadID] || dmOperation.joinerID === viewerID ) { return { isProcessingPossible: true }; } return { isProcessingPossible: false, reason: { type: 'missing_thread', threadID: dmOperation.existingThreadDetails.threadID, }, }; }, supportsAutoRetry: true, operationValidator: dmJoinThreadOperationValidator, }); export { joinThreadSpec }; diff --git a/lib/shared/dm-ops/leave-thread-spec.js b/lib/shared/dm-ops/leave-thread-spec.js index f90fab9e3..0059e69f4 100644 --- a/lib/shared/dm-ops/leave-thread-spec.js +++ b/lib/shared/dm-ops/leave-thread-spec.js @@ -1,282 +1,276 @@ // @flow -import invariant from 'invariant'; import uuid from 'uuid'; import { createPermissionsInfo } from './create-thread-spec.js'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import { type DMLeaveThreadOperation, dmLeaveThreadOperationValidator, } from '../../types/dm-ops.js'; import { messageTypes } from '../../types/message-types-enum.js'; import type { ThickRawThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import { minimallyEncodeThreadCurrentUserInfo } from '../../types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from '../../types/thread-types-enum.js'; -import type { RawThreadInfos } from '../../types/thread-types.js'; +import type { ThickRawThreadInfos } 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 { rawMessageInfoFromMessageData } from '../message-utils.js'; function createMessageDataWithInfoFromDMOperation( dmOperation: DMLeaveThreadOperation, ) { const { editorID, time, threadID, messageID } = dmOperation; const messageData = { type: messageTypes.LEAVE_THREAD, threadID, creatorID: editorID, time, }; const rawMessageInfo = rawMessageInfoFromMessageData(messageData, messageID); return { messageData, rawMessageInfo }; } function createDeleteSubthreadsUpdates( dmOperation: DMLeaveThreadOperation, threadInfo: ThickRawThreadInfo, - threadInfos: RawThreadInfos, + threadInfos: ThickRawThreadInfos, ): Array { const updates: Array = []; for (const thread of values(threadInfos)) { if (thread.parentThreadID !== threadInfo.id) { continue; } updates.push({ type: updateTypes.DELETE_THREAD, id: uuid.v4(), time: dmOperation.time, threadID: thread.id, }); } return updates; } function createLeaveSubthreadsUpdates( dmOperation: DMLeaveThreadOperation, threadInfo: ThickRawThreadInfo, - threadInfos: RawThreadInfos, + threadInfos: ThickRawThreadInfos, ): Array { const updates: Array = []; for (const thread of values(threadInfos)) { - if (thread.parentThreadID !== threadInfo.id || !thread.thick) { + if (thread.parentThreadID !== threadInfo.id) { continue; } const userID = dmOperation.editorID; let userTimestamps = thread.timestamps.members[userID]; if (!userTimestamps) { userTimestamps = { isMember: thread.creationTime, subscription: thread.creationTime, }; } if (userTimestamps.isMember > dmOperation.time) { continue; } const updatedThread = { ...thread, members: thread.members.filter(member => member.id !== userID), timestamps: { ...thread.timestamps, members: { ...thread.timestamps.members, [userID]: { ...userTimestamps, isMember: dmOperation.time, }, }, }, }; updates.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time: dmOperation.time, threadInfo: updatedThread, }); } return updates; } const leaveThreadSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async (dmOperation: DMLeaveThreadOperation) => { return { messageDatasWithMessageInfos: [ createMessageDataWithInfoFromDMOperation(dmOperation), ], }; }, processDMOperation: async ( dmOperation: DMLeaveThreadOperation, utilities: ProcessDMOperationUtilities, ) => { const { editorID, time, threadID } = dmOperation; const { viewerID, threadInfos } = utilities; const threadInfo = threadInfos[threadID]; - invariant(threadInfo.thick, 'Thread should be thick'); const { rawMessageInfo } = createMessageDataWithInfoFromDMOperation(dmOperation); const rawMessageInfos = [rawMessageInfo]; const memberTimestamps = { ...threadInfo.timestamps.members }; if (!memberTimestamps[editorID]) { memberTimestamps[editorID] = { isMember: time, subscription: threadInfo.creationTime, }; } memberTimestamps[editorID] = { ...memberTimestamps[editorID], isMember: time, }; if (viewerID === editorID) { if (threadInfo.timestamps.members[editorID]?.isMember > time) { return { rawMessageInfos, updateInfos: [], blobOps: [], }; } if (threadInfo.type !== threadTypes.THICK_SIDEBAR) { return { rawMessageInfos, updateInfos: [ { type: updateTypes.DELETE_THREAD, id: uuid.v4(), time, threadID, }, ...createDeleteSubthreadsUpdates( dmOperation, threadInfo, threadInfos, ), ], blobOps: [], }; } const parentThreadID = threadInfo.parentThreadID; const parentThreadInfo = parentThreadID ? utilities.threadInfos[parentThreadID] : null; if (parentThreadID && !parentThreadInfo) { console.log( `Parent thread with ID ${parentThreadID} was expected while ` + 'leaving a thread but is missing from the store', ); } - invariant( - parentThreadInfo?.thick, - 'Parent thread should be present and thick', - ); const viewerMembershipPermissions = createPermissionsInfo( threadID, threadInfo.type, false, parentThreadInfo, ); const { minimallyEncoded, permissions, ...currentUserInfo } = threadInfo.currentUser; const currentUser = minimallyEncodeThreadCurrentUserInfo({ ...currentUserInfo, role: null, permissions: viewerMembershipPermissions, }); const updatedThreadInfo = { ...threadInfo, members: threadInfo.members.filter(member => member.id !== editorID), currentUser, timestamps: { ...threadInfo.timestamps, members: memberTimestamps, }, }; return { rawMessageInfos, updateInfos: [ { type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time, threadInfo: updatedThreadInfo, }, ], blobOps: [], }; } const updateInfos = createLeaveSubthreadsUpdates( dmOperation, threadInfo, threadInfos, ); // It is possible that the editor has joined this thread after leaving it, // but regardless, we should possibly leave the sidebars. We need to do // that because it isn't guaranteed that the editor rejoined them. if (threadInfo.timestamps.members[editorID]?.isMember > time) { return { rawMessageInfos, updateInfos, blobOps: [], }; } const updatedThreadInfo = { ...threadInfo, members: threadInfo.members.filter(member => member.id !== editorID), timestamps: { ...threadInfo.timestamps, members: memberTimestamps, }, }; updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time, threadInfo: updatedThreadInfo, }); return { rawMessageInfos, updateInfos, blobOps: [], }; }, canBeProcessed: async ( dmOperation: DMLeaveThreadOperation, utilities: ProcessDMOperationUtilities, ) => { if (utilities.threadInfos[dmOperation.threadID]) { return { isProcessingPossible: true }; } return { isProcessingPossible: false, reason: { type: 'missing_thread', threadID: dmOperation.threadID, }, }; }, supportsAutoRetry: true, operationValidator: dmLeaveThreadOperationValidator, }); export { leaveThreadSpec }; diff --git a/lib/shared/dm-ops/remove-members-spec.js b/lib/shared/dm-ops/remove-members-spec.js index 1a5c4c987..719fc157c 100644 --- a/lib/shared/dm-ops/remove-members-spec.js +++ b/lib/shared/dm-ops/remove-members-spec.js @@ -1,138 +1,136 @@ // @flow -import invariant from 'invariant'; import uuid from 'uuid'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import { type DMRemoveMembersOperation, dmRemoveMembersOperationValidator, } from '../../types/dm-ops.js'; import { messageTypes } from '../../types/message-types-enum.js'; import { threadTypes } from '../../types/thread-types-enum.js'; import { updateTypes } from '../../types/update-types-enum.js'; import type { ClientUpdateInfo } from '../../types/update-types.js'; import { rawMessageInfoFromMessageData } from '../message-utils.js'; function createMessageDataWithInfoFromDMOperation( dmOperation: DMRemoveMembersOperation, ) { const { editorID, time, threadID, removedUserIDs, messageID } = dmOperation; const messageData = { type: messageTypes.REMOVE_MEMBERS, threadID, time, creatorID: editorID, removedUserIDs: [...removedUserIDs], }; const rawMessageInfo = rawMessageInfoFromMessageData(messageData, messageID); return { messageData, rawMessageInfo }; } const removeMembersSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async ( dmOperation: DMRemoveMembersOperation, ) => { return { messageDatasWithMessageInfos: [ createMessageDataWithInfoFromDMOperation(dmOperation), ], }; }, processDMOperation: async ( dmOperation: DMRemoveMembersOperation, utilities: ProcessDMOperationUtilities, ) => { const { time, threadID, removedUserIDs } = dmOperation; const { viewerID, threadInfos } = utilities; const threadInfo = threadInfos[threadID]; - invariant(threadInfo.thick, 'Thread should be thick'); const { rawMessageInfo } = createMessageDataWithInfoFromDMOperation(dmOperation); const rawMessageInfos = [rawMessageInfo]; const memberTimestamps = { ...threadInfo.timestamps.members }; const removedUserIDsSet = new Set(); for (const userID of removedUserIDs) { if (!memberTimestamps[userID]) { memberTimestamps[userID] = { isMember: time, subscription: threadInfo.creationTime, }; } if (memberTimestamps[userID].isMember > time) { continue; } memberTimestamps[userID] = { ...memberTimestamps[userID], isMember: time, }; removedUserIDsSet.add(userID); } const viewerIsRemoved = removedUserIDsSet.has(viewerID); const updateInfos: Array = []; if ( viewerIsRemoved && (threadInfo.type !== threadTypes.THICK_SIDEBAR || (threadInfo.parentThreadID && !threadInfos[threadInfo.parentThreadID])) ) { updateInfos.push({ type: updateTypes.DELETE_THREAD, id: uuid.v4(), time, threadID, }); } else { const updatedThreadInfo = { ...threadInfo, members: threadInfo.members.filter( member => !removedUserIDsSet.has(member.id), ), timestamps: { ...threadInfo.timestamps, members: memberTimestamps, }, }; updateInfos.push({ type: updateTypes.UPDATE_THREAD, id: uuid.v4(), time, threadInfo: updatedThreadInfo, }); } return { rawMessageInfos, updateInfos, blobOps: [], }; }, canBeProcessed: async ( dmOperation: DMRemoveMembersOperation, utilities: ProcessDMOperationUtilities, ) => { if (utilities.threadInfos[dmOperation.threadID]) { return { isProcessingPossible: true }; } return { isProcessingPossible: false, reason: { type: 'missing_thread', threadID: dmOperation.threadID, }, }; }, supportsAutoRetry: true, operationValidator: dmRemoveMembersOperationValidator, }); export { removeMembersSpec };