Feature: Wrangler (#23602)

* Migrate feature/wrangler to mono-repo

* Add wrangler files

* Fix linters, types, etc

* Fix snapshots

* Fix playwright

* Fix pipelines

* Fix more pipeline

* Fixes for pipelines

* More changes for pipeline

* Fix types

* Add support for a feature flag, but leave it defaulted on for spinwick usage for now

* Update snapshot

* fix js error when removing last value of multiselect, support CSV marshaling to string array for textsetting

* Fix linter

* Remove TODO

* Remove another TODO

* fix tests

* Fix i18n

* Add server tests

* Fix linter

* Fix linter

* Use proper icon for dot menu

* Update snapshot

* Add Cypress UI tests for various entrypoints to move thread modal, split SCSS out from forward post into its own thing

* clean up

* fix linter

* More cleanup

* Revert files to master

* Fix linter for e2e tests

* Make ForwardPostChannelSelect channel types configurable with a prop

* Add missing return

* Fixes from PR feedback

* First batch of PR Feedback

* Another batch of PR changes

* Fix linter

* Update snapshots

* Wrangler system messages are translated to each user's locale

* Initially translate Wrangler into system locale rather than initiating user

* More fixes for PR Feedback

* Fix some server tests

* More updates with master. Fixes around pipelines. Enforce Enterprise license on front/back end

* Add tests for dot_menu

* More pipeline fixes

* Fix e2etests prettier

* Update cypress tests, change occurrences of 'Wrangler' with 'Move Thread'

* Fix linter

* Remove enterprise lock

* A couple more occurrences of wrangler strings, and one more enterprise lock

* Fix server tests

* Fix i18n

* Fix e2e linter

* Feature flag shouldn't be on by default

* Enable move threads feature in smoke tests (#25657)

* enable move threads feature
* add @prod tag

* Fix move_thread_from_public_channel e2e test

* Fix e2e style

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
This commit is contained in:
Nick Misasi 2023-12-11 15:27:34 -05:00 committed by GitHub
parent bfb8320afd
commit f0a336ba07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 3107 additions and 16 deletions

View file

@ -1046,3 +1046,58 @@
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"/api/v4/posts/{post_id}/move":
post:
tags:
- posts
summary: Move a post (and any posts within that post's thread)
description: >
Move a post/thread to another channel.
THIS IS A BETA FEATURE. The API is subject to change without notice.
##### Permissions
Must have `read_channel` permission for the channel the post is in.
Must have `write_post` permission for the channel the post is being moved to.
__Minimum server version__: 9.3
operationId: MoveThread
parameters:
- name: post_id
in: path
description: The identifier of the post to move
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
type: object
required:
- channel_id
properties:
channel_id:
type: string
description: The channel identifier of where the post/thread is to be moved
description: The channel identifier of where the post/thread is to be moved
required: true
responses:
"200":
description: Post moved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"501":
$ref: "#/components/responses/NotImplemented"

View file

@ -66,6 +66,7 @@ services:
MM_SERVICESETTINGS_ENABLEONBOARDINGFLOW: "false"
MM_FEATUREFLAGS_ONBOARDINGTOURTIPS: "false"
MM_SERVICEENVIRONMENT: "test"
MM_FEATUREFLAGS_MOVETHREADSENABLED: "true"
network_mode: host
depends_on:
$(for service in $ENABLED_DOCKER_SERVICES; do

View file

@ -0,0 +1,213 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @enterprise @messaging
import * as TIMEOUTS from '../../../fixtures/timeouts';
describe('Move Thread', () => {
let user1;
let user2;
let testTeam;
let dmChannel;
let testPost;
let replyPost;
const message = 'Move this message';
const replyMessage = 'Move this reply';
beforeEach(() => {
cy.shouldHaveFeatureFlag('MoveThreadsEnabled', true);
cy.apiUpdateConfig({
ServiceSettings: {
ThreadAutoFollow: true,
CollapsedThreads: 'default_on',
},
WranglerSettings: {
MoveThreadFromDirectMessageChannelEnable: true,
},
});
// # Login as new user, create new team and visit its URL
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({
user,
team,
}) => {
user1 = user;
testTeam = team;
// # enable CRT for the user
cy.apiSaveCRTPreference(user.id, 'on');
// # Create another user
return cy.apiCreateUser({prefix: 'second_'});
}).then(({user}) => {
user2 = user;
// # Add other user to team
return cy.apiAddUserToTeam(testTeam.id, user2.id);
}).then(() => {
// # Create new DM channel
return cy.apiCreateDirectChannel([user1.id, user2.id]);
}).then(({channel}) => {
dmChannel = channel;
// # Post a sample message
return cy.postMessageAs({sender: user1, message, channelId: dmChannel.id});
}).then((post) => {
testPost = post.data;
// # Post a reply
return cy.postMessageAs({sender: user1, message: replyMessage, channelId: dmChannel.id, rootId: testPost.id});
}).then((post) => {
replyPost = post.data;
// # Got to Test channel
cy.visit(`/${testTeam.name}/channels/${dmChannel.name}`);
});
});
afterEach(() => {
// # Go to 1. public channel
cy.visit(`/${testTeam.name}/channels/${dmChannel.name}`);
});
it('Move root post from DM', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').type('{shift}W');
// # move thread
movePostFromDM();
// * Assert switch to DM channel
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', dmChannel.display_name);
// * Assert post has been moved
verifyMovedThread({post: testPost});
});
it('Move thread reply from DM', () => {
// # Open the RHS with replies to the root post
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
// * Assert RHS is open
cy.get('#rhsContainer').should('be.visible');
// # Click on ... button of reply post
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').type('{shift}W');
// # move thread
movePostFromDM();
// * Assert switch to DM channel
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', dmChannel.display_name);
// * Assert post has been moved
verifyMovedThread({post: testPost});
});
it('Move post from DM - Cancel using escape key', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').type('{shift}W');
// # move thread
movePostFromDM({cancel: true});
// * Assert still in the DM channel
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', dmChannel.display_name);
// * Assert last post id is identical with testPost
cy.getLastPostId((id) => {
assert.isEqual(id, testPost.id);
});
});
it('Should not be able to move post from DM if configured off', () => {
cy.apiUpdateConfig({
WranglerSettings: {
MoveThreadFromDirectMessageChannelEnable: false,
},
});
// # Open the RHS with replies to the root post
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
// * Assert RHS is open
cy.get('#rhsContainer').should('be.visible');
// # Click on ... button of reply post
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').should('not.exist');
});
/**
* Verify that the post has been moved
*
* @param {Post} post
*/
const verifyMovedThread = ({post}) => {
// * Assert post has been moved
cy.getLastPostId().then((id) => {
// * Assert last post is visible
cy.get(`#${id}_message`).should('be.visible').within(() => {
// * Assert the text in the preview matches the original post message
cy.get(`#postMessageText_${post.id}`).should('be.visible').should('contain.text', post.message);
});
// # Cleanup
cy.apiDeletePost(id);
});
};
/**
* move thread
*
* @param {object?} options
* @param {boolean?} options.cancel
*/
const movePostFromDM = ({cancel = false} = {}) => {
// * Assert visibility of the move thread modal
cy.get('#move-thread-modal').should('be.visible').within(() => {
// * Assert channel select is not existent
cy.get('.move-thread__select').should('not.exist');
// * Assert if button is enabled
cy.get('.GenericModal__button.confirm').should('not.be.disabled');
// * Assert Notification is shown
cy.findByTestId('notification-text').should('be.visible').should('contain.text', 'Moving this thread changes who has access');
if (cancel) {
// * Assert if button is active
cy.get('.MoveThreadModal__cancel-button').should('not.be.disabled').type('{esc}', {force: true});
} else {
// * Assert if button is active
cy.get('.GenericModal__button.confirm').should('not.be.disabled').type('{enter}', {force: true});
}
});
};
});

View file

@ -0,0 +1,238 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @enterprise @messaging
import * as TIMEOUTS from '../../../fixtures/timeouts';
describe('Move thread', () => {
let user1;
let user2;
let user3;
let testTeam;
let gmChannel;
let gmChannelName;
let testPost;
let replyPost;
const message = 'Move this message';
const replyMessage = 'Move this reply';
beforeEach(() => {
cy.shouldHaveFeatureFlag('MoveThreadsEnabled', true);
cy.apiUpdateConfig({
ServiceSettings: {
ThreadAutoFollow: true,
CollapsedThreads: 'default_on',
},
WranglerSettings: {
MoveThreadFromGroupMessageChannelEnable: true,
},
});
// # Login as new user, create new team and visit its URL
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({
user,
team,
}) => {
user1 = user;
testTeam = team;
// # enable CRT for the user
cy.apiSaveCRTPreference(user.id, 'on');
// # Create another user
return cy.apiCreateUser({prefix: 'second_'});
}).then(({user}) => {
user2 = user;
// # Add other user to team
return cy.apiAddUserToTeam(testTeam.id, user2.id);
}).then(() => {
// # Create another user
return cy.apiCreateUser({prefix: 'third_'});
}).then(({user}) => {
user3 = user;
// # Add other user to team
return cy.apiAddUserToTeam(testTeam.id, user3.id);
}).then(() => {
// # Create new GM channel
return cy.apiCreateGroupChannel([user1.id, user2.id, user3.id]);
}).then(({channel}) => {
gmChannel = channel;
gmChannelName = gmChannel.display_name.split(', ').filter(((username) => username !== user1.username)).join(', ');
// # Post a sample message
return cy.postMessageAs({sender: user1, message, channelId: gmChannel.id});
}).then((post) => {
testPost = post.data;
// # Post a reply
return cy.postMessageAs({sender: user1, message: replyMessage, channelId: gmChannel.id, rootId: testPost.id});
}).then((post) => {
replyPost = post.data;
// # Got to Test channel
cy.visit(`/${testTeam.name}/channels/${gmChannel.name}`);
});
});
afterEach(() => {
// # Go to 1. public channel
cy.visit(`/${testTeam.name}/channels/${gmChannel.name}`);
});
it('Move post from GM (with at least 2 other users)', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move menu-item
cy.findByText('Move Thread').type('{shift}W');
// # Move Post
moveThreadFromGM();
// * Assert switch to GM channel
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', gmChannelName);
// * Assert post has been moved
verifyMovedMessage({post: testPost});
});
it('Move thread with replies from GM (with at least 2 other users)', () => {
// # Open the RHS with replies to the root post
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
// * Assert RHS is open
cy.get('#rhsContainer').should('be.visible');
// # Click on ... button of reply post
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
// * Assert availability of the Move menu-item
cy.findByText('Move Thread').type('{shift}W');
// # Move Post
moveThreadFromGM();
// * Assert switch to GM channel
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', gmChannelName);
// * Assert post has been moved
verifyMovedMessage({post: testPost});
});
it('Move thread from GM (with at least 2 other users) - Cancel using X', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move menu-item
cy.findByText('Move Thread').type('{shift}W');
// # Move Post
moveThreadFromGM({cancel: true});
// * Assert switch to GM channel
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', gmChannelName);
// * Assert last post id is identical with testPost
cy.getLastPostId((id) => {
assert.isEqual(id, testPost.id);
});
});
it('Should not be able to move post from GM if configured off', () => {
cy.apiUpdateConfig({
WranglerSettings: {
MoveThreadFromGroupMessageChannelEnable: false,
},
});
// # Open the RHS with replies to the root post
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
// * Assert RHS is open
cy.get('#rhsContainer').should('be.visible');
// # Click on ... button of reply post
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').should('not.exist');
});
/**
* Verify that the post has been moved
*
* @param {string?} comment
* @param {boolean?} showMore
* @param {Post} post
*/
const verifyMovedMessage = ({post, comment, showMore}) => {
const permaLink = `${Cypress.config('baseUrl')}/${testTeam.name}/pl/${post.id}`;
// * Assert post has been moved
cy.getLastPostId().then((id) => {
// * Assert last post is visible
cy.get(`#${id}_message`).should('be.visible').within(() => {
if (comment) {
// * Assert the text in the post body is the permalink only
cy.get(`#postMessageText_${id}`).should('be.visible').should('contain.text', permaLink).should('contain.text', comment);
if (showMore) {
// * Assert show more button is rendered and works as expected
cy.get('#showMoreButton').should('be.visible').should('contain.text', 'Show more').click().should('contain.text', 'Show less').click();
}
}
// * Assert the text in the preview matches the original post message
cy.get(`#postMessageText_${post.id}`).should('be.visible').should('contain.text', post.message);
});
// # Cleanup
cy.apiDeletePost(id);
});
};
/**
* Move thread
*
* @param {object?} options
* @param {boolean?} options.cancel
*/
const moveThreadFromGM = ({cancel = false} = {}) => {
// * Assert visibility of the move thread modal
cy.get('#move-thread-modal').should('be.visible').within(() => {
// * Assert channel select is not existent
cy.get('.move-thread__select').should('not.exist');
// * Assert if button is enabled
cy.get('.GenericModal__button.confirm').should('not.be.disabled');
// * Assert Notification is shown
cy.findByTestId('notification-text').should('be.visible').should('contain.text', 'Moving this thread changes who has access');
if (cancel) {
// * Assert if button is active
cy.uiCloseModal('Move thread');
} else {
// * Assert if button is active
cy.get('.GenericModal__button.confirm').should('not.be.disabled').type('{enter}', {force: true});
}
});
};
});

