{
return {renderedSystemBotMessage}
;
}
+ if (this.props.post && this.props.post.type === Posts.POST_TYPES.WRANGLER) {
+ const renderedWranglerMessage = renderWranglerSystemMessage(this.props.post);
+ return {renderedWranglerMessage}
;
+ }
+
// Proxy images if we have an image proxy and the server hasn't already rewritten the this.props.post's image URLs.
const proxyImages = !this.props.post || !this.props.post.message_source || this.props.post.message === this.props.post.message_source;
const channelNamesMap = this.props.post && this.props.post.props && this.props.post.props.channel_mentions;
diff --git a/webapp/channels/src/components/post_markdown/system_message_helpers.tsx b/webapp/channels/src/components/post_markdown/system_message_helpers.tsx
index 3e9b2c916fc..720c20907aa 100644
--- a/webapp/channels/src/components/post_markdown/system_message_helpers.tsx
+++ b/webapp/channels/src/components/post_markdown/system_message_helpers.tsx
@@ -17,6 +17,7 @@ import CombinedSystemMessage from 'components/post_view/combined_system_message'
import GMConversionMessage from 'components/post_view/gm_conversion_message/gm_conversion_message';
import PostAddChannelMember from 'components/post_view/post_add_channel_member';
+import {t} from 'utils/i18n';
import type {TextFormattingOptions} from 'utils/text_formatting';
import {getSiteURL} from 'utils/url';
@@ -489,3 +490,29 @@ export function renderReminderSystemBotMessage(post: Post, currentTeam: Team): R
/>
);
}
+
+t('app.post.move_thread_command.direct_or_group.multiple_messages');
+t('app.post.move_thread_command.direct_or_group.one_message');
+t('app.post.move_thread_command.channel.multiple_messages');
+t('app.post.move_thread_command.channel.one_message');
+t('app.post.move_thread.from_another_channel');
+export function renderWranglerSystemMessage(post: Post): ReactNode {
+ let values = {} as any;
+ const id = post.props.TranslationID;
+ if (post.props && post.props.MovedThreadPermalink) {
+ values = {
+ Link: post.props.MovedThreadPermalink,
+ };
+ if (post.props.NumMessages > 1) {
+ values.NumMessages = post.props.NumMessages;
+ }
+ }
+ return (
+
+ );
+}
+
diff --git a/webapp/channels/src/components/widgets/settings/text_setting.tsx b/webapp/channels/src/components/widgets/settings/text_setting.tsx
index 0c0c2a73e5d..e93f3be7d53 100644
--- a/webapp/channels/src/components/widgets/settings/text_setting.tsx
+++ b/webapp/channels/src/components/widgets/settings/text_setting.tsx
@@ -14,9 +14,9 @@ export type Props = {
label: ReactNode;
labelClassName?: string;
placeholder?: string;
+ value: string | number | string[];
helpText?: ReactNode;
footer?: ReactNode;
- value: string | number;
inputClassName?: string;
maxLength?: number;
resizable?: boolean;
@@ -26,6 +26,7 @@ export type Props = {
// This is a custom prop that is not part of the HTML input element type
type?: InputTypes;
autoFocus?: boolean;
+ multiple?: boolean;
}
function TextSetting(props: Props) {
@@ -34,6 +35,12 @@ function TextSetting(props: Props) {
function handleChange(event: ChangeEvent) {
if (props.type === 'number') {
props.onChange(props.id, parseInt(event.target.value, 10));
+ } else if (props.type === 'text' && props.multiple) {
+ if (event.target.value === '') {
+ props.onChange(props.id, []);
+ } else {
+ props.onChange(props.id, event.target.value.split(','));
+ }
} else {
props.onChange(props.id, event.target.value);
}
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index d2a025db3fc..21f49c328c9 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -887,6 +887,8 @@
"admin.environment.webServer": "Web Server",
"admin.experimental.allowCustomThemes.desc": "Enables the **Display > Theme > Custom Theme** section in Settings.",
"admin.experimental.allowCustomThemes.title": "Allow Custom Themes:",
+ "admin.experimental.allowedEmailDomain.desc": "(Optional) When set, users must have an email ending in this domain to move threads. Multiple domains can be specified by separating them with commas.",
+ "admin.experimental.allowedEmailDomain.title": "Allowed Email Domain",
"admin.experimental.clientSideCertCheck.desc": "When **primary**, after the client side certificate is verified, user’s email is retrieved from the certificate and is used to log in without a password. When **secondary**, after the client side certificate is verified, user’s email is retrieved from the certificate and matched against the one supplied by the user. If they match, the user logs in with regular email/password credentials.",
"admin.experimental.clientSideCertCheck.title": "Client-Side Certification Login Method:",
"admin.experimental.clientSideCertEnable.desc": "Enables client-side certification for your Mattermost server. See documentation to learn more.",
@@ -954,6 +956,18 @@
"admin.experimental.linkMetadataTimeoutMilliseconds.desc": "The number of milliseconds to wait for metadata from a third-party link. Used with Post Metadata.",
"admin.experimental.linkMetadataTimeoutMilliseconds.example": "E.g.: \"5000\"",
"admin.experimental.linkMetadataTimeoutMilliseconds.title": "Link Metadata Timeout:",
+ "admin.experimental.moveThreadFromDirectMessageChannelEnable.desc": "Control whether move thread is permitted to move message threads from direct message channels or not.",
+ "admin.experimental.moveThreadFromDirectMessageChannelEnable.title": "Enable Moving Threads From Direct Message Channels",
+ "admin.experimental.moveThreadFromGroupMessageChannelEnable.desc": "Control whether move thread is permitted to move message threads from group message channels or not.",
+ "admin.experimental.moveThreadFromGroupMessageChannelEnable.title": "Enable Moving Threads From Group Message Channels",
+ "admin.experimental.moveThreadFromPrivateChannelEnable.desc": "Control whether move thread is permitted to move message threads from private channels or not.",
+ "admin.experimental.moveThreadFromPrivateChannelEnable.title": "Enable Moving Threads From Private Channels",
+ "admin.experimental.moveThreadMaxCount.desc": "The maximum number of messages in a thread that the plugin is allowed to move. Leave empty for unlimited messages.",
+ "admin.experimental.moveThreadMaxCount.title": "Max Thread Count Move Size",
+ "admin.experimental.moveThreadToAnotherTeamEnable.desc": "Control whether move thread is permitted to move message threads from one team to another or not.",
+ "admin.experimental.moveThreadToAnotherTeamEnable.title": "Enable Moving Threads To Different Teams",
+ "admin.experimental.PermittedMoveThreadRoles.desc": "Choose who is allowed to move threads to other channels based on roles. (Other permissions below still apply).",
+ "admin.experimental.PermittedMoveThreadRoles.title": "Permitted Roles",
"admin.experimental.samlSettingsLoginButtonBorderColor.desc": "Specify the color of the SAML login button border for white labeling purposes. Use a hex code with a #-sign before the code. This setting only applies to the mobile apps.",
"admin.experimental.samlSettingsLoginButtonBorderColor.title": "SAML login Button Border Color:",
"admin.experimental.samlSettingsLoginButtonColor.desc": "Specify the color of the SAML login button for white labeling purposes. Use a hex code with a #-sign before the code. This setting only applies to the mobile apps.",
@@ -2335,6 +2349,7 @@
"admin.sidebar.logs": "Server Logs",
"admin.sidebar.metrics": "Performance Monitoring",
"admin.sidebar.mfa": "MFA",
+ "admin.sidebar.move_thread": "Move thread (Beta)",
"admin.sidebar.notices": "Notices",
"admin.sidebar.notifications": "Notifications",
"admin.sidebar.oauth": "OAuth 2.0",
@@ -2370,6 +2385,7 @@
"admin.site.emoji": "Emoji",
"admin.site.fileSharingDownloads": "File Sharing and Downloads",
"admin.site.localization": "Localization",
+ "admin.site.move_thread": "Move thread",
"admin.site.notices": "Notices",
"admin.site.posts": "Posts",
"admin.site.public_links": "Public Links",
@@ -2742,6 +2758,11 @@
"app.channel.post_update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {old})",
"app.channel.post_update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {old} to: {new}",
"app.channel.post_update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {new}",
+ "app.post.move_thread_command.channel.multiple_messages": "A thread with {{.NumMessages}} messages has been moved: {{.Link}}\n",
+ "app.post.move_thread_command.channel.one_message": "A message has been moved: {{.Link}}\n",
+ "app.post.move_thread_command.direct_or_group.multiple_messages": "A thread with {{.NumMessages}} messages has been moved to a Direct/Group Message\n",
+ "app.post.move_thread_command.direct_or_group.one_message": "A message has been moved to a Direct/Group Message\n",
+ "app.post.move_thread.from_another_channel": "This thread was moved from another channel",
"apps.error": "Error: {error}",
"apps.error.command.field_missing": "Required fields missing: `{fieldName}`.",
"apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.",
@@ -4131,6 +4152,12 @@
"more_direct_channels.new_convo_note.full": "You've reached the maximum number of people for this conversation. Consider creating a private channel instead.",
"more_direct_channels.title": "Direct Messages",
"more.details": "More details",
+ "move_thread_modal.button.cancel": "Cancel",
+ "move_thread_modal.button.forward": "Move",
+ "move_thread_modal.notification.dm_or_gm": "Moving this thread changes who has access",
+ "move_thread_modal.preview.footer_message": "Originally posted in ~{channelName}",
+ "move_thread_modal.preview.title": "Message preview",
+ "move_thread_modal.title": "Move thread",
"msg_typing.areTyping": "{users} and {last} are typing...",
"msg_typing.isTyping": "{user} is typing...",
"multiselect.add": "Add",
@@ -4428,6 +4455,7 @@
"post_info.message.show_more": "Show more",
"post_info.message.visible": "(Only visible to you)",
"post_info.message.visible.compact": " (Only visible to you)",
+ "post_info.move_thread": "Move Thread",
"post_info.permalink": "Copy Link",
"post_info.pin": "Pin to Channel",
"post_info.post_reminder.menu": "Remind",
diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts
index 4dd642c1737..730ed804cb7 100644
--- a/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts
+++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts
@@ -58,4 +58,7 @@ export default keyMirror({
CREATE_ACK_POST_SUCCESS: null,
DELETE_ACK_POST_SUCCESS: null,
+
+ MOVE_POST_SUCCESS: null,
+ MOVE_POST_FAILURE: null,
});
diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts
index f1b1059aa37..f7006d42774 100644
--- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts
+++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts
@@ -1260,6 +1260,23 @@ export function selectPost(postId: string) {
};
}
+export function moveThread(postId: string, channelId: string) {
+ return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
+ try {
+ await Client4.moveThread(postId, channelId);
+ } catch (error) {
+ forceLogoutIfNecessary(error, dispatch, getState);
+ dispatch({type: PostTypes.MOVE_POST_FAILURE, error});
+ dispatch(logError(error));
+ return {error};
+ }
+
+ dispatch({type: PostTypes.MOVE_POST_SUCCESS});
+
+ return {data: true};
+ };
+}
+
export function selectFocusedPostId(postId: string) {
return {
type: PostTypes.RECEIVED_FOCUSED_POST,
diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts
index 98a76a47778..4d317a55fdd 100644
--- a/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts
+++ b/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts
@@ -33,6 +33,7 @@ export const PostTypes = {
ADD_BOT_TEAMS_CHANNELS: 'add_bot_teams_channels' as PostType,
SYSTEM_WARN_METRIC_STATUS: 'warn_metric_status' as PostType,
REMINDER: 'reminder' as PostType,
+ WRANGLER: 'system_wrangler' as PostType,
GM_CONVERTED_TO_CHANNEL: 'system_gm_to_channel' as PostType,
};
diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts
index 9f8b81cde10..510225ad46f 100644
--- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts
+++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts
@@ -276,6 +276,10 @@ export function cloudReverseTrial(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'CloudReverseTrial') === 'true';
}
+export function moveThreadsEnabled(state: GlobalState): boolean {
+ return getFeatureFlagValue(state, 'MoveThreadsEnabled') === 'true';
+}
+
export function streamlinedMarketplaceEnabled(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'StreamlinedMarketplace') === 'true';
}
diff --git a/webapp/channels/src/selectors/posts.ts b/webapp/channels/src/selectors/posts.ts
index ee5be0923da..7635ebb1bc9 100644
--- a/webapp/channels/src/selectors/posts.ts
+++ b/webapp/channels/src/selectors/posts.ts
@@ -1,10 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import type {Channel} from '@mattermost/types/channels';
+import type {ClientConfig} from '@mattermost/types/config';
import type {Post} from '@mattermost/types/posts';
+import type {UserProfile} from '@mattermost/types/users';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
+import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
+import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
+import {moveThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {arePreviewsCollapsed} from 'selectors/preferences';
@@ -52,3 +58,69 @@ export function isInlineImageVisible(state: GlobalState, postId: string, imageKe
return getGlobalItem(state, StoragePrefixes.INLINE_IMAGE_VISIBLE + currentUserId + '_' + postId + '_' + imageKey, !imageCollapsed);
}
+
+export function makeCanWrangler() {
+ return createSelector(
+ 'makeCanWrangler',
+ getConfig,
+ getCurrentUser,
+ moveThreadsEnabled,
+ (_state: GlobalState, channelType: Channel['type']) => channelType,
+ (_state: GlobalState, _channelType: Channel['type'], replyCount: number) => replyCount,
+ (config: Partial, user: UserProfile, enabled: boolean, channelType: Channel['type'], replyCount: number) => {
+ if (!enabled) {
+ return false;
+ }
+ const {
+ WranglerPermittedWranglerRoles,
+ WranglerAllowedEmailDomain,
+ WranglerMoveThreadMaxCount,
+ WranglerMoveThreadFromPrivateChannelEnable,
+ WranglerMoveThreadFromDirectMessageChannelEnable,
+ WranglerMoveThreadFromGroupMessageChannelEnable,
+ } = config;
+
+ let permittedUsers: string[] = [];
+ if (WranglerPermittedWranglerRoles && WranglerPermittedWranglerRoles !== '') {
+ permittedUsers = WranglerPermittedWranglerRoles?.split(',');
+ }
+
+ let allowedEmailDomains: string[] = [];
+ if (WranglerAllowedEmailDomain && WranglerAllowedEmailDomain !== '') {
+ allowedEmailDomains = WranglerAllowedEmailDomain?.split(',') || [];
+ }
+
+ if (permittedUsers.length > 0 && !user.roles.includes('system_admin')) {
+ const roles = user.roles.split(' ');
+ const hasRole = roles.some((role) => permittedUsers.includes(role));
+ if (!hasRole) {
+ return false;
+ }
+ }
+
+ if (allowedEmailDomains?.length > 0) {
+ if (!user.email || !allowedEmailDomains.includes(user.email.split('@')[1])) {
+ return false;
+ }
+ }
+
+ if (Number(WranglerMoveThreadMaxCount) < replyCount) {
+ return false;
+ }
+
+ if (channelType === 'P' && WranglerMoveThreadFromPrivateChannelEnable === 'false') {
+ return false;
+ }
+
+ if (channelType === 'D' && WranglerMoveThreadFromDirectMessageChannelEnable === 'false') {
+ return false;
+ }
+
+ if (channelType === 'G' && WranglerMoveThreadFromGroupMessageChannelEnable === 'false') {
+ return false;
+ }
+
+ return true;
+ },
+ );
+}
diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx
index 67dacd40d8d..f3e4e3f3edc 100644
--- a/webapp/channels/src/utils/constants.tsx
+++ b/webapp/channels/src/utils/constants.tsx
@@ -47,6 +47,7 @@ export const SettingsTypes = {
TYPE_JOBSTABLE: 'jobstable',
TYPE_FILE_UPLOAD: 'fileupload',
TYPE_CUSTOM: 'custom',
+ TYPE_ROLES: 'roles',
};
export const InviteTypes = {
@@ -446,6 +447,7 @@ export const ModalIdentifiers = {
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
START_TRIAL_FORM_MODAL: 'start_trial_form_modal',
START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result',
+ MOVE_THREAD_MODAL: 'move_thread_modal',
CONVERT_GM_TO_CHANNEL: 'convert_gm_to_channel',
IP_FILTERING_ADD_EDIT_MODAL: 'ip_filtering_add_edit_modal',
IP_FILTERING_DELETE_CONFIRMATION_MODAL: 'ip_filtering_delete_confirmation_modal',
@@ -769,6 +771,7 @@ export const TELEMETRY_LABELS = {
REPLY: 'reply',
UNREAD: 'unread',
FORWARD: 'forward',
+ MOVE_THREAD: 'move_thread',
};
export const PostTypes = {
@@ -795,6 +798,7 @@ export const PostTypes = {
REMOVE_LINK_PREVIEW: 'remove_link_preview',
ME: 'me',
REMINDER: 'reminder',
+ WRANGLER: 'system_wrangler',
CUSTOM_CALLS: 'custom_calls',
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
};
diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts
index 0465fa706ee..7f9490b47a8 100644
--- a/webapp/platform/client/src/client4.ts
+++ b/webapp/platform/client/src/client4.ts
@@ -1059,6 +1059,14 @@ export default class Client4 {
);
}
+ moveThread = (postId: string, channelId: string) => {
+ const url = this.getPostRoute(postId) + '/move';
+ return this.doFetch(
+ url,
+ {method: 'post', body: JSON.stringify({channel_id: channelId})},
+ );
+ }
+
switchEmailToOAuth = (service: string, email: string, password: string, mfaCode = '') => {
this.trackEvent('api', 'api_users_email_to_oauth');
diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts
index e2bdb51996f..7afbac8bf91 100644
--- a/webapp/platform/types/src/config.ts
+++ b/webapp/platform/types/src/config.ts
@@ -199,6 +199,12 @@ export type ClientConfig = {
PersistentNotificationIntervalMinutes: string;
AllowPersistentNotificationsForGuests: string;
DelayChannelAutocomplete: 'true' | 'false';
+ WranglerPermittedWranglerRoles: string;
+ WranglerAllowedEmailDomain: string;
+ WranglerMoveThreadMaxCount: string;
+ WranglerMoveThreadFromPrivateChannelEnable: string;
+ WranglerMoveThreadFromDirectMessageChannelEnable: string;
+ WranglerMoveThreadFromGroupMessageChannelEnable: string;
ServiceEnvironment: string;
UniqueEmojiReactionLimitPerPost: string;
};
@@ -485,6 +491,16 @@ export type PasswordSettings = {
EnableForgotLink: boolean;
};
+export type WranglerSettings = {
+ PermittedWranglerRoles: string[];
+ AllowedEmailDomain: string[];
+ MoveThreadMaxCount: number;
+ MoveThreadToAnotherTeamEnable: boolean;
+ MoveThreadFromPrivateChannelEnable: boolean;
+ MoveThreadFromDirectMessageChannelEnable: boolean;
+ MoveThreadFromGroupMessageChannelEnable: boolean;
+};
+
export type FileSettings = {
EnableFileAttachments: boolean;
EnableMobileUpload: boolean;
@@ -947,6 +963,7 @@ export type AdminConfig = {
FeatureFlags: FeatureFlags;
ImportSettings: ImportSettings;
ExportSettings: ExportSettings;
+ WranglerSettings: WranglerSettings;
};
export type ReplicaLagSetting = {
diff --git a/webapp/platform/types/src/posts.ts b/webapp/platform/types/src/posts.ts
index bde9ba9f928..b3495bf4e69 100644
--- a/webapp/platform/types/src/posts.ts
+++ b/webapp/platform/types/src/posts.ts
@@ -31,6 +31,7 @@ export type PostType = 'system_add_remove' |
'system_fake_parent_deleted' |
'system_generic' |
'reminder' |
+'system_wrangler' |
'';
export type PostEmbedType = 'image' | 'link' | 'message_attachment' | 'opengraph' | 'permalink';