[MM-67235] Add support for autotranslations on GM and DM (#35255)

* [MM-67235] Add support for autotranslations on GM and DM

* Address copilot feedback

* Fix tests

* Fix bug and tests

* Address feedback

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Daniel Espino García 2026-02-16 16:13:08 +01:00 committed by GitHub
parent 8738f8c4b3
commit a8dc8baa90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1195 additions and 229 deletions

View file

@ -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

View file

@ -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(() => {

View file

@ -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,
};
};
}

View file

@ -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) && (

View file

@ -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<GlobalState>): DeepPartial<GlobalState> {
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<GlobalState> {
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(
<WithTestMenuContext>
<ChannelHeaderDirectMenu {...defaultProps}/>
</WithTestMenuContext>,
getBaseState(),
);
expect(screen.getByText('Channel Settings')).toBeInTheDocument();
expect(screen.queryByText('Edit Header')).not.toBeInTheDocument();
});
it('shows Edit Header when RestrictDMAndGMAutotranslation is enabled', () => {
renderWithContext(
<WithTestMenuContext>
<ChannelHeaderDirectMenu {...defaultProps}/>
</WithTestMenuContext>,
getStateWithRestrictedDMAndGM(),
);
expect(screen.getByText('Edit Header')).toBeInTheDocument();
expect(screen.queryByText('Channel Settings')).not.toBeInTheDocument();
});
it('shows Auto-translation menu when isChannelAutotranslated is true', () => {
renderWithContext(
<WithTestMenuContext>
<ChannelHeaderDirectMenu
{...defaultProps}
isChannelAutotranslated={true}
/>
</WithTestMenuContext>,
getBaseState(),
);
expect(screen.getByText(/Auto-translation/i)).toBeInTheDocument();
});
it('does not show Auto-translation menu when isChannelAutotranslated is false', () => {
renderWithContext(
<WithTestMenuContext>
<ChannelHeaderDirectMenu
{...defaultProps}
isChannelAutotranslated={false}
/>
</WithTestMenuContext>,
getBaseState(),
);
expect(screen.queryByText(/Auto-translation/i)).not.toBeInTheDocument();
});
});

View file