View file

@ -0,0 +1,199 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @enterprise @messaging
import * as TIMEOUTS from '../../../fixtures/timeouts';
describe('Move thread', () => {
let user1;
let testTeam;
let privateChannel;
let testPost;
let replyPost;
const message = 'Move this message';
const replyMessage = 'Move this reply';
beforeEach(() => {
cy.apiRequireLicense();
cy.apiUpdateConfig({
ServiceSettings: {
ThreadAutoFollow: true,
CollapsedThreads: 'default_on',
},
WranglerSettings: {
MoveThreadFromPrivateChannelEnable: true,
},
});
// # Login as new user, create new team and visit its URL
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({
user,
team,
}) => {
user1 = user;
testTeam = team;
// # enable CRT for the user
cy.apiSaveCRTPreference(user.id, 'on');
// # Create a private channel
return cy.apiCreateChannel(testTeam.id, 'private', 'Private', 'P');
}).then(({channel}) => {
privateChannel = channel;
// # Post a sample message
return cy.postMessageAs({sender: user1, message, channelId: privateChannel.id});
}).then((post) => {
testPost = post.data;
// # Post a reply
return cy.postMessageAs({sender: user1, message: replyMessage, channelId: privateChannel.id, rootId: testPost.id});
}).then((post) => {
replyPost = post.data;
// # Got to Private channel
cy.visit(`/${testTeam.name}/channels/${privateChannel.name}`);
});
});
it('Move root post from private channel', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').type('{shift}W');
// # Move Thread
moveThreadFromPrivateChannel();
// * Assert switch to testchannel
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', privateChannel.display_name);
// * Assert post has been moved
verifyMovedMessage({post: testPost});
});
it('Move thread with replies from private channel', () => {
// # Open the RHS with replies to the root post
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
// * Assert RHS is open
cy.get('#rhsContainer').should('be.visible');
// # Click on ... button of reply post
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').type('{shift}W');
// # Move Thread
moveThreadFromPrivateChannel();
// * Assert switch to testchannel
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', privateChannel.display_name);
// * Assert post has been moved
verifyMovedMessage({post: testPost});
});
it('Move post from private channel - Cancel', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').type('{shift}W');
// # Move Thread
moveThreadFromPrivateChannel(true);
// * Assert switch to testchannel
cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').should('contain', privateChannel.display_name);
// * Assert last post id is identical with testPost
cy.getLastPostId((id) => {
assert.isEqual(id, testPost.id);
});
});
it('Should not be able to move post from private channel if configured off', () => {
cy.apiUpdateConfig({
WranglerSettings: {
MoveThreadFromPrivateChannelEnable: false,
},
});
// # Open the RHS with replies to the root post
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
// * Assert RHS is open
cy.get('#rhsContainer').should('be.visible');
// # Click on ... button of reply post
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').should('not.exist');
});
/**
* Verify that the post has been moved
*
* @param {Post} post
*/
const verifyMovedMessage = ({post}) => {
// * Assert post has been moved
cy.getLastPostId().then((id) => {
// * Assert last post is visible
cy.get(`#${id}_message`).should('be.visible').within(() => {
// * Assert the text in the preview matches the original post message
cy.get(`#postMessageText_${post.id}`).should('be.visible').should('contain.text', post.message);
});
// # Cleanup
cy.apiDeletePost(id);
});
};
/**
* Move thread
*
* @param {boolean?} cancel
*/
const moveThreadFromPrivateChannel = (cancel = false) => {
// * Assert visibility of the move thread modal
cy.get('#move-thread-modal').should('be.visible').within(() => {
// * Assert channel select is not existent
cy.get('.move-thread__select').should('not.exist');
// * Assert if button is enabled
cy.get('.GenericModal__button.confirm').should('not.be.disabled');
// * Assert Notification is shown
cy.findByTestId('notification-text').should('be.visible').should('contain.text', 'Moving this thread changes who has access');
if (cancel) {
// * Assert if button is active
cy.get('.MoveThreadModal__cancel-button').should('not.be.disabled').click();
} else {
// * Assert if button is active
cy.get('.GenericModal__button.confirm').should('not.be.disabled').click();
}
});
};
});

View file

@ -0,0 +1,230 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @enterprise @messaging
describe('Move Thread', () => {
let user1;
let user2;
let user3;
let testTeam;
let testChannel;
let otherChannel;
let privateChannel;
let dmChannel;
let gmChannel;
let testPost;
let replyPost;
const message = 'Move this message';
const replyMessage = 'Move this reply';
beforeEach(() => {
cy.apiUpdateConfig({
ServiceSettings: {
ThreadAutoFollow: true,
CollapsedThreads: 'default_on',
},
});
// # Login as new user, create new team and visit its URL
cy.apiInitSetup({loginAfter: true, promoteNewUserAsAdmin: true}).then(({
user,
team,
channel,
}) => {
user1 = user;
testTeam = team;
testChannel = channel;
// # enable CRT for the user
cy.apiSaveCRTPreference(user.id, 'on');
// # Create another user
return cy.apiCreateUser({prefix: 'second_'});
}).then(({user}) => {
user2 = user;
// # Add other user to team
return cy.apiAddUserToTeam(testTeam.id, user2.id);
}).then(() => {
// # Create another user
return cy.apiCreateUser({prefix: 'third_'});
}).then(({user}) => {
user3 = user;
// # Add other user to team
return cy.apiAddUserToTeam(testTeam.id, user3.id);
}).then(() => {
cy.apiAddUserToChannel(testChannel.id, user2.id);
cy.apiAddUserToChannel(testChannel.id, user3.id);
// # Post a sample message
return cy.postMessageAs({sender: user1, message, channelId: testChannel.id});
}).then((post) => {
testPost = post.data;
// # Post a reply
return cy.postMessageAs({sender: user1, message: replyMessage, channelId: testChannel.id, rootId: testPost.id});
}).then((post) => {
replyPost = post.data;
// # Create new DM channel
return cy.apiCreateDirectChannel([user1.id, user2.id]);
}).then(({channel}) => {
dmChannel = channel;
// # Create new DM channel
return cy.apiCreateGroupChannel([user1.id, user2.id, user3.id]);
}).then(({channel}) => {
gmChannel = channel;
console.log('this one');
// # Create a private channel to Move Thread to
return cy.apiCreateChannel(testTeam.id, 'private', 'Private');
}).then(({channel}) => {
privateChannel = channel;
console.log('no, this one');
// # Create a second channel to Move Thread to
return cy.apiCreateChannel(testTeam.id, 'movethread', 'Move Thread');
}).then(({channel}) => {
otherChannel = channel;
// # Got to Test channel
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
});
});
it('Move root post from public channel to another public channel', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').click();
// # Move Thread
moveThread({channelId: otherChannel.id});
// * Assert post has been moved
verifyMovedMessage({post: testPost});
});
it('Move reply post from public channel to another public channel', () => {
// # Open the RHS with replies to the root post
cy.uiClickPostDropdownMenu(testPost.id, 'Reply', 'CENTER');
// * Assert RHS is open
cy.get('#rhsContainer').should('be.visible');
// # Click on ... button of reply post
cy.clickPostDotMenu(replyPost.id, 'RHS_COMMENT');
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').click();
// * Move Thread
moveThread({channelId: otherChannel.id});
// * Assert post has been moved
verifyMovedMessage({post: testPost});
});
it('Move public channel post to Private channel', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').click();
// # Move Thread
moveThread({channelId: privateChannel.id});
// * Assert post has been moved
verifyMovedMessage({post: testPost});
});
it('Move public channel post to GM', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').click();
// # Move Thread
moveThread({channelId: gmChannel.id});
// * Assert post has been moved
verifyMovedMessage({post: testPost});
});
it('Move public channel post to DM', () => {
// # Check if ... button is visible in last post right side
cy.get(`#CENTER_button_${testPost.id}`).should('not.be.visible');
// # Click on ... button of last post
cy.clickPostDotMenu(testPost.id);
// * Assert availability of the Move Thread menu-item
cy.findByText('Move Thread').click();
// # Move Thread
moveThread({channelId: dmChannel.id});
// * Assert post has been moved
verifyMovedMessage({post: testPost});
});
/**
* Verify that the post has been moved
*
* @param {Post} post
*/
const verifyMovedMessage = ({post}) => {
// * Assert post has been moved
cy.getLastPostId().then((id) => {
// * Assert last post is visible
cy.get(`#${id}_message`).should('be.visible').within(() => {
// * Assert the text in the preview matches the original post message
cy.get(`#postMessageText_${post.id}`).should('be.visible').should('contain.text', post.message);
});
// # Cleanup
cy.apiDeletePost(id);
});
};
/**
* Move Thread with optional comment.
*
*/
const moveThread = () => {
// * Assert visibility of the Move Thread modal
cy.get('#move-thread-modal').should('be.visible').within(() => {
// * Assert channel select is not existent
cy.get('.move-thread__select').should('not.exist');
// * Assert if button is enabled
cy.get('.GenericModal__button.confirm').should('not.be.disabled');
// * Assert Notification is shown
cy.findByTestId('notification-text').should('be.visible').should('contain.text', 'Moving this thread changes who has access');
});
};
});

View file

@ -714,4 +714,13 @@ const defaultServerConfig: AdminConfig = {
Directory: './export',
RetentionDays: 30,
},
WranglerSettings: {
PermittedWranglerRoles: [],
AllowedEmailDomain: [],
MoveThreadMaxCount: 30,
MoveThreadToAnotherTeamEnable: true,
MoveThreadFromPrivateChannelEnable: true,
MoveThreadFromDirectMessageChannelEnable: true,
MoveThreadFromGroupMessageChannelEnable: true,
},
};

