diff --git a/lib/permissions/thread-permissions.js b/lib/permissions/thread-permissions.js index 06d4ead3b..ee0783f07 100644 --- a/lib/permissions/thread-permissions.js +++ b/lib/permissions/thread-permissions.js @@ -1,517 +1,527 @@ // @flow import invariant from 'invariant'; import { parseThreadPermissionString, constructThreadPermissionString, includeThreadPermissionForThreadType, } from './prefixes.js'; import { configurableCommunityPermissions, threadPermissionFilterPrefixes, threadPermissionPropagationPrefixes, threadPermissions, userSurfacedPermissions, threadPermissionsRequiringVoicedInAnnouncementChannels, threadPermissionsRemovedForGenesisMembers, } from '../types/thread-permission-types.js'; import type { ThreadPermission, ThreadPermissionInfo, ThreadPermissionsBlob, ThreadPermissionsInfo, ThreadRolePermissionsBlob, UserSurfacedPermission, } from '../types/thread-permission-types.js'; import { type ThreadType, type ThinThreadType, type ThickThreadType, threadTypes, threadTypeIsAnnouncementThread, threadTypeIsThick, assertThickThreadType, assertThinThreadType, threadTypeIsCommunityRoot, } from '../types/thread-types-enum.js'; function permissionLookup( permissions: ?ThreadPermissionsBlob | ?ThreadPermissionsInfo, permission: ThreadPermission, ): boolean { return !!( permissions && permissions[permission] && permissions[permission].value && permissions[threadPermissions.KNOW_OF] && permissions[threadPermissions.KNOW_OF].value ); } function getAllThreadPermissions( permissions: ?ThreadPermissionsBlob, threadID: string, ): ThreadPermissionsInfo { const result: { [permission: ThreadPermission]: ThreadPermissionInfo } = {}; for (const permissionName in threadPermissions) { const permissionKey = threadPermissions[permissionName]; const permission = permissionLookup(permissions, permissionKey); let entry: ThreadPermissionInfo = { value: false, source: null }; if (permission) { const blobEntry = permissions ? permissions[permissionKey] : null; if (blobEntry) { invariant( blobEntry.value, 'permissionLookup returned true but blob had false permission!', ); entry = { value: true, source: blobEntry.source }; } else { entry = { value: true, source: threadID }; } } result[permissionKey] = entry; } return result; } // - rolePermissions can be null if role <= 0, ie. not a member // - permissionsFromParent can be null if there are no permissions from the // parent // - return can be null if no permissions exist function makePermissionsBlob( rolePermissions: ?ThreadRolePermissionsBlob, permissionsFromParent: ?ThreadPermissionsBlob, threadID: string, threadType: ThreadType, ): ?ThreadPermissionsBlob { let permissions: { [permission: string]: ThreadPermissionInfo } = {}; const isMember = !!rolePermissions; if (permissionsFromParent) { for (const permissionKey in permissionsFromParent) { const permissionValue = permissionsFromParent[permissionKey]; const parsed = parseThreadPermissionString(permissionKey); if (!includeThreadPermissionForThreadType(parsed, threadType, isMember)) { continue; } if (parsed.propagationPrefix) { permissions[permissionKey] = permissionValue; } else { permissions[parsed.permission] = permissionValue; } } } const combinedPermissions: { [permission: string]: ThreadPermissionInfo, } = { ...permissions }; if (rolePermissions) { for (const permissionKey in rolePermissions) { const permissionValue = rolePermissions[permissionKey]; const currentValue = combinedPermissions[permissionKey]; if (permissionValue) { combinedPermissions[permissionKey] = { value: true, source: threadID, }; } else if (!currentValue || !currentValue.value) { combinedPermissions[permissionKey] = { value: false, source: null, }; } } } if (permissionLookup(combinedPermissions, threadPermissions.KNOW_OF)) { permissions = combinedPermissions; } const threadIsAnnouncementThread = threadTypeIsAnnouncementThread(threadType); const hasVoicedInAnnouncementChannelsPermission = permissionLookup( (permissions: ThreadPermissionsBlob), threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ); if ( threadIsAnnouncementThread && !hasVoicedInAnnouncementChannelsPermission ) { for (const permission of threadPermissionsRequiringVoicedInAnnouncementChannels) { delete permissions[permission]; } } if ( threadType === threadTypes.GENESIS && !hasVoicedInAnnouncementChannelsPermission ) { for (const permission of threadPermissionsRemovedForGenesisMembers) { delete permissions[permission]; } } if (Object.keys(permissions).length === 0) { return null; } return permissions; } function makePermissionsForChildrenBlob( permissions: ?ThreadPermissionsBlob, ): ?ThreadPermissionsBlob { if (!permissions) { return null; } const permissionsForChildren: { [permission: string]: ThreadPermissionInfo } = {}; for (const permissionKey in permissions) { const permissionValue = permissions[permissionKey]; const parsed = parseThreadPermissionString(permissionKey); if (!parsed.propagationPrefix) { continue; } if ( parsed.propagationPrefix === threadPermissionPropagationPrefixes.DESCENDANT ) { permissionsForChildren[permissionKey] = permissionValue; } const withoutPropagationPrefix = constructThreadPermissionString({ ...parsed, propagationPrefix: null, }); permissionsForChildren[withoutPropagationPrefix] = permissionValue; } if (Object.keys(permissionsForChildren).length === 0) { return null; } return permissionsForChildren; } function getRoleForPermissions( inputRole: string, permissions: ?ThreadPermissionsBlob, ): string { if (!permissionLookup(permissions, threadPermissions.KNOW_OF)) { return '-1'; } else if (Number(inputRole) <= 0) { return '0'; } else { return inputRole; } } function getThreadPermissionBlobFromUserSurfacedPermissions( communityUserSurfacedPermissions: $ReadOnlyArray, threadType: ThinThreadType, ): ThreadRolePermissionsBlob { const mappedUserSurfacedPermissions = communityUserSurfacedPermissions .map(permission => [...configurableCommunityPermissions[permission]]) .flat(); const userSurfacedPermissionsObj = Object.fromEntries( mappedUserSurfacedPermissions.map(p => [p, true]), ); const universalCommunityPermissions = getUniversalCommunityRootPermissionsBlob(threadType); return { ...universalCommunityPermissions, ...userSurfacedPermissionsObj, }; } export type RolePermissionBlobs = { +Members: ThreadRolePermissionsBlob, +Admins?: ThreadRolePermissionsBlob, }; const { CHILD, DESCENDANT } = threadPermissionPropagationPrefixes; const { OPEN, TOP_LEVEL, OPEN_TOP_LEVEL } = threadPermissionFilterPrefixes; const OPEN_CHILD = CHILD + OPEN; const OPEN_DESCENDANT = DESCENDANT + OPEN; const TOP_LEVEL_DESCENDANT = DESCENDANT + TOP_LEVEL; const OPEN_TOP_LEVEL_DESCENDANT = DESCENDANT + OPEN_TOP_LEVEL; const defaultUserSurfacedPermissions = [ userSurfacedPermissions.REACT_TO_MESSAGES, userSurfacedPermissions.EDIT_MESSAGES, userSurfacedPermissions.ADD_MEMBERS, userSurfacedPermissions.EDIT_CALENDAR, userSurfacedPermissions.CREATE_AND_EDIT_CHANNELS, ]; function getRolePermissionBlobsForCommunityRoot( threadType: ThinThreadType, ): RolePermissionBlobs { const memberPermissions = getThreadPermissionBlobFromUserSurfacedPermissions( defaultUserSurfacedPermissions, threadType, ); const descendantKnowOf = DESCENDANT + threadPermissions.KNOW_OF; const descendantVisible = DESCENDANT + threadPermissions.VISIBLE; const topLevelDescendantJoinThread = TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; const childJoinThread = CHILD + threadPermissions.JOIN_THREAD; const descendantVoiced = DESCENDANT + threadPermissions.VOICED; const descendantEditEntries = DESCENDANT + threadPermissions.EDIT_ENTRIES; const descendantEditThreadName = DESCENDANT + threadPermissions.EDIT_THREAD_NAME; const descendantEditThreadColor = DESCENDANT + threadPermissions.EDIT_THREAD_COLOR; const descendantEditThreadDescription = DESCENDANT + threadPermissions.EDIT_THREAD_DESCRIPTION; const descendantEditThreadAvatar = DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR; const topLevelDescendantCreateSubchannels = TOP_LEVEL_DESCENDANT + threadPermissions.CREATE_SUBCHANNELS; const topLevelDescendantCreateSidebars = TOP_LEVEL_DESCENDANT + threadPermissions.CREATE_SIDEBARS; const descendantAddMembers = DESCENDANT + threadPermissions.ADD_MEMBERS; const descendantDeleteThread = DESCENDANT + threadPermissions.DELETE_THREAD; const descendantEditPermissions = DESCENDANT + threadPermissions.EDIT_PERMISSIONS; const descendantRemoveMembers = DESCENDANT + threadPermissions.REMOVE_MEMBERS; const descendantChangeRole = DESCENDANT + threadPermissions.CHANGE_ROLE; const descendantManagePins = DESCENDANT + threadPermissions.MANAGE_PINS; const topLevelDescendantVoicedInAnnouncementChannels = TOP_LEVEL_DESCENDANT + threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS; const descendantReactToMessage = DESCENDANT + threadPermissions.REACT_TO_MESSAGE; const descendantEditMessage = DESCENDANT + threadPermissions.EDIT_MESSAGE; const baseAdminPermissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, [threadPermissions.REACT_TO_MESSAGE]: true, [threadPermissions.EDIT_MESSAGE]: true, [threadPermissions.EDIT_ENTRIES]: true, [threadPermissions.EDIT_THREAD_NAME]: true, [threadPermissions.EDIT_THREAD_COLOR]: true, [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, [threadPermissions.EDIT_THREAD_AVATAR]: true, [threadPermissions.CREATE_SUBCHANNELS]: true, [threadPermissions.CREATE_SIDEBARS]: true, [threadPermissions.DELETE_THREAD]: true, [threadPermissions.REMOVE_MEMBERS]: true, [threadPermissions.CHANGE_ROLE]: true, [threadPermissions.MANAGE_PINS]: true, [threadPermissions.MANAGE_INVITE_LINKS]: true, [threadPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS]: true, [threadPermissions.MANAGE_FARCASTER_CHANNEL_TAGS]: true, [descendantKnowOf]: true, [descendantVisible]: true, [topLevelDescendantJoinThread]: true, [childJoinThread]: true, [descendantVoiced]: true, [descendantEditEntries]: true, [descendantEditThreadName]: true, [descendantEditThreadColor]: true, [descendantEditThreadDescription]: true, [descendantEditThreadAvatar]: true, [topLevelDescendantCreateSubchannels]: true, [topLevelDescendantCreateSidebars]: true, [descendantAddMembers]: true, [descendantDeleteThread]: true, [descendantEditPermissions]: true, [descendantRemoveMembers]: true, [descendantChangeRole]: true, [descendantManagePins]: true, [topLevelDescendantVoicedInAnnouncementChannels]: true, [descendantReactToMessage]: true, [descendantEditMessage]: true, }; let adminPermissions; if (threadType === threadTypes.GENESIS) { adminPermissions = { ...baseAdminPermissions, [threadPermissions.ADD_MEMBERS]: true, }; } else { adminPermissions = { ...baseAdminPermissions, [threadPermissions.LEAVE_THREAD]: true, }; } return { Members: memberPermissions, Admins: adminPermissions, }; } function getRolePermissionBlobs(threadType: ThreadType): RolePermissionBlobs { if (threadTypeIsThick(threadType)) { const thickThreadType = assertThickThreadType(threadType); const memberPermissions = getThickThreadRolePermissionsBlob(thickThreadType); return { Members: memberPermissions, }; } const thinThreadType = assertThinThreadType(threadType); if (thinThreadType === threadTypes.SIDEBAR) { const memberPermissions = { [threadPermissions.VOICED]: true, [threadPermissions.LEAVE_THREAD]: true, }; return { Members: memberPermissions, }; } const openDescendantKnowOf = OPEN_DESCENDANT + threadPermissions.KNOW_OF; const openDescendantVisible = OPEN_DESCENDANT + threadPermissions.VISIBLE; const openChildJoinThread = OPEN_CHILD + threadPermissions.JOIN_THREAD; if (thinThreadType === threadTypes.GENESIS_PRIVATE) { const memberPermissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, [threadPermissions.CREATE_SIDEBARS]: true, [openDescendantKnowOf]: true, [openDescendantVisible]: true, [openChildJoinThread]: true, }; return { Members: memberPermissions, }; } if (thinThreadType === threadTypes.GENESIS_PERSONAL) { return { Members: { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, [threadPermissions.CREATE_SIDEBARS]: true, [openDescendantKnowOf]: true, [openDescendantVisible]: true, [openChildJoinThread]: true, }, }; } const openTopLevelDescendantJoinThread = OPEN_TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; if (!threadTypeIsCommunityRoot(thinThreadType)) { const memberPermissions = { [threadPermissions.VOICED]: true, [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.CREATE_SIDEBARS]: true, [threadPermissions.LEAVE_THREAD]: true, [openDescendantKnowOf]: true, [openDescendantVisible]: true, [openTopLevelDescendantJoinThread]: true, [openChildJoinThread]: true, }; return { Members: memberPermissions, }; } return getRolePermissionBlobsForCommunityRoot(thinThreadType); } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return function getUniversalCommunityRootPermissionsBlob( threadType: ThinThreadType, ): ThreadRolePermissionsBlob { const openDescendantKnowOf = OPEN_DESCENDANT + threadPermissions.KNOW_OF; const openDescendantVisible = OPEN_DESCENDANT + threadPermissions.VISIBLE; const openChildJoinThread = OPEN_CHILD + threadPermissions.JOIN_THREAD; const openTopLevelDescendantJoinThread = OPEN_TOP_LEVEL_DESCENDANT + threadPermissions.JOIN_THREAD; const genesisUniversalCommunityPermissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [openDescendantKnowOf]: true, [openDescendantVisible]: true, [openTopLevelDescendantJoinThread]: true, }; const baseUniversalCommunityPermissions = { ...genesisUniversalCommunityPermissions, [threadPermissions.CREATE_SIDEBARS]: true, [threadPermissions.LEAVE_THREAD]: true, [openChildJoinThread]: true, }; if (threadType === threadTypes.GENESIS) { return genesisUniversalCommunityPermissions; } else if (threadType === threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT) { return baseUniversalCommunityPermissions; } else if (threadType === threadTypes.COMMUNITY_ROOT) { return { ...baseUniversalCommunityPermissions, [threadPermissions.VOICED]: true, }; } invariant(false, 'invalid threadType parameter'); } function getThickThreadRolePermissionsBlob( threadType: ThickThreadType, ): ThreadRolePermissionsBlob { invariant(threadTypeIsThick(threadType), 'ThreadType should be thick'); + const openDescendantKnowOf = OPEN_DESCENDANT + threadPermissions.KNOW_OF; + const openDescendantVisible = OPEN_DESCENDANT + threadPermissions.VISIBLE; + const openChildJoinThread = OPEN_CHILD + threadPermissions.JOIN_THREAD; + const basePermissions = { [threadPermissions.KNOW_OF]: true, [threadPermissions.VISIBLE]: true, [threadPermissions.VOICED]: true, [threadPermissions.REACT_TO_MESSAGE]: true, [threadPermissions.EDIT_MESSAGE]: true, [threadPermissions.EDIT_THREAD_NAME]: true, [threadPermissions.EDIT_THREAD_COLOR]: true, [threadPermissions.EDIT_THREAD_DESCRIPTION]: true, [threadPermissions.EDIT_THREAD_AVATAR]: true, }; if (threadType === threadTypes.THICK_SIDEBAR) { return { ...basePermissions, [threadPermissions.JOIN_THREAD]: true, [threadPermissions.ADD_MEMBERS]: true, [threadPermissions.LEAVE_THREAD]: true, }; } if ( threadType === threadTypes.PRIVATE || threadType === threadTypes.PERSONAL ) { return { ...basePermissions, [threadPermissions.EDIT_ENTRIES]: true, [threadPermissions.CREATE_SIDEBARS]: true, + [openDescendantKnowOf]: true, + [openDescendantVisible]: true, + [openChildJoinThread]: true, }; } return { ...basePermissions, [threadPermissions.EDIT_ENTRIES]: true, [threadPermissions.CREATE_SIDEBARS]: true, [threadPermissions.ADD_MEMBERS]: true, [threadPermissions.LEAVE_THREAD]: true, + [openDescendantKnowOf]: true, + [openDescendantVisible]: true, + [openChildJoinThread]: true, }; } export { permissionLookup, getAllThreadPermissions, makePermissionsBlob, makePermissionsForChildrenBlob, getRoleForPermissions, getThreadPermissionBlobFromUserSurfacedPermissions, getRolePermissionBlobs, getUniversalCommunityRootPermissionsBlob, getThickThreadRolePermissionsBlob, }; diff --git a/lib/shared/dm-ops/add-members-spec.js b/lib/shared/dm-ops/add-members-spec.js index 77e7c148e..0d5f465dd 100644 --- a/lib/shared/dm-ops/add-members-spec.js +++ b/lib/shared/dm-ops/add-members-spec.js @@ -1,157 +1,174 @@ // @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 } 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 { joinThreadSubscription } from '../../types/subscription-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 }; } 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 defaultRoleID = values(currentThreadInfo.roles).find(role => roleIsDefaultRole(role), )?.id; invariant(defaultRoleID, 'Default role ID must exist'); + + const parentThreadID = currentThreadInfo.parentThreadID; + 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( currentThreadInfo.type, currentThreadInfo.id, defaultRoleID, + parentThreadInfo, ); 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: defaultRoleID, 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, }); export { addMembersSpec, createAddNewMembersMessageDataWithInfoFromDMOperation, }; 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 c1d978feb..a6f84df28 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,165 +1,165 @@ // @flow import uuid from 'uuid'; import { createThickRawThreadInfo } from './create-thread-spec.js'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import type { DMAddViewerToThreadMembersOperation } 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 { joinThreadSubscription } from '../../types/subscription-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 } = dmOperation; - const { viewerID, threadInfos } = utilities; + 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); } } const resultThreadInfo = createThickRawThreadInfo( { ...existingThreadDetails, allMemberIDsWithSubscriptions: [ ...existingThreadDetails.allMemberIDsWithSubscriptions, ...newMembers.map(id => ({ id, subscription: joinThreadSubscription, })), ], timestamps: { ...existingThreadDetails.timestamps, members: { ...existingThreadDetails.timestamps.members, ...memberTimestamps, }, }, }, - viewerID, + 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, }); export { addViewerToThreadMembersSpec, createAddViewerToThreadMembersMessageDataWithInfoFromDMOp, }; diff --git a/lib/shared/dm-ops/create-sidebar-spec.js b/lib/shared/dm-ops/create-sidebar-spec.js index 8d792866d..ba7581124 100644 --- a/lib/shared/dm-ops/create-sidebar-spec.js +++ b/lib/shared/dm-ops/create-sidebar-spec.js @@ -1,208 +1,208 @@ // @flow import uuid from 'uuid'; import { createThickRawThreadInfo } from './create-thread-spec.js'; import type { DMOperationSpec, ProcessDMOperationUtilities, } from './dm-op-spec.js'; import type { DMCreateSidebarOperation } from '../../types/dm-ops.js'; import { messageTypes } from '../../types/message-types-enum.js'; import { type RawMessageInfo, messageTruncationStatus, } from '../../types/message-types.js'; import { joinThreadSubscription } from '../../types/subscription-types.js'; import { threadTypes } from '../../types/thread-types-enum.js'; import { updateTypes } from '../../types/update-types-enum.js'; import { generatePendingThreadColor } from '../color-utils.js'; import { isInvalidSidebarSource, rawMessageInfoFromMessageData, } from '../message-utils.js'; import { createThreadTimestamps } from '../thread-utils.js'; async function createMessageDatasWithInfosFromDMOperation( dmOperation: DMCreateSidebarOperation, utilities: ProcessDMOperationUtilities, threadColor?: string, ) { const { threadID, creatorID, time, parentThreadID, memberIDs, sourceMessageID, newSidebarSourceMessageID, newCreateSidebarMessageID, } = dmOperation; const allMemberIDs = [creatorID, ...memberIDs]; const color = threadColor ?? generatePendingThreadColor(allMemberIDs); const sourceMessage = await utilities.fetchMessage(sourceMessageID); if (!sourceMessage) { throw new Error( `could not find sourceMessage ${sourceMessageID}... probably ` + 'joined thick thread ${parentThreadID} after its creation', ); } if (isInvalidSidebarSource(sourceMessage)) { throw new Error( `sourceMessage ${sourceMessageID} is an invalid sidebar source`, ); } const sidebarSourceMessageData = { type: messageTypes.SIDEBAR_SOURCE, threadID, creatorID, time, sourceMessage: sourceMessage, }; const createSidebarMessageData = { type: messageTypes.CREATE_SIDEBAR, threadID, creatorID, time: time + 1, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { parentThreadID, color, memberIDs: allMemberIDs, }, }; const sidebarSourceMessageInfo = rawMessageInfoFromMessageData( sidebarSourceMessageData, newSidebarSourceMessageID, ); const createSidebarMessageInfo = rawMessageInfoFromMessageData( createSidebarMessageData, newCreateSidebarMessageID, ); return { sidebarSourceMessageData, createSidebarMessageData, sidebarSourceMessageInfo, createSidebarMessageInfo, }; } const createSidebarSpec: DMOperationSpec = Object.freeze({ notificationsCreationData: async ( dmOperation: DMCreateSidebarOperation, utilities: ProcessDMOperationUtilities, ) => { const { sidebarSourceMessageData, createSidebarMessageData, createSidebarMessageInfo, sidebarSourceMessageInfo, } = await createMessageDatasWithInfosFromDMOperation( dmOperation, utilities, ); return { messageDatasWithMessageInfos: [ { messageData: sidebarSourceMessageData, rawMessageInfo: sidebarSourceMessageInfo, }, { messageData: createSidebarMessageData, rawMessageInfo: createSidebarMessageInfo, }, ], }; }, processDMOperation: async ( dmOperation: DMCreateSidebarOperation, utilities: ProcessDMOperationUtilities, ) => { const { threadID, creatorID, time, parentThreadID, memberIDs, sourceMessageID, roleID, } = dmOperation; const { viewerID } = utilities; const allMemberIDs = [creatorID, ...memberIDs]; const allMemberIDsWithSubscriptions = allMemberIDs.map(id => ({ id, subscription: joinThreadSubscription, })); const rawThreadInfo = createThickRawThreadInfo( { threadID, threadType: threadTypes.THICK_SIDEBAR, creationTime: time, parentThreadID, allMemberIDsWithSubscriptions, roleID, unread: creatorID !== viewerID, sourceMessageID, containingThreadID: parentThreadID, timestamps: createThreadTimestamps(time, allMemberIDs), }, - viewerID, + utilities, ); const { sidebarSourceMessageInfo, createSidebarMessageInfo } = await createMessageDatasWithInfosFromDMOperation( dmOperation, utilities, rawThreadInfo.color, ); const rawMessageInfos: Array = [ sidebarSourceMessageInfo, createSidebarMessageInfo, ]; 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: DMCreateSidebarOperation, utilities: ProcessDMOperationUtilities, ) => { const sourceMessage = await utilities.fetchMessage( dmOperation.sourceMessageID, ); if (!sourceMessage) { return { isProcessingPossible: false, reason: { type: 'missing_message', messageID: dmOperation.sourceMessageID, }, }; } return { isProcessingPossible: true }; }, supportsAutoRetry: true, }); export { createSidebarSpec }; diff --git a/lib/shared/dm-ops/create-thread-spec.js b/lib/shared/dm-ops/create-thread-spec.js index ebe70bc33..25eb92e42 100644 --- a/lib/shared/dm-ops/create-thread-spec.js +++ b/lib/shared/dm-ops/create-thread-spec.js @@ -1,225 +1,303 @@ // @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, DMCreateThreadOperation, } 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 = getAllThreadPermissions( - makePermissionsBlob(rolePermissions, null, threadID, 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, - viewerID: string, + 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); + 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: role.id, - permissions: membershipPermissions, - isSender: memberID === viewerID, + 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: role.id, - permissions: membershipPermissions, + 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), }, - viewerID, + 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 () => { return { isProcessingPossible: true }; }, supportsAutoRetry: true, }); export { createThickRawThreadInfo, createThreadSpec, createRoleAndPermissionForThickThreads, + createPermissionsInfo, }; diff --git a/lib/shared/dm-ops/join-thread-spec.js b/lib/shared/dm-ops/join-thread-spec.js index e3358947e..927b8086e 100644 --- a/lib/shared/dm-ops/join-thread-spec.js +++ b/lib/shared/dm-ops/join-thread-spec.js @@ -1,194 +1,211 @@ // @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 } 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, }, }, - viewerID, + 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, }); export { joinThreadSpec }; diff --git a/lib/shared/dm-ops/leave-thread-spec.js b/lib/shared/dm-ops/leave-thread-spec.js index d1ee50b4b..9a975deef 100644 --- a/lib/shared/dm-ops/leave-thread-spec.js +++ b/lib/shared/dm-ops/leave-thread-spec.js @@ -1,163 +1,197 @@ // @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 } 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 { 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 { userIsMember } from '../thread-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 createLeaveThreadSubthreadsUpdates( dmOperation: DMLeaveThreadOperation, threadInfo: ThickRawThreadInfo, viewerID: string, threadInfos: RawThreadInfos, ): $ReadOnlyArray { const updates = []; 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; } 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]; if ( viewerID === editorID && userIsMember(threadInfo, editorID) && (threadInfo.type !== threadTypes.THICK_SIDEBAR || (threadInfo.parentThreadID && !threadInfos[threadInfo.parentThreadID])) ) { return { rawMessageInfos, updateInfos: [ { type: updateTypes.DELETE_THREAD, id: uuid.v4(), time, threadID, }, ...createLeaveThreadSubthreadsUpdates( dmOperation, threadInfo, viewerID, threadInfos, ), ], blobOps: [], }; } if (threadInfo.timestamps.members[editorID]?.isMember > time) { return { rawMessageInfos, updateInfos: [], blobOps: [], }; } const memberTimestamps = { ...threadInfo.timestamps.members }; if (!memberTimestamps[editorID]) { memberTimestamps[editorID] = { isMember: time, subscription: threadInfo.creationTime, }; } memberTimestamps[editorID] = { ...memberTimestamps[editorID], isMember: time, }; + + let currentUser = threadInfo.currentUser; + if (editorID === viewerID) { + 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 } = currentUser; + 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: [], }; }, 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, }); export { leaveThreadSpec };