mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-19 10:12:47 +08:00
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:
co-authored by
Claude
Matthew Birtch
Elias Nahum
Copilot
BenCookie95
Nick Misasi
Mattermost Build
parent
24957f5e22
commit
1c7246da68
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+8
-1
@@ -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);
|
||||
+2
-3
@@ -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;
|
||||
}
|
||||
|
||||
+2
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
+151
-87
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ describe('components/post_edit_history/edited_post_item', () => {
|
||||
closeRightHandSide: jest.fn(),
|
||||
openModal: jest.fn(),
|
||||
},
|
||||
isChannelAutotranslated: false,
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
|
||||
+76
-73
@@ -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
|
||||
|
||||
+2
@@ -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>
|
||||
|
||||
+2
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
+1
@@ -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', () => {
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ describe('PostList', () => {
|
||||
lastViewedAt: 0,
|
||||
shouldStartFromBottomWhenUnread: false,
|
||||
actions: baseActions,
|
||||
isChannelAutotranslated: false,
|
||||
};
|
||||
|
||||
const postListIdsForClassNames = [
|
||||
|
||||
+7
-1
@@ -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}) => (
|
||||
<>
|
||||
|
||||
+16
@@ -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),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ describe('PostMessagePreview', () => {
|
||||
toggleEmbedVisibility: jest.fn(),
|
||||
},
|
||||
isPostPriorityEnabled: false,
|
||||
isChannelAutotranslated: false,
|
||||
};
|
||||
|
||||
test('should render correctly', () => {
|
||||
|
||||
+17
-3
@@ -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),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+20
-3
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+5
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -37,6 +37,7 @@ function getBasePropsAndState(): [Props, DeepPartial<GlobalState>] {
|
||||
isThreadView: false,
|
||||
newMessagesSeparatorActions: [],
|
||||
measureRhsOpened: jest.fn(),
|
||||
isChannelAutotranslated: false,
|
||||
};
|
||||
|
||||
const state: DeepPartial<GlobalState> = {
|
||||
|
||||
+2
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
+136
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -240,6 +240,7 @@ export type ClientConfig = {
|
||||
EnableUserManagedAttributes: string;
|
||||
|
||||
// Auto Translation Settings
|
||||
AutoTranslationLanguages: string;
|
||||
EnableAutoTranslation: string;
|
||||
RestrictDMAndGMAutotranslation: string;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user