View file

@ -44,6 +44,8 @@ func (api *API) InitPost() {
api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(acknowledgePost)).Methods("POST")
api.BaseRoutes.PostForUser.Handle("/ack", api.APISessionRequired(unacknowledgePost)).Methods("DELETE")
api.BaseRoutes.Post.Handle("/move", api.APISessionRequired(moveThread)).Methods("POST")
}
func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
@ -1128,6 +1130,83 @@ func unacknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) {
ReturnStatusOK(w)
}
func moveThread(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePostId()
if c.Err != nil {
return
}
if !c.App.Config().FeatureFlags.MoveThreadsEnabled {
c.Err = model.NewAppError("moveThread", "api.post.move_thread.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
var moveThreadParams model.MoveThreadParams
if jsonErr := json.NewDecoder(r.Body).Decode(&moveThreadParams); jsonErr != nil {
c.SetInvalidParamWithErr("post", jsonErr)
return
}
auditRec := c.MakeAuditRecord("moveThread", audit.Fail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
audit.AddEventParameter(auditRec, "original_post_id", c.Params.PostId)
audit.AddEventParameter(auditRec, "to_channel_id", moveThreadParams.ChannelId)
user, err := c.App.GetUser(c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
}
// If there are no configured PermittedWranglerRoles, skip the check
userHasRole := len(c.App.Config().WranglerSettings.PermittedWranglerRoles) == 0
for _, role := range c.App.Config().WranglerSettings.PermittedWranglerRoles {
if user.IsInRole(role) {
userHasRole = true
break
}
}
// Sysadmins are always permitted
if !userHasRole && !user.IsSystemAdmin() {
c.Err = model.NewAppError("moveThread", "api.post.move_thread.no_permission", nil, "", http.StatusForbidden)
return
}
userHasEmailDomain := len(c.App.Config().WranglerSettings.AllowedEmailDomain) == 0
for _, domain := range c.App.Config().WranglerSettings.AllowedEmailDomain {
if user.EmailDomain() == domain {
userHasEmailDomain = true
break
}
}
if !userHasEmailDomain && !user.IsSystemAdmin() {
c.Err = model.NewAppError("moveThread", "api.post.move_thread.no_permission", nil, fmt.Sprintf("User: %+v", user), http.StatusForbidden)
return
}
sourcePost, err := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), false)
if err != nil {
c.Err = err
if err.Id == "app.post.cloud.get.app_error" {
w.Header().Set(model.HeaderFirstInaccessiblePostTime, "1")
}
return
}
err = c.App.MoveThread(c.AppContext, c.Params.PostId, sourcePost.ChannelId, moveThreadParams.ChannelId, user)
if err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePostId()
if c.Err != nil {

View file

@ -730,6 +730,239 @@ func TestCreatePostWithOutgoingHook_no_content_type(t *testing.T) {
})
}
func TestMoveThread(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_MOVETHREADSENABLED", "true")
defer os.Unsetenv("MM_FEATUREFLAGS_MOVETHREADSENABLED")
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
ctx := context.Background()
basicUser1 := th.BasicUser
basicUser2 := th.BasicUser2
basicUser3 := th.CreateUser()
// Create a new public channel to move the post to
publicChannel, resp, err := client.CreateChannel(ctx, &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "test-public-channel",
DisplayName: "Test Public Channel",
Type: model.ChannelTypeOpen,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, publicChannel)
// Create a new private channel to move the post to
privateChannel, resp, err := client.CreateChannel(ctx, &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "test-private-channel",
DisplayName: "Test Private Channel",
Type: model.ChannelTypePrivate,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, privateChannel)
// Create a new direct message channel to move the post to
dmChannel, resp, err := client.CreateDirectChannel(ctx, basicUser1.Id, basicUser2.Id)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, dmChannel)
// Create a new group message channel to move the post to
gmChannel, resp, err := client.CreateGroupChannel(ctx, []string{basicUser1.Id, basicUser2.Id, basicUser3.Id})
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, gmChannel)
t.Run("Move to public channel", func(t *testing.T) {
// Create a new post to move
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test post",
}
newPost, resp, err := client.CreatePost(ctx, post)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, newPost)
// Move the post to the public channel
moveThreadParams := &model.MoveThreadParams{
ChannelId: publicChannel.Id,
}
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Check that the post was moved to the public channel
posts, resp, err := client.GetPostsForChannel(ctx, publicChannel.Id, 0, 100, "", true, false)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, posts)
// There should be 2 posts, the system join message for the user who moved it joining the channel, and the post we moved
require.Equal(t, 2, len(posts.Posts))
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
})
t.Run("Move to private channel", func(t *testing.T) {
// Create a new post to move
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test post",
}
newPost, resp, err := client.CreatePost(ctx, post)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, newPost)
// Move the post to the private channel
moveThreadParams := &model.MoveThreadParams{
ChannelId: privateChannel.Id,
}
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Check that the post was moved to the private channel
posts, resp, err := client.GetPostsForChannel(ctx, privateChannel.Id, 0, 100, "", true, false)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, posts)
// There should be 2 posts, the system join message for the user who moved it joining the channel, and the post we moved
require.Equal(t, 2, len(posts.Posts))
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
})
t.Run("Move to direct message channel", func(t *testing.T) {
// Create a new post to move
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test post",
}
newPost, resp, err := client.CreatePost(ctx, post)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, newPost)
// Move the post to the direct message channel
moveThreadParams := &model.MoveThreadParams{
ChannelId: dmChannel.Id,
}
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Check that the post was moved to the direct message channel
posts, resp, err := client.GetPostsForChannel(ctx, dmChannel.Id, 0, 100, "", true, false)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, posts)
// There should be 1 post, the post we moved
require.Equal(t, 1, len(posts.Posts))
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
})
t.Run("Move to group message channel", func(t *testing.T) {
// Create a new post to move
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test post",
}
newPost, resp, err := client.CreatePost(ctx, post)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, newPost)
// Move the post to the group message channel
moveThreadParams := &model.MoveThreadParams{
ChannelId: gmChannel.Id,
}
resp, err = client.MoveThread(ctx, newPost.Id, moveThreadParams)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Check that the post was moved to the group message channel
posts, resp, err := client.GetPostsForChannel(ctx, gmChannel.Id, 0, 100, "", true, false)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, posts)
// There should be 1 post, the post we moved
require.Equal(t, 1, len(posts.Posts))
require.Equal(t, newPost.Message, posts.Posts[posts.Order[0]].Message)
})
t.Run("Move thread with more than one post", func(t *testing.T) {
// Create a new public channel to move the post to
pChannel, resp, err := client.CreateChannel(ctx, &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "test-public-channel2",
DisplayName: "Test Public Channel",
Type: model.ChannelTypeOpen,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, pChannel)
// Create a new post to use as the root post
rootPost := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "root post",
}
rootPost, resp, err = client.CreatePost(ctx, rootPost)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, rootPost)
// Create a new post to move
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test post",
RootId: rootPost.Id,
}
newPost, resp, err := client.CreatePost(ctx, post)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, newPost)
// Create another post in the thread
post = &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test post 2",
RootId: rootPost.Id,
}
newPost2, resp, err := client.CreatePost(ctx, post)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, newPost2)
// Move the thread to the public channel
moveThreadParams := &model.MoveThreadParams{
ChannelId: pChannel.Id,
}
resp, err = client.MoveThread(ctx, rootPost.Id, moveThreadParams)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Check that the thread was moved to the public channel
posts, resp, err := client.GetPostsForChannel(ctx, pChannel.Id, 0, 100, "", false, false)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, posts)
// There should be 3 posts, the system join message for the user who moved it joining the channel, and the two posts in the thread
// require.Equal(t, 3, len(posts.Posts))
fmt.Println(posts.Order)
for _, p := range posts.Order {
fmt.Println(posts.Posts[p].Id)
fmt.Println(posts.Posts[p].Message)
}
require.Equal(t, "This thread was moved from another channel", posts.Posts[posts.Order[0]].Message)
require.Equal(t, newPost2.Message, posts.Posts[posts.Order[1]].Message)
require.Equal(t, newPost.Message, posts.Posts[posts.Order[2]].Message)
require.Equal(t, rootPost.Message, posts.Posts[posts.Order[3]].Message)
})
}
func TestCreatePostPublic(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()

View file

@ -398,6 +398,10 @@ type AppIface interface {
ValidateUserPermissionsOnChannels(c request.CTX, userId string, channelIds []string) []string
// VerifyPlugin checks that the given signature corresponds to the given plugin and matches a trusted certificate.
VerifyPlugin(plugin, signature io.ReadSeeker) *model.AppError
// validateMoveOrCopy performs validation on a provided post list to determine
// if all permissions are in place to allow the for the posts to be moved or
// copied.
ValidateMoveOrCopy(c request.CTX, wpl *model.WranglerPostList, originalChannel *model.Channel, targetChannel *model.Channel, user *model.User) error
AccountMigration() einterfaces.AccountMigrationInterface
ActivateMfa(userID, token string) *model.AppError
ActiveSearchBackend() string
@ -478,6 +482,7 @@ type AppIface interface {
Config() *model.Config
ConvertGroupMessageToChannel(c request.CTX, convertedByUserId string, gmConversionRequest *model.GroupMessageConversionRequestBody) (*model.Channel, *model.AppError)
CopyFileInfos(rctx request.CTX, userID string, fileIDs []string) ([]string, *model.AppError)
CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError)
CreateChannel(c request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError)
CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError)
CreateCommand(cmd *model.Command) (*model.Command, *model.AppError)
@ -918,6 +923,7 @@ type AppIface interface {
MigrateIdLDAP(c request.CTX, toAttribute string) *model.AppError
MoveCommand(team *model.Team, command *model.Command) *model.AppError
MoveFile(oldPath, newPath string) *model.AppError
MoveThread(c request.CTX, postID string, sourceChannelID, channelID string, user *model.User) *model.AppError
NewPluginAPI(c request.CTX, manifest *model.Manifest) plugin.API
Notification() einterfaces.NotificationInterface
NotificationsLog() *mlog.Logger

View file

@ -1884,6 +1884,28 @@ func (a *OpenTracingAppLayer) CopyFileInfos(rctx request.CTX, userID string, fil
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CopyWranglerPostlist")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0, resultVar1 := a.app.CopyWranglerPostlist(c, wpl, targetChannel)
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) CreateBot(c request.CTX, bot *model.Bot) (*model.Bot, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateBot")
@ -12664,6 +12686,28 @@ func (a *OpenTracingAppLayer) MoveFile(oldPath string, newPath string) *model.Ap
return resultVar0
}
func (a *OpenTracingAppLayer) MoveThread(c request.CTX, postID string, sourceChannelID string, channelID string, user *model.User) *model.AppError {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.MoveThread")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0 := a.app.MoveThread(c, postID, sourceChannelID, channelID, user)
if resultVar0 != nil {
span.LogFields(spanlog.Error(resultVar0))
ext.Error.Set(span, true)
}
return resultVar0
}
func (a *OpenTracingAppLayer) NewPluginAPI(c request.CTX, manifest *model.Manifest) plugin.API {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.NewPluginAPI")
@ -18590,6 +18634,28 @@ func (a *OpenTracingAppLayer) ValidateDesktopToken(token string, expiryTime int6
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) ValidateMoveOrCopy(c request.CTX, wpl *model.WranglerPostList, originalChannel *model.Channel, targetChannel *model.Channel, user *model.User) error {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ValidateMoveOrCopy")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0 := a.app.ValidateMoveOrCopy(c, wpl, originalChannel, targetChannel, user)
if resultVar0 != nil {
span.LogFields(spanlog.Error(resultVar0))
ext.Error.Set(span, true)
}
return resultVar0
}
func (a *OpenTracingAppLayer) ValidateUserPermissionsOnChannels(c request.CTX, userId string, channelIds []string) []string {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ValidateUserPermissionsOnChannels")

View file

@ -2349,3 +2349,255 @@ func (a *App) applyPostWillBeConsumedHook(post **model.Post) {
return true
}, plugin.MessagesWillBeConsumedID)
}
func makePostLink(siteURL, teamName, postID string) string {
return fmt.Sprintf("%s/%s/pl/%s", siteURL, teamName, postID)
}
// validateMoveOrCopy performs validation on a provided post list to determine
// if all permissions are in place to allow the for the posts to be moved or
// copied.
func (a *App) ValidateMoveOrCopy(c request.CTX, wpl *model.WranglerPostList, originalChannel *model.Channel, targetChannel *model.Channel, user *model.User) error {
if wpl.NumPosts() == 0 {
return errors.New("The wrangler post list contains no posts")
}
config := a.Config().WranglerSettings
switch originalChannel.Type {
case model.ChannelTypePrivate:
if !*config.MoveThreadFromPrivateChannelEnable {
return errors.New("Wrangler is currently configured to not allow moving posts from private channels")
}
case model.ChannelTypeDirect:
if !*config.MoveThreadFromDirectMessageChannelEnable {
return errors.New("Wrangler is currently configured to not allow moving posts from direct message channels")
}
case model.ChannelTypeGroup:
if !*config.MoveThreadFromGroupMessageChannelEnable {
return errors.New("Wrangler is currently configured to not allow moving posts from group message channels")
}
}
if !originalChannel.IsGroupOrDirect() && !targetChannel.IsGroupOrDirect() {
// DM and GM channels are "teamless" so it doesn't make sense to check
// the MoveThreadToAnotherTeamEnable config when dealing with those.
if !*config.MoveThreadToAnotherTeamEnable && targetChannel.TeamId != originalChannel.TeamId {
return errors.New("Wrangler is currently configured to not allow moving messages to different teams")
}
}
if *config.MoveThreadMaxCount != int64(0) && *config.MoveThreadMaxCount < int64(wpl.NumPosts()) {
return fmt.Errorf("the thread is %d posts long, but this command is configured to only move threads of up to %d posts", wpl.NumPosts(), *config.MoveThreadMaxCount)
}
_, appErr := a.GetChannelMember(c, targetChannel.Id, user.Id)
if appErr != nil {
return fmt.Errorf("channel with ID %s doesn't exist or you are not a member", targetChannel.Id)
}
_, appErr = a.GetChannelMember(c, originalChannel.Id, user.Id)
if appErr != nil {
return fmt.Errorf("channel with ID %s doesn't exist or you are not a member", originalChannel.Id)
}
return nil
}
func (a *App) CopyWranglerPostlist(c request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError) {
var appErr *model.AppError
var newRootPost *model.Post
if wpl.ContainsFileAttachments() {
// The thread contains at least one attachment. To properly move the
// thread, the files will have to be re-uploaded. This is completed
// before any messages are moved.
// TODO: check number of files that need to be re-uploaded or file size?
c.Logger().Info("Wrangler is re-uploading file attachments",
mlog.String("file_count", fmt.Sprintf("%d", wpl.FileAttachmentCount)),
)
for _, post := range wpl.Posts {
var newFileIDs []string
var fileBytes []byte
var oldFileInfo, newFileInfo *model.FileInfo
for _, fileID := range post.FileIds {
oldFileInfo, appErr = a.GetFileInfo(c, fileID)
if appErr != nil {
return nil, appErr
}
fileBytes, appErr = a.GetFile(c, fileID)
if appErr != nil {
return nil, appErr
}
newFileInfo, appErr = a.UploadFile(c, fileBytes, targetChannel.Id, oldFileInfo.Name)
if appErr != nil {
return nil, appErr
}
newFileIDs = append(newFileIDs, newFileInfo.Id)
}
post.FileIds = newFileIDs
}
}
for i, post := range wpl.Posts {
var reactions []*model.Reaction
// Store reactions to be reapplied later.
reactions, appErr = a.GetReactionsForPost(post.Id)
if appErr != nil {
// Reaction-based errors are logged, but do not abort
c.Logger().Error("Failed to get reactions on original post")
}
newPost := post.Clone()
newPost = newPost.CleanPost()
newPost.ChannelId = targetChannel.Id
if i == 0 {
newPost, appErr = a.CreatePost(c, newPost, targetChannel, false, false)
if appErr != nil {
return nil, appErr
}
newRootPost = newPost.Clone()
} else {
newPost.RootId = newRootPost.Id
newPost, appErr = a.CreatePost(c, newPost, targetChannel, false, false)
if appErr != nil {
return nil, appErr
}
}
for _, reaction := range reactions {
reaction.PostId = newPost.Id
_, appErr = a.SaveReactionForPost(c, reaction)
if appErr != nil {
// Reaction-based errors are logged, but do not abort
c.Logger().Error("Failed to reapply reactions to post")
}
}
}
return newRootPost, nil
}
func (a *App) MoveThread(c request.CTX, postID string, sourceChannelID, channelID string, user *model.User) *model.AppError {
postListResponse, appErr := a.GetPostThread(postID, model.GetPostsOptions{}, user.Id)
if appErr != nil {
return model.NewAppError("getPostThread", "app.post.move_thread_command.error", nil, "postID="+postID+", "+"UserId="+user.Id+"", http.StatusBadRequest)
}
wpl := postListResponse.BuildWranglerPostList()
originalChannel, appErr := a.GetChannel(c, sourceChannelID)
if appErr != nil {
return appErr
}
targetChannel, appErr := a.GetChannel(c, channelID)
if appErr != nil {
return appErr
}
err := a.ValidateMoveOrCopy(c, wpl, originalChannel, targetChannel, user)
if err != nil {
return model.NewAppError("validateMoveOrCopy", "app.post.move_thread_command.error", nil, err.Error(), http.StatusBadRequest)
}
var targetTeam *model.Team
if targetChannel.IsGroupOrDirect() {
if !originalChannel.IsGroupOrDirect() {
targetTeam, appErr = a.GetTeam(originalChannel.TeamId)
}
} else {
targetTeam, appErr = a.GetTeam(targetChannel.TeamId)
}
if appErr != nil {
return appErr
}
if targetTeam == nil {
return model.NewAppError("validateMoveOrCopy", "app.post.move_thread_command.error", nil, "target team is nil", http.StatusBadRequest)
}
// Begin creating the new thread.
c.Logger().Info("Wrangler is moving a thread", mlog.String("user_id", user.Id), mlog.String("original_post_id", wpl.RootPost().Id), mlog.String("original_channel_id", originalChannel.Id))
// To simulate the move, we first copy the original messages(s) to the
// new channel and later delete the original messages(s).
newRootPost, appErr := a.CopyWranglerPostlist(c, wpl, targetChannel)
if appErr != nil {
return appErr
}
T, err := i18n.GetTranslationsBySystemLocale()
if err != nil {
return model.NewAppError("MoveThread", "app.post.move_thread_command.error", nil, err.Error(), http.StatusInternalServerError)
}
ephemeralPostProps := model.StringInterface{
"TranslationID": "app.post.move_thread.from_another_channel",
}
_, appErr = a.CreatePost(c, &model.Post{
UserId: user.Id,
Type: model.PostTypeWrangler,
RootId: newRootPost.Id,
ChannelId: channelID,
Message: T("app.post.move_thread.from_another_channel"),
Props: ephemeralPostProps,
}, targetChannel, false, false)
if appErr != nil {
return appErr
}
// Cleanup is handled by simply deleting the root post. Any comments/replies
// are automatically marked as deleted for us.
_, appErr = a.DeletePost(c, wpl.RootPost().Id, user.Id)
if appErr != nil {
return appErr
}
c.Logger().Info("Wrangler thread move complete", mlog.String("user_id", user.Id), mlog.String("new_post_id", newRootPost.Id), mlog.String("channel_id", channelID))
// Translate to the system locale, webapp will attempt to render in each user's specific locale (based on the TranslationID prop) before falling back on the initiating user's locale
ephemeralPostProps = model.StringInterface{}
msg := T("app.post.move_thread_command.direct_or_group.multiple_messages", model.StringInterface{"NumMessages": wpl.NumPosts()})
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.direct_or_group.multiple_messages"
if wpl.NumPosts() == 1 {
msg = T("app.post.move_thread_command.direct_or_group.one_message")
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.direct_or_group.one_message"
}
if targetChannel.TeamId != "" {
targetTeam, teamErr := a.GetTeam(targetChannel.TeamId)
if teamErr != nil {
return teamErr
}
targetName := targetTeam.Name
newPostLink := makePostLink(*a.Config().ServiceSettings.SiteURL, targetName, newRootPost.Id)
msg = T("app.post.move_thread_command.channel.multiple_messages", model.StringInterface{"NumMessages": wpl.NumPosts(), "Link": newPostLink})
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.channel.multiple_messages"
if wpl.NumPosts() == 1 {
msg = T("app.post.move_thread_command.channel.one_message", model.StringInterface{"Link": newPostLink})
ephemeralPostProps["TranslationID"] = "app.post.move_thread_command.channel.one_message"
}
ephemeralPostProps["MovedThreadPermalink"] = newPostLink
}
ephemeralPostProps["NumMessages"] = wpl.NumPosts()
_, appErr = a.CreatePost(c, &model.Post{
UserId: user.Id,
Type: model.PostTypeWrangler,
ChannelId: originalChannel.Id,
Message: msg,
Props: ephemeralPostProps,
}, originalChannel, false, false)
if appErr != nil {
return appErr
}
c.Logger().Info(msg)
return nil
}

View file

@ -3286,3 +3286,179 @@ func TestGetEditHistoryForPost(t *testing.T) {
require.Empty(t, edits)
})
}
func TestCopyWranglerPostlist(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
// Create a post with a file attachment
fileBytes := []byte("file contents")
fileInfo, err := th.App.UploadFile(th.Context, fileBytes, th.BasicChannel.Id, "file.txt")
require.Nil(t, err)
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "test message",
UserId: th.BasicUser.Id,
FileIds: []string{fileInfo.Id},
}
rootPost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true)
require.Nil(t, err)
// Add a reaction to the post
reaction := &model.Reaction{
UserId: th.BasicUser.Id,
PostId: rootPost.Id,
EmojiName: "smile",
}
_, err = th.App.SaveReactionForPost(th.Context, reaction)
require.Nil(t, err)
// Copy the post to a new channel
targetChannel := &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "test-channel",
Type: model.ChannelTypeOpen,
}
targetChannel, err = th.App.CreateChannel(th.Context, targetChannel, false)
require.Nil(t, err)
wpl := &model.WranglerPostList{
Posts: []*model.Post{rootPost},
FileAttachmentCount: 1,
}
newRootPost, err := th.App.CopyWranglerPostlist(th.Context, wpl, targetChannel)
require.Nil(t, err)
// Check that the new post has the same message and file attachment
require.Equal(t, rootPost.Message, newRootPost.Message)
require.Len(t, newRootPost.FileIds, 1)
// Check that the new post has the same reaction
reactions, err := th.App.GetReactionsForPost(newRootPost.Id)
require.Nil(t, err)
require.Len(t, reactions, 1)
require.Equal(t, reaction.EmojiName, reactions[0].EmojiName)
}
func TestValidateMoveOrCopy(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable = model.NewBool(true)
cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable = model.NewBool(true)
cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable = model.NewBool(true)
cfg.WranglerSettings.MoveThreadToAnotherTeamEnable = model.NewBool(true)
cfg.WranglerSettings.MoveThreadMaxCount = model.NewInt64(100)
})
t.Run("empty post list", func(t *testing.T) {
err := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{}, th.BasicChannel, th.BasicChannel, th.BasicUser)
require.Error(t, err)
require.Equal(t, "The wrangler post list contains no posts", err.Error())
})
t.Run("moving from private channel with MoveThreadFromPrivateChannelEnable disabled", func(t *testing.T) {
privateChannel := &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "private-channel",
Type: model.ChannelTypePrivate,
}
privateChannel, err := th.App.CreateChannel(th.Context, privateChannel, false)
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable = model.NewBool(false)
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: privateChannel.Id}}}, privateChannel, th.BasicChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "Wrangler is currently configured to not allow moving posts from private channels", e.Error())
})
t.Run("moving from direct channel with MoveThreadFromDirectMessageChannelEnable disabled", func(t *testing.T) {
directChannel, err := th.App.createDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id)
require.Nil(t, err)
require.NotNil(t, directChannel)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable = model.NewBool(false)
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: directChannel.Id}}}, directChannel, th.BasicChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "Wrangler is currently configured to not allow moving posts from direct message channels", e.Error())
})
t.Run("moving from group channel with MoveThreadFromGroupMessageChannelEnable disabled", func(t *testing.T) {
groupChannel := &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "group-channel",
Type: model.ChannelTypeGroup,
}
groupChannel, err := th.App.CreateChannel(th.Context, groupChannel, false)
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable = model.NewBool(false)
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: groupChannel.Id}}}, groupChannel, th.BasicChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "Wrangler is currently configured to not allow moving posts from group message channels", e.Error())
})
t.Run("moving to different team with MoveThreadToAnotherTeamEnable disabled", func(t *testing.T) {
team := &model.Team{
Name: "testteam",
DisplayName: "testteam",
Type: model.TeamOpen,
}
targetTeam, err := th.App.CreateTeam(th.Context, team)
require.Nil(t, err)
require.NotNil(t, targetTeam)
targetChannel := &model.Channel{
TeamId: targetTeam.Id,
Name: "test-channel",
Type: model.ChannelTypeOpen,
}
targetChannel, err = th.App.CreateChannel(th.Context, targetChannel, false)
require.Nil(t, err)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.WranglerSettings.MoveThreadToAnotherTeamEnable = model.NewBool(false)
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, targetChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "Wrangler is currently configured to not allow moving messages to different teams", e.Error())
})
t.Run("moving to channel user is not a member of", func(t *testing.T) {
targetChannel := &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "test-channel",
Type: model.ChannelTypePrivate,
}
targetChannel, err := th.App.CreateChannel(th.Context, targetChannel, false)
require.Nil(t, err)
err = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, th.SystemAdminUser.Id, th.BasicChannel)
require.Nil(t, err)
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, targetChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, fmt.Sprintf("channel with ID %s doesn't exist or you are not a member", targetChannel.Id), e.Error())
})
t.Run("moving thread longer than MoveThreadMaxCount", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.WranglerSettings.MoveThreadMaxCount = 1
})
e := th.App.ValidateMoveOrCopy(th.Context, &model.WranglerPostList{Posts: []*model.Post{{ChannelId: th.BasicChannel.Id}, {ChannelId: th.BasicChannel.Id}}}, th.BasicChannel, th.BasicChannel, th.BasicUser)
require.Error(t, e)
require.Equal(t, "the thread is 2 posts long, but this command is configured to only move threads of up to 1 posts", e.Error())
})
}

