diff --git a/lib/utils/role-utils.js b/lib/utils/role-utils.js index 372638f15..0eeb105a6 100644 --- a/lib/utils/role-utils.js +++ b/lib/utils/role-utils.js @@ -1,38 +1,60 @@ // @flow import * as React from 'react'; import { type UserSurfacedPermissionOption, userSurfacedPermissions, userSurfacedPermissionOptions, } from '../types/thread-permission-types.js'; import type { ThreadType } from '../types/thread-types-enum.js'; import { threadTypes } from '../types/thread-types-enum.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]); } -export { useFilterPermissionOptionsByThreadType }; +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; +} + +export { + useFilterPermissionOptionsByThreadType, + constructRoleDeletionMessagePrompt, +}; diff --git a/lib/utils/role-utils.test.js b/lib/utils/role-utils.test.js new file mode 100644 index 000000000..3f01c0512 --- /dev/null +++ b/lib/utils/role-utils.test.js @@ -0,0 +1,34 @@ +// @flow + +import { constructRoleDeletionMessagePrompt } from './role-utils.js'; + +describe('constructRoleDeletionMessagePrompt', () => { + it('should return generic deletion message if no members have this role', () => { + const result = constructRoleDeletionMessagePrompt('defaultRole', 0); + expect(result).toBe('Are you sure you want to delete this role?'); + }); + + it('should correctly format message for single member', () => { + const result = constructRoleDeletionMessagePrompt('defaultRole', 1); + expect(result).toBe( + `There is currently 1 member with this role. Deleting the role will ` + + `automatically assign the member affected to the defaultRole role.`, + ); + }); + + it('should correctly format message for multiple members', () => { + const result = constructRoleDeletionMessagePrompt('defaultRole', 5); + expect(result).toBe( + `There are currently 5 members with this role. Deleting the role will ` + + `automatically assign the members affected to the defaultRole role.`, + ); + }); + + it('should correctly incorporate the name of the default role', () => { + const result = constructRoleDeletionMessagePrompt('testRole', 5); + expect(result).toBe( + `There are currently 5 members with this role. Deleting the role will ` + + `automatically assign the members affected to the testRole role.`, + ); + }); +}); diff --git a/native/roles/role-panel-entry.react.js b/native/roles/role-panel-entry.react.js index d6b54c36e..4c4a34b43 100644 --- a/native/roles/role-panel-entry.react.js +++ b/native/roles/role-panel-entry.react.js @@ -1,173 +1,195 @@ // @flow import { useActionSheet } from '@expo/react-native-action-sheet'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text, TouchableOpacity, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { UserSurfacedPermission } from 'lib/types/thread-permission-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; +import { useDisplayDeleteRoleAlert } from './role-utils.react.js'; import type { RolesNavigationProp } from './roles-navigator.react.js'; import CommIcon from '../components/comm-icon.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { CreateRolesScreenRouteName } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; type RolePanelEntryProps = { +navigation: RolesNavigationProp<'CommunityRolesScreen'>, +threadInfo: ThreadInfo, +roleName: string, +rolePermissions: $ReadOnlySet, +memberCount: number, }; function RolePanelEntry(props: RolePanelEntryProps): React.Node { const { navigation, threadInfo, roleName, rolePermissions, memberCount } = props; const styles = useStyles(unboundStyles); const existingRoleID = React.useMemo( () => Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].name === roleName, ), [roleName, threadInfo.roles], ); invariant(existingRoleID, 'Role ID must exist for an existing role'); + const defaultRoleID = Object.keys(threadInfo.roles).find( + roleID => threadInfo.roles[roleID].isDefault, + ); + invariant(defaultRoleID, 'Default role ID must exist'); + + const displayDeleteRoleAlert = useDisplayDeleteRoleAlert( + threadInfo, + existingRoleID, + defaultRoleID, + memberCount, + ); + const options = React.useMemo(() => { const availableOptions = ['Edit role']; + // Since the `Members` role is able to be renamed, we need to check if the + // default role ID is the same as the existing role ID. + if (defaultRoleID !== existingRoleID) { + availableOptions.push('Delete role'); + } + if (Platform.OS === 'ios') { availableOptions.push('Cancel'); } return availableOptions; - }, []); + }, [defaultRoleID, existingRoleID]); const onOptionSelected = React.useCallback( (index: ?number) => { if (index === undefined || index === null || index === options.length) { return; } const selectedOption = options[index]; if (selectedOption === 'Edit role') { navigation.navigate(CreateRolesScreenRouteName, { threadInfo, action: 'edit_role', existingRoleID, roleName, rolePermissions, }); + } else if (selectedOption === 'Delete role') { + displayDeleteRoleAlert(); } }, [ navigation, options, existingRoleID, roleName, rolePermissions, threadInfo, + displayDeleteRoleAlert, ], ); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const { showActionSheetWithOptions } = useActionSheet(); const insets = useSafeAreaInsets(); const showActionSheet = React.useCallback(() => { const cancelButtonIndex = Platform.OS === 'ios' ? options.length - 1 : -1; const containerStyle = { paddingBottom: insets.bottom, }; showActionSheetWithOptions( { options, cancelButtonIndex, containerStyle, userInterfaceStyle: activeTheme ?? 'dark', icons: [], }, onOptionSelected, ); }, [ options, onOptionSelected, insets.bottom, activeTheme, showActionSheetWithOptions, ]); const menuButton = React.useMemo(() => { if (roleName === 'Admins') { return ; } return ( ); }, [ roleName, styles.rolePanelEmptyMenuButton, styles.rolePanelMenuButton, showActionSheet, ]); return ( {roleName} {memberCount} {menuButton} ); } const unboundStyles = { rolePanelEntry: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 8, }, rolePanelNameEntry: { flex: 1, color: 'panelForegroundLabel', fontWeight: '600', fontSize: 14, }, rolePanelCountEntryContainer: { marginRight: 40, alignItmes: 'flex-end', }, rolePanelCountEntry: { color: 'panelForegroundLabel', fontWeight: '600', fontSize: 14, marginRight: 22, padding: 8, }, rolePanelEmptyMenuButton: { marginRight: 22, }, rolePanelMenuButton: { color: 'panelForegroundLabel', }, }; export default RolePanelEntry; diff --git a/native/roles/role-utils.react.js b/native/roles/role-utils.react.js new file mode 100644 index 000000000..f0294fcd5 --- /dev/null +++ b/native/roles/role-utils.react.js @@ -0,0 +1,64 @@ +// @flow + +import * as React from 'react'; +import { Alert } from 'react-native'; + +import { + deleteCommunityRole, + deleteCommunityRoleActionTypes, +} from 'lib/actions/thread-actions.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; +import { + useDispatchActionPromise, + useServerCall, +} from 'lib/utils/action-utils.js'; +import { constructRoleDeletionMessagePrompt } from 'lib/utils/role-utils.js'; + +function useDisplayDeleteRoleAlert( + threadInfo: ThreadInfo, + existingRoleID: string, + defaultRoleID: string, + memberCount: number, +): () => void { + const defaultRoleName = threadInfo.roles[defaultRoleID].name; + const callDeleteCommunityRole = useServerCall(deleteCommunityRole); + const dispatchActionPromise = useDispatchActionPromise(); + + const onDeleteRole = React.useCallback(() => { + dispatchActionPromise( + deleteCommunityRoleActionTypes, + callDeleteCommunityRole({ + community: threadInfo.id, + roleID: existingRoleID, + }), + ); + }, [ + callDeleteCommunityRole, + dispatchActionPromise, + existingRoleID, + threadInfo.id, + ]); + + const message = constructRoleDeletionMessagePrompt( + defaultRoleName, + memberCount, + ); + + return React.useCallback( + () => + Alert.alert('Delete role', message, [ + { + text: 'Yes, delete role', + style: 'destructive', + onPress: onDeleteRole, + }, + { + text: 'Cancel', + style: 'cancel', + }, + ]), + [message, onDeleteRole], + ); +} + +export { useDisplayDeleteRoleAlert };