Autotranslation Frontend integration (#34717)

* AutoTranslate config settings

* comment out Agents provider

* Add auto translate timeout config validation

* i18n messages for autotranslation config validation

* fix test

* validate url for libreTranslate

* Feedback review

* Admin Console UI for Auto-Translation

* fix admin console conditional section display

* i18n

* removed unintentional change

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* update admin.general.localization.autoTranslateProviderDescription newline

* fix lint

* Fix types

* UX feedback review

* fix typo in i18n

* Fix AutoTranslation feature flag

* feedback review

* Fix test default values

* feedback review

* re-add isHidden property to feature discovery

* Database Migrations, Indexes and Methods for Auto-Translation

* i18n

* fix retrylayer and storetest

* Fix search query

* fix lint

* remove the request.CTX and modify Translation model

* fix lint and external url

* Add settings to playwright

* Add empty as a valid value for the Provider

* Update jsonb queries

* Fix queries and add model methods

* fix go lint

* go lint fix 2

* fix db migrations

* feedback review + store cache

* increase migration number

* cleanup autotranslation store cache

* use NULL as objectType for posts

* fix bad merge

* fix tests

* add missing i18n

* Active WebSocket Connection User Tracking

* copilot feedback and fix styles

* remove duplicate calls

* remove early return to mitigate timing attacks

* Switch prop bags column to boolean

* fix lint

* fix tests

* Remove database search

* use Builder methods

* review feedback

* AutoTranslation interface with Core Translation Logic

* update timeouts to use short/medium/long translations

* external exports

* add configured languages to autotranslations

* added post prop for detected language

* fix bugs for storing translation and call translation service

* clean up interface

* add translations to GetPost repsonses and in the create post response

* use metadata for translation information and add new column for state of a translation

* change websocket event name

* change metadata to a map

* single in memory queue in the cluster leader

* remove unused definition

* Revert "remove unused definition"

This reverts commit e3e50cef30.

* remove webhub changes

* remove last webhub bit

* tidy up interface

* Frontend integration

* tidy up

* fix api response for translations

* Add Agents provider for auto translations (#34706)

* Add LLM backed autotranslation support

* Remove AU changes

* Remove orphaned tests for deleted GetActiveUserIDsForChannel

The GetActiveUserIDsForChannel function was removed from PlatformService
as part of the autotranslations refactoring, but its tests were left behind
causing linter/vet errors. This removes the orphaned test code:
- BenchmarkGetActiveUserIDsForChannel
- TestGetActiveUserIDsForChannel
- waitForActiveConnections helper

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add missing i18n translations and fix linter errors

- Add 17 missing translation strings for autotranslation feature
- Fix shadow variable declarations in post.go and autotranslation.go
- Remove unused autoQueueMaxAge constant
- Remove unused setupWithFastIteration test function
- Use slices.Contains instead of manual loop
- Use maps.Copy instead of manual loop
- Remove empty if branch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix tests

* Fixes for PR review

* add files

* Update webapp/channels/src/components/admin_console/localization/localization.scss

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* fixes

* Fixes

* Didn't save

* Add a translation

* Fix translations

* Fix shadow err

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>

* tidy up code for review

* add support for editing posts

* i18n-extract

* i18n

* Rename show translations and add util to get message

* Fix get posts, migrations, websockets and configuration styles

* Fix CI

* i18n-extract

* Fix webapp tests

* Address UX feedback

* i18n-extract

* Fix lint

* updated shimmer animation, fixed issue with the width on compact icon buttons

* fix migrations

* fix markdown masking for bold, italics and strikethrough

* Address feedback

* Add missing changes

* Fix and add tests

* Fix circular dependencies

* lint

* lint

* lint and i18n

* Fix lint

* Fix i18n

* Minor changes

* Add check for whether the channel is translated or not for this user

* Fix lint and add missing change

* Fix lint

* Fix test

* Remove uneeded console log

* Fix duplicated code

* Fix small screen show translation modal

* Remove interactions on show translation modal

* Disable auto translation when the language is not supported

* Fix typo

* Fix copy text

* Fix updating autotranslation for normal users

* Fix autotranslate button showing when it shouldn't

* Fix styles

* Fix test

* Fix frontend member related changes

* Revert post improvements and remove duplicated code from bad merge

* Address feedback

* Fix test and i18n

* Fix e2e tests

* Revert lingering change from post improvements

* Fix lint

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: BenCookie95 <benkcooke@gmail.com>
Co-authored-by: Nick Misasi <nick.misasi@mattermost.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Daniel Espino García
2026-02-10 17:21:01 +01:00
committed by GitHub
co-authored by Claude Matthew Birtch Elias Nahum Copilot BenCookie95 Nick Misasi Mattermost Build
parent 24957f5e22
commit 1c7246da68
94 changed files with 1896 additions and 305 deletions
@@ -69,16 +69,16 @@ test('Post actions tab support', async ({pw, axe}) => {
await channelsPage.postDotMenu.followMessageMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.markAsUnreadMenuItem).toBeFocused();
// * Should move focus to Remind after arrow down
await channelsPage.postDotMenu.markAsUnreadMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.remindMenuItem).toBeFocused();
// * Should move focus to Save after arrow down
await channelsPage.postDotMenu.remindMenuItem.press('ArrowDown');
await channelsPage.postDotMenu.markAsUnreadMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.saveMenuItem).toBeFocused();
// * Should move focus to Pin to Channel after arrow down
// * Should move focus to Remind after arrow down
await channelsPage.postDotMenu.saveMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.remindMenuItem).toBeFocused();
// * Should move focus to Pin to Channel after arrow down
await channelsPage.postDotMenu.remindMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.pinToChannelMenuItem).toBeFocused();
if (config.FeatureFlags['MoveThreadsEnabled'] && license.IsLicensed === 'true') {
@@ -86,25 +86,25 @@ test('Post actions tab support', async ({pw, axe}) => {
await channelsPage.postDotMenu.pinToChannelMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.moveThreadMenuItem).toBeFocused();
// * Should move focus to Copy Link after arrow down
// * Should move focus to Copy Text after arrow down
await channelsPage.postDotMenu.moveThreadMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.copyLinkMenuItem).toBeFocused();
await expect(channelsPage.postDotMenu.copyTextMenuItem).toBeFocused();
} else {
// * Should move focus to Copy Link after arrow down
// * Should move focus to Copy Text after arrow down
await channelsPage.postDotMenu.pinToChannelMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.copyLinkMenuItem).toBeFocused();
await expect(channelsPage.postDotMenu.copyTextMenuItem).toBeFocused();
}
// * Should move focus to Copy Link after arrow down
await channelsPage.postDotMenu.copyTextMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.copyLinkMenuItem).toBeFocused();
// * Should move focus to Edit after arrow down
await channelsPage.postDotMenu.copyLinkMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.editMenuItem).toBeFocused();
// * Should move focus to Copy Text after arrow down
await channelsPage.postDotMenu.editMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.copyTextMenuItem).toBeFocused();
// * Should move focus to Delete after arrow down
await channelsPage.postDotMenu.copyTextMenuItem.press('ArrowDown');
await channelsPage.postDotMenu.editMenuItem.press('ArrowDown');
await expect(channelsPage.postDotMenu.deleteMenuItem).toBeFocused();
// * Then, should move focus back to Reply after arrow down
+5
View File
@@ -153,6 +153,9 @@ func (a *App) populatePostListTranslations(rctx request.CTX, list *model.PostLis
}
post.Metadata.Translations[t.Lang] = t.ToPostTranslation()
if t.UpdateAt > post.UpdateAt {
post.UpdateAt = t.UpdateAt
}
}
}
}
@@ -842,6 +845,8 @@ func (a *App) getLinkMetadataForPermalink(rctx request.CTX, requestURL string) (
permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPostWithMetadata, referencedTeam, referencedChannel)}
}
a.populatePostListTranslations(rctx, &model.PostList{Posts: map[string]*model.Post{permalink.PreviewPost.Post.Id: permalink.PreviewPost.Post}})
return permalink, nil
}
+7
View File
@@ -2927,11 +2927,17 @@ func (a *App) GetThreadsForUser(rctx request.CTX, userID, teamID string, options
result.Total = result.TotalUnreadThreads
}
list := &model.PostList{
Posts: make(map[string]*model.Post, len(result.Threads)),
}
for _, thread := range result.Threads {
a.sanitizeProfiles(thread.Participants, false)
thread.Post.SanitizeProps()
list.AddPost(thread.Post)
}
a.populatePostListTranslations(rctx, list)
return &result, nil
}
@@ -2963,6 +2969,7 @@ func (a *App) GetThreadForUser(rctx request.CTX, threadMembership *model.ThreadM
a.sanitizeProfiles(thread.Participants, false)
thread.Post.SanitizeProps()
a.populatePostListTranslations(rctx, &model.PostList{Posts: map[string]*model.Post{thread.Post.Id: thread.Post}})
return thread, nil
}
+5
View File
@@ -249,6 +249,11 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["ContentFlaggingEnabled"] = strconv.FormatBool(c.FeatureFlags.ContentFlagging && *c.ContentFlaggingSettings.EnableContentFlagging)
props["EnableAutoTranslation"] = strconv.FormatBool(c.FeatureFlags.AutoTranslation && *c.AutoTranslationSettings.Enable)
if c.FeatureFlags.AutoTranslation {
props["AutoTranslationLanguages"] = strings.Join(*c.AutoTranslationSettings.TargetLanguages, ",")
} else {
props["AutoTranslationLanguages"] = ""
}
props["RestrictDMAndGMAutotranslation"] = strconv.FormatBool(*c.AutoTranslationSettings.RestrictDMAndGM)
}
}
+33 -7
View File
@@ -4,7 +4,7 @@
import {batchActions} from 'redux-batched-actions';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {Post} from '@mattermost/types/posts';
import type {Post, PostType} from '@mattermost/types/posts';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
@@ -172,29 +172,42 @@ export function showMobileSubMenuModal(elements: any[]) { // TODO Use more speci
export function sendEphemeralPost(message: string, channelId?: string, parentId?: string, userId?: string): ActionFuncAsync<boolean> {
return (doDispatch, doGetState) => {
const timestamp = Utils.getTimestamp();
const post = {
const post: Post = {
id: Utils.generateId(),
user_id: userId || '0',
channel_id: channelId || getCurrentChannelId(doGetState()),
message,
type: PostTypes.EPHEMERAL,
type: PostTypes.EPHEMERAL as PostType,
create_at: timestamp,
update_at: timestamp,
root_id: parentId || '',
props: {},
} as Post;
delete_at: 0,
edit_at: 0,
is_pinned: false,
hashtags: '',
metadata: {
embeds: [],
emojis: [],
files: [],
images: {},
},
original_id: '',
pending_post_id: '',
reply_count: 0,
};
return doDispatch(handleNewPost(post));
};
}
export function sendAddToChannelEphemeralPost(user: UserProfile, addedUsername: string, addedUserId: string, channelId: string, postRootId = '', timestamp: number) {
const post = {
const post: Post = {
id: Utils.generateId(),
user_id: user.id,
channel_id: channelId || getCurrentChannelId(getState()),
message: '',
type: PostTypes.EPHEMERAL_ADD_TO_CHANNEL,
type: PostTypes.EPHEMERAL_ADD_TO_CHANNEL as PostType,
create_at: timestamp,
update_at: timestamp,
root_id: postRootId,
@@ -203,7 +216,20 @@ export function sendAddToChannelEphemeralPost(user: UserProfile, addedUsername:
addedUsername,
addedUserId,
},
} as unknown as Post;
edit_at: 0,
delete_at: 0,
is_pinned: false,
original_id: '',
pending_post_id: '',
reply_count: 0,
metadata: {
embeds: [],
emojis: [],
files: [],
images: {},
},
hashtags: '',
};
dispatch(handleNewPost(post));
}
@@ -713,6 +713,9 @@ describe('reconnect', () => {
describe('handleChannelUpdatedEvent', () => {
const initialState = {
entities: {
general: {
config: {},
},
channels: {
currentChannelId: 'channel',
channels: {
@@ -720,6 +723,7 @@ describe('handleChannelUpdatedEvent', () => {
id: 'channel',
},
},
myMembers: {},
},
teams: {
currentTeamId: 'team',
@@ -65,6 +65,8 @@ import {
postDeleted,
receivedNewPost,
receivedPost,
resetReloadPostsInChannel,
resetReloadPostsInTranslatedChannels,
} from 'mattermost-redux/actions/posts';
import {getRecap} from 'mattermost-redux/actions/recaps';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
@@ -97,6 +99,7 @@ import {
getCurrentChannel,
getCurrentChannelId,
getRedirectChannelNameForTeam,
hasAutotranslationBecomeEnabled,
} from 'mattermost-redux/selectors/entities/channels';
import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
@@ -473,7 +476,7 @@ export function handleEvent(msg: WebSocketMessage) {
break;
case WebSocketEvents.ChannelMemberUpdated:
handleChannelMemberUpdatedEvent(msg);
dispatch(handleChannelMemberUpdatedEvent(msg));
break;
case WebSocketEvents.ChannelBookmarkCreated:
@@ -673,6 +676,9 @@ export function handleEvent(msg: WebSocketMessage) {
case WebSocketEvents.ContentFlaggingReportValueUpdated:
dispatch(handleContentFlaggingReportValueChanged(msg));
break;
case WebSocketEvents.PostTranslationUpdated:
dispatch(handlePostTranslationUpdated(msg));
break;
case WebSocketEvents.RecapUpdated:
dispatch(handleRecapUpdated(msg));
break;
@@ -724,6 +730,10 @@ export function handleChannelUpdatedEvent(msg: WebSocketMessages.ChannelUpdated)
if (existingChannel.type === General.GM_CHANNEL && channel.type === General.PRIVATE_CHANNEL) {
actions.push({type: ChannelTypes.GM_CONVERTED_TO_CHANNEL, data: channel});
}
if (hasAutotranslationBecomeEnabled(state, channel)) {
doDispatch(resetReloadPostsInChannel(channel.id));
}
}
doDispatch(batchActions(actions));
@@ -736,11 +746,21 @@ export function handleChannelUpdatedEvent(msg: WebSocketMessages.ChannelUpdated)
};
}
function handleChannelMemberUpdatedEvent(msg: WebSocketMessages.ChannelMemberUpdated) {
const channelMember = JSON.parse(msg.data.channelMember) as ChannelMembership;
const roles = channelMember.roles.split(' ');
dispatch(loadRolesIfNeeded(roles));
dispatch({type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, data: channelMember});
function handleChannelMemberUpdatedEvent(msg: WebSocketMessages.ChannelMemberUpdated): ThunkActionFunc<void> {
return (doDispatch, doGetState) => {
const channelMember = JSON.parse(msg.data.channelMember) as ChannelMembership;
const roles = channelMember.roles.split(' ');
doDispatch(loadRolesIfNeeded(roles));
const state = doGetState();
const becameEnabled = hasAutotranslationBecomeEnabled(state, channelMember);
doDispatch({type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, data: channelMember});
if (becameEnabled) {
doDispatch(resetReloadPostsInChannel(channelMember.channel_id));
}
};
}
function debouncePostEvent(wait: number) {
@@ -1248,6 +1268,10 @@ export async function handleUserUpdatedEvent(msg: WebSocketMessages.UserUpdated)
});
dispatch(loadRolesIfNeeded(user.roles.split(' ')));
}
const autotranslationIsEnabled = getConfig(state)?.EnableAutoTranslation === 'true';
if (autotranslationIsEnabled && user.locale !== currentUser.locale) {
dispatch(resetReloadPostsInTranslatedChannels());
}
} else {
dispatch({
type: UserTypes.RECEIVED_PROFILE,
@@ -1434,7 +1458,19 @@ function handleUserRoleUpdated(msg: WebSocketMessages.UserRoleUpdated) {
}
function handleConfigChanged(msg: WebSocketMessages.ConfigChanged) {
store.dispatch({type: GeneralTypes.CLIENT_CONFIG_RECEIVED, data: msg.data.config});
const state = getState();
const currentConfig = getConfig(state);
const newConfig = msg.data.config;
// Check if EnableAutoTranslation changed from enabled to disabled
const enableAutoTranslationWasEnabled = currentConfig?.EnableAutoTranslation === 'true';
const enableAutoTranslationIsEnabled = newConfig?.EnableAutoTranslation === 'true';
if (!enableAutoTranslationWasEnabled && enableAutoTranslationIsEnabled) {
dispatch(resetReloadPostsInTranslatedChannels());
}
store.dispatch({type: GeneralTypes.CLIENT_CONFIG_RECEIVED, data: newConfig});
}
function handleLicenseChanged(msg: WebSocketMessages.LicenseChanged) {
@@ -1995,6 +2031,13 @@ export function handleContentFlaggingReportValueChanged(msg: WebSocketMessages.C
};
}
export function handlePostTranslationUpdated(msg: WebSocketMessages.PostTranslationUpdated) {
return {
type: PostTypes.POST_TRANSLATION_UPDATED,
data: msg.data,
};
}
export function handleRecapUpdated(msg: WebSocketMessages.RecapUpdated): ThunkActionFunc<void> {
const recapId = msg.data.recap_id;
@@ -55,6 +55,7 @@ describe('components/ChannelHeader', () => {
intl: {
formatMessage: jest.fn(({id, defaultMessage}) => defaultMessage || id),
} as MockIntl,
isChannelAutotranslated: false,
};
const populatedProps = {
@@ -10,6 +10,7 @@ import type {WrappedComponentProps} from 'react-intl';
import CustomStatusEmoji from 'components/custom_status/custom_status_emoji';
import CustomStatusText from 'components/custom_status/custom_status_text';
import Timestamp from 'components/timestamp';
import Tag from 'components/widgets/tag/tag';
import WithTooltip from 'components/with_tooltip';
import CallButton from 'plugins/call_button';
@@ -162,6 +163,25 @@ class ChannelHeader extends React.PureComponent<Props> {
);
}
let autotranslationMessage: ReactNode = '';
if (this.props.isChannelAutotranslated) {
autotranslationMessage = (
<WithTooltip
title={this.props.intl.formatMessage({id: 'channel_header.autotranslationMessage.tooltip.title', defaultMessage: 'Auto-translation is enabled'})}
hint={this.props.intl.formatMessage({id: 'channel_header.autotranslationMessage.tooltip.hint', defaultMessage: 'This channel is being automatically translated to your language'})}
>
<div className='autotranslation-header'>
<Tag
text={this.props.intl.formatMessage({id: 'channel_header.autotranslationMessage', defaultMessage: 'Auto-translated'})}
icon={'translate'}
size='xs'
variant='default'
/>
</div>
</WithTooltip>
);
}
if (isEmptyObject(channel) ||
isEmptyObject(channelMember) ||
isEmptyObject(currentUser) ||
@@ -385,6 +405,7 @@ class ChannelHeader extends React.PureComponent<Props> {
>
{dmHeaderTextStatus}
{hasGuestsText}
{autotranslationMessage}
<ChannelHeaderText
teamId={teamId}
channel={channel}
@@ -17,6 +17,7 @@ import {
getMyCurrentChannelMembership,
isCurrentChannelMuted,
getCurrentChannelStats,
getMyChannelAutotranslation,
} from 'mattermost-redux/selectors/entities/channels';
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {getRemoteNamesForChannel} from 'mattermost-redux/selectors/entities/shared_channels';
@@ -106,6 +107,7 @@ function makeMapStateToProps() {
timestampUnits,
hideGuestTags: config.HideGuestTags === 'true',
sharedChannelsPluginsEnabled,
isChannelAutotranslated: channel ? getMyChannelAutotranslation(state, channel.id) : false,
};
};
}
@@ -11,6 +11,7 @@ import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down';
import type {UserProfile} from '@mattermost/types/users';
import {
getChannelAutotranslation,
getCurrentChannel,
isCurrentChannelDefault,
isCurrentChannelFavorite,
@@ -28,6 +29,8 @@ import * as Menu from 'components/menu';
import {Constants} from 'utils/constants';
import type {GlobalState} from 'types/store';
import ChannelDirectMenu from './channel_header_menu_items/channel_header_direct_menu';
import ChannelGroupMenu from './channel_header_menu_items/channel_header_group_menu';
import ChannelHeaderMobileMenu from './channel_header_menu_items/channel_header_mobile_menu';
@@ -57,6 +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 isReadonly = false;
@@ -178,6 +182,7 @@ export default function ChannelHeaderMenu({dmUser, gmMembers, isMobile, archived
isReadonly={isReadonly}
isLicensedForLDAPGroups={isLicensedForLDAPGroups}
isChannelBookmarksEnabled={isChannelBookmarksEnabled}
isChannelAutotranslated={isChannelAutotranslated}
/>
)}
@@ -18,6 +18,7 @@ import ChannelPermissionGate from 'components/permissions_gates/channel_permissi
import {Constants} from 'utils/constants';
import MenuItemArchiveChannel from '../menu_items/archive_channel';
import MenuItemAutotranslation from '../menu_items/autotranslation';
import MenuItemChannelBookmarks from '../menu_items/channel_bookmarks_submenu';
import MenuItemChannelSettings from '../menu_items/channel_settings_menu';
import MenuItemCloseChannel from '../menu_items/close_channel';
@@ -43,9 +44,10 @@ interface Props extends Menu.FirstMenuItemProps {
isLicensedForLDAPGroups: boolean;
pluginItems: ReactNode[];
isChannelBookmarksEnabled: boolean;
isChannelAutotranslated: boolean;
}
const ChannelHeaderPublicMenu = ({channel, user, isMuted, isDefault, isMobile, isFavorite, isLicensedForLDAPGroups, pluginItems, isChannelBookmarksEnabled, ...rest}: Props) => {
const ChannelHeaderPublicMenu = ({channel, user, isMuted, isDefault, isMobile, isFavorite, isLicensedForLDAPGroups, pluginItems, isChannelBookmarksEnabled, isChannelAutotranslated, ...rest}: Props) => {
const isGroupConstrained = channel?.group_constrained === true;
const isArchived = channel.delete_at !== 0;
const isPrivate = channel?.type === Constants.PRIVATE_CHANNEL;
@@ -81,6 +83,11 @@ const ChannelHeaderPublicMenu = ({channel, user, isMuted, isDefault, isMobile, i
)}
</>
)}
{isChannelAutotranslated && (
<MenuItemAutotranslation
channel={channel}
/>
)}
<Menu.Separator/>
{isMobile && (
<>
@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
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 {openModal} from 'actions/views/modals';
import DisableAutotranslationModal from 'components/disable_autotranslation_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
import type {GlobalState} from 'types/store';
interface Props extends Menu.FirstMenuItemProps {
channel: Channel;
}
const Autotranslation = ({channel, ...rest}: Props): JSX.Element => {
const dispatch = useDispatch();
const isAutotranslated = useSelector((state: GlobalState) => isChannelAutotranslated(state, channel.id));
const isLanguageSupported = useSelector(isUserLanguageSupportedForAutotranslation);
const handleAutotranslationToggle = useCallback(() => {
if (!isLanguageSupported) {
return;
}
if (isAutotranslated) {
// Show confirmation modal when disabling
dispatch(
openModal({
modalId: ModalIdentifiers.DISABLE_AUTOTRANSLATION_CONFIRM,
dialogType: DisableAutotranslationModal,
dialogProps: {
channel,
},
}),
);
} else {
// Enable directly without confirmation
dispatch(setMyChannelAutotranslation(channel.id, true));
}
}, [channel, isAutotranslated, isLanguageSupported, dispatch]);
const icon = useMemo(() => <TranslateIcon size='18px'/>, []);
const labels = useMemo(() => {
if (!isLanguageSupported) {
return (
<>
<FormattedMessage
id='channel_header.autotranslation.language_not_supported.title'
defaultMessage='Auto-translation'
/>
<FormattedMessage
id='channel_header.autotranslation.language_not_supported.subtitle'
defaultMessage='Your language is not supported'
/>
</>
);
}
if (isAutotranslated) {
return (
<FormattedMessage
id='channel_header.autotranslation.disable'
defaultMessage='Disable autotranslation'
/>
);
}
return (
<FormattedMessage
id='channel_header.autotranslation.enable'
defaultMessage='Enable autotranslation'
/>
);
}, [isAutotranslated, isLanguageSupported]);
return (
<Menu.Item
leadingElement={icon}
id='channelNotificationPreferences'
onClick={handleAutotranslationToggle}
labels={labels}
disabled={!isLanguageSupported}
{...rest}
/>
);
};
export default React.memo(Autotranslation);
@@ -8,10 +8,10 @@
flex-direction: column;
gap: 16px;
.channel_banner_header {
.channel_banner_header, .channel_translation_header {
display: flex;
.channel_banner_header__text {
.channel_banner_header__text, .channel_translation_header__text {
display: flex;
flex: 1;
flex-direction: column;
@@ -36,7 +36,6 @@
display: flex;
width: 100%;
height: 550px;
flex-direction: column;
gap: 16px;
}
@@ -61,6 +61,7 @@ const mockChannelWithBanner = TestHelper.getChannelMock({
const baseProps = {
channel: mockChannel,
setAreThereUnsavedChanges: jest.fn(),
canManageBanner: true,
};
describe('ChannelSettingsConfigurationTab', () => {
@@ -345,6 +346,7 @@ describe('ChannelSettingsConfigurationTab', () => {
<ChannelSettingsConfigurationTab
channel={channelWithValidColor}
setAreThereUnsavedChanges={jest.fn()}
canManageBanner={true}
/>,
);
@@ -3,12 +3,13 @@
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {useDispatch, useSelector} from 'react-redux';
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 ColorInput from 'components/color_input';
import type {TextboxElement} from 'components/textbox';
@@ -18,6 +19,7 @@ import type {SaveChangesPanelState} from 'components/widgets/modals/components/s
import SaveChangesPanel from 'components/widgets/modals/components/save_changes_panel';
import './channel_settings_configuration_tab.scss';
import type {GlobalState} from 'types/store';
const CHANNEL_BANNER_MAX_CHARACTER_LIMIT = 1024;
const CHANNEL_BANNER_MIN_CHARACTER_LIMIT = 0;
@@ -32,9 +34,23 @@ type Props = {
channel: Channel;
setAreThereUnsavedChanges?: (unsaved: boolean) => void;
showTabSwitchError?: boolean;
canManageChannelTranslation?: boolean;
canManageBanner?: boolean;
}
function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, showTabSwitchError}: Props) {
function bannerHasChanges(originalBannerInfo: Channel['banner_info'], updatedBannerInfo: Channel['banner_info']): boolean {
return (originalBannerInfo?.text?.trim() || '') !== (updatedBannerInfo?.text?.trim() || '') ||
(originalBannerInfo?.background_color?.trim() || '') !== (updatedBannerInfo?.background_color?.trim() || '') ||
originalBannerInfo?.enabled !== updatedBannerInfo?.enabled;
}
function ChannelSettingsConfigurationTab({
channel,
setAreThereUnsavedChanges,
showTabSwitchError,
canManageChannelTranslation,
canManageBanner,
}: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
@@ -44,8 +60,14 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
const bannerColorSettingTitle = formatMessage({id: 'channel_banner.banner_color.label', defaultMessage: 'Banner color'});
const bannerTextPlaceholder = formatMessage({id: 'channel_banner.banner_text.placeholder', defaultMessage: 'Channel banner text'});
const autoTranslationHeading = formatMessage({id: 'channel_translation.label.name', defaultMessage: 'Auto-translation'});
const autoTranslationSubHeading = formatMessage({id: 'channel_translation.label.subtext', defaultMessage: 'When enabled, messages in this channel will be translated to members\' own languages. Members can opt-out of this from the channel menu to view the original message instead.'});
const initialBannerInfo = channel.banner_info || DEFAULT_CHANNEL_BANNER;
const initialIsChannelAutotranslated = useSelector((state: GlobalState) => getChannelAutotranslation(state, channel.id));
const [isChannelAutotranslated, setIsChannelAutotranslated] = useState(initialIsChannelAutotranslated);
const [formError, setFormError] = useState('');
const [showBannerTextPreview, setShowBannerTextPreview] = useState(false);
const [updatedChannelBanner, setUpdatedChannelBanner] = useState(initialBannerInfo);
@@ -112,16 +134,16 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
const toggleTextPreview = useCallback(() => setShowBannerTextPreview((show) => !show), []);
const hasUnsavedChanges = useCallback(() => {
return (updatedChannelBanner.text?.trim() || '') !== (initialBannerInfo?.text?.trim() || '') ||
(updatedChannelBanner.background_color?.trim() || '') !== (initialBannerInfo?.background_color?.trim() || '') ||
updatedChannelBanner.enabled !== initialBannerInfo?.enabled;
}, [initialBannerInfo, updatedChannelBanner]);
const handleAutoTranslationToggle = useCallback(async () => {
setIsChannelAutotranslated((prev) => !prev);
}, []);
const hasUnsavedChanges = bannerHasChanges(initialBannerInfo, updatedChannelBanner) ||
isChannelAutotranslated !== initialIsChannelAutotranslated;
useEffect(() => {
const unsavedChanges = hasUnsavedChanges();
setRequireConfirm(unsavedChanges);
setAreThereUnsavedChanges?.(unsavedChanges);
setRequireConfirm(hasUnsavedChanges);
setAreThereUnsavedChanges?.(hasUnsavedChanges);
}, [hasUnsavedChanges, setAreThereUnsavedChanges]);
const handleServerError = useCallback((err: ServerError) => {
@@ -152,11 +174,17 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
const updated: Partial<Channel> = {};
updated.banner_info = {
text: updatedChannelBanner.text?.trim() || '',
background_color: updatedChannelBanner.background_color?.trim() || '',
enabled: updatedChannelBanner.enabled,
};
if (bannerHasChanges(initialBannerInfo, updatedChannelBanner)) {
updated.banner_info = {
text: updatedChannelBanner.text?.trim() || '',
background_color: updatedChannelBanner.background_color?.trim() || '',
enabled: updatedChannelBanner.enabled,
};
}
if (isChannelAutotranslated !== initialIsChannelAutotranslated) {
updated.autotranslation = isChannelAutotranslated;
}
const {error} = await dispatch(patchChannel(channel.id, updated));
if (error) {
@@ -165,7 +193,7 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
}
return true;
}, [channel, dispatch, formatMessage, handleServerError, updatedChannelBanner]);
}, [channel, dispatch, formatMessage, handleServerError, initialBannerInfo, initialIsChannelAutotranslated, isChannelAutotranslated, updatedChannelBanner]);
const handleSaveChanges = useCallback(async () => {
const success = await handleSave();
@@ -209,86 +237,122 @@ function ChannelSettingsConfigurationTab({channel, setAreThereUnsavedChanges, sh
return (
<div className='ChannelSettingsModal__configurationTab'>
<div className='channel_banner_header'>
<div className='channel_banner_header__text'>
<label
className='Input_legend'
aria-label={heading}
>
{heading}
</label>
<label
className='Input_subheading'
aria-label={heading}
>
{subHeading}
</label>
</div>
{canManageBanner && (
<>
<div className='channel_banner_header'>
<div className='channel_banner_header__text'>
<label
className='Input_legend'
aria-label={heading}
>
{heading}
</label>
<label
className='Input_subheading'
aria-label={heading}
>
{subHeading}
</label>
</div>
<div className='channel_banner_header__toggle'>
<Toggle
id='channelBannerToggle'
ariaLabel={heading}
size='btn-md'
disabled={false}
onToggle={handleToggle}
toggled={updatedChannelBanner.enabled}
tabIndex={0}
toggleClassName='btn-toggle-primary'
/>
</div>
</div>
{
updatedChannelBanner.enabled &&
<div className='channel_banner_section_body'>
{/*Banner text section*/}
<div className='setting_section'>
<span
className='setting_title'
aria-label={bannerTextSettingTitle}
>
{bannerTextSettingTitle}
</span>
<div className='setting_body'>
<AdvancedTextbox
id='channel_banner_banner_text_textbox'
value={updatedChannelBanner.text!}
channelId={channel.id}
onKeyPress={() => {}}
showCharacterCount={true}
useChannelMentions={false}
onChange={handleTextChange}
preview={showBannerTextPreview}
togglePreview={toggleTextPreview}
hasError={characterLimitExceeded}
createMessage={bannerTextPlaceholder}
maxLength={CHANNEL_BANNER_MAX_CHARACTER_LIMIT}
minLength={CHANNEL_BANNER_MIN_CHARACTER_LIMIT}
<div className='channel_banner_header__toggle'>
<Toggle
id='channelBannerToggle'
ariaLabel={heading}
size='btn-md'
disabled={false}
onToggle={handleToggle}
toggled={updatedChannelBanner.enabled}
tabIndex={0}
toggleClassName='btn-toggle-primary'
/>
</div>
</div>
{/*Banner background color section*/}
<div className='setting_section'>
<span
className='setting_title'
aria-label={bannerColorSettingTitle}
>
{bannerColorSettingTitle}
</span>
{
updatedChannelBanner.enabled &&
<div className='channel_banner_section_body'>
{/*Banner text section*/}
<div className='setting_section'>
<span
className='setting_title'
aria-label={bannerTextSettingTitle}
>
{bannerTextSettingTitle}
</span>
<div className='setting_body'>
<ColorInput
id='channel_banner_banner_background_color_picker'
onChange={handleColorChange}
value={updatedChannelBanner.background_color || ''}
/>
<div className='setting_body'>
<AdvancedTextbox
id='channel_banner_banner_text_textbox'
value={updatedChannelBanner.text!}
channelId={channel.id}
onKeyPress={() => {}}
showCharacterCount={true}
useChannelMentions={false}
onChange={handleTextChange}
preview={showBannerTextPreview}
togglePreview={toggleTextPreview}
hasError={characterLimitExceeded}
createMessage={bannerTextPlaceholder}
maxLength={CHANNEL_BANNER_MAX_CHARACTER_LIMIT}
minLength={CHANNEL_BANNER_MIN_CHARACTER_LIMIT}
/>
</div>
</div>
{/*Banner background color section*/}
<div className='setting_section'>
<span
className='setting_title'
aria-label={bannerColorSettingTitle}
>
{bannerColorSettingTitle}
</span>
<div className='setting_body'>
<ColorInput
id='channel_banner_banner_background_color_picker'
onChange={handleColorChange}
value={updatedChannelBanner.background_color || ''}
/>
</div>
</div>
</div>
}
</>
)}
{canManageChannelTranslation && (
<div className='channel_translation_header'>
<div className='channel_translation_header__text'>
<label
className='Input_legend'
aria-label={autoTranslationHeading}
>
{autoTranslationHeading}
</label>
<label
className='Input_subheading'
aria-label={autoTranslationSubHeading}
>
{autoTranslationSubHeading}
</label>
</div>
<div className='channel_translation_header__toggle'>
<Toggle
id='channelTranslationToggle'
ariaLabel={autoTranslationHeading}
size='btn-md'
disabled={false}
onToggle={handleAutoTranslationToggle}
toggled={isChannelAutotranslated}
tabIndex={0}
toggleClassName='btn-toggle-primary'
/>
</div>
</div>
}
)}
{showSaveChangesPanel && (
<SaveChangesPanel
@@ -13,7 +13,7 @@ import type {Channel} from '@mattermost/types/channels';
import Permissions from 'mattermost-redux/constants/permissions';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {
@@ -68,7 +68,12 @@ function ChannelSettingsModal({channelId, isOpen, onExited, focusOriginElement}:
);
const hasManageChannelBannerPermission = (channel.type === 'O' && canManagePublicChannelBanner) || (channel.type === 'P' && canManagePrivateChannelBanner);
const shouldShowConfigurationTab = channelBannerEnabled && hasManageChannelBannerPermission;
const channelTranslationEnabled = useSelector((state: GlobalState) => getConfig(state)?.EnableAutoTranslation === 'true');
const permissionToCheck = channel.type === Constants.PRIVATE_CHANNEL ? Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES : Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES;
const canManageChannelTranslation = useSelector((state: GlobalState) => haveIChannelPermission(state, channel.team_id, channel.id, permissionToCheck));
const canManageBanner = channelBannerEnabled && hasManageChannelBannerPermission;
const shouldShowConfigurationTab = canManageBanner || (channelTranslationEnabled && canManageChannelTranslation);
const canArchivePrivateChannels = useSelector((state: GlobalState) =>
haveIChannelPermission(state, channel.team_id, channel.id, Permissions.DELETE_PRIVATE_CHANNEL),
@@ -191,6 +196,8 @@ function ChannelSettingsModal({channelId, isOpen, onExited, focusOriginElement}:
channel={channel}
setAreThereUnsavedChanges={setAreThereUnsavedChanges}
showTabSwitchError={showTabSwitchError}
canManageChannelTranslation={channelTranslationEnabled}
canManageBanner={canManageBanner}
/>
);
};
@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import {setMyChannelAutotranslation} from 'mattermost-redux/actions/channels';
import {sendEphemeralPost} from 'actions/global_actions';
import {closeModal} from 'actions/views/modals';
import ConfirmModal from 'components/confirm_modal';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
channel: Channel;
}
const DisableAutotranslationModal = ({channel}: Props) => {
const dispatch = useDispatch();
const [show, setShow] = useState(true);
const intl = useIntl();
const handleHide = useCallback(() => {
setShow(false);
dispatch(closeModal(ModalIdentifiers.DISABLE_AUTOTRANSLATION_CONFIRM));
}, [dispatch]);
const handleConfirm = useCallback(async () => {
handleHide();
await dispatch(setMyChannelAutotranslation(channel.id, false));
// Disabling autotranslations removes all the posts in a channel,
// so we need to wait for the posts to be removed before sending the ephemeral post.
requestAnimationFrame(() => {
dispatch(sendEphemeralPost(
intl.formatMessage({id: 'channel_header.autotranslation.disable_confirm.ephemeral_message', defaultMessage: 'You disabled Auto-translation for this channel. All messages will show original text. This only affects your view.'}),
channel.id,
));
});
}, [channel.id, dispatch, handleHide, intl]);
const texts = useMemo(() => ({
title: (
<FormattedMessage
id='channel_header.autotranslation.disable_confirm.title'
defaultMessage='Turn off auto-translation'
/>
),
message: (
<FormattedMessage
id='channel_header.autotranslation.disable_confirm.message'
defaultMessage="Messages in this channel will revert to their original language. This will only affect how you see this channel. Other members won't be affected."
/>
),
confirmButtonText: (
<FormattedMessage
id='channel_header.autotranslation.disable_confirm.confirm'
defaultMessage='Turn off auto-translation'
/>
),
cancelButtonText: (
<FormattedMessage
id='channel_header.autotranslation.disable_confirm.cancel'
defaultMessage='Cancel'
/>
),
}), []);
return (
<ConfirmModal
show={show}
title={texts.title}
message={texts.message}
confirmButtonText={texts.confirmButtonText}
cancelButtonText={texts.cancelButtonText}
onConfirm={handleConfirm}
onCancel={handleHide}
onExited={handleHide}
modalClass='disableAutotranslationConfirmModal'
/>
);
};
export default DisableAutotranslationModal;
@@ -207,6 +207,7 @@ describe('components/dot_menu/DotMenu', () => {
userId: 'user_id_1',
isMilitaryTime: false,
canMove: true,
isChannelAutotranslated: false,
canReply: true,
canForward: true,
canFollowThread: true,
@@ -22,6 +22,7 @@ import {
PinIcon,
PinOutlineIcon,
ReplyOutlineIcon,
TranslateIcon,
TrashCanOutlineIcon,
} from '@mattermost/compass-icons/components';
import type {Post} from '@mattermost/types/posts';
@@ -64,6 +65,18 @@ const ShortcutKey = ({shortcutKey: shortcut}: ShortcutKeyProps) => (
</span>
);
function getMessageToCopy(post: Post, isChannelAutotranslated: boolean, locale: string): string {
const originalMessage = post.message_source || post.message;
if (!isChannelAutotranslated || post.type !== '') {
return originalMessage;
}
const translation = PostUtils.getPostTranslation(post, locale);
if (!translation || translation.state !== 'ready') {
return originalMessage;
}
return PostUtils.getPostTranslatedMessage(originalMessage, translation);
}
type Props = {
intl: IntlShape;
post: Post;
@@ -158,6 +171,7 @@ type Props = {
isFollowingThread?: boolean;
isMentionedInRootPost?: boolean;
threadReplyCount?: number;
isChannelAutotranslated: boolean;
isBurnOnReadPost: boolean;
isUnrevealedBurnOnReadPost: boolean;
}
@@ -243,7 +257,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
};
copyText = () => {
Utils.copyToClipboard(this.props.post.message_source || this.props.post.message);
Utils.copyToClipboard(getMessageToCopy(this.props.post, this.props.isChannelAutotranslated, this.props.intl.locale));
};
handlePinMenuItemActivated = (): void => {
@@ -474,6 +488,17 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
this.props.handleDropdownOpened?.(open);
};
handleShowTranslation = async () => {
// Use dynamic import to avoid circular dependency
// This breaks the cycle because the import only happens at runtime, not at module load time
const {default: ShowTranslationModal} = await import('components/show_translation_modal');
this.props.actions.openModal({
modalId: ModalIdentifiers.SHOW_TRANSLATION,
dialogType: ShowTranslationModal,
dialogProps: {postId: this.props.post.id},
});
};
render(): JSX.Element {
const {formatMessage} = this.props.intl;
const isFollowingThread = this.props.isFollowingThread ?? this.props.isMentionedInRootPost;
@@ -488,10 +513,8 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
!this.props.isUnrevealedBurnOnReadPost // Receiver sees delete only if revealed (not concealed)
);
// Delete button should show if:
// 1. Non-BoR with delete permission, OR
// 2. BoR post meeting above criteria
const shouldShowDelete = (!isBurnOnReadPost && this.state.canDelete) || shouldShowDeleteForBoR;
const translation = PostUtils.getPostTranslation(this.props.post, this.props.intl.locale);
const showTranslation = this.props.isChannelAutotranslated && translation?.state === 'ready' && this.props.post.type === '';
const forwardPostItemText = (
<span className={'dot-menu__item-new-badge'}>
@@ -561,6 +584,30 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
/>
);
const showReply = !isSystemMessage && !isBurnOnReadPost && this.props.location === Locations.CENTER;
const showForward = this.props.canForward;
const showReactions = Boolean(isMobile && !isSystemMessage && !this.props.isReadOnly && this.props.enableEmojiPicker);
const showFollowPost = this.props.canFollowThread;
const showMarkAsUnread = Boolean(!isSystemMessage && !this.props.channelIsArchived && this.props.location !== Locations.SEARCH);
const showSave = !isSystemMessage && !this.props.isUnrevealedBurnOnReadPost;
const showRemind = !isSystemMessage;
const showPin = Boolean(!isSystemMessage && !this.props.isReadOnly && !isBurnOnReadPost);
const showMove = Boolean(!isSystemMessage && this.props.canMove);
const showShowTranslation = !isSystemMessage && showTranslation;
const showCopyText = !isSystemMessage && !isBurnOnReadPost;
const showCopyLink = !isSystemMessage && (!isBurnOnReadPost || this.props.post.user_id === this.props.userId);
const showEdit = this.state.canEdit && !isBurnOnReadPost;
const showFlagContent = this.props.canFlagContent;
// Delete button should show if:
// 1. Non-BoR with delete permission, OR
// 2. BoR post meeting above criteria
const showDelete = (!isBurnOnReadPost && this.state.canDelete) || shouldShowDeleteForBoR;
const firstSectionHasItems = showReply || showForward || showReactions || showFollowPost || showMarkAsUnread || showSave || showRemind || showPin || showMove;
const secondSectionHasItems = showShowTranslation || showCopyText || showCopyLink;
const thirdSectionHasItems = showEdit || showDelete || showFlagContent;
return (
<Menu.Container
menuButton={{
@@ -584,7 +631,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
class: 'hidden-xs',
}}
>
{this.props.canReply &&
{showReply &&
<Menu.Item
id={`reply_to_post_${this.props.post.id}`}
data-testid={`reply_to_post_${this.props.post.id}`}
@@ -599,7 +646,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.handleCommentClick}
/>
}
{this.props.canForward &&
{showForward &&
<Menu.Item
id={`forward_post_${this.props.post.id}`}
data-testid={`forward_post_${this.props.post.id}`}
@@ -610,7 +657,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.handleForwardMenuItemActivated}
/>
}
{Boolean(isMobile && !isSystemMessage && !this.props.isReadOnly && this.props.enableEmojiPicker) &&
{showReactions &&
<ChannelPermissionGate
channelId={this.props.post.channel_id}
teamId={this.props.teamId}
@@ -630,7 +677,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
/>
</ChannelPermissionGate>
}
{this.props.canFollowThread &&
{showFollowPost &&
<Menu.Item
id={`follow_post_thread_${this.props.post.id}`}
data-testid={`follow_post_thread_${this.props.post.id}`}
@@ -646,7 +693,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.handleSetThreadFollow}
/>
}
{Boolean(!isSystemMessage && !this.props.channelIsArchived && this.props.location !== Locations.SEARCH) &&
{showMarkAsUnread &&
<Menu.Item
id={`unread_post_${this.props.post.id}`}
data-testid={`unread_post_${this.props.post.id}`}
@@ -661,15 +708,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.handleMarkPostAsUnread}
/>
}
{!isSystemMessage &&
<PostReminderSubMenu
userId={this.props.userId}
post={this.props.post}
isMilitaryTime={this.props.isMilitaryTime}
timezone={this.props.timezone}
/>
}
{!isSystemMessage && !this.props.isUnrevealedBurnOnReadPost &&
{showSave &&
<Menu.Item
id={`save_post_${this.props.post.id}`}
data-testid={`save_post_${this.props.post.id}`}
@@ -679,7 +718,15 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.handleFlagMenuItemActivated}
/>
}
{this.props.canPin && !this.props.isReadOnly &&
{showRemind &&
<PostReminderSubMenu
userId={this.props.userId}
post={this.props.post}
isMilitaryTime={this.props.isMilitaryTime}
timezone={this.props.timezone}
/>
}
{showPin &&
<Menu.Item
id={`${this.props.post.is_pinned ? 'unpin' : 'pin'}_post_${this.props.post.id}`}
data-testid={`pin_post_${this.props.post.id}`}
@@ -689,7 +736,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.handlePinMenuItemActivated}
/>
}
{Boolean(!isSystemMessage && this.props.canMove) &&
{showMove &&
<Menu.Item
id={`move_thread_${this.props.post.id}`}
labels={
@@ -702,37 +749,22 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.handleMoveThreadMenuItemActivated}
/>
}
{!isSystemMessage && (this.state.canEdit || this.state.canDelete) && this.props.canCopyLink && <Menu.Separator/>}
{this.props.canCopyLink &&
{firstSectionHasItems && secondSectionHasItems && <Menu.Separator/>}
{showShowTranslation && (
<Menu.Item
id={`permalink_${this.props.post.id}`}
data-testid={`permalink_${this.props.post.id}`}
id={`show_translation_${this.props.post.id}`}
data-testid={`show_translation_${this.props.post.id}`}
labels={
<FormattedMessage
id='post_info.permalink'
defaultMessage='Copy Link'
/>}
leadingElement={<LinkVariantIcon size={18}/>}
trailingElements={<ShortcutKey shortcutKey='K'/>}
onClick={this.copyLink}
id='post_info.show_translation'
defaultMessage='Show translation'
/>
}
leadingElement={<TranslateIcon size={18}/>}
onClick={this.handleShowTranslation}
/>
}
{!isSystemMessage && !isBurnOnReadPost && <Menu.Separator/>}
{this.state.canEdit &&
<Menu.Item
id={`edit_post_${this.props.post.id}`}
data-testid={`edit_post_${this.props.post.id}`}
labels={
<FormattedMessage
id='post_info.edit'
defaultMessage='Edit'
/>}
leadingElement={<PencilOutlineIcon size={18}/>}
trailingElements={<ShortcutKey shortcutKey='E'/>}
onClick={this.handleEditMenuItemActivated}
/>
}
{this.props.canCopyText &&
)}
{showCopyText &&
<Menu.Item
id={`copy_${this.props.post.id}`}
data-testid={`copy_${this.props.post.id}`}
@@ -746,9 +778,37 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.copyText}
/>
}
{shouldShowDelete && !isSystemMessage && <Menu.Separator/>}
{showCopyLink &&
<Menu.Item
id={`permalink_${this.props.post.id}`}
data-testid={`permalink_${this.props.post.id}`}
labels={
<FormattedMessage
id='post_info.permalink'
defaultMessage='Copy Link'
/>}
leadingElement={<LinkVariantIcon size={18}/>}
trailingElements={<ShortcutKey shortcutKey='K'/>}
onClick={this.copyLink}
/>
}
{thirdSectionHasItems && (firstSectionHasItems || secondSectionHasItems) && <Menu.Separator/>}
{showEdit &&
<Menu.Item
id={`edit_post_${this.props.post.id}`}
data-testid={`edit_post_${this.props.post.id}`}
labels={
<FormattedMessage
id='post_info.edit'
defaultMessage='Edit'
/>}
leadingElement={<PencilOutlineIcon size={18}/>}
trailingElements={<ShortcutKey shortcutKey='E'/>}
onClick={this.handleEditMenuItemActivated}
/>
}
{
this.props.canFlagContent &&
showFlagContent &&
<Menu.Item
id={`flag_post_${this.props.post.id}`}
className='flag_post_menu_item'
@@ -764,7 +824,7 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
isDestructive={true}
/>
}
{shouldShowDelete &&
{showDelete &&
<Menu.Item
id={`delete_post_${this.props.post.id}`}
data-testid={`delete_post_${this.props.post.id}`}
@@ -115,6 +115,7 @@ describe('components/dot_menu/DotMenu returning empty ("")', () => {
userId: 'user_id_1',
isMilitaryTime: false,
canMove: true,
isChannelAutotranslated: false,
canReply: true,
canForward: true,
canFollowThread: true,
@@ -115,6 +115,7 @@ describe('components/dot_menu/DotMenu on mobile view', () => {
userId: 'user_id_1',
isMilitaryTime: false,
canMove: true,
isChannelAutotranslated: false,
canReply: true,
canForward: true,
canFollowThread: true,
@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import type {Post} from '@mattermost/types/posts';
@@ -19,16 +20,27 @@ type Props = {
pluginPostTypes?: PluginsState['postTypes'];
isRHS: boolean;
compactDisplay?: boolean;
isChannelAutotranslated: boolean;
}
export default function MessageWithAdditionalContent({post, isEmbedVisible, pluginPostTypes, isRHS, compactDisplay}: Props) {
export default function MessageWithAdditionalContent({
post,
isEmbedVisible,
pluginPostTypes,
isRHS,
compactDisplay,
isChannelAutotranslated,
}: Props) {
const hasPlugin = post.type && pluginPostTypes && Object.hasOwn(pluginPostTypes, post.type);
const {locale} = useIntl();
let msg;
const messageWrapper = (
<PostMessageView
post={post}
isRHS={isRHS}
compactDisplay={compactDisplay}
isChannelAutotranslated={isChannelAutotranslated}
userLanguage={locale}
/>
);
if (post.state === Posts.POST_DELETED || hasPlugin) {
@@ -61,6 +61,7 @@ describe('PostComponent', () => {
location={Locations.CENTER}
post={post3}
previousPostId={post2.id}
isChannelAutotranslated={false}
/>,
{
entities: {
@@ -136,6 +137,7 @@ describe('PostComponent', () => {
location={Locations.CENTER}
post={post3}
previousPostId={post2.id}
isChannelAutotranslated={false}
/>,
{
entities: {
+17 -4
View File
@@ -11,8 +11,9 @@ 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} from 'mattermost-redux/selectors/entities/channels';
import {getDirectTeammate, getMyChannelAutotranslation} 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';
import type {UserActivityPost} from 'mattermost-redux/selectors/entities/posts';
import {
@@ -37,7 +38,7 @@ import {getIsMobileView} from 'selectors/views/browser';
import {isArchivedChannel} from 'utils/channel_utils';
import {Locations, Preferences, RHSStates} from 'utils/constants';
import {areConsecutivePostsBySameUser, canDeletePost, shouldShowActionsMenu, shouldShowDotMenu} from 'utils/post_utils';
import {areConsecutivePostsBySameUser, canDeletePost, getPostTranslation, shouldShowActionsMenu, shouldShowDotMenu} from 'utils/post_utils';
import {getDisplayNameByUser} from 'utils/utils';
import type {GlobalState} from 'types/store';
@@ -51,6 +52,7 @@ type OwnProps = {
postId?: string;
shouldHighlight?: boolean;
location: keyof typeof Locations;
preventClickInteraction?: boolean;
};
function isFirstReply(post: Post, previousPost?: Post | null): boolean {
@@ -68,7 +70,7 @@ function isFirstReply(post: Post, previousPost?: Post | null): boolean {
return false;
}
function isConsecutivePost(state: GlobalState, ownProps: OwnProps) {
function isConsecutivePost(state: GlobalState, ownProps: OwnProps, locale: string) {
let post;
if (ownProps.postId) {
post = getPost(state, ownProps.postId);
@@ -82,6 +84,15 @@ function isConsecutivePost(state: GlobalState, ownProps: OwnProps) {
if (previousPost && post && !post.metadata?.priority?.priority) {
consecutivePost = areConsecutivePostsBySameUser(post, previousPost);
}
if (previousPost && post && consecutivePost && getMyChannelAutotranslation(state, post.channel_id)) {
const translation = getPostTranslation(post, locale);
const previousTranslation = getPostTranslation(previousPost, locale);
if (translation?.state !== previousTranslation?.state) {
consecutivePost = false;
}
}
return consecutivePost;
}
@@ -171,6 +182,8 @@ function makeMapStateToProps() {
Preferences.LINK_PREVIEW_DISPLAY_DEFAULT === 'true',
);
const locale = getCurrentUserLocale(state);
return {
enableEmojiPicker,
enablePostUsernameOverride,
@@ -184,7 +197,7 @@ function makeMapStateToProps() {
pluginPostTypes: state.plugins.postTypes,
channelIsArchived: isArchivedChannel(channel),
channelIsShared: channel?.shared,
isConsecutivePost: isConsecutivePost(state, ownProps),
isConsecutivePost: isConsecutivePost(state, ownProps, locale),
previousPostIsComment,
isFlagged: isPostFlagged(state, post.id),
compactDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT) === Preferences.MESSAGE_DISPLAY_COMPACT,
@@ -56,6 +56,7 @@ describe('PostComponent', () => {
openModal: jest.fn(),
closeModal: jest.fn(),
},
isChannelAutotranslated: false,
};
describe('reactions', () => {
@@ -54,6 +54,7 @@ import type {ModalData} from 'types/actions';
import type {PostActionComponent, PostPluginComponent} from 'types/store/plugins';
import {withPostErrorBoundary} from './post_error_boundary';
import PostHeaderTranslateIcon from './post_header_translate_icon';
import PostOptions from './post_options';
import PostUserProfile from './user_profile';
@@ -128,17 +129,22 @@ export type Props = {
isCardOpen?: boolean;
canDelete?: boolean;
pluginActions: PostActionComponent[];
isChannelAutotranslated: boolean;
shouldDisplayBurnOnReadConcealed?: boolean;
burnOnReadDurationMinutes: number;
burnOnReadSkipConfirmation?: boolean;
preventClickInteraction?: boolean;
};
const preventInteractionStyle: React.CSSProperties = {pointerEvents: 'none'};
function PostComponent(props: Props) {
const {post, shouldHighlight, togglePostMenu} = props;
const {formatMessage} = useIntl();
const isSearchResultItem = (props.matches && props.matches.length > 0) || props.isMentionSearch || (props.term && props.term.length > 0);
const isRHS = props.location === Locations.RHS_ROOT || props.location === Locations.RHS_COMMENT || props.location === Locations.SEARCH;
const isModal = props.location === Locations.MODAL;
const postRef = useRef<HTMLDivElement>(null);
const postHeaderRef = useRef<HTMLDivElement>(null);
const teamId = props.team?.id ?? props.currentTeam?.id ?? '';
@@ -153,6 +159,8 @@ function PostComponent(props: Props) {
const [burnOnReadRevealing, setBurnOnReadRevealing] = useState(false);
const [burnOnReadRevealError, setBurnOnReadRevealError] = useState<string | null>(null);
const {locale} = useIntl();
const isSystemMessage = PostUtils.isSystemMessage(post);
const fromAutoResponder = PostUtils.fromAutoResponder(post);
@@ -318,6 +326,7 @@ function PostComponent(props: Props) {
'post--pinned-or-flagged': (post.is_pinned || props.isFlagged) && props.location === Locations.CENTER,
'mention-comment': props.isCommentMention,
'post--thread': isRHS,
'post--modal': isModal,
});
};
@@ -419,6 +428,11 @@ function PostComponent(props: Props) {
}
}, [handleCommentClick, handleJumpClick, props.currentTeam?.id, teamId]);
const translation = PostUtils.getPostTranslation(post, locale);
// Only show translation for normal (no custom type) posts
const isTranslating = translation?.state === 'processing' && post.type === '';
const handleRevealBurnOnRead = useCallback(async (postId: string) => {
setBurnOnReadRevealing(true);
setBurnOnReadRevealError(null);
@@ -487,7 +501,13 @@ function PostComponent(props: Props) {
handleBurnOnReadClick(false);
}, [handleBurnOnReadClick]);
const postClass = classNames('post__body', {'post--edited': PostUtils.isEdited(post), 'search-item-snippet': isSearchResultItem});
const postClass = classNames(
'post__body',
{
'post--edited': PostUtils.isEdited(post),
'search-item-snippet': isSearchResultItem,
},
);
let comment;
if (props.isFirstReply && post.type !== Constants.PostTypes.EPHEMERAL) {
@@ -564,6 +584,8 @@ function PostComponent(props: Props) {
mentionHighlight: props.isMentionSearch,
}}
isRHS={isRHS}
isChannelAutotranslated={props.isChannelAutotranslated}
userLanguage={locale}
/>
</PostBodyAdditionalContent>
);
@@ -575,6 +597,7 @@ function PostComponent(props: Props) {
pluginPostTypes={props.pluginPostTypes}
isRHS={isRHS}
compactDisplay={props.compactDisplay}
isChannelAutotranslated={props.isChannelAutotranslated}
/>
);
}
@@ -694,10 +717,15 @@ function PostComponent(props: Props) {
data-testid={postAriaLabelDivTestId}
post={post}
className={getClassName()}
style={props.preventClickInteraction ? preventInteractionStyle : undefined}
onClick={handlePostClick}
onMouseOver={handleMouseOver}
onMouseLeave={handleMouseLeave}
autotranslated={props.isChannelAutotranslated}
>
{props.isChannelAutotranslated && isTranslating && (
<div className='post-message__shimmer'/>
)}
{(Boolean(isSearchResultItem) || (props.location !== Locations.CENTER && props.isFlagged)) &&
<div
className='search-channel__name__container'
@@ -739,7 +767,9 @@ function PostComponent(props: Props) {
channelId={post.channel_id}
/>
<div
className={`post__content ${props.center ? 'center' : ''}`}
className={classNames('post__content', {
center: props.center,
})}
data-testid='postContent'
>
<div className='post__img'>
@@ -776,6 +806,13 @@ function PostComponent(props: Props) {
postAuthorId={post.user_id}
/>
)}
{!isModal && !props.isConsecutivePost && props.isChannelAutotranslated && (
<PostHeaderTranslateIcon
postId={post.id}
translationState={translation?.state}
postType={post.type}
/>
)}
{Boolean(post.props && post.props.card) &&
<WithTooltip
title={
@@ -801,7 +838,7 @@ function PostComponent(props: Props) {
}
{visibleMessage}
</div>
{!props.isPostBeingEdited &&
{!isModal && !props.isPostBeingEdited &&
<PostOptions
{...props}
teamId={teamId}
@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {TranslateIcon} from '@mattermost/compass-icons/components';
import type {PostTranslation, PostType} from '@mattermost/types/posts';
import {openModal} from 'actions/views/modals';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import WithTooltip from 'components/with_tooltip';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
postId: string;
translationState: PostTranslation['state'] | undefined;
postType: PostType;
}
function PostHeaderTranslateIcon({
postId,
translationState,
postType,
}: Props) {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const handleTranslationClick = useCallback(async () => {
// Use dynamic import to avoid circular dependency
// This breaks the cycle because the import only happens at runtime, not at module load time
const {default: ShowTranslationModal} = await import('components/show_translation_modal');
dispatch(openModal({
modalId: ModalIdentifiers.SHOW_TRANSLATION,
dialogType: ShowTranslationModal,
dialogProps: {postId},
}));
}, [dispatch, postId]);
if (postType !== '') {
return null;
}
if (translationState === 'ready') {
return (
<WithTooltip
title={formatMessage({id: 'post_info.translation_icon.title', defaultMessage: 'Auto-translated'})}
hint={formatMessage({id: 'post_info.translation_icon.hint', defaultMessage: 'Click to view original'})}
>
<button
className='btn btn-icon btn-xs btn-compact'
onClick={handleTranslationClick}
aria-label={formatMessage({id: 'post_info.translation_icon', defaultMessage: 'This post has been translated'})}
>
<TranslateIcon
size={12}
aria-label={formatMessage({
id: 'post_info.translation_icon',
defaultMessage: 'This post has been translated',
})}
/>
</button>
</WithTooltip>
);
}
if (translationState === 'processing') {
return (
<div>
<i className='post__translation-icon-processing'>
<LoadingSpinner text={formatMessage({id: 'post_info.translation_icon_processing', defaultMessage: 'Translating...'})}/>
</i>
</div>
);
}
if (translationState === 'unavailable') {
return (
<WithTooltip
title={formatMessage({id: 'post_info.translation_icon_unavailable', defaultMessage: 'Translation unavailable'})}
>
<div className='post__translation-icon-unavailable'>
<TranslateIcon
size={12}
aria-label={formatMessage({
id: 'post_info.translation_icon_unavailable',
defaultMessage: 'Translation unavailable',
})}
/>
</div>
</WithTooltip>
);
}
return null;
}
export default PostHeaderTranslateIcon;
@@ -54,6 +54,7 @@ type Props = {
isPostBeingEdited?: boolean;
canDelete?: boolean;
pluginActions: PostActionComponent[];
isChannelAutotranslated: boolean;
isBurnOnReadPost?: boolean;
shouldDisplayBurnOnReadConcealed?: boolean;
actions: {
@@ -238,6 +239,7 @@ const PostOptions = (props: Props): JSX.Element => {
isReadOnly={isReadOnly || channelIsArchived}
isMenuOpen={showDotMenu}
enableEmojiPicker={props.enableEmojiPicker}
isChannelAutotranslated={props.isChannelAutotranslated}
/>
</li>
);
@@ -29,6 +29,7 @@ describe('components/post_edit_history/edited_post_item', () => {
closeRightHandSide: jest.fn(),
openModal: jest.fn(),
},
isChannelAutotranslated: false,
};
test('should match snapshot', () => {
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {memo, useCallback, useState} from 'react';
import React, {memo, useCallback, useMemo, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
@@ -56,77 +56,18 @@ const itemMessages = defineMessages({
export type Props = PropsFromRedux & {
post: Post;
isCurrent?: boolean;
isChannelAutotranslated: boolean;
}
const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, actions}: Props) => {
const {formatMessage} = useIntl();
const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, actions, isChannelAutotranslated}: Props) => {
const {formatMessage, locale} = useIntl();
const [open, setOpen] = useState(isCurrent);
const dispatch = useDispatch();
const connectionId = useSelector(getConnectionId);
const openRestorePostModal = useCallback((e: React.MouseEvent) => {
// this prevents history item from
// collapsing and closing when clicking on restore button
e.stopPropagation();
const restorePostModalData = {
modalId: ModalIdentifiers.RESTORE_POST_MODAL,
dialogType: RestorePostModal,
dialogProps: {
post,
postHeader,
actions: {
handleRestore,
},
},
};
actions.openModal(restorePostModalData);
}, [actions, post]);
const togglePost = () => {
setOpen((prevState) => !prevState);
};
if (!post) {
return null;
}
const showInfoTooltip = () => {
const infoToastModalData = {
modalId: ModalIdentifiers.INFO_TOAST,
dialogType: InfoToast,
dialogProps: {
content: {
icon: <CheckIcon size={18}/>,
message: 'Restored Message',
undo: handleUndo,
},
},
};
actions.openModal(infoToastModalData);
};
const handleRestore = async () => {
if (!postCurrentVersion || !post) {
actions.closeRightHandSide();
return;
}
const result = await dispatch(restorePostVersion(post.original_id, post.id, connectionId));
if (result.data) {
actions.closeRightHandSide();
showInfoTooltip();
}
const key = StoragePrefixes.EDIT_DRAFT + post.original_id;
dispatch(removeDraft(key, post.channel_id, post.root_id));
};
const handleUndo = async () => {
const handleUndo = useCallback(async () => {
if (!postCurrentVersion) {
actions.closeRightHandSide();
return;
@@ -142,18 +83,42 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, actions}:
const previousPostVersion = result.data[0];
await dispatch(restorePostVersion(previousPostVersion.original_id, previousPostVersion.id, connectionId));
};
}, [actions, connectionId, dispatch, post.original_id, postCurrentVersion]);
const currentVersionIndicator = isCurrent ? (
<div className='edit-post-history__current__indicator'>
{formatMessage(itemMessages.currentVersionText)}
</div>
) : null;
const showInfoTooltip = useCallback(() => {
const infoToastModalData = {
modalId: ModalIdentifiers.INFO_TOAST,
dialogType: InfoToast,
dialogProps: {
content: {
icon: <CheckIcon size={18}/>,
message: 'Restored Message',
undo: handleUndo,
},
},
};
actions.openModal(infoToastModalData);
}, [actions, handleUndo]);
const handleRestore = useCallback(async () => {
if (!postCurrentVersion || !post) {
actions.closeRightHandSide();
return;
}
const result = await dispatch(restorePostVersion(post.original_id, post.id, connectionId));
if (result.data) {
actions.closeRightHandSide();
showInfoTooltip();
}
const key = StoragePrefixes.EDIT_DRAFT + post.original_id;
dispatch(removeDraft(key, post.channel_id, post.root_id));
}, [actions, connectionId, dispatch, post, postCurrentVersion, showInfoTooltip]);
const profileSrc = imageURLForUser(post.user_id);
const overwriteName = ensureString(post.props?.override_username);
const postHeader = (
const postHeader = useMemo(() => (
<div className='edit-post-history__header'>
<span className='profile-icon'>
<Avatar
@@ -170,13 +135,50 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, actions}:
/>
</div>
</div>
);
), [overwriteName, post.user_id, profileSrc]);
const openRestorePostModal = useCallback((e: React.MouseEvent) => {
// this prevents history item from
// collapsing and closing when clicking on restore button
e.stopPropagation();
const restorePostModalData = {
modalId: ModalIdentifiers.RESTORE_POST_MODAL,
dialogType: RestorePostModal,
dialogProps: {
post,
postHeader,
actions: {
handleRestore,
},
isChannelAutotranslated,
},
};
actions.openModal(restorePostModalData);
}, [actions, handleRestore, isChannelAutotranslated, post, postHeader]);
const togglePost = () => {
setOpen((prevState) => !prevState);
};
if (!post) {
return null;
}
const currentVersionIndicator = isCurrent ? (
<div className='edit-post-history__current__indicator'>
{formatMessage(itemMessages.currentVersionText)}
</div>
) : null;
const message = (
<PostMessageContainer
post={post}
isRHS={true}
showPostEditedIndicator={false}
userLanguage={locale}
isChannelAutotranslated={isChannelAutotranslated}
/>
);
@@ -225,6 +227,7 @@ const EditedPostItem = ({post, isCurrent = false, postCurrentVersion, actions}:
className={'a11y__section post'}
id={'searchResult_' + post.id}
post={post}
autotranslated={isChannelAutotranslated}
>
<div
className='edit-post-history__title__container'
@@ -4,7 +4,7 @@
import {connect} from 'react-redux';
import type {ConnectedProps} from 'react-redux';
import {getChannel, getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getChannel, getCurrentChannel, isChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getSelectedPostId} from 'selectors/rhs';
@@ -22,6 +22,7 @@ function mapStateToProps(state: GlobalState) {
return {
channelDisplayName,
originalPost,
isChannelAutotranslated: isChannelAutotranslated(state, originalPost.channel_id),
};
}
@@ -21,6 +21,7 @@ describe('components/post_edit_history', () => {
id: 'post_id',
message: 'post message',
}),
isChannelAutotranslated: false,
dispatch: jest.fn(),
};
const mock = jest.spyOn(Client4, 'getPostEditHistory');
@@ -22,6 +22,7 @@ import './post_edit_history.scss';
const PostEditHistory = ({
channelDisplayName,
originalPost,
isChannelAutotranslated,
}: PropsFromRedux) => {
const [postEditHistory, setPostEditHistory] = useState<Post[]>([]);
const [hasError, setHasError] = useState<boolean>(false);
@@ -104,6 +105,7 @@ const PostEditHistory = ({
post={originalPost}
key={originalPost.id}
isCurrent={true}
isChannelAutotranslated={isChannelAutotranslated}
/>
);
@@ -111,6 +113,7 @@ const PostEditHistory = ({
<EditedPostItem
key={postEdited.id}
post={postEdited}
isChannelAutotranslated={isChannelAutotranslated}
/>
))];
@@ -30,10 +30,11 @@ type Props = {
handleRestore: (post: Post) => void;
};
onExited: () => void;
isChannelAutotranslated: boolean;
}
const RestorePostModal = ({post, postHeader, actions, onExited}: Props) => {
const {formatMessage} = useIntl();
const RestorePostModal = ({post, postHeader, actions, onExited, isChannelAutotranslated}: Props) => {
const {formatMessage, locale} = useIntl();
const onHide = () => onExited();
const handleRestore = async () => {
@@ -66,6 +67,8 @@ const RestorePostModal = ({post, postHeader, actions, onExited}: Props) => {
overflowType='ellipsis'
maxHeight={100}
showPostEditedIndicator={false}
userLanguage={locale}
isChannelAutotranslated={isChannelAutotranslated}
/>
<FileAttachmentListContainer
post={post}
@@ -366,6 +366,31 @@ function renderChannelUnarchivedMessage(post: Post): ReactNode {
);
}
function renderAutoTranslationChangeMessage(post: Post): ReactNode {
if (!post.props.username) {
return null;
}
const username = renderUsername(post.props.username);
const enabled = post.props.enabled;
if (enabled) {
return (
<FormattedMessage
id='api.channel.auto_translation_change.message.enabled'
defaultMessage='{username} enabled Auto-translation for this channel. All new messages will appear in your preferred language.'
values={{username}}
/>
);
}
return (
<FormattedMessage
id='api.channel.auto_translation_change.message.disabled'
defaultMessage='{username} disabled Auto-translation for this channel. All messages will appear in the original language.'
values={{username}}
/>
);
}
function renderMeMessage(post: Post): ReactNode {
// Trim off the leading and trailing asterisk added to /me messages
const message = post.message.replace(/^\*|\*$/g, '');
@@ -390,6 +415,7 @@ const systemMessageRenderers = {
[Posts.POST_TYPES.CHANNEL_DELETED]: renderChannelDeletedMessage,
[Posts.POST_TYPES.CHANNEL_UNARCHIVED]: renderChannelUnarchivedMessage,
[Posts.POST_TYPES.ME]: renderMeMessage,
[Posts.POST_TYPES.AUTO_TRANSLATION_CHANGE]: renderAutoTranslationChangeMessage,
};
export type AddMemberProps = {
@@ -45,12 +45,13 @@ describe('PostAriaLabelDiv', () => {
},
};
const baseProps = {
const baseProps: Omit<Props, 'ref'> = {
post: TestHelper.getPostMock({
user_id: author.id,
message: 'This is a test.',
}),
} as Omit<Props, 'ref'>;
autotranslated: false,
};
test('should render aria-label in the given locale', () => {
const {mountOptions} = mockStore(baseState);
@@ -9,16 +9,18 @@ import {usePostAriaLabel} from 'utils/post_utils';
export type Props = React.HTMLProps<HTMLDivElement> & {
post: Post;
}
autotranslated: boolean;
};
const PostAriaLabelDiv = React.forwardRef((props: Props, ref: React.Ref<HTMLDivElement>) => {
const {
children,
post,
autotranslated,
...otherProps
} = props;
const ariaLabel = usePostAriaLabel(post);
const ariaLabel = usePostAriaLabel(post, autotranslated);
return (
<div
@@ -25,6 +25,7 @@ exports[`components/post_view/post_list snapshot for loading when there are no p
atLatestPost={false}
autoRetryEnable={true}
channelId="fake-id"
isChannelAutotranslated={false}
isMobileView={false}
lastViewedAt={1532345226632}
loadingNewerPosts={false}
@@ -62,6 +63,7 @@ exports[`components/post_view/post_list snapshot with couple of posts 1`] = `
atLatestPost={false}
autoRetryEnable={true}
channelId="fake-id"
isChannelAutotranslated={false}
isMobileView={false}
lastViewedAt={1532345226632}
loadingNewerPosts={false}
@@ -7,6 +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 {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';
@@ -101,6 +102,7 @@ function makeMapStateToProps() {
shouldStartFromBottomWhenUnread,
isMobileView: getIsMobileView(state),
hasInaccessiblePosts,
isChannelAutotranslated: isChannelAutotranslated(state, channelId),
};
};
}
@@ -47,6 +47,7 @@ const baseProps = {
isMobileView: false,
hasInaccessiblePosts: false,
shouldStartFromBottomWhenUnread: false,
isChannelAutotranslated: false,
};
describe('components/post_view/post_list', () => {
@@ -118,6 +118,8 @@ export interface Props {
shouldStartFromBottomWhenUnread: boolean;
hasInaccessiblePosts: boolean;
isChannelAutotranslated: boolean;
actions: {
/*
@@ -387,6 +389,7 @@ export default class PostList extends React.PureComponent<Props, State> {
latestPostTimeStamp={this.props.latestPostTimeStamp}
isMobileView={this.props.isMobileView}
lastViewedAt={this.props.lastViewedAt}
isChannelAutotranslated={this.props.isChannelAutotranslated}
/>
</div>
</div>
@@ -45,6 +45,7 @@ exports[`components/post_view/post_list_row should render channel intro message
exports[`components/post_view/post_list_row should render combined post 1`] = `
<Connect(Connect(ErrorBoundary(PostComponent)))
combinedId="user-activity-1234-5678"
isChannelAutotranslated={false}
isLastPost={false}
location="CENTER"
previousPostId="abcd"
@@ -102,6 +103,7 @@ exports[`components/post_view/post_list_row should render new messages line 1`]
exports[`components/post_view/post_list_row should render post 1`] = `
<Connect(ErrorBoundary(PostComponent))
isChannelAutotranslated={false}
isLastPost={false}
location="CENTER"
post={
@@ -42,6 +42,7 @@ describe('components/post_view/post_list_row', () => {
currentUserId: 'user_id_1',
newMessagesSeparatorActions: [],
channelId: 'channel_id_1',
isChannelAutotranslated: false,
};
test('should render more messages loading indicator', () => {
@@ -56,6 +56,8 @@ export type PostListRowProps = {
newMessagesSeparatorActions: NewMessagesSeparatorActionComponent[];
isChannelAutotranslated: boolean;
actions: {
/**
@@ -174,6 +176,7 @@ export default class PostListRow extends React.PureComponent<PostListRowProps> {
<CombinedUserActivityPost
location={Locations.CENTER}
combinedId={listId}
isChannelAutotranslated={this.props.isChannelAutotranslated}
{...postProps}
/>
);
@@ -188,6 +191,7 @@ export default class PostListRow extends React.PureComponent<PostListRowProps> {
<PostComponent
post={this.props.post}
location={Locations.CENTER}
isChannelAutotranslated={this.props.isChannelAutotranslated}
{...postProps}
/>
);
@@ -53,6 +53,7 @@ describe('LatestPostReader', () => {
const baseProps = {
postIds: [post.id],
autotranslated: false,
};
test('should render aria-label as a child in the given locale', () => {
@@ -14,14 +14,15 @@ import type {GlobalState} from 'types/store';
interface Props {
postIds?: string[];
autotranslated: boolean;
}
const LatestPostReader = (props: Props): JSX.Element => {
const {postIds} = props;
const {postIds, autotranslated} = props;
const latestPostId = useMemo(() => getLatestPostId(postIds || []), [postIds]);
const latestPost = useSelector<GlobalState, Post>((state) => getPost(state, latestPostId));
const ariaLabel = usePostAriaLabel(latestPost);
const ariaLabel = usePostAriaLabel(latestPost, autotranslated);
return (
<span
@@ -43,6 +43,7 @@ describe('PostList', () => {
lastViewedAt: 0,
shouldStartFromBottomWhenUnread: false,
actions: baseActions,
isChannelAutotranslated: false,
};
const postListIdsForClassNames = [
@@ -93,6 +93,8 @@ type Props = {
shouldStartFromBottomWhenUnread: boolean;
isChannelAutotranslated: boolean;
actions: {
/*
@@ -392,6 +394,7 @@ export default class PostList extends React.PureComponent<Props, State> {
loadingNewerPosts={this.props.loadingNewerPosts}
loadingOlderPosts={this.props.loadingOlderPosts}
channelId={this.props.channelId}
isChannelAutotranslated={this.props.isChannelAutotranslated}
/>
</div>
);
@@ -722,7 +725,10 @@ export default class PostList extends React.PureComponent<Props, State> {
id='postListContent'
className='post-list__content'
>
<LatestPostReader postIds={this.props.postListIds}/>
<LatestPostReader
postIds={this.props.postListIds}
autotranslated={this.props.isChannelAutotranslated}
/>
<AutoSizer>
{({height, width}) => (
<>
@@ -68,6 +68,7 @@ exports[`PostMessagePreview direct and group messages should render preview for
</div>
</div>
<Connect(PostMessageView)
isChannelAutotranslated={false}
maxHeight={105}
overflowType="ellipsis"
post={
@@ -77,6 +78,7 @@ exports[`PostMessagePreview direct and group messages should render preview for
"metadata": Object {},
}
}
userLanguage="en"
/>
<div
className="post__preview-footer"
@@ -165,6 +167,7 @@ exports[`PostMessagePreview direct and group messages should render preview for
</div>
</div>
<Connect(PostMessageView)
isChannelAutotranslated={false}
maxHeight={105}
overflowType="ellipsis"
post={
@@ -174,6 +177,7 @@ exports[`PostMessagePreview direct and group messages should render preview for
"metadata": Object {},
}
}
userLanguage="en"
/>
<div
className="post__preview-footer"
@@ -266,6 +270,7 @@ exports[`PostMessagePreview nested previews should render file preview 1`] = `
</div>
</div>
<Connect(PostMessageView)
isChannelAutotranslated={false}
maxHeight={105}
overflowType="ellipsis"
post={
@@ -279,6 +284,7 @@ exports[`PostMessagePreview nested previews should render file preview 1`] = `
"metadata": Object {},
}
}
userLanguage="en"
/>
<Connect(FileAttachmentList)
compactDisplay={false}
@@ -390,6 +396,7 @@ exports[`PostMessagePreview nested previews should render opengraph preview 1`]
</div>
</div>
<Connect(PostMessageView)
isChannelAutotranslated={false}
maxHeight={105}
overflowType="ellipsis"
post={
@@ -406,6 +413,7 @@ exports[`PostMessagePreview nested previews should render opengraph preview 1`]
},
}
}
userLanguage="en"
/>
<Connect(PostAttachmentOpenGraph)
isEmbedVisible={true}
@@ -520,6 +528,7 @@ exports[`PostMessagePreview should not render bot icon 1`] = `
</div>
</div>
<Connect(PostMessageView)
isChannelAutotranslated={false}
maxHeight={105}
overflowType="ellipsis"
post={
@@ -534,6 +543,7 @@ exports[`PostMessagePreview should not render bot icon 1`] = `
},
}
}
userLanguage="en"
/>
<div
className="post__preview-footer"
@@ -627,6 +637,7 @@ exports[`PostMessagePreview should render bot icon 1`] = `
</div>
</div>
<Connect(PostMessageView)
isChannelAutotranslated={false}
maxHeight={105}
overflowType="ellipsis"
post={
@@ -641,6 +652,7 @@ exports[`PostMessagePreview should render bot icon 1`] = `
},
}
}
userLanguage="en"
/>
<div
className="post__preview-footer"
@@ -729,6 +741,7 @@ exports[`PostMessagePreview should render correctly 1`] = `
</div>
</div>
<Connect(PostMessageView)
isChannelAutotranslated={false}
maxHeight={105}
overflowType="ellipsis"
post={
@@ -738,6 +751,7 @@ exports[`PostMessagePreview should render correctly 1`] = `
"metadata": Object {},
}
}
userLanguage="en"
/>
<div
className="post__preview-footer"
@@ -828,6 +842,7 @@ exports[`PostMessagePreview show render without preview when preview posts becom
</div>
</div>
<Connect(PostMessageView)
isChannelAutotranslated={false}
maxHeight={105}
overflowType="ellipsis"
post={
@@ -837,6 +852,7 @@ exports[`PostMessagePreview show render without preview when preview posts becom
"metadata": Object {},
}
}
userLanguage="en"
/>
<div
className="post__preview-footer"
@@ -8,7 +8,7 @@ import type {Dispatch} from 'redux';
import type {PostPreviewMetadata} from '@mattermost/types/posts';
import {General} from 'mattermost-redux/constants';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {isChannelAutotranslated, 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,6 +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),
};
};
}
@@ -50,6 +50,7 @@ describe('PostMessagePreview', () => {
toggleEmbedVisibility: jest.fn(),
},
isPostPriorityEnabled: false,
isChannelAutotranslated: false,
};
test('should render correctly', () => {
@@ -3,7 +3,7 @@
import classNames from 'classnames';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
@@ -12,6 +12,7 @@ import {General} from 'mattermost-redux/constants';
import {ensureString} from 'mattermost-redux/utils/post_utils';
import FileAttachmentListContainer from 'components/file_attachment_list';
import PostHeaderTranslateIcon from 'components/post/post_header_translate_icon';
import PriorityLabel from 'components/post_priority/post_priority_label';
import AiGeneratedIndicator from 'components/post_view/ai_generated_indicator/ai_generated_indicator';
import PostAttachmentOpenGraph from 'components/post_view/post_attachment_opengraph';
@@ -20,6 +21,7 @@ import Timestamp from 'components/timestamp';
import UserProfileComponent from 'components/user_profile';
import * as PostUtils from 'utils/post_utils';
import {getPostTranslation} from 'utils/post_utils';
import PreviewPostAvatar from './avatar/avatar';
@@ -43,11 +45,12 @@ export type Props = OwnProps & {
actions: {
toggleEmbedVisibility: (id: string) => void;
};
isChannelAutotranslated: boolean;
};
const PostMessagePreview = (props: Props) => {
const {currentTeamUrl, channelDisplayName, user, previewPost, metadata, isEmbedVisible, compactDisplay, preventClickAction, previewFooterMessage, handleFileDropdownOpened, isPostPriorityEnabled, overrideGenerateFileDownloadUrl, disableActions} = props;
const {currentTeamUrl, channelDisplayName, user, previewPost, metadata, isEmbedVisible, compactDisplay, preventClickAction, previewFooterMessage, handleFileDropdownOpened, isPostPriorityEnabled, overrideGenerateFileDownloadUrl, disableActions, isChannelAutotranslated} = props;
const {locale} = useIntl();
const toggleEmbedVisibility = () => {
if (previewPost) {
props.actions.toggleEmbedVisibility(previewPost.id);
@@ -116,6 +119,8 @@ const PostMessagePreview = (props: Props) => {
const overwriteName = ensureString(previewPost.props?.override_username);
const translation = getPostTranslation(previewPost, locale);
return (
<PostAttachmentContainer
className='permalink'
@@ -171,12 +176,21 @@ const PostMessagePreview = (props: Props) => {
postAuthorId={previewPost.user_id}
/>
)}
{isChannelAutotranslated && (
<PostHeaderTranslateIcon
postId={previewPost.id}
translationState={translation?.state}
postType={previewPost.type}
/>
)}
</div>
</div>
<PostMessageView
post={previewPost}
overflowType='ellipsis'
maxHeight={105}
userLanguage={locale}
isChannelAutotranslated={isChannelAutotranslated}
/>
{urlPreview}
{fileAttachmentPreview}
@@ -32,6 +32,8 @@ describe('components/post_view/PostAttachment', () => {
theme: {} as Theme,
pluginPostTypes: {},
currentRelativeTeamUrl: 'dummy_team_url',
isChannelAutotranslated: false,
userLanguage: 'en',
};
test('should match snapshot', () => {
@@ -19,6 +19,7 @@ import type {AttachmentTextOverflowType} from 'components/post_view/show_more/sh
import Pluggable from 'plugins/pluggable';
import {PostTypes} from 'utils/constants';
import {getPostTranslatedMessage, getPostTranslation} from 'utils/post_utils';
import type {TextFormattingOptions} from 'utils/text_formatting';
import * as Utils from 'utils/utils';
@@ -44,6 +45,8 @@ type Props = {
maxHeight?: number; /* The max height used by the show more component */
showPostEditedIndicator?: boolean; /* Whether or not to render the post edited indicator */
sharedChannelsPluginsEnabled?: boolean;
isChannelAutotranslated: boolean;
userLanguage: string;
}
type State = {
@@ -148,6 +151,12 @@ export default class PostMessageView extends React.PureComponent<Props, State> {
message = message.concat(visibleMessage);
}
// Use translation if channel is autotranslated and translation is available
const translation = getPostTranslation(post, this.props.userLanguage);
if (this.props.isChannelAutotranslated && post.type === '' && translation?.state === 'ready') {
message = getPostTranslatedMessage(message, translation);
}
const id = isRHS ? `rhsPostMessageText_${post.id}` : `postMessageText_${post.id}`;
// Check if channel is shared
@@ -2,13 +2,18 @@
// See LICENSE.txt for license information.
import React from 'react';
import {useSelector} from 'react-redux';
import type {Post} from '@mattermost/types/posts';
import {isChannelAutotranslated} from 'mattermost-redux/selectors/entities/channels';
import PostComponent from 'components/post';
import {Locations} from 'utils/constants';
import type {GlobalState} from 'types/store';
type Props = {
a11yIndex: number;
isFlaggedPosts: boolean;
@@ -20,6 +25,7 @@ type Props = {
}
export default function PostSearchResultsItem(props: Props) {
const autotranslated = useSelector((state: GlobalState) => isChannelAutotranslated(state, props.post.channel_id));
return (
<div
className='search-item__container'
@@ -32,6 +38,7 @@ export default function PostSearchResultsItem(props: Props) {
isMentionSearch={props.isMentionSearch}
a11yIndex={props.a11yIndex}
location={Locations.SEARCH}
isChannelAutotranslated={autotranslated}
/>
</div>
);
@@ -0,0 +1,5 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './show_translation_modal';
@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ShowTranslationModal {
min-width: 960px;
&__content {
display: flex;
flex-direction: row;
padding-bottom: 32px;
gap: 16px;
}
&__messageBlock {
flex: 1;
padding: 16px;
border-radius: 4px;
background-color: var(--center-channel-bg);
&--original {
background-color: rgba(var(--center-channel-color-rgb), 0.04);
}
}
&__badgeContainer {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 12px;
gap: 8px;
}
&__languageText {
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
}
// On narrow screens, use as much of the viewport as possible
@media screen and (max-width: 1000px) {
.ShowTranslationModal {
width: calc(100vw - 40px);
min-width: unset;
max-width: calc(100vw - 40px);
}
}
// On small screens, stack original and translated messages vertically
@media screen and (max-width: 768px) {
.ShowTranslationModal {
width: calc(100vw - 32px);
max-width: calc(100vw - 32px);
}
.ShowTranslationModal__content {
flex-direction: column;
}
}
// Below 640px, modal takes over the whole screen with no margins
@media screen and (max-width: 640px) {
.ShowTranslationModal {
width: 100vw;
max-width: 100vw;
}
}
@@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {GenericModal} from '@mattermost/components';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import Post from 'components/post';
import Tag from 'components/widgets/tag/tag';
import {Locations} from 'utils/constants';
import {getPostTranslation} from 'utils/post_utils';
import type {GlobalState} from 'types/store';
import './show_translation_modal.scss';
type Props = {
postId: string;
onExited?: () => void;
onHide?: () => void;
}
function ShowTranslationModal({postId, onExited, onHide}: Props) {
const intl = useIntl();
const post = useSelector((state: GlobalState) => getPost(state, postId));
if (!post) {
return null;
}
const translation = getPostTranslation(post, intl.locale);
const originalLanguage = translation?.source_lang || 'unknown';
const handleHide = () => {
if (onHide) {
onHide();
}
};
return (
<GenericModal
id='showTranslationModal'
className='ShowTranslationModal a11y__modal'
show={true}
onHide={handleHide}
onExited={onExited}
ariaLabelledby='showTranslationModalLabel'
compassDesign={true}
modalHeaderText={intl.formatMessage({
id: 'show_translation.modal.title',
defaultMessage: 'Show Translation',
})}
>
<div className='ShowTranslationModal__content'>
{/* Original Message */}
<div className='ShowTranslationModal__messageBlock ShowTranslationModal__messageBlock--original'>
<div className='ShowTranslationModal__badgeContainer'>
<span className='ShowTranslationModal__languageText'>
{originalLanguage === 'unknown' ? intl.formatMessage({
id: 'show_translation.unknown_language',
defaultMessage: 'Unknown',
}) : intl.formatDisplayName(originalLanguage, {type: 'language'})}
</span>
<Tag
text={intl.formatMessage({
id: 'show_translation.original_badge',
defaultMessage: 'ORIGINAL',
})}
variant='info'
size='xs'
/>
</div>
<div className='ShowTranslationModal__postContent'>
<Post
post={post}
isChannelAutotranslated={false}
location={Locations.MODAL}
preventClickInteraction={true}
/>
</div>
</div>
{/* Translated Message */}
<div className='ShowTranslationModal__messageBlock ShowTranslationModal__messageBlock--translated'>
<div className='ShowTranslationModal__badgeContainer'>
<span className='ShowTranslationModal__languageText'>
{intl.formatDisplayName(intl.locale, {type: 'language'})}
</span>
<Tag
text={intl.formatMessage({
id: 'show_translation.auto_translated_badge',
defaultMessage: 'AUTO-TRANSLATED',
})}
variant='default'
size='xs'
/>
</div>
<div className='ShowTranslationModal__postContent'>
<Post
post={post}
isChannelAutotranslated={true}
location={Locations.MODAL}
preventClickInteraction={true}
/>
</div>
</div>
</div>
</GenericModal>
);
}
export default ShowTranslationModal;
@@ -3,7 +3,7 @@
import {connect} from 'react-redux';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {isChannelAutotranslated, 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,6 +36,7 @@ function makeMapStateToProps() {
postsInThread: getPostsForThread(state, post.id),
thread: getThread(state, threadId),
isPostPriorityEnabled: isPostPriorityEnabled(state),
isChannelAutotranslated: isChannelAutotranslated(state, post.channel_id),
};
};
}
@@ -23,6 +23,7 @@ import {ensureString} from 'mattermost-redux/utils/post_utils';
import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
import Markdown from 'components/markdown';
import PostHeaderTranslateIcon from 'components/post/post_header_translate_icon';
import {makeGetMentionKeysForPost} from 'components/post_markdown';
import PriorityBadge from 'components/post_priority/post_priority_badge';
import Button from 'components/threading/common/button';
@@ -31,6 +32,7 @@ import Tag from 'components/widgets/tag/tag';
import Avatars from 'components/widgets/users/avatars';
import WithTooltip from 'components/with_tooltip';
import {getPostTranslatedMessage, getPostTranslation} from 'utils/post_utils';
import * as Utils from 'utils/utils';
import type {GlobalState} from 'types/store';
@@ -58,6 +60,7 @@ type Props = {
postsInThread?: Post[];
thread?: UserThread | null;
isPostPriorityEnabled?: boolean;
isChannelAutotranslated?: boolean;
};
const markdownPreviewOptions = {
@@ -78,10 +81,11 @@ function ThreadItem({
threadId,
isFirstThreadInList,
isPostPriorityEnabled,
isChannelAutotranslated,
}: Props & OwnProps): React.ReactElement|null {
const dispatch = useDispatch();
const {select, goToInChannel, currentTeamId} = useThreadRouting();
const {formatMessage} = useIntl();
const {formatMessage, locale} = useIntl();
const currentUserId = useSelector(getCurrentUserId);
const msgDeleted = formatMessage({id: 'post_body.deleted', defaultMessage: '(message deleted)'});
const postAuthor = ensureString(post?.props?.override_username) || displayName;
@@ -188,6 +192,12 @@ function ThreadItem({
unreadTimestamp = p.edit_at || p.create_at;
}
const translation = getPostTranslation(post, locale);
let message = post.message;
if (isChannelAutotranslated && post.type === '' && translation?.state === 'ready') {
message = getPostTranslatedMessage(message, translation);
}
return (
<>
<div
@@ -237,6 +247,13 @@ function ThreadItem({
/>
)
)}
{isChannelAutotranslated && (
<PostHeaderTranslateIcon
postId={post.id}
translationState={translation?.state}
postType={post.type}
/>
)}
</div>
<Timestamp
{...THREADING_TIME}
@@ -281,9 +298,9 @@ function ThreadItem({
onClick={handleFormattedTextClick}
onKeyDown={handleFormattedTextClick}
>
{post.message ? (
{message ? (
<Markdown
message={post.state === Posts.POST_DELETED ? msgDeleted : post.message}
message={post.state === Posts.POST_DELETED ? msgDeleted : message}
options={markdownPreviewOptions}
imagesMetadata={post?.metadata && post?.metadata?.images}
mentionKeys={mentionsKeys}
@@ -5,7 +5,7 @@ import {connect} from 'react-redux';
import type {Post} from '@mattermost/types/posts';
import {getDirectTeammate} from 'mattermost-redux/selectors/entities/channels';
import {getDirectTeammate, isChannelAutotranslated} 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,6 +55,7 @@ function makeMapStateToProps() {
lastPost,
replyListIds,
newMessagesSeparatorActions,
isChannelAutotranslated: isChannelAutotranslated(state, channelId),
};
};
}
@@ -17,6 +17,7 @@ type Props = {
post: Post;
previousPostId: string;
timestampProps?: Partial<TimestampProps>;
isChannelAutotranslated: boolean;
}
function Reply({
@@ -26,6 +27,7 @@ function Reply({
post,
previousPostId,
timestampProps,
isChannelAutotranslated,
}: Props) {
return (
<PostComponent
@@ -36,6 +38,7 @@ function Reply({
previousPostId={previousPostId}
timestampProps={timestampProps}
location={Locations.RHS_COMMENT}
isChannelAutotranslated={isChannelAutotranslated}
/>
);
}
@@ -31,6 +31,7 @@ type Props = {
timestampProps?: Partial<TimestampProps>;
threadId: string;
newMessagesSeparatorActions: NewMessagesSeparatorActionComponent[];
isChannelAutotranslated: boolean;
};
function noop() {}
@@ -45,6 +46,7 @@ function ThreadViewerRow({
timestampProps,
threadId,
newMessagesSeparatorActions,
isChannelAutotranslated,
}: Props) {
switch (true) {
case PostListUtils.isDateLine(listId): {
@@ -75,6 +77,7 @@ function ThreadViewerRow({
handleCardClick={onCardClick}
timestampProps={timestampProps}
location={Locations.RHS_ROOT}
isChannelAutotranslated={isChannelAutotranslated}
/>
{!isDeletedPost && <RootPostDivider postId={listId}/>}
</>
@@ -88,6 +91,7 @@ function ThreadViewerRow({
isLastPost={isLastPost}
shouldHighlight={false}
togglePostMenu={noop}
isChannelAutotranslated={false}
/>
);
}
@@ -100,6 +104,7 @@ function ThreadViewerRow({
onCardClick={onCardClick}
previousPostId={previousPostId}
timestampProps={timestampProps}
isChannelAutotranslated={isChannelAutotranslated}
/>
);
}
@@ -37,6 +37,7 @@ function getBasePropsAndState(): [Props, DeepPartial<GlobalState>] {
isThreadView: false,
newMessagesSeparatorActions: [],
measureRhsOpened: jest.fn(),
isChannelAutotranslated: false,
};
const state: DeepPartial<GlobalState> = {
@@ -45,6 +45,7 @@ type Props = {
newMessagesSeparatorActions: NewMessagesSeparatorActionComponent[];
inputPlaceholder?: string;
measureRhsOpened: () => void;
isChannelAutotranslated: boolean;
}
type State = {
@@ -373,6 +374,7 @@ class ThreadViewerVirtualized extends PureComponent<Props, State> {
timestampProps={this.props.useRelativeTimestamp ? THREADING_TIME : undefined}
threadId={this.props.selected.id}
newMessagesSeparatorActions={this.props.newMessagesSeparatorActions}
isChannelAutotranslated={this.props.isChannelAutotranslated}
/>
</div>
);
+27
View File
@@ -3407,6 +3407,8 @@
"announcementBar.notification.unsupportedBar.message": "Your browser does not support browser notifications.",
"api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.",
"api.channel.add_member.added": "{addedUsername} added to the channel by {username}.",
"api.channel.auto_translation_change.message.disabled": "{username} disabled Auto-translation for this channel. All messages will appear in the original language.",
"api.channel.auto_translation_change.message.enabled": "{username} enabled Auto-translation for this channel. All new messages will appear in your preferred language.",
"api.channel.delete_channel.archived": "{username} archived the channel.",
"api.channel.group_message_converted_to.private_channel": "{convertedBy} created this channel from a group message with {gmMembers}.",
"api.channel.group_message_converted_to.someone": "Someone",
@@ -3694,6 +3696,18 @@
"channel_bookmarks.editBookmarkLabel": "Bookmark menu",
"channel_bookmarks.open": "Open",
"channel_groups": "{channel} Groups",
"channel_header.autotranslation.disable": "Disable autotranslation",
"channel_header.autotranslation.disable_confirm.cancel": "Cancel",
"channel_header.autotranslation.disable_confirm.confirm": "Turn off auto-translation",
"channel_header.autotranslation.disable_confirm.ephemeral_message": "You disabled Auto-translation for this channel. All messages will show original text. This only affects your view.",
"channel_header.autotranslation.disable_confirm.message": "Messages in this channel will revert to their original language. This will only affect how you see this channel. Other members won't be affected.",
"channel_header.autotranslation.disable_confirm.title": "Turn off auto-translation",
"channel_header.autotranslation.enable": "Enable autotranslation",
"channel_header.autotranslation.language_not_supported.subtitle": "Your language is not supported",
"channel_header.autotranslation.language_not_supported.title": "Auto-translation",
"channel_header.autotranslationMessage": "Auto-translated",
"channel_header.autotranslationMessage.tooltip.hint": "This channel is being automatically translated to your language",
"channel_header.autotranslationMessage.tooltip.title": "Auto-translation is enabled",
"channel_header.back": "Back to channel",
"channel_header.channel_settings": "Channel Settings",
"channel_header.channelFiles": "Channel files",
@@ -3927,6 +3941,8 @@
"channel_switch_modal.unreadMentions": "{count, number} {count, plural, one {unread notification} other {unread notifications}}",
"channel_toggle_button.private": "Private",
"channel_toggle_button.public": "Public",
"channel_translation.label.name": "Auto-translation",
"channel_translation.label.subtext": "When enabled, messages in this channel will be translated to members' own languages. Members can opt-out of this from the channel menu to view the original message instead.",
"channelHeader.addToFavorites": "Add to Favorites",
"channelHeader.hideInfo": "Close Info",
"channelHeader.removeFromFavorites": "Remove from Favorites",
@@ -5532,11 +5548,17 @@
"post_info.post_reminder.sub_menu.tomorrow": "Tomorrow",
"post_info.post_reminder.sub_menu.two_hours": "2 hours",
"post_info.reply": "Reply",
"post_info.show_translation": "Show translation",
"post_info.submenu.icon": "submenu icon",
"post_info.submenu.mobile": "mobile submenu",
"post_info.system": "System",
"post_info.tooltip.actions": "Message actions",
"post_info.tooltip.add_reactions": "Add Reaction",
"post_info.translation_icon": "This post has been translated",
"post_info.translation_icon_processing": "Translating...",
"post_info.translation_icon_unavailable": "Translation unavailable",
"post_info.translation_icon.hint": "Click to view original",
"post_info.translation_icon.title": "Auto-translated",
"post_info.unpin": "Unpin from Channel",
"post_info.unread": "Mark as Unread",
"post_message_preview.channel": "Only visible to users in ~{channel}",
@@ -5575,6 +5597,7 @@
"post.ariaLabel.reaction": ", 1 reaction",
"post.ariaLabel.reactionMultiple": ", {reactionCount} reactions",
"post.ariaLabel.replyMessage": "At {time} {date}, {authorName} replied, {message}",
"post.ariaLabel.translated": ", translated from {sourceLanguage} to {targetLanguage}",
"post.burn_on_read.confirmation_modal.body_receiver": "This message will be permanently deleted for you right away and can't be undone.",
"post.burn_on_read.confirmation_modal.body_sender": "This message will be permanently deleted for all recipients right away. This action can't be undone. Are you sure you want to delete this message?",
"post.burn_on_read.confirmation_modal.cancel": "Cancel",
@@ -6001,6 +6024,10 @@
"shortcuts.team_nav.prev.mac": "Previous team:\t⌘|⌥|Up",
"shortcuts.team_nav.switcher": "Navigate to a specific team:\tCtrl|Alt|[1-9]",
"shortcuts.team_nav.switcher.mac": "Navigate to a specific team:\t⌘|⌥|[1-9]",
"show_translation.auto_translated_badge": "AUTO-TRANSLATED",
"show_translation.modal.title": "Show Translation",
"show_translation.original_badge": "ORIGINAL",
"show_translation.unknown_language": "Unknown",
"sidebar_left.add_channel_dropdown.browseChannels": "Browse channels",
"sidebar_left.add_channel_dropdown.createNewChannel": "Create new channel",
"sidebar_left.addChannelsCta": "Add Channels",
@@ -40,6 +40,8 @@ export default keyMirror({
POST_DELETED: null,
POST_REMOVED: null,
POST_TRANSLATION_UPDATED: null,
POST_PINNED_CHANGED: null,
RECEIVED_FOCUSED_POST: null,
@@ -28,6 +28,7 @@ import {getCategoryInTeamByType} from 'mattermost-redux/selectors/entities/chann
import {
getChannel as getChannelSelector,
getMyChannelMember as getMyChannelMemberSelector,
hasAutotranslationBecomeEnabled,
isManuallyUnread,
} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
@@ -37,6 +38,7 @@ import {DelayedDataLoader} from 'mattermost-redux/utils/data_loader';
import {addChannelToInitialCategory, addChannelToCategory} from './channel_categories';
import {logError} from './errors';
import {bindClientFunc, forceLogoutIfNecessary} from './helpers';
import {resetReloadPostsInChannel} from './posts';
import {savePreferences} from './preferences';
import {loadRolesIfNeeded} from './roles';
import {getMissingProfilesByIds} from './users';
@@ -262,6 +264,44 @@ export function patchChannel(channelId: string, patch: Partial<Channel>): Action
});
}
export function setMyChannelAutotranslation(channelId: string, enabled: boolean): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
const state = getState();
const myChannelMember = getMyChannelMemberSelector(state, channelId);
if (!myChannelMember) {
return {data: false, error: 'Channel member not found'};
}
const updatedMember: ChannelMembership = {
...myChannelMember,
autotranslation_disabled: !enabled,
};
try {
await Client4.setMyChannelAutotranslation(channelId, enabled);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {data: undefined, error};
}
// We check before updating the store
const becameEnabled = hasAutotranslationBecomeEnabled(state, updatedMember);
dispatch({
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
data: updatedMember,
});
// If autotranslation changed, delete posts for this channel
if (becameEnabled) {
await dispatch(resetReloadPostsInChannel(channelId));
}
return {data: true, error: undefined};
};
}
export function updateChannelPrivacy(channelId: string, privacy: string): ActionFuncAsync<Channel> {
return bindClientFunc({
clientFunc: Client4.updateChannelPrivacy,
@@ -27,7 +27,7 @@ import {decrementThreadCounts} from 'mattermost-redux/actions/threads';
import {getProfilesByIds, getProfilesByUsernames, getStatusesByIds} from 'mattermost-redux/actions/users';
import {Client4, DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE} from 'mattermost-redux/client';
import {General, Preferences, Posts} from 'mattermost-redux/constants';
import {getCurrentChannelId, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels';
import {getAllChannels, getCurrentChannelId, getMyChannelMember, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels';
import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common';
import {getCustomEmojisByName as selectCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {getAllGroupsByName} from 'mattermost-redux/selectors/entities/groups';
@@ -1281,17 +1281,39 @@ export function moveHistoryIndexForward(index: string): ActionFuncAsync {
};
}
export function resetReloadPostsInTranslatedChannels(): ActionFuncAsync {
return async (dispatch, getState) => {
const state = getState();
const channels = getAllChannels(state);
for (const channel of Object.values(channels)) {
if (!channel.autotranslation) {
continue;
}
const myMember = getMyChannelMember(state, channel.id);
if (myMember?.autotranslation_disabled) {
continue;
}
dispatch(resetReloadPostsInChannel(channel.id));
}
return {data: true};
};
}
/**
* Ensures thread-replies in channels correctly follow CRT:ON/OFF
*/
export function resetReloadPostsInChannel(): ActionFuncAsync {
export function resetReloadPostsInChannel(channelId?: string): ActionFuncAsync {
return async (dispatch, getState) => {
dispatch({
type: PostTypes.RESET_POSTS_IN_CHANNEL,
channelId,
});
const currentChannelId = getCurrentChannelId(getState());
if (currentChannelId) {
if (currentChannelId && (!channelId || channelId === currentChannelId)) {
// wait for channel to be fully deselected; prevent stuck loading screen
// full state-change/reconciliation will cause prefetchChannelPosts to reload posts
await dispatch(selectChannel('')); // do not remove await
@@ -12,6 +12,7 @@ export const PostTypes = {
EPHEMERAL_ADD_TO_CHANNEL: 'system_ephemeral_add_to_channel' as PostType,
HEADER_CHANGE: 'system_header_change' as PostType,
PURPOSE_CHANGE: 'system_purpose_change' as PostType,
AUTO_TRANSLATION_CHANGE: 'system_autotranslation' as PostType,
JOIN_LEAVE: 'system_join_leave' as PostType,
JOIN_CHANNEL: 'system_join_channel' as PostType,
@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {WebSocketMessages} from '@mattermost/client';
import type {
OpenGraphMetadata,
Post,
@@ -432,6 +433,33 @@ export function handlePosts(state: IDMappedObjects<Post> = {}, action: MMReduxAc
};
}
case PostTypes.POST_TRANSLATION_UPDATED: {
const data: WebSocketMessages.PostTranslationUpdated['data'] = action.data;
if (!state[data.object_id]) {
return state;
}
const translations = state[data.object_id].metadata?.translations || {};
const newTranslations = {
...translations,
[data.language]: {
lang: data.language,
object: data.translation ? JSON.parse(data.translation) : undefined,
state: data.state,
source_lang: data.src_lang,
}};
return {
...state,
[data.object_id]: {
...state[data.object_id],
metadata: {
...state[data.object_id].metadata,
translations: newTranslations,
},
},
};
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
@@ -578,7 +606,13 @@ export function handlePendingPosts(state: string[] = [], action: MMReduxAction)
export function postsInChannel(state: Record<string, PostOrderBlock[]> = {}, action: MMReduxAction, prevPosts: IDMappedObjects<Post>, nextPosts: Record<string, Post>) {
switch (action.type) {
case PostTypes.RESET_POSTS_IN_CHANNEL: {
return {};
const {channelId} = action;
if (!channelId) {
return {};
}
const nextState = {...state};
Reflect.deleteProperty(nextState, channelId);
return nextState;
}
case PostTypes.RECEIVED_NEW_POST: {
const post = action.data as Post;
@@ -939,9 +973,7 @@ export function postsInChannel(state: Record<string, PostOrderBlock[]> = {}, act
// For BoR posts: only remove the post itself (BoR doesn't support threads)
// For regular posts: remove the post and its thread replies
const nextOrder = isBoRPost ?
block.order.filter((postId: string) => postId !== post.id) :
block.order.filter((postId: string) => postId !== post.id && prevPosts[postId]?.root_id !== post.id);
const nextOrder = isBoRPost ? block.order.filter((postId: string) => postId !== post.id) : block.order.filter((postId: string) => postId !== post.id && prevPosts[postId]?.root_id !== post.id);
if (nextOrder.length !== block.order.length) {
nextPostsForChannel[i] = {
@@ -3,6 +3,7 @@
import type {Channel} from '@mattermost/types/channels';
import type {GlobalState} from '@mattermost/types/store';
import type {DeepPartial} from '@mattermost/types/utilities';
import {General, Permissions} from 'mattermost-redux/constants';
import {CategoryTypes} from 'mattermost-redux/constants/channel_categories';
@@ -3228,3 +3229,138 @@ describe('Selectors.Channels.getRecentProfilesFromDMs', () => {
]);
});
});
describe('hasAutotranslationBecomeEnabled', () => {
const enabledChannel = {...TestHelper.fakeChannel('team_id'), autotranslation: true};
const enabledMember = {...TestHelper.fakeChannelMember('user_id', enabledChannel.id), autotranslation_disabled: false};
const disabledChannel = {...enabledChannel, autotranslation: false};
const disabledMember = {...enabledMember, autotranslation_disabled: true};
it('returns false when EnableAutoTranslation is not enabled in config', () => {
const state: DeepPartial<GlobalState> = {
entities: {
general: {config: {EnableAutoTranslation: 'false'}},
channels: {
channels: {
[disabledChannel.id]: disabledChannel,
},
myMembers: {
[enabledChannel.id]: enabledMember,
},
},
},
};
expect(Selectors.hasAutotranslationBecomeEnabled(state as GlobalState, enabledChannel)).toBe(false);
});
it('returns false when missing state', () => {
const missingChannelState: DeepPartial<GlobalState> = {
entities: {
general: {config: {EnableAutoTranslation: 'true'}},
channels: {
channels: {},
myMembers: {[disabledChannel.id]: disabledMember},
},
},
};
expect(Selectors.hasAutotranslationBecomeEnabled(missingChannelState as GlobalState, enabledMember)).toBe(false);
const missingMemberState: DeepPartial<GlobalState> = {
entities: {
general: {config: {EnableAutoTranslation: 'true'}},
channels: {
channels: {[disabledChannel.id]: disabledChannel},
myMembers: {},
},
},
};
expect(Selectors.hasAutotranslationBecomeEnabled(missingMemberState as GlobalState, enabledChannel)).toBe(false);
});
it('returns false on disable', () => {
const state: DeepPartial<GlobalState> = {
entities: {
general: {config: {EnableAutoTranslation: 'true'}},
channels: {
channels: {[enabledChannel.id]: enabledChannel},
myMembers: {[enabledChannel.id]: enabledMember},
},
},
};
expect(Selectors.hasAutotranslationBecomeEnabled(state as GlobalState, disabledChannel)).toBe(false);
expect(Selectors.hasAutotranslationBecomeEnabled(state as GlobalState, disabledMember)).toBe(false);
});
it('returns false when no change', () => {
const stateEnabled: DeepPartial<GlobalState> = {
entities: {
general: {config: {EnableAutoTranslation: 'true'}},
channels: {
channels: {[enabledChannel.id]: enabledChannel},
myMembers: {[enabledChannel.id]: enabledMember},
},
},
};
expect(Selectors.hasAutotranslationBecomeEnabled(stateEnabled as GlobalState, enabledChannel)).toBe(false);
expect(Selectors.hasAutotranslationBecomeEnabled(stateEnabled as GlobalState, enabledMember)).toBe(false);
const stateDisabled: DeepPartial<GlobalState> = {
entities: {
general: {config: {EnableAutoTranslation: 'true'}},
channels: {
channels: {[disabledChannel.id]: disabledChannel},
myMembers: {[disabledChannel.id]: disabledMember},
},
},
};
expect(Selectors.hasAutotranslationBecomeEnabled(stateDisabled as GlobalState, disabledChannel)).toBe(false);
expect(Selectors.hasAutotranslationBecomeEnabled(stateDisabled as GlobalState, disabledMember)).toBe(false);
});
it('returns false when change doesn\'t enable autotranslation', () => {
const state: DeepPartial<GlobalState> = {
entities: {
general: {config: {EnableAutoTranslation: 'true'}},
channels: {
channels: {[disabledChannel.id]: disabledChannel},
myMembers: {[disabledChannel.id]: disabledMember},
},
},
};
expect(Selectors.hasAutotranslationBecomeEnabled(state as GlobalState, enabledChannel)).toBe(false);
expect(Selectors.hasAutotranslationBecomeEnabled(state as GlobalState, enabledMember)).toBe(false);
});
it('returns true when autotranslation becomes enabled', () => {
const enabledChannelState: DeepPartial<GlobalState> = {
entities: {
general: {config: {EnableAutoTranslation: 'true'}},
channels: {
channels: {[enabledChannel.id]: enabledChannel},
myMembers: {[disabledChannel.id]: disabledMember},
},
},
};
expect(Selectors.hasAutotranslationBecomeEnabled(enabledChannelState as GlobalState, enabledMember)).toBe(true);
const enabledMemberState: DeepPartial<GlobalState> = {
entities: {
general: {config: {EnableAutoTranslation: 'true'}},
channels: {
channels: {[disabledChannel.id]: disabledChannel},
myMembers: {[enabledChannel.id]: enabledMember},
},
},
};
expect(Selectors.hasAutotranslationBecomeEnabled(enabledMemberState as GlobalState, enabledChannel)).toBe(true);
});
});
@@ -34,6 +34,7 @@ import {
getMyCurrentChannelMembership as getMyCurrentChannelMembershipInternal,
getUsers,
} from 'mattermost-redux/selectors/entities/common';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {
getTeammateNameDisplaySetting,
isCollapsedThreadsEnabled,
@@ -67,6 +68,7 @@ import {
} from 'mattermost-redux/utils/channel_utils';
import {createIdsSelector} from 'mattermost-redux/utils/helpers';
import {getCurrentUserLocale} from './i18n';
import {isPostPriorityEnabled} from './posts';
import {getThreadCounts, getThreadCountsIncludingDirect} from './threads';
@@ -1455,3 +1457,69 @@ export function getChannelBanner(state: GlobalState, channelId: string): Channel
const channel = getChannel(state, channelId);
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),
);
}
export function isChannelAutotranslated(state: GlobalState, channelId: string): boolean {
return getMyChannelAutotranslation(state, channelId);
}
export function isUserLanguageSupportedForAutotranslation(state: GlobalState): boolean {
const locale = getCurrentUserLocale(state);
const config = getConfig(state);
if (config?.EnableAutoTranslation !== 'true') {
return false;
}
const targetLanguages = config?.AutoTranslationLanguages?.split(',').map((l) => l.trim()).filter(Boolean);
return Boolean(targetLanguages?.length && targetLanguages.includes(locale));
}
export function hasAutotranslationBecomeEnabled(state: GlobalState, channelOrMember: Channel | ChannelMembership) {
const autotranslationEnabled = getConfig(state)?.EnableAutoTranslation === 'true';
let existingChannel: Channel | undefined;
let existingMember: ChannelMembership | undefined;
let newChannel: Channel | undefined;
let newMember: ChannelMembership | undefined;
if ('channel_id' in channelOrMember) {
newMember = channelOrMember;
existingChannel = getChannel(state, newMember.channel_id);
existingMember = getMyChannelMember(state, newMember.channel_id);
newChannel = existingChannel;
} else {
newChannel = channelOrMember;
existingChannel = getChannel(state, newChannel.id);
existingMember = getMyChannelMember(state, newChannel.id);
newMember = existingMember;
}
if (!existingChannel || !existingMember) {
// channel is not in the state, so there is no need
// to reload posts
return false;
}
const wasTranslating = autotranslationEnabled && existingChannel.autotranslation && !existingMember.autotranslation_disabled;
const isTranslating = autotranslationEnabled && newChannel?.autotranslation && !newMember?.autotranslation_disabled;
return !wasTranslating && isTranslating;
}
@@ -4,6 +4,7 @@ exports[`plugins/PostMessageView should match snapshot with extended post type 1
<PostMessageView
currentRelativeTeamUrl="team_url"
enableFormatting={true}
isChannelAutotranslated={false}
isRHS={false}
options={Object {}}
pluginPostTypes={
@@ -53,6 +54,7 @@ exports[`plugins/PostMessageView should match snapshot with extended post type 1
"type": "Denim",
}
}
userLanguage="en"
>
<PostTypePlugin
isRHS={false}
@@ -33,6 +33,8 @@ describe('plugins/PostMessageView', () => {
theme: Preferences.THEMES.denim,
enableFormatting: true,
currentRelativeTeamUrl: 'team_url',
isChannelAutotranslated: false,
userLanguage: 'en',
};
test('should match snapshot with extended post type', () => {
@@ -86,6 +86,7 @@ button {
&.btn-compact {
width: 20px;
min-width: 20px;
height: 20px;
}
@@ -104,6 +105,7 @@ button {
&.btn-compact {
width: 28px;
min-width: 28px;
height: 28px;
}
@@ -119,6 +121,7 @@ button {
&.btn-compact {
width: 36px;
min-width: 36px;
height: 36px;
}
@@ -1039,6 +1039,18 @@
}
}
&.post--modal {
padding: 0;
.post__content {
padding: 0;
}
.post__img {
width: 42px;
}
}
&.same--root {
&.same--user {
padding: 0 0.5em 0 1.5em;
@@ -1659,6 +1671,28 @@
}
}
.post__translation-icon-processing {
color: rgba(var(--center-channel-color-rgb), 0.73);
font-size: 10px;
line-height: 16px;
.LoadingSpinner.with-text .spinner {
margin-right: 3px;
}
}
.post__translation-icon-unavailable {
display: inline-flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
padding: 0;
color: var(--dnd-indicator);
font-size: 12px;
}
.post_permalink_mobile_view {
display: inline-block;
color: inherit;
@@ -2294,3 +2328,33 @@
}
}
}
.post-message__shimmer {
position: absolute;
z-index: 999;
top: 0;
right: 0;
bottom: 0;
left: 0;
border-radius: 4px;
animation: post-message-shimmer 3.5s linear infinite;
background: linear-gradient(
110deg,
rgba(var(--center-channel-bg-rgb), 0.40) 0%,
rgba(var(--center-channel-bg-rgb), 0.40) 40%,
var(--center-channel-bg) 50%,
rgba(var(--center-channel-bg-rgb), 0.40) 60%,
rgba(var(--center-channel-bg-rgb), 0.40) 100%
);
background-size: 300% 100%;
pointer-events: none;
}
@keyframes post-message-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
+19 -2
View File
@@ -188,7 +188,6 @@
&::after {
height: 20px;
border-left: solid 1px rgba(var(--center-channel-color-rgb), 0.12);
margin-right: 8px;
margin-left: 8px;
content: "";
}
@@ -202,6 +201,15 @@
content: "";
}
}
&:has(.header-placeholder) .autotranslation-header {
&::after {
height: 20px;
border-left: solid 1px rgba(var(--center-channel-color-rgb), 0.12);
margin-left: 8px;
content: "";
}
}
}
}
}
@@ -371,6 +379,7 @@
align-items: center;
margin: 0;
font-size: 12px;
gap: 8px;
line-height: 24px;
text-overflow: ellipsis;
@@ -405,7 +414,6 @@
&::after {
height: 20px;
border-left: solid 1px rgba(var(--center-channel-color-rgb), 0.12);
margin-right: 8px;
margin-left: 8px;
content: "";
}
@@ -419,6 +427,15 @@
content: "";
}
}
.autotranslation-header {
&::after {
height: 20px;
border-left: solid 1px rgba(var(--center-channel-color-rgb), 0.12);
margin-left: 8px;
content: "";
}
}
}
.header-description__text {
@@ -107,7 +107,6 @@
border-radius: 0 4px 4px 0;
border-left: none;
margin: 5px 0 5px 20px;
background: var(--center-channel-bg);
&.permalink--opengraph {
margin: 5px 0 0 20px;
@@ -314,6 +314,10 @@
.post-menu {
top: -34px;
}
.badges-wrapper {
z-index: 1;
}
}
&.other--root {
+23
View File
@@ -363,6 +363,8 @@ export const ModalIdentifiers = {
USER_SETTINGS: 'user_settings',
QUICK_SWITCH: 'quick_switch',
REMOVED_FROM_CHANNEL: 'removed_from_channel',
SHOW_TRANSLATION: 'show_translation',
DISABLE_AUTOTRANSLATION_CONFIRM: 'disable_autotranslation_confirm',
EMAIL_INVITE: 'email_invite',
INTERACTIVE_DIALOG: 'interactive_dialog',
APPS_MODAL: 'apps_modal',
@@ -619,6 +621,26 @@ export const SocketEvents = {
APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings',
APPS_FRAMEWORK_PLUGIN_ENABLED: 'custom_com.mattermost.apps_plugin_enabled',
APPS_FRAMEWORK_PLUGIN_DISABLED: 'custom_com.mattermost.apps_plugin_disabled',
FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED: 'first_admin_visit_marketplace_status_received',
THREAD_UPDATED: 'thread_updated',
THREAD_FOLLOW_CHANGED: 'thread_follow_changed',
THREAD_READ_CHANGED: 'thread_read_changed',
POST_ACKNOWLEDGEMENT_ADDED: 'post_acknowledgement_added',
POST_ACKNOWLEDGEMENT_REMOVED: 'post_acknowledgement_removed',
DRAFT_CREATED: 'draft_created',
DRAFT_UPDATED: 'draft_updated',
DRAFT_DELETED: 'draft_deleted',
SCHEDULED_POST_CREATED: 'scheduled_post_created',
SCHEDULED_POST_UPDATED: 'scheduled_post_updated',
SCHEDULED_POST_DELETED: 'scheduled_post_deleted',
PERSISTENT_NOTIFICATION_TRIGGERED: 'persistent_notification_triggered',
HOSTED_CUSTOMER_SIGNUP_PROGRESS_UPDATED: 'hosted_customer_signup_progress_updated',
CPA_FIELD_CREATED: 'custom_profile_attributes_field_created',
CPA_FIELD_UPDATED: 'custom_profile_attributes_field_updated',
CPA_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
CPA_VALUES_UPDATED: 'custom_profile_attributes_values_updated',
CONTENT_FLAGGING_REPORT_VALUE_CHANGED: 'content_flagging_report_value_updated',
RECAP_UPDATED: 'recap_updated',
};
export const TutorialSteps = {
@@ -725,6 +747,7 @@ export const PostTypes = {
CUSTOM_CALLS: 'custom_calls',
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
CUSTOM_DATA_SPILLAGE_REPORT: 'custom_spillage_report',
AUTO_TRANSLATION_CHANGE: 'system_autotranslation',
BURN_ON_READ: 'burn_on_read',
};
+143 -7
View File
@@ -892,7 +892,7 @@ describe('PostUtils.createAriaLabelForPost', () => {
};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting);
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting, false);
expect(ariaLabel.indexOf(author)).not.toBe(-1);
expect(ariaLabel.indexOf(testPost.message)).not.toBe(-1);
expect(ariaLabel.indexOf('3 attachments')).not.toBe(-1);
@@ -912,7 +912,7 @@ describe('PostUtils.createAriaLabelForPost', () => {
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting);
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting, false);
expect(ariaLabel.indexOf('replied')).not.toBe(-1);
});
@@ -927,7 +927,7 @@ describe('PostUtils.createAriaLabelForPost', () => {
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting);
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting, false);
expect(ariaLabel.indexOf('smile emoji')).not.toBe(-1);
expect(ariaLabel.indexOf('+1 emoji')).not.toBe(-1);
expect(ariaLabel.indexOf('non-potable water emoji')).not.toBe(-1);
@@ -947,7 +947,7 @@ describe('PostUtils.createAriaLabelForPost', () => {
const reactions = {};
const isFlagged = true;
expect(() => PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting)).not.toThrow();
expect(() => PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting, false)).not.toThrow();
});
test('Should not mention reactions if passed an empty object', () => {
@@ -962,7 +962,7 @@ describe('PostUtils.createAriaLabelForPost', () => {
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting);
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting, false);
expect(ariaLabel.indexOf('reaction')).toBe(-1);
});
@@ -978,7 +978,7 @@ describe('PostUtils.createAriaLabelForPost', () => {
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting);
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, teammateNameDisplaySetting, false);
expect(ariaLabel.indexOf('@benjamin.cooke')).not.toBe(-1);
});
@@ -994,9 +994,145 @@ describe('PostUtils.createAriaLabelForPost', () => {
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, 'nickname_full_name');
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, 'nickname_full_name', false);
expect(ariaLabel.indexOf('@sysadmin')).not.toBe(-1);
});
test('Should show translated message', () => {
const intl = createIntl({locale: 'en', messages: enMessages, defaultLocale: 'en'});
const testPost = TestHelper.getPostMock({
message: 'test_message in Spanish',
create_at: (new Date().getTime() / 1000) || 0,
type: '',
});
testPost.metadata.translations = {
en: {
object: {
message: 'test_message in English',
},
state: 'ready',
source_lang: 'es',
},
};
const author = 'test_author';
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, 'nickname_full_name', true);
expect(ariaLabel.indexOf('translated from Spanish to English')).not.toBe(-1);
expect(ariaLabel.indexOf('test_message in English')).not.toBe(-1);
expect(ariaLabel.indexOf('test_message in Spanish')).toBe(-1);
});
test('Should show original message if translation is not ready', () => {
const intl = createIntl({locale: 'en', messages: enMessages, defaultLocale: 'en'});
const testPost = TestHelper.getPostMock({
message: 'test_message in Spanish',
create_at: (new Date().getTime() / 1000) || 0,
type: '',
});
testPost.metadata.translations = {
en: {
object: {
message: 'test_message in English',
},
state: 'processing',
source_lang: 'es',
},
};
const author = 'test_author';
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, 'nickname_full_name', true);
expect(ariaLabel.indexOf('translated from')).toBe(-1);
expect(ariaLabel.indexOf('test_message in Spanish')).not.toBe(-1);
expect(ariaLabel.indexOf('test_message in English')).toBe(-1);
});
test('Should show original message if there is no translation for the current language', () => {
const intl = createIntl({locale: 'en', messages: enMessages, defaultLocale: 'en'});
const testPost = TestHelper.getPostMock({
message: 'test_message in Spanish',
create_at: (new Date().getTime() / 1000) || 0,
type: '',
});
testPost.metadata.translations = {
de: {
object: {
message: 'test_message in German',
},
state: 'unavailable',
source_lang: 'es',
},
};
const author = 'test_author';
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, 'nickname_full_name', true);
expect(ariaLabel.indexOf('translated from')).toBe(-1);
expect(ariaLabel.indexOf('test_message in Spanish')).not.toBe(-1);
expect(ariaLabel.indexOf('test_message in German')).toBe(-1);
});
test('Should show original message if we pass autotranslated as false', () => {
const intl = createIntl({locale: 'en', messages: enMessages, defaultLocale: 'en'});
const testPost = TestHelper.getPostMock({
message: 'test_message in Spanish',
create_at: (new Date().getTime() / 1000) || 0,
type: '',
});
testPost.metadata.translations = {
en: {
object: {
message: 'test_message in English',
},
state: 'ready',
source_lang: 'es',
},
};
const author = 'test_author';
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, 'nickname_full_name', false);
expect(ariaLabel.indexOf('translated from')).toBe(-1);
expect(ariaLabel.indexOf('test_message in Spanish')).not.toBe(-1);
expect(ariaLabel.indexOf('test_message in English')).toBe(-1);
});
test('Should show original message if the post type is not empty', () => {
const intl = createIntl({locale: 'en', messages: enMessages, defaultLocale: 'en'});
const testPost = TestHelper.getPostMock({
message: 'test_message in Spanish',
create_at: (new Date().getTime() / 1000) || 0,
type: 'system_autotranslation',
});
testPost.metadata.translations = {
en: {
object: {
message: 'test_message in English',
},
state: 'ready',
source_lang: 'es',
},
};
const author = 'test_author';
const reactions = {};
const isFlagged = true;
const ariaLabel = PostUtils.createAriaLabelForPost(testPost, author, isFlagged, reactions, intl, emojiMap, users, 'nickname_full_name', false);
expect(ariaLabel.indexOf('translated from')).toBe(-1);
expect(ariaLabel.indexOf('test_message in Spanish')).not.toBe(-1);
expect(ariaLabel.indexOf('test_message in English')).toBe(-1);
});
});
describe('PostUtils.splitMessageBasedOnCaretPosition', () => {
+44 -7
View File
@@ -11,7 +11,7 @@ import type {ClientConfig, ClientLicense} from '@mattermost/types/config';
import type {ServerError} from '@mattermost/types/errors';
import type {Group} from '@mattermost/types/groups';
import {isMessageAttachmentArray} from '@mattermost/types/message_attachments';
import type {Post, PostPriorityMetadata} from '@mattermost/types/posts';
import type {Post, PostPriorityMetadata, PostTranslation} from '@mattermost/types/posts';
import {PostPriority} from '@mattermost/types/posts';
import type {Reaction} from '@mattermost/types/reactions';
import type {UserProfile} from '@mattermost/types/users';
@@ -462,7 +462,7 @@ export function makeGetMentionsFromMessage(): (state: GlobalState, post: Post) =
);
}
export function usePostAriaLabel(post: Post | undefined) {
export function usePostAriaLabel(post: Post | undefined, autotranslated: boolean) {
const intl = useIntl();
const getDisplayName = useMemo(makeGetDisplayName, []);
@@ -492,17 +492,26 @@ export function usePostAriaLabel(post: Post | undefined) {
emojiMap,
mentions,
teammateNameDisplaySetting,
autotranslated,
);
});
}
export function createAriaLabelForPost(post: Post, author: string, isFlagged: boolean, reactions: Record<string, Reaction> | undefined, intl: IntlShape, emojiMap: EmojiMap, mentions: Record<string, UserProfile>, teammateNameDisplaySetting: string): string {
export function createAriaLabelForPost(post: Post, author: string, isFlagged: boolean, reactions: Record<string, Reaction> | undefined, intl: IntlShape, emojiMap: EmojiMap, mentions: Record<string, UserProfile>, teammateNameDisplaySetting: string, autotranslated: boolean): string {
const {formatMessage, formatTime, formatDate} = intl;
let message = post.state === Posts.POST_DELETED ? formatMessage({
id: 'post_body.deleted',
defaultMessage: '(message deleted)',
}) : post.message || '';
const translation = getPostTranslation(post, intl.locale);
const isTranslated = autotranslated && post.type === '' && translation?.state === 'ready';
let message = post.message || '';
if (post.state === Posts.POST_DELETED) {
message = formatMessage({
id: 'post_body.deleted',
defaultMessage: '(message deleted)',
});
} else if (isTranslated) {
message = getPostTranslatedMessage(message, translation);
}
let match;
// Match all the shorthand forms of emojis first
@@ -620,6 +629,25 @@ export function createAriaLabelForPost(post: Post, author: string, isFlagged: bo
});
}
if (isTranslated) {
const originalLanguage = translation?.source_lang || 'unknown';
const originalLanguageName = originalLanguage === 'unknown' ? intl.formatMessage({
id: 'show_translation.unknown_language',
defaultMessage: 'Unknown',
}) : intl.formatDisplayName(originalLanguage, {type: 'language'});
const targetLanguageName = intl.formatDisplayName(intl.locale, {type: 'language'});
ariaLabel += formatMessage({
id: 'post.ariaLabel.translated',
defaultMessage: ', translated from {sourceLanguage} to {targetLanguage}',
},
{
sourceLanguage: originalLanguageName,
targetLanguage: targetLanguageName,
});
}
return ariaLabel;
}
@@ -877,3 +905,12 @@ export function hasRequestedPersistentNotifications(priority?: PostPriorityMetad
priority?.persistent_notifications
);
}
export function getPostTranslation(post: Post, locale: string): PostTranslation | undefined {
const normalizedLocale = locale.split('-')[0];
return post.metadata?.translations?.[normalizedLocale];
}
export function getPostTranslatedMessage(originalMessage: string, translation: PostTranslation): string {
return translation.object?.message || originalMessage;
}
+3 -3
View File
@@ -15,7 +15,7 @@
"platform/types"
],
"dependencies": {
"@mattermost/compass-icons": "0.1.52",
"@mattermost/compass-icons": "0.1.53",
"react-intl": "7.1.14",
"typescript": "5.6.3"
},
@@ -4370,7 +4370,7 @@
"link": true
},
"node_modules/@mattermost/compass-icons": {
"version": "0.1.52",
"version": "0.1.53",
"license": "MIT",
"peer": true
},
@@ -23148,7 +23148,7 @@
},
"peerDependencies": {
"@babel/runtime-corejs3": "^7.17.8",
"@mattermost/compass-icons": "0.1.52",
"@mattermost/compass-icons": "0.1.53",
"classnames": "^2.3.1",
"lodash": "^4.17.21",
"react-intl": "*",
+1 -1
View File
@@ -22,7 +22,7 @@
"gen-lang-imports": "node scripts/gen_lang_imports.mjs"
},
"dependencies": {
"@mattermost/compass-icons": "0.1.52",
"@mattermost/compass-icons": "0.1.53",
"react-intl": "7.1.14",
"typescript": "5.6.3"
},
+7
View File
@@ -1754,6 +1754,13 @@ export default class Client4 {
);
};
setMyChannelAutotranslation = (channelId: string, enabled: boolean) => {
return this.doFetch<StatusOK>(
`${this.getChannelMemberRoute(channelId, 'me')}/autotranslation`,
{method: 'put', body: JSON.stringify({autotranslation_disabled: !enabled})},
);
};
updateChannelNotifyProps = (props: any) => {
return this.doFetch<StatusOK>(
`${this.getChannelMemberRoute(props.channel_id, props.user_id)}/notify_props`,
@@ -90,4 +90,5 @@ export const enum WebSocketEvents {
CPAValuesUpdated = 'custom_profile_attributes_values_updated',
ContentFlaggingReportValueUpdated = 'content_flagging_report_value_updated',
RecapUpdated = 'recap_updated',
PostTranslationUpdated = 'post_translation_updated',
}
@@ -22,6 +22,7 @@ export type WebSocketMessage = (
Messages.PostDraft |
Messages.PersistentNotificationTriggered |
Messages.ScheduledPost |
Messages.PostTranslationUpdated |
Messages.ThreadUpdated |
Messages.ThreadFollowedChanged |
@@ -420,6 +420,16 @@ export type RecapUpdated = BaseWebSocketMessage<WebSocketEvents.RecapUpdated, {
recap_id: string;
}>;
// Post translation messages
export type PostTranslationUpdated = BaseWebSocketMessage<WebSocketEvents.PostTranslationUpdated, {
language: string;
object_id: string;
src_lang: string;
state: 'ready' | 'skipped' | 'processing' | 'unavailable';
translation: string;
}>;
// Plugin and integration messages
export type Plugin = BaseWebSocketMessage<WebSocketEvents.PluginEnabled | WebSocketEvents.PluginDisabled, {
+1 -1
View File
@@ -40,7 +40,7 @@
},
"peerDependencies": {
"@babel/runtime-corejs3": "^7.17.8",
"@mattermost/compass-icons": "0.1.52",
"@mattermost/compass-icons": "0.1.53",
"classnames": "^2.3.1",
"lodash": "^4.17.21",
"react-intl": "*",
+2
View File
@@ -71,6 +71,7 @@ export type Channel = {
policy_enforced?: boolean;
policy_is_active?: boolean;
default_category_name?: string;
autotranslation?: boolean;
};
export type ServerChannel = Channel & {
@@ -136,6 +137,7 @@ export type ChannelMembership = {
scheme_user: boolean;
scheme_admin: boolean;
post_root_id?: string;
autotranslation_disabled?: boolean;
};
export type ChannelUnread = {
+1
View File
@@ -240,6 +240,7 @@ export type ClientConfig = {
EnableUserManagedAttributes: string;
// Auto Translation Settings
AutoTranslationLanguages: string;
EnableAutoTranslation: string;
RestrictDMAndGMAutotranslation: string;
};
+4 -3
View File
@@ -33,6 +33,7 @@ export type PostType = 'system_add_remove' |
'reminder' |
'system_wrangler' |
'custom_spillage_report' |
'system_autotranslation' |
'burn_on_read' |
'';
@@ -64,9 +65,9 @@ export type PostPriorityMetadata = {
}
export type PostTranslation = {
text: string;
type: string;
confidence?: number;
object?: {
message: string;
};
state: 'ready' | 'skipped' | 'processing' | 'unavailable';
source_lang?: string;
};