View file

@ -142,6 +142,14 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete)
props["UniqueEmojiReactionLimitPerPost"] = strconv.FormatInt(int64(*c.ServiceSettings.UniqueEmojiReactionLimitPerPost), 10)
props["WranglerPermittedWranglerRoles"] = strings.Join(c.WranglerSettings.PermittedWranglerRoles, ",")
props["WranglerAllowedEmailDomain"] = strings.Join(c.WranglerSettings.AllowedEmailDomain, ",")
props["WranglerMoveThreadMaxCount"] = strconv.FormatInt(*c.WranglerSettings.MoveThreadMaxCount, 10)
props["WranglerMoveThreadToAnotherTeamEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadToAnotherTeamEnable)
props["WranglerMoveThreadFromPrivateChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromPrivateChannelEnable)
props["WranglerMoveThreadFromDirectMessageChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromDirectMessageChannelEnable)
props["WranglerMoveThreadFromGroupMessageChannelEnable"] = strconv.FormatBool(*c.WranglerSettings.MoveThreadFromGroupMessageChannelEnable)
if license != nil {
props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)

View file

@ -2454,6 +2454,14 @@
"other": "{{.Count}} images sent: {{.Filenames}}"
}
},
{
"id": "api.post.move_thread.disabled.app_error",
"translation": "Thread moving is disabled"
},
{
"id": "api.post.move_thread.no_permission",
"translation": "You do not have permission to move this thread."
},
{
"id": "api.post.patch_post.can_not_update_post_in_deleted.error",
"translation": "Can not update a post in a deleted channel."
@ -6442,6 +6450,30 @@
"id": "app.post.marshal.app_error",
"translation": "Failed to marshal post."
},
{
"id": "app.post.move_thread.from_another_channel",
"translation": "This thread was moved from another channel"
},
{
"id": "app.post.move_thread_command.channel.multiple_messages",
"translation": "A thread with {{.NumMessages}} messages has been moved: {{.Link}}\n"
},
{
"id": "app.post.move_thread_command.channel.one_message",
"translation": "A message has been moved: {{.Link}}\n"
},
{
"id": "app.post.move_thread_command.direct_or_group.multiple_messages",
"translation": "A thread with {{.NumMessages}} messages has been moved to a Direct/Group Message\n"
},
{
"id": "app.post.move_thread_command.direct_or_group.one_message",
"translation": "A message has been moved to a Direct/Group Message\n"
},
{
"id": "app.post.move_thread_command.error",
"translation": "Unable to remove thread"
},
{
"id": "app.post.overwrite.app_error",
"translation": "Unable to overwrite the Post."
@ -8894,6 +8926,10 @@
"id": "model.config.is_valid.message_export.global_relay.smtp_username.app_error",
"translation": "Message export job GlobalRelaySettings.SmtpUsername must be set."
},
{
"id": "model.config.is_valid.move_thread.domain_invalid.app_error",
"translation": "Invalid domain for move thread settings"
},
{
"id": "model.config.is_valid.password_length.app_error",
"translation": "Minimum password length must be a whole number greater than or equal to {{.MinLength}} and less than or equal to {{.MaxLength}}."

View file

@ -76,6 +76,7 @@ const (
TrackConfigImageProxy = "config_image_proxy"
TrackConfigBleve = "config_bleve"
TrackConfigExport = "config_export"
TrackConfigWrangler = "config_wrangler"
TrackFeatureFlags = "config_feature_flags"
TrackConfigProducts = "products"
TrackPermissionsGeneral = "permissions_general"
@ -880,6 +881,16 @@ func (ts *TelemetryService) trackConfig() {
"retention_days": *cfg.ExportSettings.RetentionDays,
})
ts.SendTelemetry(TrackConfigWrangler, map[string]any{
"permitted_wrangler_users": cfg.WranglerSettings.PermittedWranglerRoles,
"allowed_email_domain": cfg.WranglerSettings.AllowedEmailDomain,
"move_thread_max_count": cfg.WranglerSettings.MoveThreadMaxCount,
"move_thread_to_another_team_enable": cfg.WranglerSettings.MoveThreadToAnotherTeamEnable,
"move_thread_from_private_channel_enable": cfg.WranglerSettings.MoveThreadFromPrivateChannelEnable,
"move_thread_from_direct_message_channel_enable": cfg.WranglerSettings.MoveThreadFromDirectMessageChannelEnable,
"move_thread_from_group_message_channel_enable": cfg.WranglerSettings.MoveThreadFromGroupMessageChannelEnable,
})
// Convert feature flags to map[string]any for sending
flags := cfg.FeatureFlags.ToMap()
interfaceFlags := make(map[string]any)

View file

@ -4184,6 +4184,21 @@ func (c *Client4) GetPostsBefore(ctx context.Context, channelId, postId string,
return &list, BuildResponse(r), nil
}
// MoveThread moves a thread based on provided post id, and channel id string.
func (c *Client4) MoveThread(ctx context.Context, postId string, params *MoveThreadParams) (*Response, error) {
js, err := json.Marshal(params)
if err != nil {
return nil, NewAppError("MoveThread", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
r, err := c.DoAPIPost(ctx, c.postRoute(postId)+"/move", string(js))
if err != nil {
return BuildResponse(r), err
}
defer closeBody(r)
return BuildResponse(r), nil
}
// GetPostsAroundLastUnread gets a list of posts around last unread post by a user in a channel.
func (c *Client4) GetPostsAroundLastUnread(ctx context.Context, userId, channelId string, limitBefore, limitAfter int, collapsedThreads bool) (*PostList, *Response, error) {
query := fmt.Sprintf("?limit_before=%v&limit_after=%v", limitBefore, limitAfter)

View file

@ -3108,6 +3108,51 @@ func (s *PluginSettings) SetDefaults(ls LogSettings) {
}
}
type WranglerSettings struct {
PermittedWranglerRoles []string
AllowedEmailDomain []string
MoveThreadMaxCount *int64
MoveThreadToAnotherTeamEnable *bool
MoveThreadFromPrivateChannelEnable *bool
MoveThreadFromDirectMessageChannelEnable *bool
MoveThreadFromGroupMessageChannelEnable *bool
}
func (w *WranglerSettings) SetDefaults() {
if w.PermittedWranglerRoles == nil {
w.PermittedWranglerRoles = make([]string, 0)
}
if w.AllowedEmailDomain == nil {
w.AllowedEmailDomain = make([]string, 0)
}
if w.MoveThreadMaxCount == nil {
w.MoveThreadMaxCount = NewInt64(100)
}
if w.MoveThreadToAnotherTeamEnable == nil {
w.MoveThreadToAnotherTeamEnable = NewBool(false)
}
if w.MoveThreadFromPrivateChannelEnable == nil {
w.MoveThreadFromPrivateChannelEnable = NewBool(false)
}
if w.MoveThreadFromDirectMessageChannelEnable == nil {
w.MoveThreadFromDirectMessageChannelEnable = NewBool(false)
}
if w.MoveThreadFromGroupMessageChannelEnable == nil {
w.MoveThreadFromGroupMessageChannelEnable = NewBool(false)
}
}
func (w *WranglerSettings) IsValid() *AppError {
validDomainRegex := regexp.MustCompile(`^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|([a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9]))\.([a-zA-Z]{2,6}|[a-zA-Z0-9-]{2,30}\.[a-zA-Z]{2,3})$`)
for _, domain := range w.AllowedEmailDomain {
if !validDomainRegex.MatchString(domain) && domain != "localhost" {
return NewAppError("Config.IsValid", "model.config.is_valid.move_thread.domain_invalid.app_error", nil, "", http.StatusBadRequest)
}
}
return nil
}
type GlobalRelayMessageExportSettings struct {
CustomerType *string `access:"compliance_compliance_export"` // must be either A9, A10 or CUSTOM, dictates SMTP server url
SMTPUsername *string `access:"compliance_compliance_export"`
@ -3405,6 +3450,7 @@ type Config struct {
FeatureFlags *FeatureFlags `access:"*_read" json:",omitempty"`
ImportSettings ImportSettings // telemetry: none
ExportSettings ExportSettings
WranglerSettings WranglerSettings
}
func (o *Config) Auditable() map[string]interface{} {
@ -3520,6 +3566,7 @@ func (o *Config) SetDefaults() {
}
o.ImportSettings.SetDefaults()
o.ExportSettings.SetDefaults()
o.WranglerSettings.SetDefaults()
}
func (o *Config) IsValid() *AppError {
@ -3610,6 +3657,11 @@ func (o *Config) IsValid() *AppError {
if appErr := o.ImportSettings.isValid(); appErr != nil {
return appErr
}
if appErr := o.WranglerSettings.IsValid(); appErr != nil {
return appErr
}
return nil
}

View file

@ -143,6 +143,24 @@ func TestConfigOverwriteSignatureAlgorithm(t *testing.T) {
require.Equal(t, *c1.SamlSettings.CanonicalAlgorithm, testAlgorithm)
}
func TestWranglerSettingsIsValid(t *testing.T) {
// // Test valid domains
w := &WranglerSettings{
AllowedEmailDomain: []string{"example.com", "subdomain.example.com"},
}
if err := w.IsValid(); err != nil {
t.Errorf("Expected no error for valid domains, but got %v", err)
}
// Test invalid domains
w = &WranglerSettings{
AllowedEmailDomain: []string{"example", "example..com", "example-.com", "-example.com", "example.com.", "example.com-"},
}
if err := w.IsValid(); err == nil {
t.Errorf("Expected error for invalid domains, but got none")
}
}
func TestConfigIsValidDefaultAlgorithms(t *testing.T) {
c1 := Config{}
c1.SetDefaults()

View file

@ -42,6 +42,8 @@ type FeatureFlags struct {
EnableExportDirectDownload bool
MoveThreadsEnabled bool
StreamlinedMarketplace bool
CloudIPFiltering bool
@ -62,6 +64,7 @@ func (f *FeatureFlags) SetDefaults() {
f.OnboardingTourTips = true
f.CloudReverseTrial = false
f.EnableExportDirectDownload = false
f.MoveThreadsEnabled = false
f.StreamlinedMarketplace = true
f.CloudIPFiltering = false
f.ConsumePostHook = false

View file

@ -45,6 +45,7 @@ const (
PostTypeChannelRestored = "system_channel_restored"
PostTypeEphemeral = "system_ephemeral"
PostTypeChangeChannelPrivacy = "system_change_chan_privacy"
PostTypeWrangler = "system_wrangler"
PostTypeGMConvertedToChannel = "system_gm_to_channel"
PostTypeAddBotTeamsChannels = "add_bot_teams_channels"
PostTypeSystemWarnMetricStatus = "warn_metric_status"
@ -193,6 +194,10 @@ type GetPersistentNotificationsPostsParams struct {
PerPage int
}
type MoveThreadParams struct {
ChannelId string `json:"channel_id"`
}
type SearchParameter struct {
Terms *string `json:"terms"`
IsOrSearch *bool `json:"is_or_search"`
@ -444,6 +449,7 @@ func (o *Post) IsValid(maxPostSize int) *AppError {
PostTypeSystemWarnMetricStatus,
PostTypeReminder,
PostTypeMe,
PostTypeWrangler,
PostTypeGMConvertedToChannel:
default:
if !strings.HasPrefix(o.Type, PostCustomTypePrefix) {
@ -893,3 +899,11 @@ func (o *Post) IsUrgent() bool {
return *postPriority.Priority == PostPriorityUrgent
}
func (o *Post) CleanPost() *Post {
o.Id = ""
o.CreateAt = 0
o.UpdateAt = 0
o.EditAt = 0
return o
}

View file

@ -190,3 +190,39 @@ func (o *PostList) IsChannelId(channelId string) bool {
return true
}
func (o *PostList) BuildWranglerPostList() *WranglerPostList {
wpl := &WranglerPostList{}
o.UniqueOrder()
o.SortByCreateAt()
posts := o.ToSlice()
if len(posts) == 0 {
// Something was sorted wrong or an empty PostList was provided.
return wpl
}
// A separate ID key map to ensure no duplicates.
idKeys := make(map[string]bool)
for i := range posts {
p := posts[len(posts)-i-1]
// Add UserID to metadata if it's new.
if _, ok := idKeys[p.UserId]; !ok {
idKeys[p.UserId] = true
wpl.ThreadUserIDs = append(wpl.ThreadUserIDs, p.UserId)
}
wpl.FileAttachmentCount += int64(len(p.FileIds))
wpl.Posts = append(wpl.Posts, p)
}
// Set metadata for earliest and latest posts
wpl.EarlistPostTimestamp = wpl.RootPost().CreateAt
wpl.LatestPostTimestamp = wpl.Posts[wpl.NumPosts()-1].CreateAt
return wpl
}

View file

@ -1016,6 +1016,12 @@ type UsersWithGroupsAndCount struct {
Count int64 `json:"total_count"`
}
func (u *User) EmailDomain() string {
at := strings.LastIndex(u.Email, "@")
// at >= 0 holds true and this is not checked here. It holds true, because during signup we run `mail.ParseAddress(email)`
return u.Email[at+1:]
}
type UserPostStats struct {
LastLogin int64 `json:"last_login_at,omitempty"`
LastStatusAt *int64 `json:"last_status_at,omitempty"`

View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// WranglerPostList provides a list of posts along with metadata about those
// posts.
type WranglerPostList struct {
Posts []*Post
ThreadUserIDs []string
EarlistPostTimestamp int64
LatestPostTimestamp int64
FileAttachmentCount int64
}
// NumPosts returns the number of posts in a post list.
func (wpl *WranglerPostList) NumPosts() int {
return len(wpl.Posts)
}
// RootPost returns the root post in a post list.
func (wpl *WranglerPostList) RootPost() *Post {
if wpl.NumPosts() < 1 {
return nil
}
return wpl.Posts[0]
}
// ContainsFileAttachments returns if the post list contains any file attachments.
func (wpl *WranglerPostList) ContainsFileAttachments() bool {
return wpl.FileAttachmentCount != 0
}

View file

@ -85,7 +85,7 @@ func InitTranslations(serverLocale, clientLocale string) error {
defaultClientLocale = clientLocale
var err error
T, err = getTranslationsBySystemLocale()
T, err = GetTranslationsBySystemLocale()
return err
}
@ -151,7 +151,7 @@ func GetTranslationFuncForDir(dir string) (TranslationFuncByLocal, error) {
}, nil
}
func getTranslationsBySystemLocale() (TranslateFunc, error) {
func GetTranslationsBySystemLocale() (TranslateFunc, error) {
locale := defaultServerLocale
if _, ok := locales[locale]; !ok {
mlog.Warn("Failed to load system translations for selected locale, attempting to fall back to default", mlog.String("locale", locale), mlog.String("default_locale", defaultLocale))

View file

@ -80,7 +80,7 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with
onChange={[Function]}
placeholder="e.g. some setting"
setByEnv={false}
type="input"
type="text"
value="fsdsdg"
/>
<BooleanSetting

View file

@ -3199,6 +3199,91 @@ const AdminDefinition: AdminDefinitionType = {
],
},
},
wrangler: {
url: 'site_config/wrangler',
title: t('admin.sidebar.move_thread'),
title_default: 'Move Thread (Beta)',
isHidden: it.any(it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), it.configIsFalse('FeatureFlags', 'MoveThreadsEnabled')),
schema: {
id: 'WranglerSettings',
name: t('admin.site.move_thread'),
name_default: 'Move Thread (Beta)',
settings: [
{
type: 'roles',
multiple: true,
key: 'WranglerSettings.PermittedWranglerRoles',
label: t('admin.experimental.PermittedMoveThreadRoles.title'),
label_default: 'Permitted Roles',
help_text: t('admin.experimental.PermittedMoveThreadRoles.desc'),
help_text_default: 'Choose who is allowed to move threads to other channels based on roles. (Other permissions below still apply).',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'text',
key: 'WranglerSettings.AllowedEmailDomain',
multiple: true,
label: t('admin.experimental.allowedEmailDomain.title'),
label_default: 'Allowed Email Domain',
help_text: t('admin.experimental.allowedEmailDomain.desc'),
help_text_default: '(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.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'number',
key: 'WranglerSettings.MoveThreadMaxCount',
label: t('admin.experimental.moveThreadMaxCount.title'),
label_default: 'Max Thread Count Move Size',
help_text: t('admin.experimental.moveThreadMaxCount.desc'),
help_text_default: 'The maximum number of messages in a thread that the plugin is allowed to move. Leave empty for unlimited messages.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'bool',
key: 'WranglerSettings.MoveThreadToAnotherTeamEnable',
label: t('admin.experimental.moveThreadToAnotherTeamEnable.title'),
label_default: 'Enable Moving Threads To Different Teams',
help_text: t('admin.experimental.moveThreadToAnotherTeamEnable.desc'),
help_text_default: 'Control whether Wrangler is permitted to move message threads from one team to another or not.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'bool',
key: 'WranglerSettings.MoveThreadFromPrivateChannelEnable',
label: t('admin.experimental.moveThreadFromPrivateChannelEnable.title'),
label_default: 'Enable Moving Threads From Private Channels',
help_text: t('admin.experimental.moveThreadFromPrivateChannelEnable.desc'),
help_text_default: 'Control whether Wrangler is permitted to move message threads from private channels or not.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'bool',
key: 'WranglerSettings.MoveThreadFromDirectMessageChannelEnable',
label: t('admin.experimental.moveThreadFromDirectMessageChannelEnable.title'),
label_default: 'Enable Moving Threads From Direct Message Channels',
help_text: t('admin.experimental.moveThreadFromDirectMessageChannelEnable.desc'),
help_text_default: 'Control whether Wrangler is permitted to move message threads from direct message channels or not.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'bool',
key: 'WranglerSettings.MoveThreadFromGroupMessageChannelEnable',
label: t('admin.experimental.moveThreadFromGroupMessageChannelEnable.title'),
label_default: 'Enable Moving Threads From Group Message Channels',
help_text: t('admin.experimental.moveThreadFromGroupMessageChannelEnable.desc'),
help_text_default: 'Control whether Wrangler is permitted to move message threads from group message channels or not.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
],
},
},
file_sharing_downloads: {
url: 'site_config/file_sharing_downloads',
title: t('admin.sidebar.fileSharingDownloads'),

View file

@ -46,7 +46,7 @@ exports[`components/admin_console/CustomPluginSettings should match snapshot wit
onChange={[Function]}
placeholder="e.g. some setting"
setByEnv={false}
type="input"
type="text"
value="setting_default"
/>
<BooleanSetting
@ -356,7 +356,7 @@ exports[`components/admin_console/CustomPluginSettings should match snapshot wit
onChange={[Function]}
placeholder="e.g. some setting"
setByEnv={false}
type="input"
type="text"
value="fsdsdg"
/>
<BooleanSetting

View file

@ -45,9 +45,9 @@ State
}
handleChange = (newValue: ValueType<Option>) => {
const values = (newValue as Option[]).map((n) => {
const values = newValue ? (newValue as Option[]).map((n) => {
return n.value;
});
}) : [];
this.props.onChange(this.props.id, values);
this.setState({error: false});

View file

@ -37,6 +37,8 @@ import Setting from './setting';
import './schema_admin_settings.scss';
const emptyList = [];
export default class SchemaAdminSettings extends React.PureComponent {
static propTypes = {
config: PropTypes.object,
@ -75,6 +77,7 @@ export default class SchemaAdminSettings extends React.PureComponent {
[Constants.SettingsTypes.TYPE_LANGUAGE]: this.buildLanguageSetting,
[Constants.SettingsTypes.TYPE_JOBSTABLE]: this.buildJobsTableSetting,
[Constants.SettingsTypes.TYPE_FILE_UPLOAD]: this.buildFileUploadSetting,
[Constants.SettingsTypes.TYPE_ROLES]: this.buildRolesSetting,
[Constants.SettingsTypes.TYPE_CUSTOM]: this.buildCustomSetting,
};
this.state = {
@ -439,16 +442,20 @@ export default class SchemaAdminSettings extends React.PureComponent {
};
buildTextSetting = (setting) => {
let inputType = 'input';
let inputType = 'text';
if (setting.type === Constants.SettingsTypes.TYPE_NUMBER) {
inputType = 'number';
} else if (setting.type === Constants.SettingsTypes.TYPE_LONG_TEXT) {
inputType = 'textarea';
}
let value = this.state[setting.key] ?? '';
let value = '';
if (setting.dynamic_value) {
value = setting.dynamic_value(value, this.props.config, this.state, this.props.license);
} else if (setting.multiple) {
value = this.state[setting.key] ? this.state[setting.key].join(',') : '';
} else {
value = this.state[setting.key] || '';
}
let footer = null;
@ -466,6 +473,7 @@ export default class SchemaAdminSettings extends React.PureComponent {
<TextSetting
key={this.props.schema.id + '_text_' + setting.key}
id={setting.key}
multiple={setting.multiple}
type={inputType}
label={this.renderLabel(setting)}
helpText={this.renderHelpText(setting)}
@ -570,6 +578,53 @@ export default class SchemaAdminSettings extends React.PureComponent {
);
};
buildRolesSetting = (setting) => {
const {roles} = this.props;
const values = Object.keys(roles).map((r) => {
return {
value: roles[r].name,
text: roles[r].name,
};
});
if (setting.multiple) {
const noResultText = (
<FormattedMessage
id={setting.no_result}
defaultMessage={setting.no_result_default}
/>
);
return (
<MultiSelectSetting
key={this.props.schema.id + '_language_' + setting.key}
id={setting.key}
label={this.renderLabel(setting)}
values={values}
helpText={this.renderHelpText(setting)}
selected={(this.state[setting.key] || emptyList)}
disabled={this.isDisabled(setting)}
setByEnv={this.isSetByEnv(setting.key)}
onChange={this.handleChange}
noResultText={noResultText}
/>
);
}
return (
<DropdownSetting
key={this.props.schema.id + '_language_' + setting.key}
id={setting.key}
label={this.renderLabel(setting)}
values={values}
helpText={this.renderHelpText(setting)}
value={this.state[setting.key] || values[0].value}
disabled={this.isDisabled(setting)}
setByEnv={this.isSetByEnv(setting.key)}
onChange={this.handleChange}
/>
);
};
buildLanguageSetting = (setting) => {
const locales = I18n.getAllLanguages();
const values = Object.keys(locales).map((l) => {

View file

@ -45,6 +45,14 @@ type AdminDefinitionSettingBanner = AdminDefinitionSettingBase & {
banner_type: 'info' | 'warning';
}
type AdminDefinitionSettingRole = AdminDefinitionSettingBase & {
type: 'roles';
multiple?: boolean;
help_text?: string;
help_text_default?: string;
help_text_markdown?: boolean;
}
type AdminDefinitionSettingInput = AdminDefinitionSettingBase & {
type: 'text' | 'bool' | 'longtext' | 'number' | 'color';
help_text?: string | JSX.Element;
@ -55,6 +63,7 @@ type AdminDefinitionSettingInput = AdminDefinitionSettingBase & {
disabled_help_text_default?: string;
disabled_help_text_markdown?: boolean;
placeholder?: string;
multiple?: boolean;
placeholder_default?: string;
validate?: Validator;
setFromMetadataField?: string;
@ -155,7 +164,7 @@ export type AdminDefinitionSetting = AdminDefinitionSettingCustom |
AdminDefinitionSettingInput | AdminDefinitionSettingGenerated |
AdminDefinitionSettingBanner | AdminDefinitionSettingDropdown |
AdminDefinitionSettingButton | AdminDefinitionSettingFileUpload |
AdminDefinitionSettingJobsTable | AdminDefinitionSettingLanguage;
AdminDefinitionSettingJobsTable | AdminDefinitionSettingLanguage | AdminDefinitionSettingRole;
type AdminDefinitionConfigSchemaSettings = {
id: string;

View file

@ -1,5 +1,114 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/dot_menu/DotMenu should match snapshot, can move 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>
</body>,
"container": <div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"unmount": [Function],
"updateStoreState": [Function],
}
`;
exports[`components/dot_menu/DotMenu should match snapshot, canDelete 1`] = `
Object {
"asFragment": [Function],
@ -109,6 +218,115 @@ Object {
}
`;
exports[`components/dot_menu/DotMenu should match snapshot, cannot move 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>
</body>,
"container": <div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"unmount": [Function],
"updateStoreState": [Function],
}
`;
exports[`components/dot_menu/DotMenu should match snapshot, on Center 1`] = `
<Menu
menu={
@ -301,6 +519,26 @@ exports[`components/dot_menu/DotMenu should match snapshot, on Center 1`] = `
/>
}
/>
<MenuItem
id="move_thread_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Move Thread"
id="post_info.move_thread"
/>
}
leadingElement={
<MessageArrowRightOutlineIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="W"
/>
}
/>
<MenuItemSeparator />
<MenuItem
data-testid="permalink_post_id_1"

View file

@ -146,6 +146,7 @@ describe('components/dot_menu/DotMenu', () => {
threadReplyCount: 0,
userId: 'user_id_1',
isMilitaryTime: false,
canMove: true,
};
test('should match snapshot, on Center', () => {
@ -180,6 +181,32 @@ describe('components/dot_menu/DotMenu', () => {
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, can move', () => {
const props = {
...baseProps,
canMove: true,
};
const wrapper = renderWithContext(
<DotMenu {...props}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, cannot move', () => {
const props = {
...baseProps,
canMove: false,
};
const wrapper = renderWithContext(
<DotMenu {...props}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
});
test('should show mark as unread when channel is not archived', () => {
const props = {
...baseProps,

View file

@ -15,6 +15,7 @@ import {
EmoticonPlusOutlineIcon,
LinkVariantIcon,
MarkAsUnreadIcon,
MessageArrowRightOutlineIcon,
MessageCheckOutlineIcon,
MessageMinusOutlineIcon,
PencilOutlineIcon,
@ -31,6 +32,7 @@ import Permissions from 'mattermost-redux/constants/permissions';
import DeletePostModal from 'components/delete_post_modal';
import ForwardPostModal from 'components/forward_post_modal';
import * as Menu from 'components/menu';
import MoveThreadModal from 'components/move_thread_modal';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import {Locations, ModalIdentifiers, Constants, TELEMETRY_LABELS} from 'utils/constants';
@ -76,6 +78,7 @@ type Props = {
isMobileView: boolean;
timezone?: string;
isMilitaryTime: boolean;
canMove: boolean;
actions: {
@ -118,6 +121,7 @@ type Props = {
* Function to set the thread as followed/unfollowed
*/
setThreadFollow: (userId: string, teamId: string, threadId: string, newState: boolean) => void;
}; // TechDebt: Made non-mandatory while converting to typescript
canEdit: boolean;
@ -251,6 +255,24 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
trackDotMenuEvent(e, TELEMETRY_LABELS.DELETE);
};
handleMoveThreadMenuItemActivated = (e: ChangeEvent): void => {
e.preventDefault();
if (!this.props.canMove) {
return;
}
trackDotMenuEvent(e, TELEMETRY_LABELS.MOVE_THREAD);
const moveThreadModalData = {
modalId: ModalIdentifiers.MOVE_THREAD_MODAL,
dialogType: MoveThreadModal,
dialogProps: {
post: this.props.post,
},
};
this.props.actions.openModal(moveThreadModalData);
};
handleForwardMenuItemActivated = (e: ChangeEvent): void => {
if (!this.canPostBeForwarded) {
// adding this early return since only hiding the Item from the menu is not enough,
@ -361,6 +383,12 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
this.handleDeleteMenuItemActivated(event);
break;
// move thread
case Keyboard.isKeyPressed(event, Constants.KeyCodes.W):
this.handleMoveThreadMenuItemActivated(event);
this.props.handleDropdownOpened(false);
break;
// pin / unpin
case Keyboard.isKeyPressed(event, Constants.KeyCodes.P):
forceCloseMenu();
@ -595,6 +623,19 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.handlePinMenuItemActivated}
/>
}
{Boolean(!isSystemMessage && this.props.canMove) &&
<Menu.Item
id={`move_thread_${this.props.post.id}`}
labels={
<FormattedMessage
id={'post_info.move_thread'}
defaultMessage={'Move Thread'}
/>}
leadingElement={<MessageArrowRightOutlineIcon size={18}/>}
trailingElements={<ShortcutKey shortcutKey='W'/>}
onClick={this.handleMoveThreadMenuItemActivated}
/>
}
{!isSystemMessage && (this.state.canEdit || this.state.canDelete) && <Menu.Separator/>}
{!isSystemMessage &&
<Menu.Item

View file

@ -60,6 +60,7 @@ describe('components/dot_menu/DotMenu returning empty ("")', () => {
threadId: 'post_id_1',
userId: 'user_id_1',
isMilitaryTime: false,
canMove: true,
};
const wrapper = shallow(

View file

@ -60,6 +60,7 @@ describe('components/dot_menu/DotMenu on mobile view', () => {
threadId: 'post_id_1',
userId: 'user_id_1',
isMilitaryTime: false,
canMove: true,
};
const wrapper = shallow(

View file

@ -29,6 +29,7 @@ import {
markPostAsUnread,
} from 'actions/post_actions';
import {openModal} from 'actions/views/modals';
import {makeCanWrangler} from 'selectors/posts';
import {getIsMobileView} from 'selectors/views/browser';
import {isArchivedChannel} from 'utils/channel_utils';
@ -58,6 +59,7 @@ type Props = {
function makeMapStateToProps() {
const getThreadOrSynthetic = makeGetThreadOrSynthetic();
const canWrangler = makeCanWrangler();
return function mapStateToProps(state: GlobalState, ownProps: Props) {
const {post} = ownProps;
@ -123,6 +125,7 @@ function makeMapStateToProps() {
isMobileView: getIsMobileView(state),
timezone: getCurrentTimezone(state),
isMilitaryTime,
canMove: canWrangler(state, channel.type, threadReplyCount),
};
};
}

View file

@ -222,15 +222,14 @@ const DropdownIndicator = (props: IndicatorProps<ChannelOption>) => {
);
};
const validChannelTypes = ['O', 'P', 'D', 'G'];
type Props<O extends OptionTypeBase> = {
onSelect: (channel: ValueType<O>) => void;
currentBodyHeight: number;
value?: O;
validChannelTypes?: string[];
}
function ForwardPostChannelSelect({onSelect, value, currentBodyHeight}: Props<ChannelOption>) {
function ForwardPostChannelSelect({onSelect, value, currentBodyHeight, validChannelTypes = ['O', 'P', 'D', 'G']}: Props<ChannelOption>) {
const {formatMessage} = useIntl();
const {current: provider} = useRef<SwitchChannelProvider>(new SwitchChannelProvider());

View file

@ -141,5 +141,14 @@
opacity: 0.64 !important;
}
}
.post-error {
position: relative;
top: 3px;
margin-bottom: 0;
font-size: 0.85em;
font-weight: normal;
opacity: 0.55;
}
}
}

View file

@ -223,7 +223,7 @@ const ForwardPostModal = ({onExited, post, actions}: Props) => {
const postPreviewFooterMessage = formatMessage({
id: 'forward_post_modal.preview.footer_message',
defaultMessage: 'Originally posted in ~{channelName}',
defaultMessage: 'Originally posted in ~{channel}',
},
{
channel: channel.display_name,

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ConnectedProps} from 'react-redux';
import {connect} from 'react-redux';
import type {ActionCreatorsMapObject, Dispatch} from 'redux';
import {bindActionCreators} from 'redux';
import {moveThread} from 'mattermost-redux/actions/posts';
import {joinChannelById, switchToChannel} from 'actions/views/channel';
import type {ActionProps} from './move_thread_modal';
import MoveThreadModal from './move_thread_modal';
export type PropsFromRedux = ConnectedProps<typeof connector>;
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<any>, ActionProps>({
joinChannelById,
switchToChannel,
moveThread,
}, dispatch),
};
}
const connector = connect(null, mapDispatchToProps);
export default connector(MoveThreadModal);

View file

@ -0,0 +1,158 @@
.move-thread {
.modal-body {
overflow: visible;
}
&.modal-dialog {
margin-top: 64px;
}
&__body {
display: flex;
overflow: visible;
flex-direction: column;
color: var(--center-channel-color);
gap: 24px;
.custom-textarea,
#move_thread_textbox {
min-height: 40px;
padding: 10px 16px;
}
#move_thread_textbox {
overflow: auto;
border: none;
box-shadow: inset 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.16);
&:focus {
box-shadow: inset 0 0 0 2px var(--button-bg);
}
&:focus:not(:focus-visible) {
box-shadow: inset 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.16);
}
&:focus:focus-visible {
box-shadow: inset 0 0 0 2px var(--button-bg);
}
}
}
&__select {
position: relative;
.option,
.singleValue {
top: 100%;
display: grid;
align-content: center;
align-items: center;
color: var(--center-channel-color);
gap: 12px;
grid-auto-columns: minmax(20%, max-content);
grid-template-columns: 24px 1fr;
grid-template-rows: 24px;
svg,
.status-wrapper,
.status--group {
place-self: center;
}
.status--group {
display: flex;
width: 1.8rem;
height: 1.8rem;
align-items: center;
justify-content: center;
margin: 0;
background-color: rgba(var(--center-channel-color-rgb), 0.12);
}
.status-wrapper {
height: 2.4rem;
}
&__content {
display: flex;
overflow: hidden;
max-width: 100%;
flex-direction: row;
gap: 8px;
&--text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--description {
color: rgba(var(--center-channel-color-rgb), 0.56);
}
.emoticon {
margin-left: 0;
}
}
&__team-name {
overflow: hidden;
width: 100%;
color: rgba(var(--center-channel-color-rgb), 0.56);
grid-column: 3 / 4;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
&__post-preview {
&--title {
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
&--override {
padding: 2px 0;
pointer-events: none;
&:not(.post--editing):not(.post--editing):hover {
background-color: transparent;
.attachment__content.attachment__content--permalink {
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
}
}
}
}
}
.console__body .modal .GenericModal.move-thread,
.app__body .modal .GenericModal.move-thread {
margin-top: 64px;
.modal-body {
max-height: calc(100vh - 128px - 61px - 88px);
.form-control {
border: none;
box-shadow: none;
&#move_thread_textbox_placeholder {
opacity: 0.64 !important;
}
}
.post-error {
position: relative;
top: 3px;
margin-bottom: 0;
font-size: 0.85em;
font-weight: normal;
opacity: 0.55;
}
}
}

View file

@ -0,0 +1,253 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import type {ValueType} from 'react-select';
import type {ClientError} from '@mattermost/client';
import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
import type {Post, PostPreviewMetadata} from '@mattermost/types/posts';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import type {ActionResult} from 'mattermost-redux/types/actions';
import type {ChannelOption} from 'components/forward_post_modal/forward_post_channel_select';
import ChannelSelector from 'components/forward_post_modal/forward_post_channel_select';
import NotificationBox from 'components/notification_box';
import PostMessagePreview from 'components/post_view/post_message_preview';
import Constants from 'utils/constants';
import type {GlobalState} from 'types/store';
import './move_thread_modal.scss';
export type ActionProps = {
// join the selected channel when necessary
joinChannelById: (channelId: string) => Promise<ActionResult>;
// switch to the selected channel
switchToChannel: (channel: Channel) => Promise<ActionResult>;
// action called to move the post from the original channel to a new channel
moveThread: (postId: string, channelId: string) => Promise<ActionResult>;
}
export type OwnProps = {
// The function called immediately after the modal is hidden
onExited?: () => void;
// the post that is going to be moved
post: Post;
};
export type Props = OwnProps & {actions: ActionProps};
const noop = () => {};
const getChannel = makeGetChannel();
// since the original post has a click handler specified we should prevent any action here
const preventActionOnPreview = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const MoveThreadModal = ({onExited, post, actions}: Props) => {
const {formatMessage} = useIntl();
const originalChannel = useSelector((state: GlobalState) => getChannel(state, {id: post.channel_id}));
const currentTeam = useSelector(getCurrentTeam);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [bodyHeight, setBodyHeight] = useState<number>(0);
const [hasError, setHasError] = useState<boolean>(false);
const [postError, setPostError] = useState<React.ReactNode>(null);
const [selectedChannel, setSelectedChannel] = useState<ChannelOption>();
const [isButtonClicked, setIsButtonClicked] = useState<boolean>(false);
const bodyRef = useRef<HTMLDivElement>();
const measuredRef = useCallback((node) => {
if (node !== null) {
bodyRef.current = node;
setBodyHeight(node.getBoundingClientRect().height);
}
}, []);
const onHide = useCallback(() => {
onExited?.();
}, [onExited]);
const handleChannelSelect = useCallback((channel: ValueType<ChannelOption>) => {
if (Array.isArray(channel)) {
setSelectedChannel(channel[0]);
return;
}
setSelectedChannel(channel as ChannelOption);
}, []);
const messagePreviewTitle = formatMessage({
id: 'move_thread_modal.preview.title',
defaultMessage: 'Message preview',
});
const previewMetaData: PostPreviewMetadata = useMemo(() => ({
post,
post_id: post.id,
team_name: currentTeam.name,
channel_display_name: originalChannel.display_name,
channel_type: originalChannel.type,
channel_id: originalChannel.id,
}), [post, currentTeam.name, originalChannel.display_name, originalChannel.type, originalChannel.id]);
const notificationText = formatMessage({
id: 'move_thread_modal.notification.dm_or_gm',
defaultMessage: 'Moving this thread changes who has access',
});
const notification = (
<NotificationBox
variant={'info'}
text={notificationText}
id={'move_thread'}
/>
);
const handlePostError = useCallback((error: ClientError) => {
setIsButtonClicked(false);
setPostError(error.message);
setHasError(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setHasError(false);
timeoutRef.current = null;
}, Constants.ANIMATION_TIMEOUT);
}, []);
const handleSubmit = useCallback(async () => {
setIsButtonClicked(true);
if (!selectedChannel) {
setIsButtonClicked(false);
return;
}
const channel = selectedChannel.details;
let result = await actions.moveThread(post.root_id || post.id, channel.id);
if (result.error) {
handlePostError(result.error);
return;
}
if (
channel.type === Constants.MENTION_MORE_CHANNELS &&
channel.type === Constants.OPEN_CHANNEL
) {
result = await actions.joinChannelById(channel.id);
if (result.error) {
handlePostError(result.error);
return;
}
}
result = await actions.switchToChannel(channel);
if (result.error) {
handlePostError(result.error);
return;
}
onHide();
}, [selectedChannel, post, actions, handlePostError, onHide]);
const postPreviewFooterMessage = formatMessage({
id: 'move_thread_modal.preview.footer_message',
defaultMessage: 'Originally posted in ~{channelName}',
},
{
channelName: originalChannel.display_name,
});
return (
<GenericModal
className='a11y__modal forward-post move-thread'
id='move-thread-modal'
show={true}
autoCloseOnConfirmButton={false}
compassDesign={true}
modalHeaderText={formatMessage({
id: 'move_thread_modal.title',
defaultMessage: 'Move thread',
})}
confirmButtonText={formatMessage({
id: 'move_thread_modal.button.forward',
defaultMessage: 'Move',
})}
cancelButtonText={formatMessage({
id: 'move_thread_modal.button.cancel',
defaultMessage: 'Cancel',
})}
cancelButtonClassName={'MoveThreadModal__cancel-button'}
isConfirmDisabled={isButtonClicked}
handleConfirm={handleSubmit}
handleEnterKeyPress={handleSubmit}
handleCancel={onHide}
onExited={onHide}
>
<div
className={'move-thread__body'}
ref={measuredRef}
>
{notification}
<ChannelSelector
onSelect={handleChannelSelect}
value={selectedChannel}
currentBodyHeight={bodyHeight}
validChannelTypes={['O', 'P']}
/>
<div className={'move-thread__post-preview'}>
<span className={'move-thread__post-preview--title'}>
{messagePreviewTitle}
</span>
<div
className='post move-thread__post-preview--override'
onClick={preventActionOnPreview}
>
<PostMessagePreview
metadata={previewMetaData}
handleFileDropdownOpened={noop}
preventClickAction={true}
previewFooterMessage={postPreviewFooterMessage}
/>
</div>
{postError && (
<label
className={classNames('post-error', {
'animation--highlight': hasError,
})}
>
{postError}
</label>
)}
</div>
</div>
</GenericModal>
);
};
export default MoveThreadModal;

View file

@ -12,7 +12,7 @@ import Markdown from 'components/markdown';
import type {TextFormattingOptions} from 'utils/text_formatting';
import {renderReminderSystemBotMessage, renderSystemMessage} from './system_message_helpers';
import {renderReminderSystemBotMessage, renderSystemMessage, renderWranglerSystemMessage} from './system_message_helpers';
import {type PropsFromRedux} from './index';
@ -82,6 +82,11 @@ export default class PostMarkdown extends React.PureComponent<Props> {
return <div>{renderedSystemBotMessage}</div>;
}
if (this.props.post && this.props.post.type === Posts.POST_TYPES.WRANGLER) {
const renderedWranglerMessage = renderWranglerSystemMessage(this.props.post);
return <div>{renderedWranglerMessage}</div>;
}
// 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;

View file

@ -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 (
<FormattedMessage
id={id}
defaultMessage={post.message}
values={values}
/>
);
}

View file

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

View file

@ -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, users email is retrieved from the certificate and is used to log in without a password. When **secondary**, after the client side certificate is verified, users 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 <link>documentation</link> 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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1059,6 +1059,14 @@ export default class Client4 {
);
}
moveThread = (postId: string, channelId: string) => {
const url = this.getPostRoute(postId) + '/move';
return this.doFetch<StatusOK>(
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');

View file

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

View file

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