diff --git a/e2e-tests/cypress/tests/integration/channels/channel/convert_channel_type_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/convert_channel_type_spec.ts index 5aff70b7d64..eaf67a8bb21 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/convert_channel_type_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/convert_channel_type_spec.ts @@ -162,6 +162,9 @@ describe('Channel Type Conversion (Public to Private Only)', () => { // Verify settings were saved verifySettingsSaved(); + + // Verify the modal completely closed to avoid flakiness + cy.get('#confirmModal').should('not.exist'); }; // Function kept for potential future use but not used in current tests diff --git a/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.ts index 7d6122a8f8a..f7dbf28d9de 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/convert_group_message_to_private_spec.ts @@ -158,6 +158,9 @@ describe('Group Message Conversion To Private Channel', () => { // Open the GM cy.visit(`/${testTeam1.name}/messages/${gm.name}`); + // Wait until the channel is loaded + cy.get('#channelHeaderDropdownButton').should('be.visible'); + // convert via API call const timestamp = Date.now(); cy.apiConvertGMToPrivateChannel(gm.id, testTeam2.id, `Channel ${timestamp}`, `c-${timestamp}`).then(() => { diff --git a/webapp/channels/src/components/channel_header/index.ts b/webapp/channels/src/components/channel_header/index.ts index 8909ffe15ad..1d914bec54c 100644 --- a/webapp/channels/src/components/channel_header/index.ts +++ b/webapp/channels/src/components/channel_header/index.ts @@ -17,7 +17,7 @@ import { getMyCurrentChannelMembership, isCurrentChannelMuted, getCurrentChannelStats, - getMyChannelAutotranslation, + isMyChannelAutotranslated, } from 'mattermost-redux/selectors/entities/channels'; import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; import {getRemoteNamesForChannel} from 'mattermost-redux/selectors/entities/shared_channels'; @@ -107,7 +107,7 @@ function makeMapStateToProps() { timestampUnits, hideGuestTags: config.HideGuestTags === 'true', sharedChannelsPluginsEnabled, - isChannelAutotranslated: channel ? getMyChannelAutotranslation(state, channel.id) : false, + isChannelAutotranslated: channel ? isMyChannelAutotranslated(state, channel.id) : false, }; }; } diff --git a/webapp/channels/src/components/channel_header_menu/channel_header_menu.tsx b/webapp/channels/src/components/channel_header_menu/channel_header_menu.tsx index b3ddd0e3b8a..3b6f4539135 100644 --- a/webapp/channels/src/components/channel_header_menu/channel_header_menu.tsx +++ b/webapp/channels/src/components/channel_header_menu/channel_header_menu.tsx @@ -11,7 +11,7 @@ import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down'; import type {UserProfile} from '@mattermost/types/users'; import { - getChannelAutotranslation, + isChannelAutotranslated as isChannelAutotranslatedSelector, getCurrentChannel, isCurrentChannelDefault, isCurrentChannelFavorite, @@ -60,7 +60,7 @@ export default function ChannelHeaderMenu({dmUser, gmMembers, isMobile, archived const pluginMenuItems = useSelector(getChannelHeaderMenuPluginComponents); const isChannelBookmarksEnabled = useSelector(getIsChannelBookmarksEnabled); const pluginItemsVisible = usePluginVisibilityInSharedChannel(channel?.id); - const isChannelAutotranslated = useSelector((state: GlobalState) => (channel?.id ? getChannelAutotranslation(state, channel.id) : false)); + const isChannelAutotranslated = useSelector((state: GlobalState) => (channel?.id ? isChannelAutotranslatedSelector(state, channel.id) : false)); const isReadonly = false; @@ -157,6 +157,7 @@ export default function ChannelHeaderMenu({dmUser, gmMembers, isMobile, archived isFavorite={isFavorite} isMobile={isMobile || false} isChannelBookmarksEnabled={isChannelBookmarksEnabled} + isChannelAutotranslated={isChannelAutotranslated} /> )} {isGroup && ( @@ -168,6 +169,7 @@ export default function ChannelHeaderMenu({dmUser, gmMembers, isMobile, archived isFavorite={isFavorite} isMobile={isMobile || false} isChannelBookmarksEnabled={isChannelBookmarksEnabled} + isChannelAutotranslated={isChannelAutotranslated} /> )} {(!isDirect && !isGroup) && ( diff --git a/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_direct_menu.test.tsx b/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_direct_menu.test.tsx new file mode 100644 index 00000000000..04164ee65c0 --- /dev/null +++ b/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_direct_menu.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {DeepPartial} from '@mattermost/types/utilities'; + +import {WithTestMenuContext} from 'components/menu/menu_context_test'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import type {GlobalState} from 'types/store'; + +import ChannelHeaderDirectMenu from './channel_header_direct_menu'; + +const DM_CHANNEL_ID = 'dm_channel_id'; +const CURRENT_USER_ID = 'user_id'; + +function getBaseState(overrides?: DeepPartial): DeepPartial { + const channel = TestHelper.getChannelMock({id: DM_CHANNEL_ID, type: 'D'}); + const currentUser = TestHelper.getUserMock({id: CURRENT_USER_ID}); + return { + entities: { + channels: { + channels: { + [DM_CHANNEL_ID]: channel, + }, + }, + general: { + config: { + EnableAutoTranslation: 'true', + }, + }, + users: { + currentUserId: CURRENT_USER_ID, + profiles: { + [CURRENT_USER_ID]: currentUser, + }, + }, + }, + ...overrides, + }; +} + +function getStateWithRestrictedDMAndGM(): DeepPartial { + const state = getBaseState(); + state!.entities!.general!.config!.RestrictDMAndGMAutotranslation = 'true'; + return state; +} + +describe('components/ChannelHeaderMenu/ChannelHeaderDirectMenu', () => { + const channel = TestHelper.getChannelMock({id: DM_CHANNEL_ID, type: 'D'}); + const user = TestHelper.getUserMock({id: CURRENT_USER_ID}); + const defaultProps = { + channel, + user, + isMuted: false, + isMobile: false, + isFavorite: false, + pluginItems: [], + isChannelBookmarksEnabled: false, + isChannelAutotranslated: false, + }; + + it('shows Channel Settings when RestrictDMAndGMAutotranslation is not enabled', () => { + renderWithContext( + + + , + getBaseState(), + ); + + expect(screen.getByText('Channel Settings')).toBeInTheDocument(); + expect(screen.queryByText('Edit Header')).not.toBeInTheDocument(); + }); + + it('shows Edit Header when RestrictDMAndGMAutotranslation is enabled', () => { + renderWithContext( + + + , + getStateWithRestrictedDMAndGM(), + ); + + expect(screen.getByText('Edit Header')).toBeInTheDocument(); + expect(screen.queryByText('Channel Settings')).not.toBeInTheDocument(); + }); + + it('shows Auto-translation menu when isChannelAutotranslated is true', () => { + renderWithContext( + + + , + getBaseState(), + ); + + expect(screen.getByText(/Auto-translation/i)).toBeInTheDocument(); + }); + + it('does not show Auto-translation menu when isChannelAutotranslated is false', () => { + renderWithContext( + + + , + getBaseState(), + ); + + expect(screen.queryByText(/Auto-translation/i)).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_direct_menu.tsx b/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_direct_menu.tsx index 74e229b2e20..88a56d722f7 100644 --- a/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_direct_menu.tsx +++ b/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_direct_menu.tsx @@ -3,6 +3,7 @@ import type {ReactNode} from 'react'; import React from 'react'; +import {useSelector} from 'react-redux'; import {CogOutlineIcon} from '@mattermost/compass-icons/components'; import type {Channel} from '@mattermost/types/channels'; @@ -10,10 +11,16 @@ import type {UserProfile} from '@mattermost/types/users'; import {isGuest} from 'mattermost-redux/utils/user_utils'; +import {canAccessChannelSettings} from 'selectors/views/channel_settings'; + import ChannelMoveToSubMenu from 'components/channel_move_to_sub_menu'; import * as Menu from 'components/menu'; +import type {GlobalState} from 'types/store'; + +import MenuItemAutotranslation from '../menu_items/autotranslation'; import MenuItemChannelBookmarks from '../menu_items/channel_bookmarks_submenu'; +import MenuItemChannelSettings from '../menu_items/channel_settings_menu'; import CloseMessage from '../menu_items/close_message'; import EditConversationHeader from '../menu_items/edit_conversation_header'; import MenuItemPluginItems from '../menu_items/plugins_submenu'; @@ -30,9 +37,12 @@ interface Props extends Menu.FirstMenuItemProps { isFavorite: boolean; pluginItems: ReactNode[]; isChannelBookmarksEnabled: boolean; + isChannelAutotranslated: boolean; } -const ChannelHeaderDirectMenu = ({channel, user, isMuted, isMobile, isFavorite, pluginItems, isChannelBookmarksEnabled, ...rest}: Props) => { +const ChannelHeaderDirectMenu = ({channel, user, isMuted, isMobile, isFavorite, pluginItems, isChannelBookmarksEnabled, isChannelAutotranslated, ...rest}: Props) => { + const canAccessChannelSettingsForChannel = useSelector((state: GlobalState) => canAccessChannelSettings(state, channel.id)); + return ( <> )} - } - channel={channel} - /> + {canAccessChannelSettingsForChannel ? ( + + ) : ( + } + channel={channel} + /> + )} + {isChannelAutotranslated && ( + + )} {!isGuest(user.roles) && isChannelBookmarksEnabled && ( { + const channel = TestHelper.getChannelMock({ + id: GM_CHANNEL_ID, + type: 'G' as const, + group_constrained: false, + delete_at: 0, + }); + const currentUser = TestHelper.getUserMock({id: CURRENT_USER_ID, roles: 'system_user'}); + return { + entities: { + channels: { + channels: { + [GM_CHANNEL_ID]: channel, + }, + }, + general: { + config: { + EnableAutoTranslation: 'true', + }, + }, + users: { + currentUserId: CURRENT_USER_ID, + profiles: { + [CURRENT_USER_ID]: currentUser, + }, + }, + }, + }; +} + +function getStateWithRestrictedDMAndGM(): DeepPartial { + const state = getBaseState(); + return { + ...state, + entities: { + ...state.entities, + general: { + config: {RestrictDMAndGMAutotranslation: 'true'}, + }, + }, + }; +} + +describe('components/ChannelHeaderMenu/ChannelHeaderGroupMenu', () => { + const channel = TestHelper.getChannelMock({ + id: GM_CHANNEL_ID, + type: 'G' as const, + group_constrained: false, + delete_at: 0, + }); + const user = TestHelper.getUserMock({id: CURRENT_USER_ID, roles: 'system_user'}); + const defaultProps = { + channel, + user, + isMuted: false, + isMobile: false, + isFavorite: false, + pluginItems: [], + isChannelBookmarksEnabled: false, + isChannelAutotranslated: false, + }; + + it('shows Channel Settings when RestrictDMAndGMAutotranslation is not enabled', () => { + renderWithContext( + + + , + getBaseState(), + ); + + expect(screen.getByText('Channel Settings')).toBeInTheDocument(); + expect(screen.queryByText('Edit Header')).not.toBeInTheDocument(); + }); + + it('shows Settings submenu when RestrictDMAndGMAutotranslation is enabled', () => { + renderWithContext( + + + , + getStateWithRestrictedDMAndGM(), + ); + + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('shows Auto-translation menu when isChannelAutotranslated is true', () => { + renderWithContext( + + + , + getBaseState(), + ); + + expect(screen.getByText(/Auto-translation/i)).toBeInTheDocument(); + }); + + it('does not show Auto-translation menu when isChannelAutotranslated is false', () => { + renderWithContext( + + + , + getBaseState(), + ); + + expect(screen.queryByText(/Auto-translation/i)).not.toBeInTheDocument(); + }); + + it('does not show Channel Settings when the channel is archived', () => { + const archivedChannel = TestHelper.getChannelMock({ + ...channel, + delete_at: 1234567890, + }); + const archivedChannelState = getBaseState(); + archivedChannelState.entities!.channels!.channels![GM_CHANNEL_ID]!.delete_at = 1234567890; + + renderWithContext( + + + , + archivedChannelState, + ); + + expect(screen.queryByText('Channel Settings')).not.toBeInTheDocument(); + }); + + it('does not show Settings submenu when the channel is archived', () => { + const archivedChannel = TestHelper.getChannelMock({ + ...channel, + delete_at: 1234567890, + }); + const archivedChannelState = getStateWithRestrictedDMAndGM(); + archivedChannelState.entities!.channels!.channels![GM_CHANNEL_ID] = { + ...archivedChannelState.entities!.channels!.channels![GM_CHANNEL_ID], + delete_at: 1234567890, + }; + + renderWithContext( + + + , + archivedChannelState, + ); + + expect(screen.queryByText('Settings')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_group_menu.tsx b/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_group_menu.tsx index d436e5d1d48..8570796570e 100644 --- a/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_group_menu.tsx +++ b/webapp/channels/src/components/channel_header_menu/channel_header_menu_items/channel_header_group_menu.tsx @@ -4,6 +4,7 @@ import type {ReactNode} from 'react'; import React from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; import { ChevronRightIcon, @@ -15,11 +16,17 @@ import type {UserProfile} from '@mattermost/types/users'; import {Permissions} from 'mattermost-redux/constants'; import {isGuest} from 'mattermost-redux/utils/user_utils'; +import {canAccessChannelSettings} from 'selectors/views/channel_settings'; + import ChannelMoveToSubMenu from 'components/channel_move_to_sub_menu'; import * as Menu from 'components/menu'; import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate'; +import type {GlobalState} from 'types/store'; + +import MenuItemAutotranslation from '../menu_items/autotranslation'; import MenuItemChannelBookmarks from '../menu_items/channel_bookmarks_submenu'; +import MenuItemChannelSettings from '../menu_items/channel_settings_menu'; import CloseMessage from '../menu_items/close_message'; import MenuItemConvertToPrivate from '../menu_items/convert_gm_to_private'; import EditConversationHeader from '../menu_items/edit_conversation_header'; @@ -39,12 +46,14 @@ interface Props extends Menu.FirstMenuItemProps { isFavorite: boolean; pluginItems: ReactNode[]; isChannelBookmarksEnabled: boolean; + isChannelAutotranslated: boolean; } -const ChannelHeaderGroupMenu = ({channel, user, isMuted, isMobile, isFavorite, pluginItems, isChannelBookmarksEnabled, ...rest}: Props) => { +const ChannelHeaderGroupMenu = ({channel, user, isMuted, isMobile, isFavorite, pluginItems, isChannelBookmarksEnabled, isChannelAutotranslated, ...rest}: Props) => { const isGroupConstrained = channel?.group_constrained === true; const isArchived = channel.delete_at !== 0; const {formatMessage} = useIntl(); + const canAccessChannelSettingsForChannel = useSelector((state: GlobalState) => canAccessChannelSettings(state, channel.id)); return ( <> @@ -69,38 +78,62 @@ const ChannelHeaderGroupMenu = ({channel, user, isMuted, isMobile, isFavorite, p )} {!isArchived && ( - - )} - {(!isArchived && isGuest(user.roles)) && ( - } - channel={channel} - /> - )} - {(!isArchived && !isGroupConstrained && !isGuest(user.roles)) && ( - + {( + - } - leadingElement={} - trailingElements={} - menuId={'channelSettings-menu'} - menuAriaLabel={formatMessage({id: 'channel_header.settings', defaultMessage: 'Settings'})} - > - - - + )} + {canAccessChannelSettingsForChannel ? ( + <> + + {(!isGroupConstrained && !isGuest(user.roles)) && ( + + )} + + ) : ( + <> + {(isGuest(user.roles)) && ( + } + channel={channel} + /> + )} + {(!isGroupConstrained && !isGuest(user.roles)) && ( + + } + leadingElement={} + trailingElements={} + menuId={'channelSettings-menu'} + menuAriaLabel={formatMessage({id: 'channel_header.settings', defaultMessage: 'Settings'})} + > + + + + )} + + )} + + )} + {isChannelAutotranslated && ( + )} {!isArchived && !isGuest(user.roles) && isChannelBookmarksEnabled && ( { const dispatch = useDispatch(); - const isAutotranslated = useSelector((state: GlobalState) => isChannelAutotranslated(state, channel.id)); + const isAutotranslated = useSelector((state: GlobalState) => isMyChannelAutotranslated(state, channel.id)); const isLanguageSupported = useSelector(isUserLanguageSupportedForAutotranslation); const handleAutotranslationToggle = useCallback(() => { diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx index f7e552671be..cccb9983e5e 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx @@ -9,7 +9,7 @@ import type {Channel} from '@mattermost/types/channels'; import type {ServerError} from '@mattermost/types/errors'; import {patchChannel} from 'mattermost-redux/actions/channels'; -import {getChannelAutotranslation} from 'mattermost-redux/selectors/entities/channels'; +import {isChannelAutotranslated as isChannelAutotranslatedSelector} from 'mattermost-redux/selectors/entities/channels'; import ColorInput from 'components/color_input'; import type {TextboxElement} from 'components/textbox'; @@ -65,7 +65,7 @@ function ChannelSettingsConfigurationTab({ const initialBannerInfo = channel.banner_info || DEFAULT_CHANNEL_BANNER; - const initialIsChannelAutotranslated = useSelector((state: GlobalState) => getChannelAutotranslation(state, channel.id)); + const initialIsChannelAutotranslated = useSelector((state: GlobalState) => isChannelAutotranslatedSelector(state, channel.id)); const [isChannelAutotranslated, setIsChannelAutotranslated] = useState(initialIsChannelAutotranslated); const [formError, setFormError] = useState(''); diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.test.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.test.tsx index bf519280399..c955a5545a5 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.test.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.test.tsx @@ -202,7 +202,6 @@ describe('ChannelSettingsInfoTab', () => { // Note: URL should remain unchanged when editing existing channels expect(patchChannel).toHaveBeenCalledWith('channel1', { display_name: 'Updated Channel Name', - name: 'test-channel', // URL should remain unchanged when editing existing channels purpose: 'Updated purpose', header: 'Updated header', }); @@ -241,7 +240,6 @@ describe('ChannelSettingsInfoTab', () => { // Verify patchChannel was called with the trimmed values expect(patchChannel).toHaveBeenCalledWith('channel1', { display_name: 'Channel Name With Whitespace', // Whitespace should be trimmed - name: 'test-channel', // URL should remain unchanged when editing existing channels purpose: 'Purpose with whitespace', // Whitespace should be trimmed header: 'Header with whitespace', // Whitespace should be trimmed }); diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx index 9ebecbe9ee6..ef99ff02da5 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx @@ -51,19 +51,36 @@ function ChannelSettingsInfoTab({ const shouldShowPreviewPurpose = useSelector(showPreviewOnChannelSettingsPurposeModal); const shouldShowPreviewHeader = useSelector(showPreviewOnChannelSettingsHeaderModal); + const isPrivate = channel.type === Constants.PRIVATE_CHANNEL; + const isDirect = channel.type === Constants.DM_CHANNEL; + const isGroup = channel.type === Constants.GM_CHANNEL; + const isDMorGroupChannel = isDirect || isGroup; + // Permissions for transforming channel type - const canConvertToPrivate = useSelector((state: GlobalState) => - haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE), - ); - const canConvertToPublic = useSelector((state: GlobalState) => - haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CONVERT_PRIVATE_CHANNEL_TO_PUBLIC), - ); + const canConvertToPrivate = useSelector((state: GlobalState) => { + if (isDMorGroupChannel) { + // Group channels can be converted to private, + // but that is handled in a different flow. + return false; + } + return haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CONVERT_PUBLIC_CHANNEL_TO_PRIVATE); + }); + + const canConvertToPublic = useSelector((state: GlobalState) => { + if (isDMorGroupChannel) { + return false; + } + return haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CONVERT_PRIVATE_CHANNEL_TO_PUBLIC); + }); // Permissions for managing channel (name, header, purpose) - const channelPropertiesPermission = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES; - const canManageChannelProperties = useSelector((state: GlobalState) => - haveIChannelPermission(state, channel.team_id, channel.id, channelPropertiesPermission), - ); + const channelPropertiesPermission = isPrivate ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES; + const canManageChannelProperties = useSelector((state: GlobalState) => { + if (isDMorGroupChannel) { + return true; + } + return haveIChannelPermission(state, channel.team_id, channel.id, channelPropertiesPermission); + }); // Constants const HEADER_MAX_LENGTH = 1024; @@ -190,7 +207,7 @@ function ChannelSettingsInfoTab({ } }, [formError, channelNameError, setFormError, formatMessage]); - const handleServerError = (err: ServerError) => { + const handleServerError = useCallback((err: ServerError) => { const errorMsg = err.message || formatMessage({id: 'channel_settings.unknown_error', defaultMessage: 'Something went wrong.'}); setFormError(errorMsg); setSaveChangesPanelState('error'); @@ -203,7 +220,7 @@ function ChannelSettingsInfoTab({ )) { setUrlError(errorMsg); // Set the URL error to show in the URL input } - }; + }, [formatMessage]); // Validate & Save - using useCallback to ensure it has the latest state values const handleSave = useCallback(async (): Promise => { @@ -228,13 +245,24 @@ function ChannelSettingsInfoTab({ } } - // Build updated channel object - const updated: Partial = { - display_name: displayName.trim(), - name: channelUrl.trim(), - purpose: channelPurpose.trim(), - header: channelHeader.trim(), - }; + const updated: Partial = {}; + if (!isDMorGroupChannel && displayName.trim() !== channel.display_name) { + updated.display_name = displayName.trim(); + } + if (!isDMorGroupChannel && channelUrl.trim() !== channel.name) { + updated.name = channelUrl.trim(); + } + if (!isDMorGroupChannel && channelPurpose.trim() !== channel.purpose) { + updated.purpose = channelPurpose.trim(); + } + if (channelHeader.trim() !== channel.header) { + updated.header = channelHeader.trim(); + } + + if (Object.keys(updated).length === 0) { + // Return true if no changes were made + return true; + } const {data, error} = await dispatch(patchChannel(channel.id, updated)); if (error) { @@ -244,12 +272,14 @@ function ChannelSettingsInfoTab({ // After every successful save, update local state to match the saved values // with this, we make sure that the unsavedChanges check will return false after saving - setDisplayName(data?.display_name ?? updated.display_name ?? ''); - setChannelURL(data?.name ?? updated.name ?? ''); - setChannelPurpose(data?.purpose ?? updated.purpose ?? ''); - setChannelHeader(data?.header ?? updated.header ?? ''); + if (!isDMorGroupChannel) { + setDisplayName(data?.display_name ?? updated.display_name ?? channel.display_name); + setChannelURL(data?.name ?? updated.name ?? channel.name); + setChannelPurpose(data?.purpose ?? updated.purpose ?? channel.purpose); + } + setChannelHeader(data?.header ?? updated.header ?? channel.header); return true; - }, [channel, displayName, channelType, channelUrl, channelPurpose, channelHeader, dispatch, formatMessage, handleServerError]); + }, [channel, displayName, channelType, isDMorGroupChannel, channelUrl, channelPurpose, channelHeader, dispatch, formatMessage, handleServerError]); // Handle save changes panel actions const handleSaveChanges = useCallback(async () => { @@ -312,16 +342,19 @@ function ChannelSettingsInfoTab({ // Memoize the calculation for whether to show the save changes panel const shouldShowPanel = useMemo(() => { - const unsavedChanges = channel ? ( - displayName.trim() !== channel.display_name || - channelUrl.trim() !== channel.name || - channelPurpose.trim() !== channel.purpose || - channelHeader.trim() !== channel.header || - channelType !== channel.type - ) : false; + let unsavedChanges = false; + if (channel) { + unsavedChanges = unsavedChanges || channelHeader.trim() !== channel.header; + if (!isDMorGroupChannel) { + unsavedChanges = unsavedChanges || displayName.trim() !== channel.display_name; + unsavedChanges = unsavedChanges || channelUrl.trim() !== channel.name; + unsavedChanges = unsavedChanges || channelPurpose.trim() !== channel.purpose; + unsavedChanges = unsavedChanges || channelType !== channel.type; + } + } return unsavedChanges || saveChangesPanelState === 'saved'; - }, [channel, displayName, channelUrl, channelPurpose, channelHeader, channelType, saveChangesPanelState]); + }, [channel, isDMorGroupChannel, displayName, channelUrl, channelPurpose, channelHeader, channelType, saveChangesPanelState]); return (
@@ -348,74 +381,79 @@ function ChannelSettingsInfoTab({ > {formatMessage({id: 'channel_settings.channel_info_tab.name', defaultMessage: 'Channel Info'})}
- { - setDisplayName(name); - }} - onURLChange={handleURLChange} - onErrorStateChange={handleChannelNameError} - urlError={internalUrlError} - currentUrl={channelUrl} - readOnly={!canManageChannelProperties} - isEditingExistingChannel={true} - /> - + {!isDMorGroupChannel && ( + { + setDisplayName(name); + }} + onURLChange={handleURLChange} + onErrorStateChange={handleChannelNameError} + urlError={internalUrlError} + currentUrl={channelUrl} + readOnly={!canManageChannelProperties} + isEditingExistingChannel={true} + /> + )} {/* Channel Type Section*/} - + // Always disable public button if current channel is private, regardless of permissions + disabled: channel.type === Constants.PRIVATE_CHANNEL || !canConvertToPublic, + }} + privateButtonProps={{ + title: formatMessage({id: 'channel_modal.type.private.title', defaultMessage: 'Private Channel'}), + description: formatMessage({id: 'channel_modal.type.private.description', defaultMessage: 'Only invited members'}), + disabled: !canConvertToPrivate, + }} + onChange={handleChannelTypeChange} + /> + )} {/* Purpose Section*/} - {}} - descriptionMessage={formatMessage({ - id: 'channel_settings.purpose.description', - defaultMessage: 'Describe how this channel should be used.', - })} - hasError={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH} - errorMessage={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH ? formatMessage({ - id: 'channel_settings.error_purpose_length', - defaultMessage: 'The text entered exceeds the character limit. The channel purpose is limited to {maxLength} characters.', - }, { - maxLength: Constants.MAX_CHANNELPURPOSE_LENGTH, - }) : undefined - } - showCharacterCount={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH} - readOnly={!canManageChannelProperties} - name={formatMessage({id: 'channel_settings.purpose.label', defaultMessage: 'Channel Purpose'})} - /> + {!isDMorGroupChannel && ( + {}} + descriptionMessage={formatMessage({ + id: 'channel_settings.purpose.description', + defaultMessage: 'Describe how this channel should be used.', + })} + hasError={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH} + errorMessage={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH ? formatMessage({ + id: 'channel_settings.error_purpose_length', + defaultMessage: 'The text entered exceeds the character limit. The channel purpose is limited to {maxLength} characters.', + }, { + maxLength: Constants.MAX_CHANNELPURPOSE_LENGTH, + }) : undefined + } + showCharacterCount={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH} + readOnly={!canManageChannelProperties} + name={formatMessage({id: 'channel_settings.purpose.label', defaultMessage: 'Channel Purpose'})} + /> + )} {/* Channel Header Section*/} isChannelAutotranslated(state, props.post.channel_id)); + const autotranslated = useSelector((state: GlobalState) => isMyChannelAutotranslated(state, props.post.channel_id)); return (
{ expect(Selectors.hasAutotranslationBecomeEnabled(enabledMemberState as GlobalState, enabledChannel)).toBe(true); }); }); + +describe('isChannelAutotranslated', () => { + const publicChannelId = 'public_channel_id'; + const dmChannelId = 'dm_channel_id'; + const gmChannelId = 'gm_channel_id'; + + const publicChannel = { + ...TestHelper.fakeChannelWithId('team_id'), + id: publicChannelId, + type: General.OPEN_CHANNEL, + autotranslation: true, + }; + const dmChannel = { + ...TestHelper.fakeChannelWithId('team_id'), + id: dmChannelId, + type: General.DM_CHANNEL, + autotranslation: true, + }; + const gmChannel = { + ...TestHelper.fakeChannelWithId('team_id'), + id: gmChannelId, + type: General.GM_CHANNEL, + autotranslation: true, + }; + + it('returns false when channel does not exist', () => { + const state: DeepPartial = { + entities: { + general: {config: {EnableAutoTranslation: 'true'}}, + channels: {channels: {}}, + }, + }; + expect(Selectors.isChannelAutotranslated(state as GlobalState, 'nonexistent')).toBe(false); + }); + + it('returns false when channel has no autotranslation', () => { + const channelWithoutAutotranslation = {...publicChannel, autotranslation: false}; + const state: DeepPartial = { + entities: { + general: {config: {EnableAutoTranslation: 'true'}}, + channels: { + channels: {[publicChannelId]: channelWithoutAutotranslation}, + }, + }, + }; + expect(Selectors.isChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(false); + }); + + it('returns false when EnableAutoTranslation is not enabled in config', () => { + const state: DeepPartial = { + entities: { + general: {config: {EnableAutoTranslation: 'false'}}, + channels: { + channels: {[publicChannelId]: publicChannel}, + }, + }, + }; + expect(Selectors.isChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(false); + }); + + it('returns true for public channel with autotranslation when EnableAutoTranslation is enabled', () => { + const state: DeepPartial = { + entities: { + general: {config: {EnableAutoTranslation: 'true'}}, + channels: { + channels: {[publicChannelId]: publicChannel}, + }, + }, + }; + expect(Selectors.isChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(true); + }); + + it('returns true for DM channel with autotranslation when RestrictDMAndGMAutotranslation is not enabled', () => { + const state: DeepPartial = { + entities: { + general: { + config: { + EnableAutoTranslation: 'true', + RestrictDMAndGMAutotranslation: 'false', + }, + }, + channels: { + channels: {[dmChannelId]: dmChannel}, + }, + }, + }; + expect(Selectors.isChannelAutotranslated(state as GlobalState, dmChannelId)).toBe(true); + }); + + it('returns true for DM channel with autotranslation when RestrictDMAndGMAutotranslation is not set', () => { + const state: DeepPartial = { + entities: { + general: { + config: {EnableAutoTranslation: 'true'}, + }, + channels: { + channels: {[dmChannelId]: dmChannel}, + }, + }, + }; + expect(Selectors.isChannelAutotranslated(state as GlobalState, dmChannelId)).toBe(true); + }); + + it('returns false for DM channel when RestrictDMAndGMAutotranslation is enabled', () => { + const state: DeepPartial = { + entities: { + general: { + config: { + EnableAutoTranslation: 'true', + RestrictDMAndGMAutotranslation: 'true', + }, + }, + channels: { + channels: {[dmChannelId]: dmChannel}, + }, + }, + }; + expect(Selectors.isChannelAutotranslated(state as GlobalState, dmChannelId)).toBe(false); + }); + + it('returns true for GM channel with autotranslation when RestrictDMAndGMAutotranslation is not enabled', () => { + const state: DeepPartial = { + entities: { + general: { + config: { + EnableAutoTranslation: 'true', + RestrictDMAndGMAutotranslation: 'false', + }, + }, + channels: { + channels: {[gmChannelId]: gmChannel}, + }, + }, + }; + expect(Selectors.isChannelAutotranslated(state as GlobalState, gmChannelId)).toBe(true); + }); + + it('returns false for GM channel when RestrictDMAndGMAutotranslation is enabled', () => { + const state: DeepPartial = { + entities: { + general: { + config: { + EnableAutoTranslation: 'true', + RestrictDMAndGMAutotranslation: 'true', + }, + }, + channels: { + channels: {[gmChannelId]: gmChannel}, + }, + }, + }; + expect(Selectors.isChannelAutotranslated(state as GlobalState, gmChannelId)).toBe(false); + }); +}); + +describe('isMyChannelAutotranslated', () => { + const publicChannelId = 'public_channel_id'; + const dmChannelId = 'dm_channel_id'; + const gmChannelId = 'gm_channel_id'; + const currentUserId = 'current_user_id'; + + const publicChannel = { + ...TestHelper.fakeChannelWithId('team_id'), + id: publicChannelId, + type: General.OPEN_CHANNEL, + autotranslation: true, + }; + const dmChannel = { + ...TestHelper.fakeChannelWithId('team_id'), + id: dmChannelId, + type: General.DM_CHANNEL, + autotranslation: true, + }; + const gmChannel = { + ...TestHelper.fakeChannelWithId('team_id'), + id: gmChannelId, + type: General.GM_CHANNEL, + autotranslation: true, + }; + + const currentUserWithLocale = (locale: string) => ({ + ...TestHelper.fakeUserWithId(), + id: currentUserId, + locale, + }); + + const baseConfig = { + EnableAutoTranslation: 'true' as const, + AutoTranslationLanguages: 'en,es,fr', + RestrictDMAndGMAutotranslation: 'false' as const, + }; + + it('returns false when channel does not exist', () => { + const state: DeepPartial = { + entities: { + general: {config: baseConfig}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: {channels: {}, myMembers: {}}, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, 'nonexistent')).toBe(false); + }); + + it('returns false when EnableAutoTranslation is not enabled in config', () => { + const state: DeepPartial = { + entities: { + general: {config: {...baseConfig, EnableAutoTranslation: 'false'}}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[publicChannelId]: publicChannel}, + myMembers: {[publicChannelId]: {channel_id: publicChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(false); + }); + + it('returns false when channel has no autotranslation', () => { + const channelWithoutAutotranslation = {...publicChannel, autotranslation: false}; + const state: DeepPartial = { + entities: { + general: {config: baseConfig}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[publicChannelId]: channelWithoutAutotranslation}, + myMembers: {[publicChannelId]: {channel_id: publicChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(false); + }); + + it('returns false when my channel member has autotranslation_disabled', () => { + const state: DeepPartial = { + entities: { + general: {config: baseConfig}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[publicChannelId]: publicChannel}, + myMembers: { + [publicChannelId]: {channel_id: publicChannelId, autotranslation_disabled: true}, + }, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(false); + }); + + it('returns false when user locale is not in AutoTranslationLanguages', () => { + const state: DeepPartial = { + entities: { + general: {config: baseConfig}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('de')}, + }, + channels: { + channels: {[publicChannelId]: publicChannel}, + myMembers: {[publicChannelId]: {channel_id: publicChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(false); + }); + + it('returns false when AutoTranslationLanguages is empty or not set', () => { + const stateEmpty: DeepPartial = { + entities: { + general: {config: {...baseConfig, AutoTranslationLanguages: ''}}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[publicChannelId]: publicChannel}, + myMembers: {[publicChannelId]: {channel_id: publicChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(stateEmpty as GlobalState, publicChannelId)).toBe(false); + + const stateMissing: DeepPartial = { + entities: { + general: {config: {EnableAutoTranslation: 'true', RestrictDMAndGMAutotranslation: 'false'}}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[publicChannelId]: publicChannel}, + myMembers: {[publicChannelId]: {channel_id: publicChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(stateMissing as GlobalState, publicChannelId)).toBe(false); + }); + + it('returns true for public channel when all conditions are met', () => { + const state: DeepPartial = { + entities: { + general: {config: baseConfig}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[publicChannelId]: publicChannel}, + myMembers: {[publicChannelId]: {channel_id: publicChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(true); + }); + + it('returns true when my channel member has autotranslation_disabled false', () => { + const state: DeepPartial = { + entities: { + general: {config: baseConfig}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('es')}, + }, + channels: { + channels: {[publicChannelId]: publicChannel}, + myMembers: { + [publicChannelId]: {channel_id: publicChannelId, autotranslation_disabled: false}, + }, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(true); + }); + + it('returns true when my channel member is missing (no autotranslation_disabled)', () => { + const state: DeepPartial = { + entities: { + general: {config: baseConfig}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[publicChannelId]: publicChannel}, + myMembers: {}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, publicChannelId)).toBe(true); + }); + + it('returns false for DM channel when RestrictDMAndGMAutotranslation is enabled', () => { + const state: DeepPartial = { + entities: { + general: { + config: { + ...baseConfig, + RestrictDMAndGMAutotranslation: 'true', + }, + }, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[dmChannelId]: dmChannel}, + myMembers: {[dmChannelId]: {channel_id: dmChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, dmChannelId)).toBe(false); + }); + + it('returns false for GM channel when RestrictDMAndGMAutotranslation is enabled', () => { + const state: DeepPartial = { + entities: { + general: { + config: { + ...baseConfig, + RestrictDMAndGMAutotranslation: 'true', + }, + }, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[gmChannelId]: gmChannel}, + myMembers: {[gmChannelId]: {channel_id: gmChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, gmChannelId)).toBe(false); + }); + + it('returns true for DM channel when RestrictDMAndGMAutotranslation is not enabled', () => { + const state: DeepPartial = { + entities: { + general: {config: baseConfig}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('fr')}, + }, + channels: { + channels: {[dmChannelId]: dmChannel}, + myMembers: {[dmChannelId]: {channel_id: dmChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, dmChannelId)).toBe(true); + }); + + it('returns true for GM channel when RestrictDMAndGMAutotranslation is not enabled', () => { + const state: DeepPartial = { + entities: { + general: {config: baseConfig}, + users: { + currentUserId, + profiles: {[currentUserId]: currentUserWithLocale('en')}, + }, + channels: { + channels: {[gmChannelId]: gmChannel}, + myMembers: {[gmChannelId]: {channel_id: gmChannelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, gmChannelId)).toBe(true); + }); +}); + +describe('isChannelAutotranslated', () => { + it('returns same result as getMyChannelAutotranslation', () => { + const channelId = 'channel_id'; + const state: DeepPartial = { + entities: { + general: { + config: { + EnableAutoTranslation: 'true', + AutoTranslationLanguages: 'en', + RestrictDMAndGMAutotranslation: 'false', + }, + }, + users: { + currentUserId: 'user_id', + profiles: { + user_id: { + ...TestHelper.fakeUserWithId(), + id: 'user_id', + locale: 'en', + }, + }, + }, + channels: { + channels: { + [channelId]: { + ...TestHelper.fakeChannelWithId('team_id'), + id: channelId, + autotranslation: true, + }, + }, + myMembers: {[channelId]: {channel_id: channelId}}, + }, + }, + }; + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, channelId)).toBe( + Selectors.isMyChannelAutotranslated(state as GlobalState, channelId), + ); + expect(Selectors.isMyChannelAutotranslated(state as GlobalState, channelId)).toBe(true); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts index 5c0c2f01669..5939ae59560 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts @@ -1458,29 +1458,38 @@ export function getChannelBanner(state: GlobalState, channelId: string): Channel return channel ? channel.banner_info : undefined; } -export function getChannelAutotranslation(state: GlobalState, channelId: string): boolean { - const channel = getChannel(state, channelId); - const config = getConfig(state); - - return Boolean(channel?.autotranslation && config?.EnableAutoTranslation === 'true'); -} - -export function getMyChannelAutotranslation(state: GlobalState, channelId: string): boolean { - const locale = getCurrentUserLocale(state); - const channel = getChannel(state, channelId); - const myChannelMember = getMyChannelMember(state, channelId); - const config = getConfig(state); - const targetLanguages = config?.AutoTranslationLanguages?.split(','); - return Boolean( - config?.EnableAutoTranslation === 'true' && - channel?.autotranslation && - !myChannelMember?.autotranslation_disabled && - targetLanguages?.includes(locale), - ); -} - +// isChannelAutotranslated returns whether the channel is autotranslated +// in general terms in this server. export function isChannelAutotranslated(state: GlobalState, channelId: string): boolean { - return getMyChannelAutotranslation(state, channelId); + const channel = getChannel(state, channelId); + const config = getConfig(state); + + if (!channel?.autotranslation || config?.EnableAutoTranslation !== 'true') { + return false; + } + + // When autotranslation is restricted on DMs and GMs, do not consider them autotranslated + const isDMOrGM = channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL; + if (isDMOrGM && config?.RestrictDMAndGMAutotranslation === 'true') { + return false; + } + + return true; +} + +// isMyChannelAutotranslated returns whether the current user is seeing the autotranslated +// version of the channel. +export function isMyChannelAutotranslated(state: GlobalState, channelId: string): boolean { + if (!isChannelAutotranslated(state, channelId)) { + return false; + } + + if (!isUserLanguageSupportedForAutotranslation(state)) { + return false; + } + + const myChannelMember = getMyChannelMember(state, channelId); + return !myChannelMember?.autotranslation_disabled; } export function isUserLanguageSupportedForAutotranslation(state: GlobalState): boolean { diff --git a/webapp/channels/src/selectors/views/channel_settings.test.ts b/webapp/channels/src/selectors/views/channel_settings.test.ts index 5069cbacb90..ec62816607d 100644 --- a/webapp/channels/src/selectors/views/channel_settings.test.ts +++ b/webapp/channels/src/selectors/views/channel_settings.test.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {DeepPartial} from '@mattermost/types/utilities'; + import {Permissions} from 'mattermost-redux/constants'; import type {GlobalState} from 'types/store'; @@ -12,58 +14,74 @@ describe('Selectors.Views.ChannelSettings', () => { const channelId = 'channel1'; const defaultChannelId = 'default_channel'; const privateChannelId = 'private_channel1'; + const dmChannelId = 'dm_channel1'; + const gmChannelId = 'gm_channel1'; - // Create a more complete mock state - const baseState = { - entities: { - channels: { + function getBaseState(): GlobalState { + const state: DeepPartial = { + entities: { channels: { - [channelId]: { - id: channelId, - team_id: teamId, - name: 'test-channel', - type: 'O', // Constants.OPEN_CHANNEL - }, - [defaultChannelId]: { - id: defaultChannelId, - team_id: teamId, - name: 'town-square', // Constants.DEFAULT_CHANNEL - type: 'O', // Constants.OPEN_CHANNEL - }, - [privateChannelId]: { - id: privateChannelId, - team_id: teamId, - name: 'private-channel', - type: 'P', // Constants.PRIVATE_CHANNEL + channels: { + [channelId]: { + id: channelId, + team_id: teamId, + name: 'test-channel', + type: 'O', // Constants.OPEN_CHANNEL + }, + [defaultChannelId]: { + id: defaultChannelId, + team_id: teamId, + name: 'town-square', // Constants.DEFAULT_CHANNEL + type: 'O', // Constants.OPEN_CHANNEL + }, + [privateChannelId]: { + id: privateChannelId, + team_id: teamId, + name: 'private-channel', + type: 'P', // Constants.PRIVATE_CHANNEL + }, + [dmChannelId]: { + id: dmChannelId, + team_id: teamId, + name: 'user1__user2', + type: 'D', // Constants.DM_CHANNEL + }, + [gmChannelId]: { + id: gmChannelId, + team_id: teamId, + name: 'group-channel', + type: 'G', // Constants.GM_CHANNEL + }, }, }, - }, - roles: { - roles: {}, - }, - general: { - config: {}, - }, - users: { - currentUserId: 'current_user_id', - profiles: { - current_user_id: { - id: 'current_user_id', - roles: 'system_user', + roles: { + roles: {}, + }, + general: { + config: {}, + }, + users: { + currentUserId: 'current_user_id', + profiles: { + current_user_id: { + id: 'current_user_id', + roles: 'system_user', + }, }, }, - }, - teams: { - currentTeamId: teamId, teams: { - [teamId]: { - id: teamId, - name: 'test-team', + currentTeamId: teamId, + teams: { + [teamId]: { + id: teamId, + name: 'test-team', + }, }, }, }, - }, - } as unknown as GlobalState; + }; + return state as GlobalState; + } // Mock the dependencies directly beforeEach(() => { @@ -86,7 +104,7 @@ describe('Selectors.Views.ChannelSettings', () => { }; it('should return false when channel does not exist', () => { - const result = canAccessChannelSettings(baseState, 'nonexistent_channel'); + const result = canAccessChannelSettings(getBaseState(), 'nonexistent_channel'); expect(result).toBe(false); }); @@ -96,7 +114,7 @@ describe('Selectors.Views.ChannelSettings', () => { [Permissions.MANAGE_PUBLIC_CHANNEL_BANNER]: false, [Permissions.DELETE_PUBLIC_CHANNEL]: false, }); - const result = canAccessChannelSettings(baseState, channelId); + const result = canAccessChannelSettings(getBaseState(), channelId); expect(result).toBe(true); }); @@ -106,7 +124,7 @@ describe('Selectors.Views.ChannelSettings', () => { [Permissions.MANAGE_PRIVATE_CHANNEL_BANNER]: false, [Permissions.DELETE_PRIVATE_CHANNEL]: false, }); - const result = canAccessChannelSettings(baseState, privateChannelId); + const result = canAccessChannelSettings(getBaseState(), privateChannelId); expect(result).toBe(true); }); @@ -116,7 +134,7 @@ describe('Selectors.Views.ChannelSettings', () => { [Permissions.MANAGE_PUBLIC_CHANNEL_BANNER]: true, [Permissions.DELETE_PUBLIC_CHANNEL]: false, }); - const result = canAccessChannelSettings(baseState, channelId); + const result = canAccessChannelSettings(getBaseState(), channelId); expect(result).toBe(true); }); @@ -126,7 +144,7 @@ describe('Selectors.Views.ChannelSettings', () => { [Permissions.MANAGE_PUBLIC_CHANNEL_BANNER]: false, [Permissions.DELETE_PUBLIC_CHANNEL]: true, }); - const result = canAccessChannelSettings(baseState, channelId); + const result = canAccessChannelSettings(getBaseState(), channelId); expect(result).toBe(true); }); @@ -147,7 +165,53 @@ describe('Selectors.Views.ChannelSettings', () => { [Permissions.MANAGE_PUBLIC_CHANNEL_BANNER]: false, [Permissions.DELETE_PUBLIC_CHANNEL]: true, // This should be ignored for default channel }); - const result = canAccessChannelSettings(baseState, defaultChannelId); + const result = canAccessChannelSettings(getBaseState(), defaultChannelId); expect(result).toBe(false); }); + + describe('DM and GM channels with RestrictDMAndGMAutotranslation', () => { + it('should return false when autotranslation is not enabled for DM', () => { + const state = getBaseState(); + state.entities.general.config.EnableAutoTranslation = 'false'; + const result = canAccessChannelSettings(state, dmChannelId); + expect(result).toBe(false); + }); + + it('should return false when autotranslation is not enabled for GM', () => { + const state = getBaseState(); + state.entities.general.config.EnableAutoTranslation = 'false'; + const result = canAccessChannelSettings(state, gmChannelId); + expect(result).toBe(false); + }); + + it('should return true for DM channel when RestrictDMAndGMAutotranslation is not enabled', () => { + const state = getBaseState(); + state.entities.general.config.EnableAutoTranslation = 'true'; + const result = canAccessChannelSettings(state, dmChannelId); + expect(result).toBe(true); + }); + + it('should return true for GM channel when RestrictDMAndGMAutotranslation is not enabled', () => { + const state = getBaseState(); + state.entities.general.config.EnableAutoTranslation = 'true'; + const result = canAccessChannelSettings(state, gmChannelId); + expect(result).toBe(true); + }); + + it('should return false for DM channel when RestrictDMAndGMAutotranslation is enabled', () => { + const state = getBaseState(); + state.entities.general.config.EnableAutoTranslation = 'true'; + state.entities.general.config.RestrictDMAndGMAutotranslation = 'true'; + const result = canAccessChannelSettings(state, dmChannelId); + expect(result).toBe(false); + }); + + it('should return false for GM channel when RestrictDMAndGMAutotranslation is enabled', () => { + const state = getBaseState(); + state.entities.general.config.EnableAutoTranslation = 'true'; + state.entities.general.config.RestrictDMAndGMAutotranslation = 'true'; + const result = canAccessChannelSettings(state, gmChannelId); + expect(result).toBe(false); + }); + }); }); diff --git a/webapp/channels/src/selectors/views/channel_settings.ts b/webapp/channels/src/selectors/views/channel_settings.ts index 05b9829696b..ebc02061c29 100644 --- a/webapp/channels/src/selectors/views/channel_settings.ts +++ b/webapp/channels/src/selectors/views/channel_settings.ts @@ -3,6 +3,7 @@ import {Permissions} from 'mattermost-redux/constants'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; import Constants from 'utils/constants'; @@ -19,12 +20,22 @@ export const canAccessChannelSettings = createSelector( (state: GlobalState) => state, (state: GlobalState) => state.entities.channels.channels, (state: GlobalState, channelId: string) => channelId, - (state, channels, channelId) => { + (state: GlobalState) => getConfig(state)?.RestrictDMAndGMAutotranslation === 'true', + (state: GlobalState) => getConfig(state)?.EnableAutoTranslation === 'true', + (state, channels, channelId, isDMAndGMAutotranslationRestricted, isAutoTranslationEnabled) => { const channel = channels[channelId]; if (!channel) { return false; } + const isDM = channel.type === Constants.DM_CHANNEL; + const isGM = channel.type === Constants.GM_CHANNEL; + + // For DM and GM: allow Channel Settings when "Restrict auto-translation on DM and GM" is not enabled + if (isDM || isGM) { + return isAutoTranslationEnabled && !isDMAndGMAutotranslationRestricted; + } + const isPrivate = channel.type === Constants.PRIVATE_CHANNEL; const isDefaultChannel = channel.name === Constants.DEFAULT_CHANNEL; @@ -51,6 +62,15 @@ export const canAccessChannelSettings = createSelector( bannerPermission, ); + // Configuration tab (translation) permissions + const translationPermission = isPrivate ? Permissions.MANAGE_PRIVATE_CHANNEL_AUTO_TRANSLATION : Permissions.MANAGE_PUBLIC_CHANNEL_AUTO_TRANSLATION; + const hasTranslationPermission = haveIChannelPermission( + state, + teamId, + channelId, + translationPermission, + ); + // Archive tab permissions const archivePermission = isPrivate ? Permissions.DELETE_PRIVATE_CHANNEL : Permissions.DELETE_PUBLIC_CHANNEL; @@ -62,6 +82,6 @@ export const canAccessChannelSettings = createSelector( ); // User can access channel settings if they have permission for at least one tab - return hasInfoPermission || hasBannerPermission || hasArchivePermission; + return hasInfoPermission || hasBannerPermission || hasTranslationPermission || hasArchivePermission; }, );