diff --git a/lib/utils/role-utils.js b/lib/utils/role-utils.js index ff52c317d..9846c2e4a 100644 --- a/lib/utils/role-utils.js +++ b/lib/utils/role-utils.js @@ -1,163 +1,134 @@ // @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]); -} +} from '../types/thread-types'; +import { threadTypes } from '../types/thread-types-enum.js'; 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, }; diff --git a/native/roles/create-roles-screen.react.js b/native/roles/create-roles-screen.react.js index bce8a3400..f985e24e8 100644 --- a/native/roles/create-roles-screen.react.js +++ b/native/roles/create-roles-screen.react.js @@ -1,299 +1,292 @@ // @flow import * as React from 'react'; import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { modifyCommunityRoleActionTypes } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MinimallyEncodedThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type UserSurfacedPermissionOption, type UserSurfacedPermission, + userSurfacedPermissionOptions, } from 'lib/types/thread-permission-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; -import { useFilterPermissionOptionsByThreadType } from 'lib/utils/role-utils.js'; import CreateRolesHeaderRightButton from './create-roles-header-right-button.react.js'; import type { RolesNavigationProp } from './roles-navigator.react.js'; import EnumSettingsOption from '../components/enum-settings-option.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import TextInput from '../components/text-input.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; export type CreateRolesScreenParams = { +threadInfo: ThreadInfo | MinimallyEncodedThreadInfo, +action: 'create_role' | 'edit_role', +existingRoleID?: string, +roleName: string, +rolePermissions: $ReadOnlySet, }; type CreateRolesScreenProps = { +navigation: RolesNavigationProp<'CreateRolesScreen'>, +route: NavigationRoute<'CreateRolesScreen'>, }; const createRolesLoadingStatusSelector = createLoadingStatusSelector( modifyCommunityRoleActionTypes, ); function CreateRolesScreen(props: CreateRolesScreenProps): React.Node { const { threadInfo, action, existingRoleID, roleName: defaultRoleName, rolePermissions: defaultRolePermissions, } = props.route.params; const createRolesLoadingStatus: LoadingStatus = useSelector( createRolesLoadingStatusSelector, ); const [customRoleName, setCustomRoleName] = React.useState(defaultRoleName); const [selectedPermissions, setSelectedPermissions] = React.useState< $ReadOnlySet, >(defaultRolePermissions); const [roleCreationFailed, setRoleCreationFailed] = React.useState(false); const styles = useStyles(unboundStyles); const errorStyles = React.useMemo( () => roleCreationFailed ? [styles.errorContainer, styles.errorContainerVisible] : styles.errorContainer, [roleCreationFailed, styles.errorContainer, styles.errorContainerVisible], ); const onClearPermissions = React.useCallback(() => { setSelectedPermissions(new Set()); }, []); const isSelectedPermissionsEmpty = selectedPermissions.size === 0; const clearPermissionsText = React.useMemo(() => { const textStyle = isSelectedPermissionsEmpty ? styles.clearPermissionsTextDisabled : styles.clearPermissionsText; return ( Clear permissions ); }, [ isSelectedPermissionsEmpty, onClearPermissions, styles.clearPermissionsText, styles.clearPermissionsTextDisabled, ]); const isUserSurfacedPermissionSelected = React.useCallback( (option: UserSurfacedPermissionOption) => selectedPermissions.has(option.userSurfacedPermission), [selectedPermissions], ); const onEnumValuePress = React.useCallback( (option: UserSurfacedPermissionOption) => setSelectedPermissions(currentPermissions => { if (currentPermissions.has(option.userSurfacedPermission)) { const newPermissions = new Set(currentPermissions); newPermissions.delete(option.userSurfacedPermission); return newPermissions; } else { return new Set([ ...currentPermissions, option.userSurfacedPermission, ]); } }), [], ); React.useEffect( () => props.navigation.setParams({ threadInfo, action, existingRoleID, roleName: customRoleName, rolePermissions: selectedPermissions, }), [ props.navigation, threadInfo, action, existingRoleID, customRoleName, selectedPermissions, ], ); - const filteredUserSurfacedPermissionOptions = - useFilterPermissionOptionsByThreadType(threadInfo.type); - const permissionsList = React.useMemo( () => - [...filteredUserSurfacedPermissionOptions].map(permission => ( + [...userSurfacedPermissionOptions].map(permission => ( onEnumValuePress(permission)} /> )), - [ - isUserSurfacedPermissionSelected, - filteredUserSurfacedPermissionOptions, - onEnumValuePress, - ], + [isUserSurfacedPermissionSelected, onEnumValuePress], ); const onChangeRoleNameInput = React.useCallback((roleName: string) => { setRoleCreationFailed(false); setCustomRoleName(roleName); }, []); React.useEffect( () => props.navigation.setOptions({ // eslint-disable-next-line react/display-name headerRight: () => { if (createRolesLoadingStatus === 'loading') { return ( ); } return ( ); }, }), [ createRolesLoadingStatus, props.navigation, styles.activityIndicator, props.route, ], ); return ( ROLE NAME There is already a role with this name in the community PERMISSIONS {clearPermissionsText} {permissionsList} ); } const unboundStyles = { roleNameContainer: { marginTop: 30, }, roleNameText: { color: 'panelBackgroundLabel', fontSize: 12, marginBottom: 5, marginLeft: 10, }, roleInput: { backgroundColor: 'panelForeground', padding: 12, flexDirection: 'row', justifyContent: 'space-between', }, roleInputComponent: { color: 'panelForegroundLabel', fontSize: 16, }, pencilIcon: { color: 'panelInputSecondaryForeground', }, errorContainer: { marginTop: 10, alignItems: 'center', opacity: 0, }, errorContainerVisible: { opacity: 1, }, errorText: { color: 'redText', fontSize: 14, }, permissionsContainer: { marginTop: 20, paddingBottom: 220, }, permissionsHeader: { flexDirection: 'row', justifyContent: 'space-between', }, permissionsText: { color: 'panelBackgroundLabel', fontSize: 12, marginLeft: 10, }, clearPermissionsText: { color: 'purpleLink', fontSize: 12, marginRight: 15, }, clearPermissionsTextDisabled: { color: 'disabledButton', fontSize: 12, marginRight: 15, }, permissionsListContainer: { backgroundColor: 'panelForeground', marginTop: 10, }, activityIndicator: { paddingRight: 15, }, }; export default CreateRolesScreen; diff --git a/web/roles/create-roles-modal.react.js b/web/roles/create-roles-modal.react.js index 4e9384fd1..f80c667d1 100644 --- a/web/roles/create-roles-modal.react.js +++ b/web/roles/create-roles-modal.react.js @@ -1,293 +1,286 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { useModifyCommunityRole, modifyCommunityRoleActionTypes, } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; -import type { - UserSurfacedPermission, - UserSurfacedPermissionOption, +import { + type UserSurfacedPermission, + type UserSurfacedPermissionOption, + userSurfacedPermissionOptions, } from 'lib/types/thread-permission-types.js'; import type { ThreadInfo, RoleModificationRequest, } from 'lib/types/thread-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import { values } from 'lib/utils/objects.js'; -import { useFilterPermissionOptionsByThreadType } from 'lib/utils/role-utils.js'; import css from './create-roles-modal.css'; import Button, { buttonThemes } from '../components/button.react.js'; import EnumSettingsOption from '../components/enum-settings-option.react.js'; import LoadingIndicator from '../loading-indicator.react.js'; import Input from '../modals/input.react.js'; import Modal from '../modals/modal.react.js'; import UnsavedChangesModal from '../modals/unsaved-changes-modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; const createRolesLoadingStatusSelector = createLoadingStatusSelector( modifyCommunityRoleActionTypes, ); type CreateRolesModalProps = { +threadInfo: ThreadInfo, +action: 'create_role' | 'edit_role', +existingRoleID?: string, +roleName: string, +rolePermissions: $ReadOnlySet, }; type RoleCreationErrorVariant = 'already_exists' | 'unknown_error'; function CreateRolesModal(props: CreateRolesModalProps): React.Node { const { pushModal, popModal } = useModalContext(); const { threadInfo, action, existingRoleID, roleName, rolePermissions } = props; const modalName = action === 'create_role' ? 'Create role' : 'Edit role'; const callModifyCommunityRole = useModifyCommunityRole(); const dispatchActionPromise = useDispatchActionPromise(); const createRolesLoadingStatus: LoadingStatus = useSelector( createRolesLoadingStatusSelector, ); const [pendingRoleName, setPendingRoleName] = React.useState(roleName); const [pendingRolePermissions, setPendingRolePermissions] = React.useState<$ReadOnlySet>(rolePermissions); const [roleCreationFailed, setRoleCreationFailed] = React.useState(); const createButtonText = action === 'create_role' ? 'Create' : 'Save'; const onChangeRoleName = React.useCallback( (event: SyntheticEvent) => { setRoleCreationFailed(null); setPendingRoleName(event.currentTarget.value); }, [], ); const onCloseModal = React.useCallback(() => { const pendingSet = new Set(pendingRolePermissions); const roleSet = new Set(rolePermissions); let arePermissionsEqual = true; if (pendingSet.size !== roleSet.size) { arePermissionsEqual = false; } for (const permission of pendingSet) { if (!roleSet.has(permission)) { arePermissionsEqual = false; break; } } if (pendingRoleName === roleName && arePermissionsEqual) { popModal(); return; } pushModal(); }, [ pendingRoleName, roleName, pendingRolePermissions, rolePermissions, pushModal, popModal, ]); const clearPermissionsClassNames = classNames({ [css.clearPermissions]: true, [css.clearPermissionsDisabled]: pendingRolePermissions.size === 0, [css.clearPermissionsEnabled]: pendingRolePermissions.size > 0, }); const onClearPermissions = React.useCallback( () => setPendingRolePermissions(new Set()), [], ); const isUserSurfacedPermissionSelected = React.useCallback( (option: UserSurfacedPermissionOption) => pendingRolePermissions.has(option.userSurfacedPermission), [pendingRolePermissions], ); const onEnumValuePress = React.useCallback( (option: UserSurfacedPermissionOption) => setPendingRolePermissions(currentPermissions => { if (currentPermissions.has(option.userSurfacedPermission)) { const newPermissions = new Set(currentPermissions); newPermissions.delete(option.userSurfacedPermission); return newPermissions; } else { return new Set([ ...currentPermissions, option.userSurfacedPermission, ]); } }), [], ); - const filteredUserSurfacedPermissionOptions = - useFilterPermissionOptionsByThreadType(threadInfo.type); - const permissionsList = React.useMemo( () => - [...filteredUserSurfacedPermissionOptions].map(permission => ( + [...userSurfacedPermissionOptions].map(permission => ( onEnumValuePress(permission)} icon={null} title={permission.title} type="checkbox" statements={[{ statement: permission.description }]} /> )), - [ - filteredUserSurfacedPermissionOptions, - isUserSurfacedPermissionSelected, - onEnumValuePress, - ], + [isUserSurfacedPermissionSelected, onEnumValuePress], ); const errorMessageClassNames = classNames({ [css.errorMessage]: true, [css.errorMessageVisible]: !!roleCreationFailed, }); const threadRoleNames = React.useMemo( () => values(threadInfo.roles).map(role => role.name), [threadInfo], ); const onClickCreateRole = React.useCallback(() => { if (threadRoleNames.includes(pendingRoleName) && action === 'create_role') { setRoleCreationFailed('already_exists'); return; } let callModifyCommunityRoleParams: RoleModificationRequest; if (action === 'create_role') { callModifyCommunityRoleParams = { community: threadInfo.id, action, name: pendingRoleName, permissions: [...pendingRolePermissions], }; } else { invariant(existingRoleID, 'existingRoleID should be defined'); callModifyCommunityRoleParams = { community: threadInfo.id, existingRoleID, action, name: pendingRoleName, permissions: [...pendingRolePermissions], }; } dispatchActionPromise( modifyCommunityRoleActionTypes, (async () => { try { const response = await callModifyCommunityRole( callModifyCommunityRoleParams, ); popModal(); return response; } catch (e) { setRoleCreationFailed('unknown_error'); throw e; } })(), ); }, [ callModifyCommunityRole, dispatchActionPromise, threadInfo, action, existingRoleID, pendingRoleName, pendingRolePermissions, popModal, threadRoleNames, ]); const errorMessage = React.useMemo(() => { if (roleCreationFailed === 'already_exists') { return 'There is already a role with this name in the community'; } else { return 'An unknown error occurred. Please try again'; } }, [roleCreationFailed]); const saveButtonContent = React.useMemo(() => { if (createRolesLoadingStatus === 'loading') { return ( ); } return createButtonText; }, [createRolesLoadingStatus, createButtonText]); return (
Role name
{errorMessage}

Permissions
Clear Permissions
{permissionsList}
); } export default CreateRolesModal;