diff --git a/api/v4/source/posts.yaml b/api/v4/source/posts.yaml index b54023b25a5..fd8732dd046 100644 --- a/api/v4/source/posts.yaml +++ b/api/v4/source/posts.yaml @@ -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" diff --git a/e2e-tests/.ci/server.generate.sh b/e2e-tests/.ci/server.generate.sh index d80009e8174..402100b8af2 100755 --- a/e2e-tests/.ci/server.generate.sh +++ b/e2e-tests/.ci/server.generate.sh @@ -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 diff --git a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_dm_spec.js b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_dm_spec.js new file mode 100644 index 00000000000..ed2094ae58e --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_dm_spec.js @@ -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}); + } + }); + }; +}); diff --git a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_gm_spec.js b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_gm_spec.js new file mode 100644 index 00000000000..6e58bef2ccd --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_gm_spec.js @@ -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}); + } + }); + }; +}); diff --git a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_private_channel_spec.js b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_private_channel_spec.js new file mode 100644 index 00000000000..cfd4cad3936 --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_private_channel_spec.js @@ -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(); + } + }); + }; +}); diff --git a/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_public_channel_spec.js b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_public_channel_spec.js new file mode 100644 index 00000000000..6f15a077a79 --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/move_thread/move_thread_from_public_channel_spec.js @@ -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'); + }); + }; +}); diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index d8b9eca8564..2bcfdbee8fe 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -714,4 +714,13 @@ const defaultServerConfig: AdminConfig = { Directory: './export', RetentionDays: 30, }, + WranglerSettings: { + PermittedWranglerRoles: [], + AllowedEmailDomain: [], + MoveThreadMaxCount: 30, + MoveThreadToAnotherTeamEnable: true, + MoveThreadFromPrivateChannelEnable: true, + MoveThreadFromDirectMessageChannelEnable: true, + MoveThreadFromGroupMessageChannelEnable: true, + }, }; diff --git a/server/channels/api4/post.go b/server/channels/api4/post.go index c3a028b2255..aca668585be 100644 --- a/server/channels/api4/post.go +++ b/server/channels/api4/post.go @@ -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 { diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index 7a5a751b4a9..813a843daa7 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -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() diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 171749156ae..4fa762e2980 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -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 diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 35595ff1a5f..e014b1f1211 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -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") diff --git a/server/channels/app/post.go b/server/channels/app/post.go index 2d9613204f8..002276e3bd7 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -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 +} diff --git a/server/channels/app/post_test.go b/server/channels/app/post_test.go index 8f9b36ccdf0..69aabe605e8 100644 --- a/server/channels/app/post_test.go +++ b/server/channels/app/post_test.go @@ -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()) + }) +} diff --git a/server/config/client.go b/server/config/client.go index e7d47c83194..213ade78ba5 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -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) diff --git a/server/i18n/en.json b/server/i18n/en.json index 5b46c55c8c0..515fcbfd853 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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}}." diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index 2f97d7b0e6c..fac03bf47c2 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -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) diff --git a/server/public/model/client4.go b/server/public/model/client4.go index b43af1f1329..7cb48720955 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -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) diff --git a/server/public/model/config.go b/server/public/model/config.go index a13385a7ae1..2bc83f7551c 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -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 } diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index b4824c3c3bb..f590bc0bf83 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -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() diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index e09e0a425cd..bc4ac21ec9e 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -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 diff --git a/server/public/model/post.go b/server/public/model/post.go index 3d761e8d3fd..4eb9563e22b 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -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 +} diff --git a/server/public/model/post_list.go b/server/public/model/post_list.go index 76c701b5458..55e6345f0e0 100644 --- a/server/public/model/post_list.go +++ b/server/public/model/post_list.go @@ -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 +} diff --git a/server/public/model/user.go b/server/public/model/user.go index 956914abae9..da99d045081 100644 --- a/server/public/model/user.go +++ b/server/public/model/user.go @@ -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"` diff --git a/server/public/model/wrangler.go b/server/public/model/wrangler.go new file mode 100644 index 00000000000..6a7261e6d70 --- /dev/null +++ b/server/public/model/wrangler.go @@ -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 +} diff --git a/server/public/shared/i18n/i18n.go b/server/public/shared/i18n/i18n.go index b6c11b66fe2..cc3cc27b48b 100644 --- a/server/public/shared/i18n/i18n.go +++ b/server/public/shared/i18n/i18n.go @@ -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)) diff --git a/webapp/channels/src/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap index 46dda512551..d8beef855a5 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap @@ -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" /> ) => { - 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}); diff --git a/webapp/channels/src/components/admin_console/schema_admin_settings.jsx b/webapp/channels/src/components/admin_console/schema_admin_settings.jsx index e0b723d93ac..fdb87320b8f 100644 --- a/webapp/channels/src/components/admin_console/schema_admin_settings.jsx +++ b/webapp/channels/src/components/admin_console/schema_admin_settings.jsx @@ -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 { { + 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 = ( + + ); + return ( + + ); + } + return ( + + ); + }; + buildLanguageSetting = (setting) => { const locales = I18n.getAllLanguages(); const values = Object.keys(locales).map((l) => { diff --git a/webapp/channels/src/components/admin_console/types.ts b/webapp/channels/src/components/admin_console/types.ts index 928677e5c5c..0902260dfbb 100644 --- a/webapp/channels/src/components/admin_console/types.ts +++ b/webapp/channels/src/components/admin_console/types.ts @@ -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; diff --git a/webapp/channels/src/components/dot_menu/__snapshots__/dot_menu.test.tsx.snap b/webapp/channels/src/components/dot_menu/__snapshots__/dot_menu.test.tsx.snap index f457fb68341..264cf3fa55c 100644 --- a/webapp/channels/src/components/dot_menu/__snapshots__/dot_menu.test.tsx.snap +++ b/webapp/channels/src/components/dot_menu/__snapshots__/dot_menu.test.tsx.snap @@ -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": +
+ +
+ , + "container":
+ +
, + "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": +
+ +
+ , + "container":
+ +
, + "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`] = ` } /> + + } + leadingElement={ + + } + onClick={[Function]} + trailingElements={ + + } + /> { 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( + , + initialState, + ); + + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot, cannot move', () => { + const props = { + ...baseProps, + canMove: false, + }; + const wrapper = renderWithContext( + , + initialState, + ); + + expect(wrapper).toMatchSnapshot(); + }); + test('should show mark as unread when channel is not archived', () => { const props = { ...baseProps, diff --git a/webapp/channels/src/components/dot_menu/dot_menu.tsx b/webapp/channels/src/components/dot_menu/dot_menu.tsx index 5e67532c1e7..c7945fe2a8c 100644 --- a/webapp/channels/src/components/dot_menu/dot_menu.tsx +++ b/webapp/channels/src/components/dot_menu/dot_menu.tsx @@ -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 { 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 { 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 { onClick={this.handlePinMenuItemActivated} /> } + {Boolean(!isSystemMessage && this.props.canMove) && + } + leadingElement={} + trailingElements={} + onClick={this.handleMoveThreadMenuItemActivated} + /> + } {!isSystemMessage && (this.state.canEdit || this.state.canDelete) && } {!isSystemMessage && { threadId: 'post_id_1', userId: 'user_id_1', isMilitaryTime: false, + canMove: true, }; const wrapper = shallow( diff --git a/webapp/channels/src/components/dot_menu/dot_menu_mobile.test.tsx b/webapp/channels/src/components/dot_menu/dot_menu_mobile.test.tsx index 64825243e56..06968e9b6f5 100644 --- a/webapp/channels/src/components/dot_menu/dot_menu_mobile.test.tsx +++ b/webapp/channels/src/components/dot_menu/dot_menu_mobile.test.tsx @@ -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( diff --git a/webapp/channels/src/components/dot_menu/index.ts b/webapp/channels/src/components/dot_menu/index.ts index c48edab4118..2aa6956fe16 100644 --- a/webapp/channels/src/components/dot_menu/index.ts +++ b/webapp/channels/src/components/dot_menu/index.ts @@ -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), }; }; } diff --git a/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx b/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx index 361d9d56110..fe8cd3101f6 100644 --- a/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx +++ b/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx @@ -222,15 +222,14 @@ const DropdownIndicator = (props: IndicatorProps) => { ); }; -const validChannelTypes = ['O', 'P', 'D', 'G']; - type Props = { onSelect: (channel: ValueType) => void; currentBodyHeight: number; value?: O; + validChannelTypes?: string[]; } -function ForwardPostChannelSelect({onSelect, value, currentBodyHeight}: Props) { +function ForwardPostChannelSelect({onSelect, value, currentBodyHeight, validChannelTypes = ['O', 'P', 'D', 'G']}: Props) { const {formatMessage} = useIntl(); const {current: provider} = useRef(new SwitchChannelProvider()); diff --git a/webapp/channels/src/components/forward_post_modal/forward_post_modal.scss b/webapp/channels/src/components/forward_post_modal/forward_post_modal.scss index 2be42fdbf6f..9b71d93a087 100644 --- a/webapp/channels/src/components/forward_post_modal/forward_post_modal.scss +++ b/webapp/channels/src/components/forward_post_modal/forward_post_modal.scss @@ -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; + } } } diff --git a/webapp/channels/src/components/forward_post_modal/forward_post_modal.tsx b/webapp/channels/src/components/forward_post_modal/forward_post_modal.tsx index 844b05bfd1e..c1a8ed65d65 100644 --- a/webapp/channels/src/components/forward_post_modal/forward_post_modal.tsx +++ b/webapp/channels/src/components/forward_post_modal/forward_post_modal.tsx @@ -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, diff --git a/webapp/channels/src/components/move_thread_modal/index.ts b/webapp/channels/src/components/move_thread_modal/index.ts new file mode 100644 index 00000000000..e0bea32e62a --- /dev/null +++ b/webapp/channels/src/components/move_thread_modal/index.ts @@ -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; + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, ActionProps>({ + joinChannelById, + switchToChannel, + moveThread, + }, dispatch), + }; +} +const connector = connect(null, mapDispatchToProps); + +export default connector(MoveThreadModal); diff --git a/webapp/channels/src/components/move_thread_modal/move_thread_modal.scss b/webapp/channels/src/components/move_thread_modal/move_thread_modal.scss new file mode 100644 index 00000000000..783262ad17f --- /dev/null +++ b/webapp/channels/src/components/move_thread_modal/move_thread_modal.scss @@ -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; + } + } +} diff --git a/webapp/channels/src/components/move_thread_modal/move_thread_modal.tsx b/webapp/channels/src/components/move_thread_modal/move_thread_modal.tsx new file mode 100644 index 00000000000..9a53fc53d1e --- /dev/null +++ b/webapp/channels/src/components/move_thread_modal/move_thread_modal.tsx @@ -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; + + // switch to the selected channel + switchToChannel: (channel: Channel) => Promise; + + // action called to move the post from the original channel to a new channel + moveThread: (postId: string, channelId: string) => Promise; +} + +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(null); + + const [bodyHeight, setBodyHeight] = useState(0); + const [hasError, setHasError] = useState(false); + const [postError, setPostError] = useState(null); + const [selectedChannel, setSelectedChannel] = useState(); + const [isButtonClicked, setIsButtonClicked] = useState(false); + + const bodyRef = useRef(); + + const measuredRef = useCallback((node) => { + if (node !== null) { + bodyRef.current = node; + setBodyHeight(node.getBoundingClientRect().height); + } + }, []); + + const onHide = useCallback(() => { + onExited?.(); + }, [onExited]); + + const handleChannelSelect = useCallback((channel: ValueType) => { + 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 = ( + + ); + + 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 ( + +
+ {notification} + +
+ + {messagePreviewTitle} + +
+ +
+ {postError && ( + + )} +
+
+
+ ); +}; + +export default MoveThreadModal; diff --git a/webapp/channels/src/components/post_markdown/post_markdown.tsx b/webapp/channels/src/components/post_markdown/post_markdown.tsx index ad77ddb43f9..8fd8e133753 100644 --- a/webapp/channels/src/components/post_markdown/post_markdown.tsx +++ b/webapp/channels/src/components/post_markdown/post_markdown.tsx @@ -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 { return
{renderedSystemBotMessage}
; } + if (this.props.post && this.props.post.type === Posts.POST_TYPES.WRANGLER) { + const renderedWranglerMessage = renderWranglerSystemMessage(this.props.post); + return
{renderedWranglerMessage}
; + } + // Proxy images if we have an image proxy and the server hasn't already rewritten the this.props.post's image URLs. const proxyImages = !this.props.post || !this.props.post.message_source || this.props.post.message === this.props.post.message_source; const channelNamesMap = this.props.post && this.props.post.props && this.props.post.props.channel_mentions; diff --git a/webapp/channels/src/components/post_markdown/system_message_helpers.tsx b/webapp/channels/src/components/post_markdown/system_message_helpers.tsx index 3e9b2c916fc..720c20907aa 100644 --- a/webapp/channels/src/components/post_markdown/system_message_helpers.tsx +++ b/webapp/channels/src/components/post_markdown/system_message_helpers.tsx @@ -17,6 +17,7 @@ import CombinedSystemMessage from 'components/post_view/combined_system_message' import GMConversionMessage from 'components/post_view/gm_conversion_message/gm_conversion_message'; import PostAddChannelMember from 'components/post_view/post_add_channel_member'; +import {t} from 'utils/i18n'; import type {TextFormattingOptions} from 'utils/text_formatting'; import {getSiteURL} from 'utils/url'; @@ -489,3 +490,29 @@ export function renderReminderSystemBotMessage(post: Post, currentTeam: Team): R /> ); } + +t('app.post.move_thread_command.direct_or_group.multiple_messages'); +t('app.post.move_thread_command.direct_or_group.one_message'); +t('app.post.move_thread_command.channel.multiple_messages'); +t('app.post.move_thread_command.channel.one_message'); +t('app.post.move_thread.from_another_channel'); +export function renderWranglerSystemMessage(post: Post): ReactNode { + let values = {} as any; + const id = post.props.TranslationID; + if (post.props && post.props.MovedThreadPermalink) { + values = { + Link: post.props.MovedThreadPermalink, + }; + if (post.props.NumMessages > 1) { + values.NumMessages = post.props.NumMessages; + } + } + return ( + + ); +} + diff --git a/webapp/channels/src/components/widgets/settings/text_setting.tsx b/webapp/channels/src/components/widgets/settings/text_setting.tsx index 0c0c2a73e5d..e93f3be7d53 100644 --- a/webapp/channels/src/components/widgets/settings/text_setting.tsx +++ b/webapp/channels/src/components/widgets/settings/text_setting.tsx @@ -14,9 +14,9 @@ export type Props = { label: ReactNode; labelClassName?: string; placeholder?: string; + value: string | number | string[]; helpText?: ReactNode; footer?: ReactNode; - value: string | number; inputClassName?: string; maxLength?: number; resizable?: boolean; @@ -26,6 +26,7 @@ export type Props = { // This is a custom prop that is not part of the HTML input element type type?: InputTypes; autoFocus?: boolean; + multiple?: boolean; } function TextSetting(props: Props) { @@ -34,6 +35,12 @@ function TextSetting(props: Props) { function handleChange(event: ChangeEvent) { if (props.type === 'number') { props.onChange(props.id, parseInt(event.target.value, 10)); + } else if (props.type === 'text' && props.multiple) { + if (event.target.value === '') { + props.onChange(props.id, []); + } else { + props.onChange(props.id, event.target.value.split(',')); + } } else { props.onChange(props.id, event.target.value); } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index d2a025db3fc..21f49c328c9 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -887,6 +887,8 @@ "admin.environment.webServer": "Web Server", "admin.experimental.allowCustomThemes.desc": "Enables the **Display > Theme > Custom Theme** section in Settings.", "admin.experimental.allowCustomThemes.title": "Allow Custom Themes:", + "admin.experimental.allowedEmailDomain.desc": "(Optional) When set, users must have an email ending in this domain to move threads. Multiple domains can be specified by separating them with commas.", + "admin.experimental.allowedEmailDomain.title": "Allowed Email Domain", "admin.experimental.clientSideCertCheck.desc": "When **primary**, after the client side certificate is verified, user’s email is retrieved from the certificate and is used to log in without a password. When **secondary**, after the client side certificate is verified, user’s email is retrieved from the certificate and matched against the one supplied by the user. If they match, the user logs in with regular email/password credentials.", "admin.experimental.clientSideCertCheck.title": "Client-Side Certification Login Method:", "admin.experimental.clientSideCertEnable.desc": "Enables client-side certification for your Mattermost server. See documentation to learn more.", @@ -954,6 +956,18 @@ "admin.experimental.linkMetadataTimeoutMilliseconds.desc": "The number of milliseconds to wait for metadata from a third-party link. Used with Post Metadata.", "admin.experimental.linkMetadataTimeoutMilliseconds.example": "E.g.: \"5000\"", "admin.experimental.linkMetadataTimeoutMilliseconds.title": "Link Metadata Timeout:", + "admin.experimental.moveThreadFromDirectMessageChannelEnable.desc": "Control whether move thread is permitted to move message threads from direct message channels or not.", + "admin.experimental.moveThreadFromDirectMessageChannelEnable.title": "Enable Moving Threads From Direct Message Channels", + "admin.experimental.moveThreadFromGroupMessageChannelEnable.desc": "Control whether move thread is permitted to move message threads from group message channels or not.", + "admin.experimental.moveThreadFromGroupMessageChannelEnable.title": "Enable Moving Threads From Group Message Channels", + "admin.experimental.moveThreadFromPrivateChannelEnable.desc": "Control whether move thread is permitted to move message threads from private channels or not.", + "admin.experimental.moveThreadFromPrivateChannelEnable.title": "Enable Moving Threads From Private Channels", + "admin.experimental.moveThreadMaxCount.desc": "The maximum number of messages in a thread that the plugin is allowed to move. Leave empty for unlimited messages.", + "admin.experimental.moveThreadMaxCount.title": "Max Thread Count Move Size", + "admin.experimental.moveThreadToAnotherTeamEnable.desc": "Control whether move thread is permitted to move message threads from one team to another or not.", + "admin.experimental.moveThreadToAnotherTeamEnable.title": "Enable Moving Threads To Different Teams", + "admin.experimental.PermittedMoveThreadRoles.desc": "Choose who is allowed to move threads to other channels based on roles. (Other permissions below still apply).", + "admin.experimental.PermittedMoveThreadRoles.title": "Permitted Roles", "admin.experimental.samlSettingsLoginButtonBorderColor.desc": "Specify the color of the SAML login button border for white labeling purposes. Use a hex code with a #-sign before the code. This setting only applies to the mobile apps.", "admin.experimental.samlSettingsLoginButtonBorderColor.title": "SAML login Button Border Color:", "admin.experimental.samlSettingsLoginButtonColor.desc": "Specify the color of the SAML login button for white labeling purposes. Use a hex code with a #-sign before the code. This setting only applies to the mobile apps.", @@ -2335,6 +2349,7 @@ "admin.sidebar.logs": "Server Logs", "admin.sidebar.metrics": "Performance Monitoring", "admin.sidebar.mfa": "MFA", + "admin.sidebar.move_thread": "Move thread (Beta)", "admin.sidebar.notices": "Notices", "admin.sidebar.notifications": "Notifications", "admin.sidebar.oauth": "OAuth 2.0", @@ -2370,6 +2385,7 @@ "admin.site.emoji": "Emoji", "admin.site.fileSharingDownloads": "File Sharing and Downloads", "admin.site.localization": "Localization", + "admin.site.move_thread": "Move thread", "admin.site.notices": "Notices", "admin.site.posts": "Posts", "admin.site.public_links": "Public Links", @@ -2742,6 +2758,11 @@ "app.channel.post_update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {old})", "app.channel.post_update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {old} to: {new}", "app.channel.post_update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {new}", + "app.post.move_thread_command.channel.multiple_messages": "A thread with {{.NumMessages}} messages has been moved: {{.Link}}\n", + "app.post.move_thread_command.channel.one_message": "A message has been moved: {{.Link}}\n", + "app.post.move_thread_command.direct_or_group.multiple_messages": "A thread with {{.NumMessages}} messages has been moved to a Direct/Group Message\n", + "app.post.move_thread_command.direct_or_group.one_message": "A message has been moved to a Direct/Group Message\n", + "app.post.move_thread.from_another_channel": "This thread was moved from another channel", "apps.error": "Error: {error}", "apps.error.command.field_missing": "Required fields missing: `{fieldName}`.", "apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.", @@ -4131,6 +4152,12 @@ "more_direct_channels.new_convo_note.full": "You've reached the maximum number of people for this conversation. Consider creating a private channel instead.", "more_direct_channels.title": "Direct Messages", "more.details": "More details", + "move_thread_modal.button.cancel": "Cancel", + "move_thread_modal.button.forward": "Move", + "move_thread_modal.notification.dm_or_gm": "Moving this thread changes who has access", + "move_thread_modal.preview.footer_message": "Originally posted in ~{channelName}", + "move_thread_modal.preview.title": "Message preview", + "move_thread_modal.title": "Move thread", "msg_typing.areTyping": "{users} and {last} are typing...", "msg_typing.isTyping": "{user} is typing...", "multiselect.add": "Add", @@ -4428,6 +4455,7 @@ "post_info.message.show_more": "Show more", "post_info.message.visible": "(Only visible to you)", "post_info.message.visible.compact": " (Only visible to you)", + "post_info.move_thread": "Move Thread", "post_info.permalink": "Copy Link", "post_info.pin": "Pin to Channel", "post_info.post_reminder.menu": "Remind", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts index 4dd642c1737..730ed804cb7 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts @@ -58,4 +58,7 @@ export default keyMirror({ CREATE_ACK_POST_SUCCESS: null, DELETE_ACK_POST_SUCCESS: null, + + MOVE_POST_SUCCESS: null, + MOVE_POST_FAILURE: null, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts index f1b1059aa37..f7006d42774 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -1260,6 +1260,23 @@ export function selectPost(postId: string) { }; } +export function moveThread(postId: string, channelId: string) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + try { + await Client4.moveThread(postId, channelId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch({type: PostTypes.MOVE_POST_FAILURE, error}); + dispatch(logError(error)); + return {error}; + } + + dispatch({type: PostTypes.MOVE_POST_SUCCESS}); + + return {data: true}; + }; +} + export function selectFocusedPostId(postId: string) { return { type: PostTypes.RECEIVED_FOCUSED_POST, diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts index 98a76a47778..4d317a55fdd 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts @@ -33,6 +33,7 @@ export const PostTypes = { ADD_BOT_TEAMS_CHANNELS: 'add_bot_teams_channels' as PostType, SYSTEM_WARN_METRIC_STATUS: 'warn_metric_status' as PostType, REMINDER: 'reminder' as PostType, + WRANGLER: 'system_wrangler' as PostType, GM_CONVERTED_TO_CHANNEL: 'system_gm_to_channel' as PostType, }; diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts index 9f8b81cde10..510225ad46f 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts @@ -276,6 +276,10 @@ export function cloudReverseTrial(state: GlobalState): boolean { return getFeatureFlagValue(state, 'CloudReverseTrial') === 'true'; } +export function moveThreadsEnabled(state: GlobalState): boolean { + return getFeatureFlagValue(state, 'MoveThreadsEnabled') === 'true'; +} + export function streamlinedMarketplaceEnabled(state: GlobalState): boolean { return getFeatureFlagValue(state, 'StreamlinedMarketplace') === 'true'; } diff --git a/webapp/channels/src/selectors/posts.ts b/webapp/channels/src/selectors/posts.ts index ee5be0923da..7635ebb1bc9 100644 --- a/webapp/channels/src/selectors/posts.ts +++ b/webapp/channels/src/selectors/posts.ts @@ -1,10 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {Channel} from '@mattermost/types/channels'; +import type {ClientConfig} from '@mattermost/types/config'; import type {Post} from '@mattermost/types/posts'; +import type {UserProfile} from '@mattermost/types/users'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; +import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getPost} from 'mattermost-redux/selectors/entities/posts'; +import {moveThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {arePreviewsCollapsed} from 'selectors/preferences'; @@ -52,3 +58,69 @@ export function isInlineImageVisible(state: GlobalState, postId: string, imageKe return getGlobalItem(state, StoragePrefixes.INLINE_IMAGE_VISIBLE + currentUserId + '_' + postId + '_' + imageKey, !imageCollapsed); } + +export function makeCanWrangler() { + return createSelector( + 'makeCanWrangler', + getConfig, + getCurrentUser, + moveThreadsEnabled, + (_state: GlobalState, channelType: Channel['type']) => channelType, + (_state: GlobalState, _channelType: Channel['type'], replyCount: number) => replyCount, + (config: Partial, user: UserProfile, enabled: boolean, channelType: Channel['type'], replyCount: number) => { + if (!enabled) { + return false; + } + const { + WranglerPermittedWranglerRoles, + WranglerAllowedEmailDomain, + WranglerMoveThreadMaxCount, + WranglerMoveThreadFromPrivateChannelEnable, + WranglerMoveThreadFromDirectMessageChannelEnable, + WranglerMoveThreadFromGroupMessageChannelEnable, + } = config; + + let permittedUsers: string[] = []; + if (WranglerPermittedWranglerRoles && WranglerPermittedWranglerRoles !== '') { + permittedUsers = WranglerPermittedWranglerRoles?.split(','); + } + + let allowedEmailDomains: string[] = []; + if (WranglerAllowedEmailDomain && WranglerAllowedEmailDomain !== '') { + allowedEmailDomains = WranglerAllowedEmailDomain?.split(',') || []; + } + + if (permittedUsers.length > 0 && !user.roles.includes('system_admin')) { + const roles = user.roles.split(' '); + const hasRole = roles.some((role) => permittedUsers.includes(role)); + if (!hasRole) { + return false; + } + } + + if (allowedEmailDomains?.length > 0) { + if (!user.email || !allowedEmailDomains.includes(user.email.split('@')[1])) { + return false; + } + } + + if (Number(WranglerMoveThreadMaxCount) < replyCount) { + return false; + } + + if (channelType === 'P' && WranglerMoveThreadFromPrivateChannelEnable === 'false') { + return false; + } + + if (channelType === 'D' && WranglerMoveThreadFromDirectMessageChannelEnable === 'false') { + return false; + } + + if (channelType === 'G' && WranglerMoveThreadFromGroupMessageChannelEnable === 'false') { + return false; + } + + return true; + }, + ); +} diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 67dacd40d8d..f3e4e3f3edc 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -47,6 +47,7 @@ export const SettingsTypes = { TYPE_JOBSTABLE: 'jobstable', TYPE_FILE_UPLOAD: 'fileupload', TYPE_CUSTOM: 'custom', + TYPE_ROLES: 'roles', }; export const InviteTypes = { @@ -446,6 +447,7 @@ export const ModalIdentifiers = { SELF_HOSTED_EXPANSION: 'self_hosted_expansion', START_TRIAL_FORM_MODAL: 'start_trial_form_modal', START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result', + MOVE_THREAD_MODAL: 'move_thread_modal', CONVERT_GM_TO_CHANNEL: 'convert_gm_to_channel', IP_FILTERING_ADD_EDIT_MODAL: 'ip_filtering_add_edit_modal', IP_FILTERING_DELETE_CONFIRMATION_MODAL: 'ip_filtering_delete_confirmation_modal', @@ -769,6 +771,7 @@ export const TELEMETRY_LABELS = { REPLY: 'reply', UNREAD: 'unread', FORWARD: 'forward', + MOVE_THREAD: 'move_thread', }; export const PostTypes = { @@ -795,6 +798,7 @@ export const PostTypes = { REMOVE_LINK_PREVIEW: 'remove_link_preview', ME: 'me', REMINDER: 'reminder', + WRANGLER: 'system_wrangler', CUSTOM_CALLS: 'custom_calls', CUSTOM_CALLS_RECORDING: 'custom_calls_recording', }; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 0465fa706ee..7f9490b47a8 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -1059,6 +1059,14 @@ export default class Client4 { ); } + moveThread = (postId: string, channelId: string) => { + const url = this.getPostRoute(postId) + '/move'; + return this.doFetch( + url, + {method: 'post', body: JSON.stringify({channel_id: channelId})}, + ); + } + switchEmailToOAuth = (service: string, email: string, password: string, mfaCode = '') => { this.trackEvent('api', 'api_users_email_to_oauth'); diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index e2bdb51996f..7afbac8bf91 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -199,6 +199,12 @@ export type ClientConfig = { PersistentNotificationIntervalMinutes: string; AllowPersistentNotificationsForGuests: string; DelayChannelAutocomplete: 'true' | 'false'; + WranglerPermittedWranglerRoles: string; + WranglerAllowedEmailDomain: string; + WranglerMoveThreadMaxCount: string; + WranglerMoveThreadFromPrivateChannelEnable: string; + WranglerMoveThreadFromDirectMessageChannelEnable: string; + WranglerMoveThreadFromGroupMessageChannelEnable: string; ServiceEnvironment: string; UniqueEmojiReactionLimitPerPost: string; }; @@ -485,6 +491,16 @@ export type PasswordSettings = { EnableForgotLink: boolean; }; +export type WranglerSettings = { + PermittedWranglerRoles: string[]; + AllowedEmailDomain: string[]; + MoveThreadMaxCount: number; + MoveThreadToAnotherTeamEnable: boolean; + MoveThreadFromPrivateChannelEnable: boolean; + MoveThreadFromDirectMessageChannelEnable: boolean; + MoveThreadFromGroupMessageChannelEnable: boolean; +}; + export type FileSettings = { EnableFileAttachments: boolean; EnableMobileUpload: boolean; @@ -947,6 +963,7 @@ export type AdminConfig = { FeatureFlags: FeatureFlags; ImportSettings: ImportSettings; ExportSettings: ExportSettings; + WranglerSettings: WranglerSettings; }; export type ReplicaLagSetting = { diff --git a/webapp/platform/types/src/posts.ts b/webapp/platform/types/src/posts.ts index bde9ba9f928..b3495bf4e69 100644 --- a/webapp/platform/types/src/posts.ts +++ b/webapp/platform/types/src/posts.ts @@ -31,6 +31,7 @@ export type PostType = 'system_add_remove' | 'system_fake_parent_deleted' | 'system_generic' | 'reminder' | +'system_wrangler' | ''; export type PostEmbedType = 'image' | 'link' | 'message_attachment' | 'opengraph' | 'permalink';