From 6f73204448105792a386f201c3db7084d67f9717 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Wed, 18 Dec 2024 13:17:42 +0530 Subject: [PATCH] [MM-56713] Show consistent markdown formatting buttons & keyboard shortcuts when editing messages (#29398) * footer * changes * edit * some changes * form submit * ci fixes * rev fix 1 * ci fix * Fixed some styles * Added delete on empty post option * fix E2E tests * fix more tests * Fixed UI isses when editing post in RHS * Fixed formatting bar behjaviour * Reset draft to original post when cancelling or escaping * DFisplayed @mention warning * fixed existing test * Added test for @mention during editing * Displayed long message warning during edit post * Removed a console log: * Handled message with image links when using image proxy * Fixed a11y styling for button * Fixed emoji picker keyboard shortcut * Checnged edit box ID * Added draft test * Fixed edit text box id * e2e fix * e2e fix * handled deleting empty fposts * Fixed e2e test --------- Co-authored-by: Mattermost Build Co-authored-by: Harshil Sharma Co-authored-by: yasserfaraazkhan --- .../keyboard_shortcuts/dot_menu_spec.js | 4 +- .../up_arrow_edit_message_rhs_spec.js | 2 +- .../center_channel_rhs_overlap_spec.js | 6 +- .../channels/messaging/direct_message_spec.js | 2 +- .../channels/messaging/edit_message_spec.js | 12 +- .../channels/src/actions/post_actions.test.ts | 61 +- webapp/channels/src/actions/post_actions.ts | 71 +- .../src/actions/views/create_comment.tsx | 4 +- .../advanced_text_editor.test.tsx | 27 + .../advanced_text_editor.tsx | 236 ++++-- .../advanced_text_editor/edit_post_footer.tsx | 59 ++ .../advanced_text_editor/footer.tsx | 61 ++ .../formatting_bar/formatting_bar.tsx | 6 +- .../advanced_text_editor/use_key_handler.tsx | 16 +- .../advanced_text_editor/use_submit.tsx | 28 +- .../advanced_text_editor/use_upload_files.tsx | 3 +- .../__snapshots__/channel_view.test.tsx.snap | 4 +- .../components/channel_view/channel_view.tsx | 4 +- .../src/components/drafts/draft_row.tsx | 16 +- .../src/components/edit_post/edit_post.scss | 44 ++ .../src/components/edit_post/edit_post.tsx | 673 +----------------- .../src/components/edit_post/index.ts | 97 +-- .../edit_scheduled_post/edit_post.tsx | 671 +++++++++++++++++ .../edit_post_footer.tsx | 0 .../components/edit_scheduled_post/index.ts | 101 +++ .../style.scss | 0 .../src/components/post/post_component.tsx | 4 +- .../src/components/textbox/textbox.tsx | 1 + webapp/channels/src/reducers/storage.ts | 8 +- webapp/channels/src/reducers/views/posts.ts | 25 +- webapp/channels/src/sass/utils/_mixins.scss | 4 +- webapp/channels/src/selectors/drafts.test.ts | 59 +- webapp/channels/src/selectors/drafts.ts | 42 ++ webapp/channels/src/selectors/posts.ts | 11 +- webapp/channels/src/selectors/rhs.ts | 49 +- webapp/channels/src/types/store/draft.ts | 10 + webapp/channels/src/types/store/index.ts | 6 +- webapp/channels/src/types/store/storage.ts | 14 + webapp/channels/src/types/store/views.ts | 13 +- 39 files changed, 1500 insertions(+), 954 deletions(-) create mode 100644 webapp/channels/src/components/advanced_text_editor/edit_post_footer.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/footer.tsx create mode 100644 webapp/channels/src/components/edit_post/edit_post.scss create mode 100644 webapp/channels/src/components/edit_scheduled_post/edit_post.tsx rename webapp/channels/src/components/{edit_post => edit_scheduled_post}/edit_post_footer.tsx (100%) create mode 100644 webapp/channels/src/components/edit_scheduled_post/index.ts rename webapp/channels/src/components/{edit_post => edit_scheduled_post}/style.scss (100%) create mode 100644 webapp/channels/src/types/store/storage.ts diff --git a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/dot_menu_spec.js b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/dot_menu_spec.js index 9f3678a8c1..0d51562ede 100644 --- a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/dot_menu_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/dot_menu_spec.js @@ -68,8 +68,8 @@ describe('Keyboard Shortcuts', () => { cy.uiPostDropdownMenuShortcut(postId, 'Edit', 'E'); // # add test to the message - cy.get('body').type(postEditMessage); - cy.get('body').type('{enter}'); + cy.get('#edit_textbox').type(postEditMessage); + cy.get('#edit_textbox').type('{enter}'); // * Verify edited message cy.uiWaitUntilMessagePostedIncludes(postMessage + postEditMessage); diff --git a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/up_arrow_edit_message_rhs_spec.js b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/up_arrow_edit_message_rhs_spec.js index a76bbdf697..b6963079da 100644 --- a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/up_arrow_edit_message_rhs_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/up_arrow_edit_message_rhs_spec.js @@ -26,7 +26,7 @@ describe('Keyboard Shortcuts', () => { cy.clickPostDotMenu(postId); cy.findByText('Reply').click(); const replyMessage = 'Well, hello there.'; - cy.uiGetReplyTextBox().type(replyMessage); + cy.uiGetReplyTextBox().type(replyMessage, {delay: 100}); cy.uiGetReplyTextBox().type('{enter}'); cy.uiWaitUntilMessagePostedIncludes(replyMessage); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/center_channel_rhs_overlap_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/center_channel_rhs_overlap_spec.js index cb679ca75b..a25ec39768 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/center_channel_rhs_overlap_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/center_channel_rhs_overlap_spec.js @@ -116,7 +116,8 @@ describe('Messaging', () => { cy.get('#edit_textbox').should('be.visible'); // * Update the post message and type ENTER - cy.get('#edit_textbox').invoke('val', '').type(message2).type('{enter}').wait(TIMEOUTS.HALF_SEC); + cy.get('#edit_textbox').clear().type(message2); + cy.get('#edit_textbox').type('{enter}').wait(TIMEOUTS.HALF_SEC); // * Edit Post Input is still visible after typing ENTER cy.get('#edit_textbox').should('be.visible'); @@ -764,9 +765,6 @@ describe('Messaging', () => { // * Edit Post Input should appear cy.get('#edit_textbox').should('be.visible'); - // # Check that a scrollbar exists - cy.get('.post--editing__wrapper.scroll').should('be.visible'); - // # Update the message cy.get('#edit_textbox', {timeout: TIMEOUTS.FIVE_SEC}).type(' test').wait(TIMEOUTS.HALF_SEC); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/direct_message_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/direct_message_spec.js index 561abb13e7..7e27cb8b4a 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/direct_message_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/direct_message_spec.js @@ -67,7 +67,7 @@ describe('Direct Message', () => { // * Edit post Input should appear, and edit the post cy.get('#edit_textbox').should('be.visible'); - cy.get('#edit_textbox').should('have.text', originalMessage).type(' World{enter}'); + cy.get('#edit_textbox').should('have.text', originalMessage).type(' World{enter}', {delay: 100}); cy.get('#edit_textbox').should('not.exist'); // * Verify that last post does contain "Edited" diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/edit_message_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/edit_message_spec.js index fd104b9b3e..f1c8f1087a 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/edit_message_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/edit_message_spec.js @@ -65,7 +65,7 @@ describe('Edit Message', () => { cy.get('#suggestionList').should('not.exist'); // # In the modal click the emoji picker icon - cy.get('#editPostEmoji').click(); + cy.get('div.post-edit__container button#emojiPickerButton').click(); // * Assert emoji picker is visible cy.get('#emojiPicker').should('be.visible'); @@ -93,7 +93,7 @@ describe('Edit Message', () => { cy.get(`#edit_post_${postId}`).click(); // # Edit the post - cy.get('#edit_textbox').type('Some text {enter}'); + cy.get('#edit_textbox').type('Some text {enter}', {delay: 100}); // # Mouseover the post again cy.get(`#post_${postId}`).trigger('mouseover'); @@ -138,7 +138,7 @@ describe('Edit Message', () => { // * Edit Post Input should appear, and edit the post cy.get('#edit_textbox').should('be.visible'); - cy.get('#edit_textbox').should('have.text', secondMessage).type(' Another new message{enter}'); + cy.get('#edit_textbox').should('have.text', secondMessage).type(' Another new message{enter}', {delay: 100}); cy.get('#edit_textbox').should('not.exist'); // * Check the second post and verify that it contains new edited message. @@ -162,7 +162,7 @@ describe('Edit Message', () => { cy.get('#edit_textbox').should('be.visible'); // * Press the escape key to cancel - cy.get('#edit_textbox').should('have.text', message).type(' Another new message{esc}'); + cy.get('#edit_textbox').should('have.text', message).type(' Another new message{esc}', {delay: 100}); cy.get('#edit_textbox').should('not.exist'); // * Check that the message wasn't edited @@ -195,14 +195,14 @@ describe('Edit Message', () => { cy.get('#edit_textbox').type(' @user'); // # Press the enter key - cy.get('#edit_textbox').wait(TIMEOUTS.HALF_SEC).focus().type('{enter}'); + cy.get('#edit_textbox').wait(TIMEOUTS.HALF_SEC).focus().type('{enter}', {delay: 100}); // * Check if the textbox contains expected text cy.get('.post-body__info').should('be.visible'); cy.get('.post-body__info').contains('span', "Editing this message with an '@mention' will not notify the recipient."); // # Press the escape key - cy.get('#edit_textbox').wait(TIMEOUTS.HALF_SEC).focus().type('{enter}'); + cy.get('#edit_textbox').wait(TIMEOUTS.HALF_SEC).focus().type('{enter}', {delay: 100}); // # Open the RHS cy.getLastPostId().then((postId) => { diff --git a/webapp/channels/src/actions/post_actions.test.ts b/webapp/channels/src/actions/post_actions.test.ts index 1eb5e2cf4d..fd6340685f 100644 --- a/webapp/channels/src/actions/post_actions.test.ts +++ b/webapp/channels/src/actions/post_actions.test.ts @@ -75,7 +75,7 @@ describe('Actions.Posts', () => { user_id: 'current_user_id', message: 'test msg', channel_id: 'current_channel_id', - type: 'normal,', + type: 'normal', }; const initialState = { entities: { @@ -200,6 +200,9 @@ describe('Actions.Posts', () => { filesSearchExtFilter: [], }, }, + storage: { + storage: {}, + }, } as unknown as GlobalState; test('handleNewPost', async () => { @@ -276,8 +279,8 @@ describe('Actions.Posts', () => { expect(dataSet).toEqual(true); // matches the action to set editingPost - expect(testStore.getActions()).toEqual( - [{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}], + expect(testStore.getActions()[0].payload[0]).toEqual( + {data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}, ); // clear actions @@ -285,11 +288,11 @@ describe('Actions.Posts', () => { // dispatch action to unset the editingPost const {data: dataUnset} = testStore.dispatch(Actions.unsetEditingPost()); - expect(dataUnset).toEqual({show: false}); + expect(dataUnset).toEqual(true); // matches the action to unset editingPost - expect(testStore.getActions()).toEqual( - [{data: {show: false}, type: ActionTypes.TOGGLE_EDITING_POST}], + expect(testStore.getActions()[0].payload[0]).toEqual( + {data: {show: false}, type: ActionTypes.TOGGLE_EDITING_POST}, ); // editingPost value is empty object, as it should @@ -302,8 +305,15 @@ describe('Actions.Posts', () => { const {data} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test')); expect(data).toEqual(true); - expect(testStore.getActions()).toEqual( - [{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}], + let actions = testStore.getActions(); + expect(actions.length).toEqual(1); + expect(actions[0].payload.length).toEqual(2); + + expect(actions[0].payload[0]).toEqual( + {data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}, + ); + expect(actions[0].payload[1]).toEqual( + {args: ['edit_draft_latest_post_id', {id: 'latest_post_id', user_id: 'current_user_id', message: 'test msg', channel_id: 'current_channel_id', type: 'normal'}], type: 'MOCK_SET_GLOBAL_ITEM'}, ); const general = { @@ -321,8 +331,9 @@ describe('Actions.Posts', () => { const {data: withLicenseData} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test')); expect(withLicenseData).toEqual(true); - expect(testStore.getActions()).toEqual( - [{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}], + + expect(testStore.getActions()[0].payload[0]).toEqual( + {data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}, ); // should not allow edit for pending post @@ -335,6 +346,36 @@ describe('Actions.Posts', () => { const {data: withPendingPostData} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test')); expect(withPendingPostData).toEqual(false); expect(testStore.getActions()).toEqual([]); + + // should not save draft when it already exists + const stateWithDraft = { + ...initialState, + storage: { + ...initialState.storage, + storage: { + ...initialState.storage.storage, + edit_draft_latest_post_id: { + timestamp: new Date(), + value: {id: 'latest_post_id', user_id: 'current_user_id', message: 'test msg', channel_id: 'current_channel_id', type: 'normal'}, + }, + }, + }, + } as unknown as GlobalState; + + stateWithDraft.entities.posts.posts[latestPost.id] = latestPost as Post; + + testStore = mockStore(stateWithDraft); + + const {data: dataExisting} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test')); + expect(dataExisting).toEqual(true); + + actions = testStore.getActions(); + expect(actions.length).toEqual(1); + expect(actions[0].payload.length).toEqual(1); + + expect(actions[0].payload[0]).toEqual( + {data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}, + ); }); test('searchForTerm', async () => { diff --git a/webapp/channels/src/actions/post_actions.ts b/webapp/channels/src/actions/post_actions.ts index cfc5b53ae9..1c658c2c58 100644 --- a/webapp/channels/src/actions/post_actions.ts +++ b/webapp/channels/src/actions/post_actions.ts @@ -1,6 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {AnyAction} from 'redux'; +import {batchActions} from 'redux-batched-actions'; + import type {FileInfo} from '@mattermost/types/files'; import type {GroupChannel} from '@mattermost/types/groups'; import type {Post} from '@mattermost/types/posts'; @@ -20,6 +23,7 @@ import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selec import {canEditPost, comparePosts} from 'mattermost-redux/utils/post_utils'; import {addRecentEmoji, addRecentEmojis} from 'actions/emoji_actions'; +import {setGlobalItem} from 'actions/storage'; import * as StorageActions from 'actions/storage'; import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions'; import {removeDraft} from 'actions/views/drafts'; @@ -44,12 +48,14 @@ import {matchEmoticons} from 'utils/emoticons'; import {makeGetIsReactionAlreadyAddedToPost, makeGetUniqueEmojiNameReactionsForPost} from 'utils/post_utils'; import type { + GlobalState, DispatchFunc, ActionFunc, ActionFuncAsync, ThunkActionFunc, - GlobalState, } from 'types/store'; +import type {PostDraft} from 'types/store/draft'; +import type {StorageItem} from 'types/store/storage'; import type {NewPostMessageProps} from './new_post'; import {completePostReceive} from './new_post'; @@ -316,7 +322,7 @@ export function unpinPost(postId: string): ActionFuncAsync { }; } -export function setEditingPost(postId = '', refocusId = '', isRHS = false): ActionFunc { +export function setEditingPost(postId = '', refocusId = '', isRHS = false): ActionFunc { return (dispatch, getState) => { const state = getState(); const post = PostSelectors.getPost(state, postId); @@ -331,27 +337,58 @@ export function setEditingPost(postId = '', refocusId = '', isRHS = false): Acti const channel = getChannel(state, post.channel_id); const teamId = channel?.team_id || ''; - const canEditNow = canEditPost(state, config, license, teamId, post.channel_id, userId, post); + const canEdit = canEditPost(state, config, license, teamId, post.channel_id, userId, post); - // Only show the modal if we can edit the post now, but allow it to be hidden at any time - - if (canEditNow) { - dispatch({ - type: ActionTypes.TOGGLE_EDITING_POST, - data: {postId, refocusId, isRHS, show: true}, - }); + if (!canEdit) { + return {data: false}; } - return {data: canEditNow}; + const storageKey = `${StoragePrefixes.EDIT_DRAFT}${post.id}`; + + const actions: AnyAction[] = [{ + type: ActionTypes.TOGGLE_EDITING_POST, + data: {postId, refocusId, isRHS, show: true}, + }]; + + // We need to see if post's draft is already in store, if it is, we don't need to set it again + const editDraftInStore = getGlobalItem(state, storageKey, null) as StorageItem['value'] | null; + + if ( + !editDraftInStore || + (editDraftInStore && + editDraftInStore?.message?.length === 0 && + editDraftInStore?.fileInfos?.length === 0 && + editDraftInStore?.uploadsInProgress?.length === 0 + ) + ) { + actions.push(setGlobalItem(storageKey, post)); + } + + dispatch(batchActions(actions)); + + return {data: true}; }; } -export function unsetEditingPost() { - return { - type: ActionTypes.TOGGLE_EDITING_POST, - data: { - show: false, - }, +export function unsetEditingPost(): ActionFunc { + return (dispatch, getState) => { + const editingPostId = getState().views.posts.editingPost.postId; + + const actions: AnyAction[] = [{ + type: ActionTypes.TOGGLE_EDITING_POST, + data: { + show: false, + }, + }]; + + if (editingPostId) { + const storageKey = `${StoragePrefixes.EDIT_DRAFT}${editingPostId}`; + actions.push(StorageActions.removeGlobalItem(storageKey)); + } + + dispatch(batchActions(actions)); + + return {data: true}; }; } diff --git a/webapp/channels/src/actions/views/create_comment.tsx b/webapp/channels/src/actions/views/create_comment.tsx index 7d000ac8f0..9bedbd10dc 100644 --- a/webapp/channels/src/actions/views/create_comment.tsx +++ b/webapp/channels/src/actions/views/create_comment.tsx @@ -32,7 +32,7 @@ import EmojiMap from 'utils/emoji_map'; import {containsAtChannel, groupsMentionedInText} from 'utils/post_utils'; import * as Utils from 'utils/utils'; -import type {ActionFunc, ActionFuncAsync} from 'types/store'; +import type {ActionFunc, ActionFuncAsync, GlobalState} from 'types/store'; import type {PostDraft} from 'types/store/draft'; export function submitPost( @@ -200,7 +200,7 @@ export function onSubmit( }; } -export function editLatestPost(channelId: string, rootId = ''): ActionFunc { +export function editLatestPost(channelId: string, rootId = ''): ActionFunc { return (dispatch, getState) => { const state = getState(); diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx index 9ea50aa1c5..b1bd8c064c 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx @@ -385,4 +385,31 @@ describe('components/avanced_text_editor/advanced_text_editor', () => { expect(mockedRemoveDraft).not.toHaveBeenCalled(); expect(mockedUpdateDraft).not.toHaveBeenCalled(); }); + + it('should show @mention warning when a mention exists in the message', () => { + const props = { + ...baseProps, + postId: 'post_id_1', + isInEditMode: true, + }; + + renderWithContext( + , + mergeObjects(initialState, { + storage: { + storage: { + [StoragePrefixes.COMMENT_DRAFT + 'post_id_1']: { + value: TestHelper.getPostDraftMock({ + message: 'mentioning @user', + }), + }, + }, + }, + }), + ); + + expect(screen.getByTestId('editPostAtMentionWarning')).toBeVisible(); + }); }); diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx index fadc606e38..dd172778bd 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -6,6 +6,7 @@ import React, {lazy, useCallback, useEffect, useMemo, useRef, useState} from 're import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; +import {InformationOutlineIcon} from '@mattermost/compass-icons/components'; import type {ServerError} from '@mattermost/types/errors'; import type {SchedulingInfo} from '@mattermost/types/schedule_post'; @@ -13,6 +14,7 @@ import {savePreferences} from 'mattermost-redux/actions/preferences'; import {Permissions} from 'mattermost-redux/constants'; import {getChannel, makeGetChannel, getDirectChannel} from 'mattermost-redux/selectors/entities/channels'; import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; +import {getPost} from 'mattermost-redux/selectors/entities/posts'; import {get, getBool, getInt} from 'mattermost-redux/selectors/entities/preferences'; import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; import {getCurrentUserId, isCurrentUserGuestUser, getStatusForUserId, makeGetDisplayName} from 'mattermost-redux/selectors/entities/users'; @@ -21,7 +23,9 @@ import * as GlobalActions from 'actions/global_actions'; import {actionOnGlobalItemsWithPrefix} from 'actions/storage'; import type {SubmitPostReturnType} from 'actions/views/create_comment'; import {removeDraft, updateDraft} from 'actions/views/drafts'; -import {getSelectedPostFocussedAt, makeGetDraft} from 'selectors/rhs'; +import {openModal} from 'actions/views/modals'; +import {makeGetDraft} from 'selectors/drafts'; +import {getSelectedPostFocussedAt} from 'selectors/rhs'; import {connectionErrorCount} from 'selectors/views/system'; import LocalStorageStore from 'stores/local_storage_store'; @@ -29,8 +33,7 @@ import PostBoxIndicator from 'components/advanced_text_editor/post_box_indicator import {makeAsyncComponent} from 'components/async_load'; import AutoHeightSwitcher from 'components/common/auto_height_switcher'; import useDidUpdate from 'components/common/hooks/useDidUpdate'; -import MessageSubmitError from 'components/message_submit_error'; -import MsgTyping from 'components/msg_typing'; +import DeletePostModal from 'components/delete_post_modal'; import RhsSuggestionList from 'components/suggestion/rhs_suggestion_list'; import SuggestionList from 'components/suggestion/suggestion_list'; import Textbox from 'components/textbox'; @@ -39,17 +42,28 @@ import type TextboxClass from 'components/textbox/textbox'; import {OnboardingTourSteps, OnboardingTourStepsForGuestUsers, TutorialTourName} from 'components/tours/constant'; import {SendMessageTour} from 'components/tours/onboarding_tour'; -import Constants, {Locations, StoragePrefixes, Preferences, AdvancedTextEditor as AdvancedTextEditorConst, UserStatuses} from 'utils/constants'; +import Constants, { + Locations, + StoragePrefixes, + Preferences, + AdvancedTextEditor as AdvancedTextEditorConst, + UserStatuses, + ModalIdentifiers, +} from 'utils/constants'; import {canUploadFiles as canUploadFilesAccordingToConfig} from 'utils/file_utils'; -import {applyMarkdown as applyMarkdownUtil} from 'utils/markdown/apply_markdown'; import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; +import {applyMarkdown as applyMarkdownUtil} from 'utils/markdown/apply_markdown'; import {isErrorInvalidSlashCommand} from 'utils/post_utils'; +import {allAtMentions} from 'utils/text_formatting'; import * as Utils from 'utils/utils'; import type {GlobalState} from 'types/store'; import type {PostDraft} from 'types/store/draft'; +import {isPostDraftEmpty} from 'types/store/draft'; import DoNotDisturbWarning from './do_not_disturb_warning'; +import EditPostFooter from './edit_post_footer'; +import Footer from './footer'; import FormattingBar from './formatting_bar'; import {FormattingBarSpacer, Separator} from './formatting_bar/formatting_bar'; import SendButton from './send_button'; @@ -69,10 +83,6 @@ import './advanced_text_editor.scss'; const FileLimitStickyBanner = makeAsyncComponent('FileLimitStickyBanner', lazy(() => import('components/file_limit_sticky_banner'))); -function isDraftEmpty(draft: PostDraft) { - return draft.message === '' && draft.fileInfos.length === 0 && draft.uploadsInProgress.length === 0; -} - type Props = { /** @@ -83,6 +93,13 @@ type Props = { postId: string; isThreadView?: boolean; placeholder?: string; + isInEditMode?: boolean; + + /** + * Key to store the draft in the storage + * If not provided, draft key will be computed based on the post + */ + storageKey?: string; /** * Used by plugins to act after the post is made @@ -96,7 +113,9 @@ const AdvancedTextEditor = ({ postId, isThreadView = false, placeholder, + isInEditMode = false, afterSubmit, + storageKey, }: Props) => { const {formatMessage} = useIntl(); @@ -108,17 +127,32 @@ const AdvancedTextEditor = ({ const isRHS = Boolean(postId && !isThreadView); + const getFormattingBarPreferenceName = () => { + let name: string; + if (isRHS) { + name = isInEditMode ? AdvancedTextEditorConst.EDIT : AdvancedTextEditorConst.COMMENT; + } else { + name = AdvancedTextEditorConst.POST; + } + + return name; + }; + + const post = useSelector((state: GlobalState) => getPost(state, postId)); const currentUserId = useSelector(getCurrentUserId); const channel = useSelector((state: GlobalState) => getChannelSelector(state, channelId)); const channelDisplayName = channel?.display_name || ''; const channelType = channel?.type || ''; const isChannelShared = channel?.shared; - const draftFromStore = useSelector((state: GlobalState) => getDraftSelector(state, channelId, postId)); + const draftFromStore = useSelector((state: GlobalState) => getDraftSelector(state, channelId, postId, storageKey)); const badConnection = useSelector((state: GlobalState) => connectionErrorCount(state) > 1); const maxPostSize = useSelector((state: GlobalState) => parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT); const canUploadFiles = useSelector((state: GlobalState) => canUploadFilesAccordingToConfig(getConfig(state))); const fullWidthTextBox = useSelector((state: GlobalState) => get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN); - const isFormattingBarHidden = useSelector((state: GlobalState) => getBool(state, Preferences.ADVANCED_TEXT_EDITOR, isRHS ? AdvancedTextEditorConst.COMMENT : AdvancedTextEditorConst.POST)); + const isFormattingBarHidden = useSelector((state: GlobalState) => { + const preferenceName = getFormattingBarPreferenceName(); + return getBool(state, Preferences.ADVANCED_TEXT_EDITOR, preferenceName); + }); const teammateId = useSelector((state: GlobalState) => getDirectChannel(state, channelId)?.teammate_id || ''); const teammateDisplayName = useSelector((state: GlobalState) => (teammateId ? getDisplayName(state, teammateId) : '')); const showDndWarning = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false)); @@ -166,6 +200,7 @@ const AdvancedTextEditor = ({ const [isMessageLong, setIsMessageLong] = useState(false); const [renderScrollbar, setRenderScrollbar] = useState(false); const [keepEditorInFocus, setKeepEditorInFocus] = useState(false); + const [showMentionHelper, setShowMentionHelper] = useState(false); const readOnlyChannel = !canPost; const hasDraftMessage = Boolean(draft.message); @@ -182,6 +217,15 @@ const AdvancedTextEditor = ({ GlobalActions.emitLocalUserTypingEvent(channelId, postId); }, [channelId, postId]); + const handleShowMentionHelper = useCallback((message: string) => { + if (!isInEditMode) { + return; + } + + const isMentions = allAtMentions(message).length > 0; + setShowMentionHelper(isMentions); + }, [isInEditMode]); + const handleDraftChange = useCallback((draftToChange: PostDraft, options: {instant?: boolean; show?: boolean} = {instant: false, show: false}) => { if (saveDraftFrame.current) { clearTimeout(saveDraftFrame.current); @@ -190,12 +234,15 @@ const AdvancedTextEditor = ({ setDraft(draftToChange); const saveDraft = () => { - let key = `${StoragePrefixes.DRAFT}${draftToChange.channelId}`; + let prefix = StoragePrefixes.DRAFT; + let suffix = draftToChange.channelId; if (draftToChange.rootId) { - key = `${StoragePrefixes.COMMENT_DRAFT}${draftToChange.rootId}`; + prefix = StoragePrefixes.COMMENT_DRAFT; + suffix = draftToChange.rootId; } + const key = storageKey || `${prefix}${suffix}`; - if (isDraftEmpty(draftToChange)) { + if (isPostDraftEmpty(draftToChange)) { dispatch(removeDraft(key, draftToChange.channelId, draftToChange.rootId)); return; } @@ -241,22 +288,36 @@ const AdvancedTextEditor = ({ dispatch(savePreferences(currentUserId, [{ category: Preferences.ADVANCED_TEXT_EDITOR, user_id: currentUserId, - name: isRHS ? AdvancedTextEditorConst.COMMENT : AdvancedTextEditorConst.POST, + + // name: isRHS ? AdvancedTextEditorConst.COMMENT : AdvancedTextEditorConst.POST, + name: getFormattingBarPreferenceName(), value: String(!isFormattingBarHidden), }])); - }, [currentUserId, isRHS, isFormattingBarHidden, dispatch]); + }, [dispatch, currentUserId, getFormattingBarPreferenceName, isFormattingBarHidden]); useOrientationHandler(textboxRef, postId); const pluginItems = usePluginItems(draft, textboxRef, handleDraftChange); const focusTextbox = useTextboxFocus(textboxRef, channelId, isRHS, canPost); - const [attachmentPreview, fileUploadJSX] = useUploadFiles(draft, postId, channelId, isThreadView, storedDrafts, isDisabled, textboxRef, handleDraftChange, focusTextbox, setServerError); + const [attachmentPreview, fileUploadJSX] = useUploadFiles( + draft, + postId, + channelId, + isThreadView, + storedDrafts, + isDisabled, + textboxRef, + handleDraftChange, + focusTextbox, + setServerError, + isInEditMode, + ); const { emojiPicker, enableEmojiPicker, toggleEmojiPicker, } = useEmojiPicker(isDisabled, draft, caretPosition, setCaretPosition, handleDraftChange, showPreview, focusTextbox); const { - labels, + labels: priorityLabels, additionalControl: priorityAdditionalControl, isValidPersistentNotifications, onSubmitCheck: prioritySubmitCheck, @@ -275,7 +336,38 @@ const AdvancedTextEditor = ({ prioritySubmitCheck, undefined, afterSubmit, + undefined, + isInEditMode, ); + + const handleCancel = useCallback(() => { + // This resets the draft to the post's original content + handleDraftChange({ + ...draft, + message: post?.message || '', + }); + }, [handleDraftChange, draft, post]); + + const handleSubmitWrapper = useCallback(() => { + const isEmptyPost = isPostDraftEmpty(draft); + + if (isInEditMode && isEmptyPost) { + const deletePostModalData = { + modalId: ModalIdentifiers.DELETE_POST, + dialogType: DeletePostModal, + dialogProps: { + post: draft, + isRHS, + }, + }; + + dispatch(openModal(deletePostModalData)); + return; + } + + handleSubmit(); + }, [dispatch, draft, handleSubmit, isInEditMode, isRHS]); + const [handleKeyDown, postMsgKeyPress] = useKeyHandler( draft, channelId, @@ -288,14 +380,19 @@ const AdvancedTextEditor = ({ focusTextbox, applyMarkdown, handleDraftChange, - handleSubmit, + handleSubmitWrapper, emitTypingEvent, handleShowPreview, toggleAdvanceTextEditor, toggleEmojiPicker, + isInEditMode, + handleCancel, ); - const noArgumentHandleSubmit = useCallback(() => handleSubmit(), [handleSubmit]); + const handleSubmitWithEvent = useCallback((e: React.FormEvent) => { + e.preventDefault(); + handleSubmit(); + }, [handleSubmit]); const handlePostError = useCallback((err: React.ReactNode) => { setPostError(err); @@ -465,8 +562,13 @@ const AdvancedTextEditor = ({ }; }, [channelId, postId]); + useEffect(() => { + // this checks for the mention helper for initial load of component. + handleShowMentionHelper(draft.message); + }, [draft.message, handleShowMentionHelper]); + const disableSendButton = Boolean(isDisabled || (!draft.message.trim().length && !draft.fileInfos.length)) || !isValidPersistentNotifications; - const sendButton = readOnlyChannel ? null : ( + const sendButton = readOnlyChannel || isInEditMode ? null : ( - [ - priorityAdditionalControl, - ...(pluginItems || []), - ].filter(Boolean), - [pluginItems, priorityAdditionalControl]); + const additionalControls = useMemo(() => [ + !isInEditMode && priorityAdditionalControl, + ...(pluginItems || []), + ].filter(Boolean), [pluginItems, priorityAdditionalControl, isInEditMode]); const formattingBar = ( {canPost && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0) && ( )} {showDndWarning && } - + {!isInEditMode && ( + + )}
- {labels} + {!isInEditMode && priorityLabels} {attachmentPreview} {!isDisabled && (showFormattingBar || showPreview) && ( @@ -693,28 +801,40 @@ const AdvancedTextEditor = ({ )}
-
- {postError && ( -
- {postError} -
- )} - {serverError && ( - - )} - + + + + { + formatMessage({ + id: 'edit_post.no_notification_trigger_on_mention', + defaultMessage: "Editing this message with an '@mention' will not notify the recipient.", + }) + } +
) : null + } + {isInEditMode && ( + - + )} +