@ -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 (
<>
<MenuItemToggleInfo
@ -55,10 +65,21 @@ const ChannelHeaderDirectMenu = ({channel, user, isMuted, isMobile, isFavorite,
/>
</>
)}
<EditConversationHeader
leadingElement={<CogOutlineIcon size='18px'/>}
channel={channel}
/>
{canAccessChannelSettingsForChannel ? (
<MenuItemChannelSettings
channel={channel}
/>
) : (
<EditConversationHeader
leadingElement={<CogOutlineIcon size='18px'/>}
channel={channel}
/>
)}
{isChannelAutotranslated && (
<MenuItemAutotranslation
channel={channel}
/>
)}
<Menu.Separator/>
{!isGuest(user.roles) && isChannelBookmarksEnabled && (
<MenuItemChannelBookmarks

View file

@ -0,0 +1,177 @@
// 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 ChannelHeaderGroupMenu from './channel_header_group_menu';
const GM_CHANNEL_ID = 'gm_channel_id';
const CURRENT_USER_ID = 'user_id';
function getBaseState(): DeepPartial<GlobalState> {
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<GlobalState> {
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(
<WithTestMenuContext>
<ChannelHeaderGroupMenu {...defaultProps}/>
</WithTestMenuContext>,
getBaseState(),
);
expect(screen.getByText('Channel Settings')).toBeInTheDocument();
expect(screen.queryByText('Edit Header')).not.toBeInTheDocument();
});
it('shows Settings submenu when RestrictDMAndGMAutotranslation is enabled', () => {
renderWithContext(
<WithTestMenuContext>
<ChannelHeaderGroupMenu {...defaultProps}/>
</WithTestMenuContext>,
getStateWithRestrictedDMAndGM(),
);
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('shows Auto-translation menu when isChannelAutotranslated is true', () => {
renderWithContext(
<WithTestMenuContext>
<ChannelHeaderGroupMenu
{...defaultProps}
isChannelAutotranslated={true}
/>
</WithTestMenuContext>,
getBaseState(),
);
expect(screen.getByText(/Auto-translation/i)).toBeInTheDocument();
});
it('does not show Auto-translation menu when isChannelAutotranslated is false', () => {
renderWithContext(
<WithTestMenuContext>
<ChannelHeaderGroupMenu
{...defaultProps}
isChannelAutotranslated={false}
/>
</WithTestMenuContext>,
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(
<WithTestMenuContext>
<ChannelHeaderGroupMenu
{...defaultProps}
channel={archivedChannel}
/>
</WithTestMenuContext>,
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(
<WithTestMenuContext>
<ChannelHeaderGroupMenu
{...defaultProps}
channel={archivedChannel}
/>
</WithTestMenuContext>,
archivedChannelState,
);
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
});
});

View file

@ -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 && (
<MenuItemNotification
user={user}
channel={channel}
/>
)}
{(!isArchived && isGuest(user.roles)) && (
<EditConversationHeader
leadingElement={<CogOutlineIcon size='18px'/>}
channel={channel}
/>
)}
{(!isArchived && !isGroupConstrained && !isGuest(user.roles)) && (
<Menu.SubMenu
id={'channelSettings'}
labels={
<FormattedMessage
id='channel_header.settings'
defaultMessage='Settings'
<>
{(
<MenuItemNotification
user={user}
channel={channel}
/>
}
leadingElement={<CogOutlineIcon size={18}/>}
trailingElements={<ChevronRightIcon size={16}/>}
menuId={'channelSettings-menu'}
menuAriaLabel={formatMessage({id: 'channel_header.settings', defaultMessage: 'Settings'})}
>
<EditConversationHeader
channel={channel}
/>
<MenuItemConvertToPrivate
channel={channel}
/>
</Menu.SubMenu>
)}
{canAccessChannelSettingsForChannel ? (
<>
<MenuItemChannelSettings
channel={channel}
/>
{(!isGroupConstrained && !isGuest(user.roles)) && (
<MenuItemConvertToPrivate
channel={channel}
/>
)}
</>
) : (
<>
{(isGuest(user.roles)) && (
<EditConversationHeader
leadingElement={<CogOutlineIcon size='18px'/>}
channel={channel}
/>
)}
{(!isGroupConstrained && !isGuest(user.roles)) && (
<Menu.SubMenu
id={'channelSettings'}
labels={
<FormattedMessage
id='channel_header.settings'
defaultMessage='Settings'
/>
}
leadingElement={<CogOutlineIcon size={18}/>}
trailingElements={<ChevronRightIcon size={16}/>}
menuId={'channelSettings-menu'}
menuAriaLabel={formatMessage({id: 'channel_header.settings', defaultMessage: 'Settings'})}
>
<EditConversationHeader
channel={channel}
/>
<MenuItemConvertToPrivate
channel={channel}
/>
</Menu.SubMenu>
)}
</>
)}
</>
)}
{isChannelAutotranslated && (
<MenuItemAutotranslation
channel={channel}
/>
)}
{!isArchived && !isGuest(user.roles) && isChannelBookmarksEnabled && (
<MenuItemChannelBookmarks

View file

@ -9,7 +9,7 @@ import {TranslateIcon} from '@mattermost/compass-icons/components';
import type {Channel} from '@mattermost/types/channels';
import {setMyChannelAutotranslation} from 'mattermost-redux/actions/channels';
import {isChannelAutotranslated, isUserLanguageSupportedForAutotranslation} from 'mattermost-redux/selectors/entities/channels';
import {isMyChannelAutotranslated, isUserLanguageSupportedForAutotranslation} from 'mattermost-redux/selectors/entities/channels';
import {openModal} from 'actions/views/modals';
@ -27,7 +27,7 @@ interface Props extends Menu.FirstMenuItemProps {
const Autotranslation = ({channel, ...rest}: Props): JSX.Element => {
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(() => {

View file

@ -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('');

View file

@ -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
});

View file

@ -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<boolean> => {
@ -228,13 +245,24 @@ function ChannelSettingsInfoTab({
}
}
// Build updated channel object
const updated: Partial<Channel> = {
display_name: displayName.trim(),
name: channelUrl.trim(),
purpose: channelPurpose.trim(),
header: channelHeader.trim(),
};
const updated: Partial<Channel> = {};
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 (
<div className='ChannelSettingsModal__infoTab'>
@ -348,74 +381,79 @@ function ChannelSettingsInfoTab({
>
{formatMessage({id: 'channel_settings.channel_info_tab.name', defaultMessage: 'Channel Info'})}
</div>
<ChannelNameFormField
value={displayName}
name='channel-settings-name'
placeholder={formatMessage({
id: 'channel_settings_modal.name.placeholder',
defaultMessage: 'Enter a name for your channel',
})}
onDisplayNameChange={(name) => {
setDisplayName(name);
}}
onURLChange={handleURLChange}
onErrorStateChange={handleChannelNameError}
urlError={internalUrlError}
currentUrl={channelUrl}
readOnly={!canManageChannelProperties}
isEditingExistingChannel={true}
/>
{!isDMorGroupChannel && (
<ChannelNameFormField
value={displayName}
name='channel-settings-name'
placeholder={formatMessage({
id: 'channel_settings_modal.name.placeholder',
defaultMessage: 'Enter a name for your channel',
})}
onDisplayNameChange={(name) => {
setDisplayName(name);
}}
onURLChange={handleURLChange}
onErrorStateChange={handleChannelNameError}
urlError={internalUrlError}
currentUrl={channelUrl}
readOnly={!canManageChannelProperties}
isEditingExistingChannel={true}
/>
)}
{/* Channel Type Section*/}
<PublicPrivateSelector
className='ChannelSettingsModal__typeSelector'
selected={channelType}
publicButtonProps={{
title: formatMessage({id: 'channel_modal.type.public.title', defaultMessage: 'Public Channel'}),
description: formatMessage({id: 'channel_modal.type.public.description', defaultMessage: 'Anyone can join'}),
{!isDMorGroupChannel && (
<PublicPrivateSelector
className='ChannelSettingsModal__typeSelector'
selected={channelType}
publicButtonProps={{
title: formatMessage({id: 'channel_modal.type.public.title', defaultMessage: 'Public Channel'}),
description: formatMessage({id: 'channel_modal.type.public.description', defaultMessage: 'Anyone can join'}),
// 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}
/>
// 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*/}
<AdvancedTextbox
id='channel_settings_purpose_textbox'
value={channelPurpose}
channelId={channel.id}
onChange={handlePurposeChange}
createMessage={formatMessage({
id: 'channel_settings_modal.purpose.placeholder',
defaultMessage: 'Enter a purpose for this channel (optional)',
})}
maxLength={Constants.MAX_CHANNELPURPOSE_LENGTH}
preview={shouldShowPreviewPurpose}
togglePreview={togglePurposePreview}
useChannelMentions={false}
onKeyPress={() => {}}
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 && (
<AdvancedTextbox
id='channel_settings_purpose_textbox'
value={channelPurpose}
channelId={channel.id}
onChange={handlePurposeChange}
createMessage={formatMessage({
id: 'channel_settings_modal.purpose.placeholder',
defaultMessage: 'Enter a purpose for this channel (optional)',
})}
maxLength={Constants.MAX_CHANNELPURPOSE_LENGTH}
preview={shouldShowPreviewPurpose}
togglePreview={togglePurposePreview}
useChannelMentions={false}
onKeyPress={() => {}}
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*/}
<AdvancedTextbox

View file

@ -11,7 +11,7 @@ import type {Post} from '@mattermost/types/posts';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {General, Preferences as ReduxPreferences} from 'mattermost-redux/constants';
import {getDirectTeammate, getMyChannelAutotranslation} from 'mattermost-redux/selectors/entities/channels';
import {getDirectTeammate, isMyChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n';
import {getPost, makeGetCommentCountForPost, makeIsPostCommentMention, isPostAcknowledgementsEnabled, isPostPriorityEnabled, isPostFlagged} from 'mattermost-redux/selectors/entities/posts';
@ -85,7 +85,7 @@ function isConsecutivePost(state: GlobalState, ownProps: OwnProps, locale: strin
consecutivePost = areConsecutivePostsBySameUser(post, previousPost);
}
if (previousPost && post && consecutivePost && getMyChannelAutotranslation(state, post.channel_id)) {
if (previousPost && post && consecutivePost && isMyChannelAutotranslated(state, post.channel_id)) {
const translation = getPostTranslation(post, locale);
const previousTranslation = getPostTranslation(previousPost, locale);
if (translation?.state !== previousTranslation?.state) {

View file

@ -4,7 +4,7 @@
import {connect} from 'react-redux';
import type {ConnectedProps} from 'react-redux';
import {getChannel, getCurrentChannel, isChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import {getChannel, getCurrentChannel, isMyChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getSelectedPostId} from 'selectors/rhs';
@ -22,7 +22,7 @@ function mapStateToProps(state: GlobalState) {
return {
channelDisplayName,
originalPost,
isChannelAutotranslated: isChannelAutotranslated(state, originalPost.channel_id),
isChannelAutotranslated: isMyChannelAutotranslated(state, originalPost.channel_id),
};
}

View file

@ -7,7 +7,7 @@ import type {Dispatch} from 'redux';
import {markChannelAsRead} from 'mattermost-redux/actions/channels';
import {RequestStatus} from 'mattermost-redux/constants';
import {isChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import {isMyChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import {getRecentPostsChunkInChannel, makeGetPostsChunkAroundPost, getUnreadPostsChunk, getPost, isPostsChunkIncludingUnreadsPosts, getLimitedViews} from 'mattermost-redux/selectors/entities/posts';
import {memoizeResult} from 'mattermost-redux/utils/helpers';
import {makePreparePostIdsForPostList} from 'mattermost-redux/utils/post_list';
@ -102,7 +102,7 @@ function makeMapStateToProps() {
shouldStartFromBottomWhenUnread,
isMobileView: getIsMobileView(state),
hasInaccessiblePosts,
isChannelAutotranslated: isChannelAutotranslated(state, channelId),
isChannelAutotranslated: isMyChannelAutotranslated(state, channelId),
};
};
}

View file

@ -8,7 +8,7 @@ import type {Dispatch} from 'redux';
import type {PostPreviewMetadata} from '@mattermost/types/posts';
import {General} from 'mattermost-redux/constants';
import {isChannelAutotranslated, makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {isMyChannelAutotranslated, makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getPost, isPostPriorityEnabled} from 'mattermost-redux/selectors/entities/posts';
import {get} from 'mattermost-redux/selectors/entities/preferences';
@ -63,7 +63,7 @@ function makeMapStateToProps() {
isEmbedVisible: embedVisible,
compactDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT,
isPostPriorityEnabled: isPostPriorityEnabled(state),
isChannelAutotranslated: isChannelAutotranslated(state, previewPost?.channel_id),
isChannelAutotranslated: isMyChannelAutotranslated(state, previewPost?.channel_id),
};
};
}

View file

@ -6,7 +6,7 @@ import {useSelector} from 'react-redux';
import type {Post} from '@mattermost/types/posts';
import {isChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import {isMyChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import PostComponent from 'components/post';
@ -25,7 +25,7 @@ type Props = {
}
export default function PostSearchResultsItem(props: Props) {
const autotranslated = useSelector((state: GlobalState) => isChannelAutotranslated(state, props.post.channel_id));
const autotranslated = useSelector((state: GlobalState) => isMyChannelAutotranslated(state, props.post.channel_id));
return (
<div
className='search-item__container'

View file

@ -3,7 +3,7 @@
import {connect} from 'react-redux';
import {isChannelAutotranslated, makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {isMyChannelAutotranslated, makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getPost, isPostPriorityEnabled, makeGetPostsForThread} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams';
import {getThread} from 'mattermost-redux/selectors/entities/threads';
@ -36,7 +36,7 @@ function makeMapStateToProps() {
postsInThread: getPostsForThread(state, post.id),
thread: getThread(state, threadId),
isPostPriorityEnabled: isPostPriorityEnabled(state),
isChannelAutotranslated: isChannelAutotranslated(state, post.channel_id),
isChannelAutotranslated: isMyChannelAutotranslated(state, post.channel_id),
};
};
}

View file

@ -5,7 +5,7 @@ import {connect} from 'react-redux';
import type {Post} from '@mattermost/types/posts';
import {getDirectTeammate, isChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import {getDirectTeammate, isMyChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
@ -55,7 +55,7 @@ function makeMapStateToProps() {
lastPost,
replyListIds,
newMessagesSeparatorActions,
isChannelAutotranslated: isChannelAutotranslated(state, channelId),
isChannelAutotranslated: isMyChannelAutotranslated(state, channelId),
};
};
}

View file

@ -3364,3 +3364,484 @@ describe('hasAutotranslationBecomeEnabled', () => {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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<GlobalState> = {
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);
});
});

View file

@ -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 {

View file

@ -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<GlobalState> = {
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);
});
});
});

View file

@ -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;
},
);