diff --git a/lib/types/thread-permission-types.js b/lib/types/thread-permission-types.js index f5b5828fe..6ef5c108b 100644 --- a/lib/types/thread-permission-types.js +++ b/lib/types/thread-permission-types.js @@ -1,422 +1,346 @@ // @flow import invariant from 'invariant'; import t, { type TDict, type TUnion, type TEnums } from 'tcomb'; import { values } from '../utils/objects.js'; import { tBool, tShape, tID } from '../utils/validation-utils.js'; // When a new permission is added, if it should be configurable for a role, it // should be either added to an existing set or a new set alongside a // new user-facing permission. If it is a permission that should be ensured // across all roles, it should be added to `universalCommunityPermissions`. export const threadPermissions = Object.freeze({ KNOW_OF: 'know_of', VISIBLE: 'visible', VOICED: 'voiced', EDIT_ENTRIES: 'edit_entries', EDIT_THREAD_NAME: 'edit_thread', EDIT_THREAD_DESCRIPTION: 'edit_thread_description', EDIT_THREAD_COLOR: 'edit_thread_color', DELETE_THREAD: 'delete_thread', CREATE_SUBCHANNELS: 'create_subthreads', CREATE_SIDEBARS: 'create_sidebars', JOIN_THREAD: 'join_thread', EDIT_PERMISSIONS: 'edit_permissions', ADD_MEMBERS: 'add_members', REMOVE_MEMBERS: 'remove_members', CHANGE_ROLE: 'change_role', LEAVE_THREAD: 'leave_thread', REACT_TO_MESSAGE: 'react_to_message', EDIT_MESSAGE: 'edit_message', EDIT_THREAD_AVATAR: 'edit_thread_avatar', MANAGE_PINS: 'manage_pins', MANAGE_INVITE_LINKS: 'manage_invite_links', }); export type ThreadPermission = $Values; export function assertThreadPermissions( ourThreadPermissions: string, ): ThreadPermission { invariant( ourThreadPermissions === 'know_of' || ourThreadPermissions === 'visible' || ourThreadPermissions === 'voiced' || ourThreadPermissions === 'edit_entries' || ourThreadPermissions === 'edit_thread' || ourThreadPermissions === 'edit_thread_description' || ourThreadPermissions === 'edit_thread_color' || ourThreadPermissions === 'delete_thread' || ourThreadPermissions === 'create_subthreads' || ourThreadPermissions === 'create_sidebars' || ourThreadPermissions === 'join_thread' || ourThreadPermissions === 'edit_permissions' || ourThreadPermissions === 'add_members' || ourThreadPermissions === 'remove_members' || ourThreadPermissions === 'change_role' || ourThreadPermissions === 'leave_thread' || ourThreadPermissions === 'react_to_message' || ourThreadPermissions === 'edit_message' || ourThreadPermissions === 'edit_thread_avatar' || ourThreadPermissions === 'manage_pins' || ourThreadPermissions === 'manage_invite_links', 'string is not threadPermissions enum', ); return ourThreadPermissions; } const threadPermissionValidator = t.enums.of(values(threadPermissions)); export const threadPermissionPropagationPrefixes = Object.freeze({ DESCENDANT: 'descendant_', CHILD: 'child_', }); export type ThreadPermissionPropagationPrefix = $Values< typeof threadPermissionPropagationPrefixes, >; export const threadPermissionFilterPrefixes = Object.freeze({ // includes only SIDEBAR, COMMUNITY_OPEN_SUBTHREAD, // COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD OPEN: 'open_', // excludes only SIDEBAR TOP_LEVEL: 'toplevel_', // includes only COMMUNITY_OPEN_SUBTHREAD, // COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD OPEN_TOP_LEVEL: 'opentoplevel_', }); export type ThreadPermissionFilterPrefix = $Values< typeof threadPermissionFilterPrefixes, >; // These are the set of user-facing permissions that we display as configurable // to the user when they are creating a custom role for their given community. // They are per-community rather than per-thread, so when configured they are // to be expected to be propagated across the community. Also notably, // `threadPermissions` is used on the keyserver for permission checks to // validate actions, but these `userSurfacedPermissions` are only used // on the client for the UI and propagated to the server. The // `configurableCommunityPermissions` mapping below is the association between // each userSurfacedPermission and a set of threadPermissions. export const userSurfacedPermissions = Object.freeze({ EDIT_CALENDAR: 'edit_calendar', KNOW_OF_SECRET_CHANNELS: 'know_of_secret_channels', VOICED_IN_ANNOUNCEMENT_CHANNELS: 'voiced_in_announcement_channels', CREATE_AND_EDIT_CHANNELS: 'create_and_edit_channels', DELETE_CHANNELS: 'delete_channels', ADD_MEMBERS: 'add_members', REMOVE_MEMBERS: 'remove_members', CHANGE_ROLES: 'change_roles', EDIT_VISIBILITY: 'edit_visibility', MANAGE_PINS: 'manage_pins', REACT_TO_MESSAGES: 'react_to_messages', EDIT_MESSAGES: 'edit_messages', MANAGE_INVITE_LINKS: 'manage_invite_links', }); export type UserSurfacedPermission = $Values; export const userSurfacedPermissionsSet: $ReadOnlySet = new Set(values(userSurfacedPermissions)); export const userSurfacedPermissionValidator: TEnums = t.enums.of( values(userSurfacedPermissions), ); const editCalendarPermission = { title: 'Edit calendar', description: 'Allows members to edit the community calendar', userSurfacedPermission: userSurfacedPermissions.EDIT_CALENDAR, }; const editEntries = threadPermissions.EDIT_ENTRIES; -const descendantEditEntries = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.EDIT_ENTRIES; -const editCalendarPermissions = new Set([editEntries, descendantEditEntries]); +const editCalendarPermissions = new Set([editEntries]); const knowOfSecretChannelsPermission = { title: 'Know of secret channels', description: 'Allows members to know of all secret channels', userSurfacedPermission: userSurfacedPermissions.KNOW_OF_SECRET_CHANNELS, }; const descendantKnowOf = threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.KNOW_OF; const descendantVisible = threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.VISIBLE; const descendantTopLevelJoinThread = threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.JOIN_THREAD; const descendantJoinThread = threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.JOIN_THREAD; const childJoinThread = threadPermissionPropagationPrefixes.CHILD + threadPermissions.JOIN_THREAD; const knowOfSecretChannelsPermissions = new Set([ descendantKnowOf, descendantVisible, descendantTopLevelJoinThread, descendantJoinThread, childJoinThread, ]); const voicedPermission = { title: 'Voiced in announcement channels', description: 'Allows members to send messages in announcement channels', userSurfacedPermission: userSurfacedPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, }; const voiced = threadPermissions.VOICED; const voicedPermissions = new Set([voiced]); const createAndEditChannelsPermission = { title: 'Create and edit channels', description: 'Allows members to create new and edit existing channels', userSurfacedPermission: userSurfacedPermissions.CREATE_AND_EDIT_CHANNELS, }; const editThreadName = threadPermissions.EDIT_THREAD_NAME; -const descendantEditThreadName = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.EDIT_THREAD_NAME; const editThreadDescription = threadPermissions.EDIT_THREAD_DESCRIPTION; -const descendantEditThreadDescription = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.EDIT_THREAD_DESCRIPTION; const editThreadColor = threadPermissions.EDIT_THREAD_COLOR; -const descendantEditThreadColor = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.EDIT_THREAD_COLOR; const createSubchannels = threadPermissions.CREATE_SUBCHANNELS; -const descendantCreateSubchannels = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissionFilterPrefixes.TOP_LEVEL + - threadPermissions.CREATE_SUBCHANNELS; const editThreadAvatar = threadPermissions.EDIT_THREAD_AVATAR; -const descendantEditThreadAvatar = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.EDIT_THREAD_AVATAR; -const descendantTopLevelCreateSidebars = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissionFilterPrefixes.TOP_LEVEL + - threadPermissions.CREATE_SIDEBARS; const createAndEditChannelsPermissions = new Set([ editThreadName, - descendantEditThreadName, editThreadDescription, - descendantEditThreadDescription, editThreadColor, - descendantEditThreadColor, createSubchannels, - descendantCreateSubchannels, editThreadAvatar, - descendantEditThreadAvatar, - descendantTopLevelCreateSidebars, ]); const deleteChannelsPermission = { title: 'Delete channels', description: 'Allows members to delete channels', userSurfacedPermission: userSurfacedPermissions.DELETE_CHANNELS, }; const deleteThread = threadPermissions.DELETE_THREAD; -const descendantDeleteThread = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.DELETE_THREAD; -const deleteChannelsPermissions = new Set([ - deleteThread, - descendantDeleteThread, -]); +const deleteChannelsPermissions = new Set([deleteThread]); const addMembersPermission = { title: 'Add members', description: 'Allows members to add other members to channels', userSurfacedPermission: userSurfacedPermissions.ADD_MEMBERS, }; const addMembers = threadPermissions.ADD_MEMBERS; -const descendantAddMembers = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.ADD_MEMBERS; const childOpenAddMembers = threadPermissionPropagationPrefixes.CHILD + threadPermissionFilterPrefixes.OPEN + threadPermissions.ADD_MEMBERS; -const addMembersPermissions = new Set([ - addMembers, - descendantAddMembers, - childOpenAddMembers, -]); +const addMembersPermissions = new Set([addMembers, childOpenAddMembers]); const removeMembersPermission = { title: 'Remove members', description: 'Allows members to remove anybody they can demote from channels', userSurfacedPermission: userSurfacedPermissions.REMOVE_MEMBERS, }; const removeMembers = threadPermissions.REMOVE_MEMBERS; -const descendantRemoveMembers = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.REMOVE_MEMBERS; -const removeMembersPermissions = new Set([ - removeMembers, - descendantRemoveMembers, -]); +const removeMembersPermissions = new Set([removeMembers]); const changeRolePermission = { title: 'Change roles', description: 'Allows members to promote and demote other members', userSurfacedPermission: userSurfacedPermissions.CHANGE_ROLES, }; const changeRole = threadPermissions.CHANGE_ROLE; -const descendantChangeRole = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.CHANGE_ROLE; -const changeRolePermissions = new Set([changeRole, descendantChangeRole]); +const changeRolePermissions = new Set([changeRole]); const editVisibilityPermission = { title: 'Edit visibility', description: 'Allows members to edit visibility permissions of channels', userSurfacedPermission: userSurfacedPermissions.EDIT_VISIBILITY, }; const editPermissions = threadPermissions.EDIT_PERMISSIONS; -const descendantEditPermissions = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.EDIT_PERMISSIONS; -const editVisibilityPermissions = new Set([ - editPermissions, - descendantEditPermissions, -]); +const editVisibilityPermissions = new Set([editPermissions]); const managePinsPermission = { title: 'Manage pins', description: 'Allows members to pin or unpin messages in channels', userSurfacedPermission: userSurfacedPermissions.MANAGE_PINS, }; const managePins = threadPermissions.MANAGE_PINS; -const descendantManagePins = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.MANAGE_PINS; -const managePinsPermissions = new Set([managePins, descendantManagePins]); +const managePinsPermissions = new Set([managePins]); const reactToMessagePermission = { title: 'React to messages', description: 'Allows members to add reactions to messages', userSurfacedPermission: userSurfacedPermissions.REACT_TO_MESSAGES, }; const reactToMessage = threadPermissions.REACT_TO_MESSAGE; -const descendantReactToMessage = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.REACT_TO_MESSAGE; -const reactToMessagePermissions = new Set([ - reactToMessage, - descendantReactToMessage, -]); +const reactToMessagePermissions = new Set([reactToMessage]); const editMessagePermission = { title: 'Edit messages', description: 'Allows members to edit their sent messages', userSurfacedPermission: userSurfacedPermissions.EDIT_MESSAGES, }; const editMessage = threadPermissions.EDIT_MESSAGE; -const descendantEditMessage = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.EDIT_MESSAGE; -const editMessagePermissions = new Set([editMessage, descendantEditMessage]); +const editMessagePermissions = new Set([editMessage]); const manageInviteLinksPermission = { title: 'Manage invite links', description: 'Allows members to create and delete invite links', userSurfacedPermission: userSurfacedPermissions.MANAGE_INVITE_LINKS, }; const manageInviteLinks = threadPermissions.MANAGE_INVITE_LINKS; -const descendantManageInviteLinks = - threadPermissionPropagationPrefixes.DESCENDANT + - threadPermissions.MANAGE_INVITE_LINKS; -const manageInviteLinksPermissions = new Set([ - manageInviteLinks, - descendantManageInviteLinks, -]); +const manageInviteLinksPermissions = new Set([manageInviteLinks]); export type UserSurfacedPermissionOption = { +title: string, +description: string, +userSurfacedPermission: UserSurfacedPermission, }; export const userSurfacedPermissionOptions: $ReadOnlySet = new Set([ editCalendarPermission, knowOfSecretChannelsPermission, voicedPermission, createAndEditChannelsPermission, deleteChannelsPermission, addMembersPermission, removeMembersPermission, changeRolePermission, editVisibilityPermission, managePinsPermission, reactToMessagePermission, editMessagePermission, manageInviteLinksPermission, ]); type ConfigurableCommunityPermission = { +[permission: UserSurfacedPermission]: $ReadOnlySet, }; export const configurableCommunityPermissions: ConfigurableCommunityPermission = Object.freeze({ [userSurfacedPermissions.EDIT_CALENDAR]: editCalendarPermissions, [userSurfacedPermissions.KNOW_OF_SECRET_CHANNELS]: knowOfSecretChannelsPermissions, [userSurfacedPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS]: voicedPermissions, [userSurfacedPermissions.CREATE_AND_EDIT_CHANNELS]: createAndEditChannelsPermissions, [userSurfacedPermissions.DELETE_CHANNELS]: deleteChannelsPermissions, [userSurfacedPermissions.ADD_MEMBERS]: addMembersPermissions, [userSurfacedPermissions.REMOVE_MEMBERS]: removeMembersPermissions, [userSurfacedPermissions.CHANGE_ROLES]: changeRolePermissions, [userSurfacedPermissions.EDIT_VISIBILITY]: editVisibilityPermissions, [userSurfacedPermissions.MANAGE_PINS]: managePinsPermissions, [userSurfacedPermissions.REACT_TO_MESSAGES]: reactToMessagePermissions, [userSurfacedPermissions.EDIT_MESSAGES]: editMessagePermissions, [userSurfacedPermissions.MANAGE_INVITE_LINKS]: manageInviteLinksPermissions, }); export const universalCommunityPermissions: $ReadOnlyArray = [ // know_of | descendant_open_know_of threadPermissions.KNOW_OF, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.OPEN + threadPermissions.KNOW_OF, // visible | descendant_open_visible threadPermissions.VISIBLE, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.OPEN + threadPermissions.VISIBLE, - // join_thread | child_open_join_thread | descendant_opentoplevel_join_thread - threadPermissions.JOIN_THREAD, + // child_open_join_thread | descendant_opentoplevel_join_thread threadPermissionPropagationPrefixes.CHILD + threadPermissionFilterPrefixes.OPEN + threadPermissions.JOIN_THREAD, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.OPEN_TOP_LEVEL + threadPermissions.JOIN_THREAD, threadPermissions.CREATE_SIDEBARS, threadPermissions.LEAVE_THREAD, ]; export type ThreadPermissionInfo = | { +value: true, +source: string } | { +value: false, +source: null }; export const threadPermissionInfoValidator: TUnion = t.union([ tShape({ value: tBool(true), source: tID }), tShape({ value: tBool(false), source: t.Nil }), ]); export type ThreadPermissionsBlob = { +[permission: string]: ThreadPermissionInfo, }; export type ThreadRolePermissionsBlob = { +[permission: string]: boolean }; export const threadRolePermissionsBlobValidator: TDict = t.dict(t.String, t.Boolean); export type ThreadPermissionsInfo = { +[permission: ThreadPermission]: ThreadPermissionInfo, }; export const threadPermissionsInfoValidator: TDict = t.dict(threadPermissionValidator, threadPermissionInfoValidator); diff --git a/lib/types/thread-permission-types.test.js b/lib/types/thread-permission-types.test.js index 0d4e7858a..e66d4c36b 100644 --- a/lib/types/thread-permission-types.test.js +++ b/lib/types/thread-permission-types.test.js @@ -1,95 +1,107 @@ // @flow +import _isEqual from 'lodash/fp/isEqual.js'; + import { configurableCommunityPermissions, universalCommunityPermissions, userSurfacedPermissions, - type UserSurfacedPermission, threadPermissions, } from './thread-permission-types.js'; import { getRolePermissionBlobs } from '../permissions/thread-permissions.js'; import { threadTypes } from '../types/thread-types-enum.js'; -import { deepDiff, values } from '../utils/objects.js'; +import { values } from '../utils/objects.js'; +import { toggleUserSurfacedPermission } from '../utils/role-utils.js'; describe('Community Announcement Root', () => { - it('should find Member permissions from getRolePermissionBlobs and user-surfaced permissions to be equal', () => { - const { Members: membersPermissionBlob } = getRolePermissionBlobs( - threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, - ); + const { Members: membersPermissionBlob } = getRolePermissionBlobs( + threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT, + ); + it('should find Member permissions from getRolePermissionBlobs and user-surfaced permissions to be equal', () => { const membersPermissionsConstructed = [ ...configurableCommunityPermissions[userSurfacedPermissions.ADD_MEMBERS], ...configurableCommunityPermissions[ userSurfacedPermissions.REACT_TO_MESSAGES ], ...configurableCommunityPermissions[ userSurfacedPermissions.EDIT_MESSAGES ], ...universalCommunityPermissions, ]; const membersPermissionsConstructedBlob = Object.fromEntries( membersPermissionsConstructed.map(permission => [permission, true]), ); expect( - deepDiff(membersPermissionBlob, membersPermissionsConstructedBlob), - ).toEqual({}); + _isEqual(membersPermissionBlob, membersPermissionsConstructedBlob), + ).toBe(true); }); - it('should find Admin permissions from getRolePermissionBlobs and user-surfaced permissions to be equal', () => { - const adminsPermissionBlob = - getRolePermissionBlobs(threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT).Admins ?? - {}; - - const adminsPermissionsConstructed = [ - ...values(userSurfacedPermissions) - .map((permission: UserSurfacedPermission) => [ - ...configurableCommunityPermissions[permission], - ]) - .flat(), - ...universalCommunityPermissions, - ]; - - const adminsPermissionsConstructedBlob = Object.fromEntries( - adminsPermissionsConstructed.map(permission => [permission, true]), - ); + it('should find equal permission blobs when toggling user-surfaced permissions', () => { + for (const userSurfacedPermission of values(userSurfacedPermissions)) { + const firstTimeToggledPermissionSet = toggleUserSurfacedPermission( + membersPermissionBlob, + userSurfacedPermission, + ); + const secondTimeToggledPermissionSet = toggleUserSurfacedPermission( + firstTimeToggledPermissionSet, + userSurfacedPermission, + ); - // Context: https://phab.comm.dev/D8478#inline-55680 - expect( - deepDiff(adminsPermissionBlob, adminsPermissionsConstructedBlob), - ).toEqual({ descendant_voiced: true }); + expect( + _isEqual(membersPermissionBlob, secondTimeToggledPermissionSet), + ).toBe(true); + } }); }); describe('Community Root', () => { - it('should find Member permissions from getRolePermissionBlobs and user-surfaced permissions to be equal', () => { - const { Members: membersPermissionBlob } = getRolePermissionBlobs( - threadTypes.COMMUNITY_ROOT, - ); + const { Members: membersPermissionBlob } = getRolePermissionBlobs( + threadTypes.COMMUNITY_ROOT, + ); + it('should find Member permissions from getRolePermissionBlobs and user-surfaced permissions to be equal', () => { const membersPermissionsConstructed = [ ...configurableCommunityPermissions[userSurfacedPermissions.ADD_MEMBERS], ...configurableCommunityPermissions[ userSurfacedPermissions.REACT_TO_MESSAGES ], ...configurableCommunityPermissions[ userSurfacedPermissions.EDIT_MESSAGES ], ...configurableCommunityPermissions[ userSurfacedPermissions.CREATE_AND_EDIT_CHANNELS ], ...configurableCommunityPermissions[ userSurfacedPermissions.EDIT_CALENDAR ], threadPermissions.VOICED, ...universalCommunityPermissions, ]; const membersPermissionsConstructedBlob = Object.fromEntries( membersPermissionsConstructed.map(permission => [permission, true]), ); expect( - deepDiff(membersPermissionBlob, membersPermissionsConstructedBlob), - ).toEqual({}); + _isEqual(membersPermissionBlob, membersPermissionsConstructedBlob), + ).toBe(true); + }); + + it('should find equal permission blobs when toggling user-surfaced permissions', () => { + for (const userSurfacedPermission of values(userSurfacedPermissions)) { + const firstTimeToggledPermissionSet = toggleUserSurfacedPermission( + membersPermissionBlob, + userSurfacedPermission, + ); + const secondTimeToggledPermissionSet = toggleUserSurfacedPermission( + firstTimeToggledPermissionSet, + userSurfacedPermission, + ); + + expect( + _isEqual(membersPermissionBlob, secondTimeToggledPermissionSet), + ).toBe(true); + } }); }); diff --git a/lib/utils/migration-utils.js b/lib/utils/migration-utils.js index f6ac8ec00..dc5471da5 100644 --- a/lib/utils/migration-utils.js +++ b/lib/utils/migration-utils.js @@ -1,145 +1,189 @@ // @flow import type { TranslatedThreadMessageInfos } from './message-ops-utils.js'; import { entries } from './objects.js'; import { ashoatKeyserverID } from './validation-utils.js'; import { convertRawMessageInfoToNewIDSchema, convertRawThreadInfoToNewIDSchema, } from '../_generated/migration-utils.js'; import { parsePendingThreadID, getPendingThreadID, draftKeySuffix, } from '../shared/thread-utils.js'; import type { ClientDBDraftInfo, ClientDBDraftStoreOperation, DraftStore, } from '../types/draft-types'; import type { RawMessageInfo } from '../types/message-types.js'; import { threadPermissions, threadPermissionPropagationPrefixes, threadPermissionFilterPrefixes, } from '../types/thread-permission-types.js'; import type { ThreadStoreThreadInfos } from '../types/thread-types.js'; function convertDraftKeyToNewIDSchema(key: string): string { const threadID = key.slice(0, -draftKeySuffix.length); const convertedThreadID = convertIDToNewSchema(threadID, ashoatKeyserverID); return `${convertedThreadID}${draftKeySuffix}`; } function convertDraftStoreToNewIDSchema(store: DraftStore): DraftStore { return { drafts: Object.fromEntries( entries(store.drafts).map(([key, value]) => [ convertDraftKeyToNewIDSchema(key), value, ]), ), }; } function generateIDSchemaMigrationOpsForDrafts( drafts: $ReadOnlyArray, ): $ReadOnlyArray { const operations = drafts.map(draft => ({ type: 'update', payload: { key: convertDraftKeyToNewIDSchema(draft.key), text: draft.text, }, })); return [{ type: 'remove_all' }, ...operations]; } function convertMessageStoreThreadsToNewIDSchema( messageStoreThreads: TranslatedThreadMessageInfos, ): TranslatedThreadMessageInfos { return Object.fromEntries( entries(messageStoreThreads).map(([id, translatedThreadMessageInfo]) => [ `${ashoatKeyserverID}|` + id, translatedThreadMessageInfo, ]), ); } function convertThreadStoreThreadInfosToNewIDSchema( threadStoreThreadInfos: ThreadStoreThreadInfos, ): ThreadStoreThreadInfos { return Object.fromEntries( entries(threadStoreThreadInfos).map(([id, threadInfo]) => [ `${ashoatKeyserverID}|` + id, convertRawThreadInfoToNewIDSchema(threadInfo), ]), ); } function convertIDToNewSchema(threadID: string, idPrefix: string): string { const pendingIDContents = parsePendingThreadID(threadID); if (!pendingIDContents) { return convertNonPendingIDToNewSchema(threadID, idPrefix); } const { threadType, sourceMessageID, memberIDs } = pendingIDContents; if (!sourceMessageID) { return threadID; } return getPendingThreadID( threadType, memberIDs, convertNonPendingIDToNewSchema(sourceMessageID, idPrefix), ); } function convertNonPendingIDToNewSchema( threadID: string, idPrefix: string, ): string { if (threadID.indexOf('|') === -1) { return `${idPrefix}|${threadID}`; } return threadID; } function convertNotificationMessageInfoToNewIDSchema( messageInfosString: ?string, ): ?$ReadOnlyArray { let messageInfos: ?$ReadOnlyArray = null; if (messageInfosString) { messageInfos = JSON.parse(messageInfosString); } if (messageInfos?.some(message => message.threadID.indexOf('|') === -1)) { messageInfos = messageInfos?.map(convertRawMessageInfoToNewIDSchema); } return messageInfos; } // This is an array of all permissions that need to be removed // in an upcoming migration for roles. Once the migrations are landed, // no changes to this array should be made to prevent future migrations // from having unexpected behavior. // See context in https://linear.app/comm/issue/ENG-5622/#comment-2d98a2cd const permissionsToRemoveInMigration: $ReadOnlyArray = [ threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.OPEN + threadPermissions.VOICED, + + threadPermissions.JOIN_THREAD, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.EDIT_ENTRIES, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.EDIT_THREAD_NAME, + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.EDIT_THREAD_DESCRIPTION, + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.EDIT_THREAD_COLOR, + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissionFilterPrefixes.TOP_LEVEL + + threadPermissions.CREATE_SUBCHANNELS, + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.EDIT_THREAD_AVATAR, + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissionFilterPrefixes.TOP_LEVEL + + threadPermissions.CREATE_SIDEBARS, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.ADD_MEMBERS, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.REMOVE_MEMBERS, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.CHANGE_ROLE, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.EDIT_PERMISSIONS, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.MANAGE_PINS, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.REACT_TO_MESSAGE, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.EDIT_MESSAGE, + + threadPermissionPropagationPrefixes.DESCENDANT + + threadPermissions.MANAGE_INVITE_LINKS, ]; export { convertDraftKeyToNewIDSchema, convertDraftStoreToNewIDSchema, generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, convertNonPendingIDToNewSchema, convertIDToNewSchema, convertNotificationMessageInfoToNewIDSchema, permissionsToRemoveInMigration, }; diff --git a/lib/utils/role-utils.js b/lib/utils/role-utils.js index f038646fa..ff52c317d 100644 --- a/lib/utils/role-utils.js +++ b/lib/utils/role-utils.js @@ -1,133 +1,163 @@ // @flow import * as React from 'react'; import { useSelector } from './redux-utils.js'; import { threadInfoSelector } from '../selectors/thread-selectors.js'; import type { MinimallyEncodedRelativeMemberInfo, MinimallyEncodedRoleInfo, MinimallyEncodedThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import { type UserSurfacedPermissionOption, userSurfacedPermissions, userSurfacedPermissionOptions, + configurableCommunityPermissions, + type ThreadRolePermissionsBlob, + type UserSurfacedPermission, } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; import type { ThreadInfo, RelativeMemberInfo, RoleInfo, } from '../types/thread-types.js'; function useFilterPermissionOptionsByThreadType( threadType: ThreadType, ): $ReadOnlySet { // If the thread is a community announcement root, we want to allow // the option to be voiced in the announcement channels. Otherwise, // we want to remove that option from being configured since this will // be guaranteed on the keyserver. const shouldFilterVoicedInAnnouncementChannel = threadType === threadTypes.COMMUNITY_ROOT; return React.useMemo(() => { if (!shouldFilterVoicedInAnnouncementChannel) { return userSurfacedPermissionOptions; } return new Set( [...userSurfacedPermissionOptions].filter( option => option.userSurfacedPermission !== userSurfacedPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ), ); }, [shouldFilterVoicedInAnnouncementChannel]); } function constructRoleDeletionMessagePrompt( defaultRoleName: string, memberCount: number, ): string { let message; if (memberCount === 0) { message = 'Are you sure you want to delete this role?'; } else { const messageNoun = memberCount === 1 ? 'member' : 'members'; const messageVerb = memberCount === 1 ? 'is' : 'are'; message = `There ${messageVerb} currently ${memberCount} ${messageNoun} with ` + `this role. Deleting the role will automatically assign the ` + `${messageNoun} affected to the ${defaultRoleName} role.`; } return message; } type RoleDeletableAndEditableStatus = { +isDeletable: boolean, +isEditable: boolean, }; function useRoleDeletableAndEditableStatus( roleName: string, defaultRoleID: string, existingRoleID: string, ): RoleDeletableAndEditableStatus { return React.useMemo(() => { const canDelete = roleName !== 'Admins' && defaultRoleID !== existingRoleID; const canEdit = roleName !== 'Admins'; return { isDeletable: canDelete, isEditable: canEdit, }; }, [roleName, defaultRoleID, existingRoleID]); } function useRolesFromCommunityThreadInfo( threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, memberInfos: $ReadOnlyArray< RelativeMemberInfo | MinimallyEncodedRelativeMemberInfo, >, ): $ReadOnlyMap { // Our in-code system has chat-specific roles, while the // user-surfaced system has roles only for communities. We retrieve roles // from the top-level community thread for accuracy, with a rare fallback // for potential issues reading memberInfos, primarily in GENESIS threads. // The special case is GENESIS threads, since per prior discussion // (see context: https://linear.app/comm/issue/ENG-4077/), we don't really // support roles for it. Also with GENESIS, the list of members are not // populated in the community root. So in this case to prevent crashing, we // should just return the role name from the current thread info. const { community } = threadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const topMostThreadInfo = communityThreadInfo || threadInfo; const roleMap = new Map(); if (topMostThreadInfo.type === threadTypes.GENESIS) { memberInfos.forEach(memberInfo => roleMap.set( memberInfo.id, memberInfo.role ? threadInfo.roles[memberInfo.role] : null, ), ); return roleMap; } const { members: memberInfosFromTopMostThreadInfo, roles } = topMostThreadInfo; memberInfosFromTopMostThreadInfo.forEach(memberInfo => { roleMap.set(memberInfo.id, memberInfo.role ? roles[memberInfo.role] : null); }); return roleMap; } +function toggleUserSurfacedPermission( + rolePermissions: ThreadRolePermissionsBlob, + userSurfacedPermission: UserSurfacedPermission, +): ThreadRolePermissionsBlob { + const userSurfacedPermissionSet = Array.from( + configurableCommunityPermissions[userSurfacedPermission], + ); + const currentRolePermissions = { ...rolePermissions }; + + const roleHasPermission = userSurfacedPermissionSet.every( + permission => currentRolePermissions[permission], + ); + + if (roleHasPermission) { + for (const permission of userSurfacedPermissionSet) { + delete currentRolePermissions[permission]; + } + } else { + for (const permission of userSurfacedPermissionSet) { + currentRolePermissions[permission] = true; + } + } + + return currentRolePermissions; +} + export { useFilterPermissionOptionsByThreadType, constructRoleDeletionMessagePrompt, useRoleDeletableAndEditableStatus, useRolesFromCommunityThreadInfo, + toggleUserSurfacedPermission, };