diff --git a/webapp/channels/src/components/advanced_create_comment/__snapshots__/advanced_create_comment.test.tsx.snap b/webapp/channels/src/components/advanced_create_comment/__snapshots__/advanced_create_comment.test.tsx.snap index 4378cbf03f..f59732a3ea 100644 --- a/webapp/channels/src/components/advanced_create_comment/__snapshots__/advanced_create_comment.test.tsx.snap +++ b/webapp/channels/src/components/advanced_create_comment/__snapshots__/advanced_create_comment.test.tsx.snap @@ -10,7 +10,9 @@ exports[`components/AdvancedCreateComment should match snapshot when cannot post badConnection={false} canPost={false} canUploadFiles={true} + caretPosition={12} channelId="g6139tbospd18cmxroesdk3kkc" + ctrlSend={false} currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" draft={ Object { @@ -88,7 +90,6 @@ exports[`components/AdvancedCreateComment should match snapshot when cannot post handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -97,9 +98,13 @@ exports[`components/AdvancedCreateComment should match snapshot when cannot post handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="RHS_COMMENT" maxPostSize={4000} message="Test message" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} removePreview={[Function]} @@ -130,7 +135,9 @@ exports[`components/AdvancedCreateComment should match snapshot, comment with me badConnection={false} canPost={true} canUploadFiles={true} + caretPosition={12} channelId="g6139tbospd18cmxroesdk3kkc" + ctrlSend={true} currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" draft={ Object { @@ -159,7 +166,6 @@ exports[`components/AdvancedCreateComment should match snapshot, comment with me handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -168,9 +174,13 @@ exports[`components/AdvancedCreateComment should match snapshot, comment with me handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="RHS_COMMENT" maxPostSize={4000} message="Test message" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} removePreview={[Function]} @@ -202,7 +212,9 @@ exports[`components/AdvancedCreateComment should match snapshot, emoji picker di badConnection={false} canPost={true} canUploadFiles={true} + caretPosition={12} channelId="g6139tbospd18cmxroesdk3kkc" + ctrlSend={false} currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" draft={ Object { @@ -280,7 +292,6 @@ exports[`components/AdvancedCreateComment should match snapshot, emoji picker di handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -289,9 +300,13 @@ exports[`components/AdvancedCreateComment should match snapshot, emoji picker di handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="RHS_COMMENT" maxPostSize={4000} message="Test message" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} removePreview={[Function]} @@ -322,7 +337,9 @@ exports[`components/AdvancedCreateComment should match snapshot, empty comment 1 badConnection={false} canPost={true} canUploadFiles={true} + caretPosition={0} channelId="g6139tbospd18cmxroesdk3kkc" + ctrlSend={true} currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" draft={ Object { @@ -351,7 +368,6 @@ exports[`components/AdvancedCreateComment should match snapshot, empty comment 1 handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -360,9 +376,13 @@ exports[`components/AdvancedCreateComment should match snapshot, empty comment 1 handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="RHS_COMMENT" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} removePreview={[Function]} @@ -394,7 +414,9 @@ exports[`components/AdvancedCreateComment should match snapshot, non-empty messa badConnection={false} canPost={true} canUploadFiles={true} + caretPosition={12} channelId="g6139tbospd18cmxroesdk3kkc" + ctrlSend={false} currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" draft={ Object { @@ -472,7 +494,6 @@ exports[`components/AdvancedCreateComment should match snapshot, non-empty messa handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -481,9 +502,13 @@ exports[`components/AdvancedCreateComment should match snapshot, non-empty messa handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="RHS_COMMENT" maxPostSize={4000} message="Test message" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} removePreview={[Function]} diff --git a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.tsx b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.tsx index 127f87b675..6b1c194a51 100644 --- a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.tsx +++ b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.tsx @@ -11,12 +11,9 @@ import type {ActionResult} from 'mattermost-redux/types/actions'; import type {Props} from 'components/advanced_create_comment/advanced_create_comment'; import AdvancedCreateComment from 'components/advanced_create_comment/advanced_create_comment'; -import AdvanceTextEditor from 'components/advanced_text_editor/advanced_text_editor'; import {testComponentForLineBreak} from 'tests/helpers/line_break_helpers'; -import {testComponentForMarkdownHotkeys} from 'tests/helpers/markdown_hotkey_helpers.js'; import Constants, {ModalIdentifiers} from 'utils/constants'; -import {execCommandInsertText} from 'utils/exec_commands'; import {TestHelper} from 'utils/test_helper'; import type {PostDraft} from 'types/store/draft'; @@ -65,8 +62,8 @@ describe('components/AdvancedCreateComment', () => { updateCommentDraftWithRootId: jest.fn(), onSubmit: jest.fn(), onResetHistoryIndex: jest.fn(), - onMoveHistoryIndexBack: jest.fn(), - onMoveHistoryIndexForward: jest.fn(), + moveHistoryIndexBack: jest.fn(), + moveHistoryIndexForward: jest.fn(), onEditLatestPost: jest.fn(), resetCreatePostRequest: jest.fn(), setShowPreview: jest.fn(), @@ -1208,99 +1205,6 @@ describe('components/AdvancedCreateComment', () => { expect(instance.focusTextbox).toHaveBeenCalledTimes(1); }); - test('should call functions on handleKeyDown', () => { - const onMoveHistoryIndexBack = jest.fn(); - const onMoveHistoryIndexForward = jest.fn(); - const onEditLatestPost = jest.fn(). - mockImplementationOnce(() => ({data: true})). - mockImplementationOnce(() => ({data: false})); - const wrapper = shallow( - , - ); - const instance = wrapper.instance(); - instance.commentMsgKeyPress = jest.fn(); - instance.focusTextbox = jest.fn(); - const blur = jest.fn(); - - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - getBoundingClientRect: jest.fn(mockTop), - blur: jest.fn(), - focus: jest.fn(), - }; - }; - - const mockTop = () => { - return document.createElement('div'); - }; - - (instance as any).textboxRef.current = {blur, focus, getInputBox: jest.fn(mockImpl)}; - - const mockTarget = { - selectionStart: 0, - selectionEnd: 0, - value: 'brown\nfox jumps over lazy dog', - }; - - const commentMsgKey: any = { - preventDefault: jest.fn(), - ctrlKey: true, - key: Constants.KeyCodes.ENTER[0], - keyCode: Constants.KeyCodes.ENTER[1], - target: mockTarget, - }; - instance.handleKeyDown(commentMsgKey); - expect(instance.commentMsgKeyPress).toHaveBeenCalledTimes(1); - - const upKey: any = { - preventDefault: jest.fn(), - ctrlKey: true, - key: Constants.KeyCodes.UP[0], - keyCode: Constants.KeyCodes.UP[1], - target: mockTarget, - }; - instance.handleKeyDown(upKey); - expect(upKey.preventDefault).toHaveBeenCalledTimes(1); - expect(onMoveHistoryIndexBack).toHaveBeenCalledTimes(1); - - const downKey = { - preventDefault: jest.fn(), - ctrlKey: true, - key: Constants.KeyCodes.DOWN[0], - keyCode: Constants.KeyCodes.DOWN[1], - target: mockTarget, - }; - instance.handleKeyDown(downKey as any); - expect(downKey.preventDefault).toHaveBeenCalledTimes(1); - expect(onMoveHistoryIndexForward).toHaveBeenCalledTimes(1); - - wrapper.setState({draft: emptyDraft}); - const upKeyForEdit: any = { - preventDefault: jest.fn(), - ctrlKey: false, - key: Constants.KeyCodes.UP[0], - keyCode: Constants.KeyCodes.UP[1], - target: mockTarget, - }; - instance.handleKeyDown(upKeyForEdit); - expect(upKeyForEdit.preventDefault).toHaveBeenCalledTimes(1); - expect(onEditLatestPost).toHaveBeenCalledTimes(1); - expect(blur).toHaveBeenCalledTimes(1); - - instance.handleKeyDown(upKeyForEdit); - expect(upKeyForEdit.preventDefault).toHaveBeenCalledTimes(2); - expect(onEditLatestPost).toHaveBeenCalledTimes(2); - expect(instance.focusTextbox).toHaveBeenCalledTimes(1); - expect(instance.focusTextbox).toHaveBeenCalledWith(true); - }); - test('should the RHS thread scroll to bottom one time after mount when props.draft.message is not empty', () => { const draft: PostDraft = emptyDraft; const scrollToBottom = jest.fn(); @@ -1348,181 +1252,6 @@ describe('components/AdvancedCreateComment', () => { expect(scrollToBottom).toBeCalledTimes(2); }); - test('should be able to format a pasted markdown table', () => { - const draft: PostDraft = emptyDraft; - const wrapper = shallow( - , - ); - - const mockTop = () => { - return document.createElement('div'); - }; - - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - getBoundingClientRect: jest.fn(mockTop), - focus: jest.fn(), - }; - }; - - (wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - - const event: any = { - target: { - id: 'reply_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/html'], - getData: () => { - return '
testtest
testtest
'; - }, - }, - }; - - const markdownTable = '| test | test |\n| --- | --- |\n| test | test |'; - - wrapper.instance().pasteHandler(event); - expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable); - }); - - test('should be able to format a pasted markdown table without headers', () => { - const draft: PostDraft = emptyDraft; - const wrapper = shallow( - , - ); - - const mockTop = () => { - return document.createElement('div'); - }; - - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - getBoundingClientRect: jest.fn(mockTop), - focus: jest.fn(), - }; - }; - - (wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - - const event: any = { - target: { - id: 'reply_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/html'], - getData: () => { - return '
testtest
testtest
'; - }, - }, - }; - - const markdownTable = '| test | test |\n| --- | --- |\n| test | test |\n'; - - wrapper.instance().pasteHandler(event); - expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable); - }); - - test('should be able to format a pasted hyperlink', () => { - const draft: PostDraft = emptyDraft; - const wrapper = shallow( - , - ); - - const mockTop = () => { - return document.createElement('div'); - }; - - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - getBoundingClientRect: jest.fn(mockTop), - focus: jest.fn(), - }; - }; - - (wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - - const event: any = { - target: { - id: 'reply_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/html'], - getData: () => { - return 'link text'; - }, - }, - }; - - const markdownLink = '[link text](https://test.domain)'; - - wrapper.instance().pasteHandler(event); - expect(execCommandInsertText).toHaveBeenCalledWith(markdownLink); - }); - - test('should be able to format a github codeblock (pasted as a table)', () => { - const draft: PostDraft = emptyDraft; - const wrapper = shallow( - , - ); - - const mockTop = () => { - return document.createElement('div'); - }; - - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - getBoundingClientRect: jest.fn(mockTop), - focus: jest.fn(), - }; - }; - - (wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - - const event: any = { - target: { - id: 'reply_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/plain', 'text/html'], - getData: (type: any) => { - if (type === 'text/plain') { - return '// a javascript codeblock example\nif (1 > 0) {\n return \'condition is true\';\n}'; - } - return '
// a javascript codeblock example
 if (1 > 0) {
 console.log(\'condition is true\');
 }
'; - }, - }, - }; - - const codeBlockMarkdown = "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```"; - - wrapper.instance().pasteHandler(event); - expect(execCommandInsertText).toHaveBeenCalledWith(codeBlockMarkdown); - }); - test('should show preview and edit mode, and return focus on preview disable', () => { const wrapper = shallow( , @@ -1552,73 +1281,4 @@ describe('components/AdvancedCreateComment', () => { ctrlSend={true} /> ), (instance: any) => instance.state().draft.message, false); - - testComponentForMarkdownHotkeys( - (value: any) => ( - - ), - (wrapper: any, setSelectionRangeFn: any) => { - const mockTop = () => { - return document.createElement('div'); - }; - wrapper.instance().textboxRef = { - current: { - getInputBox: jest.fn(() => { - return { - focus: jest.fn(), - getBoundingClientRect: jest.fn(mockTop), - setSelectionRange: setSelectionRangeFn, - }; - }), - }, - }; - }, - (instance: any) => instance.find(AdvanceTextEditor), - (instance: any) => instance.state().draft.message, - false, - 'reply_textbox', - ); - - it('should blur when ESCAPE is pressed', () => { - const wrapper = shallow( - , - ); - const instance = wrapper.instance(); - const blur = jest.fn(); - - const mockImpl = () => { - return { - blur: jest.fn(), - focus: jest.fn(), - }; - }; - - (instance as any).textboxRef.current = {blur, getInputBox: jest.fn(mockImpl)}; - - const mockTarget = { - selectionStart: 0, - selectionEnd: 0, - value: 'brown\nfox jumps over lazy dog', - }; - - const commentEscapeKey = { - preventDefault: jest.fn(), - ctrlKey: true, - key: Constants.KeyCodes.ESCAPE[0], - keyCode: Constants.KeyCodes.ESCAPE[1], - target: mockTarget, - }; - - instance.handleKeyDown(commentEscapeKey as any); - expect(blur).toHaveBeenCalledTimes(1); - }); }); diff --git a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx index bdf16ef47f..3b98468609 100644 --- a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx +++ b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx @@ -3,7 +3,6 @@ /* eslint-disable max-lines */ -import {isNil} from 'lodash'; import React from 'react'; import type {ChannelMemberCountsByGroup} from '@mattermost/types/channels'; @@ -14,6 +13,7 @@ import {GroupSource} from '@mattermost/types/groups'; import type {Group} from '@mattermost/types/groups'; import type {PreferenceType} from '@mattermost/types/preferences'; +import {Posts} from 'mattermost-redux/constants'; import type {ActionResult} from 'mattermost-redux/types/actions'; import {sortFileInfos} from 'mattermost-redux/utils/file_utils'; @@ -28,22 +28,11 @@ import PostDeletedModal from 'components/post_deleted_modal'; import type {TextboxClass, TextboxElement} from 'components/textbox'; import Constants, {AdvancedTextEditor as AdvancedTextEditorConst, Locations, ModalIdentifiers, Preferences} from 'utils/constants'; -import {execCommandInsertText} from 'utils/exec_commands'; -import * as Keyboard from 'utils/keyboard'; import { applyMarkdown, } from 'utils/markdown/apply_markdown'; import type { ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; -import { - getHtmlTable, - hasHtmlLink, - formatMarkdownMessage, - isGitHubCodeBlock, - formatGithubCodePaste, - isTextUrl, - formatMarkdownLinkMessage, -} from 'utils/paste'; import { specialMentionsInText, postMessageOnKeyPress, @@ -52,7 +41,6 @@ import { splitMessageBasedOnCaretPosition, groupsMentionedInText, mentionsMinusSpecialMentionsInText, - isWithinCodeBlock, } from 'utils/post_utils'; import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; @@ -61,8 +49,6 @@ import type {ModalData} from 'types/actions'; import type {PostDraft} from 'types/store/draft'; import type {PluginComponent} from 'types/store/plugins'; -const KeyCodes = Constants.KeyCodes; - export type Props = { currentTeamId: string; @@ -130,10 +116,10 @@ export type Props = { onResetHistoryIndex: () => void; // Called when navigating back through comment message history - onMoveHistoryIndexBack: () => void; + moveHistoryIndexBack: (index: string) => Promise; // Called when navigating forward through comment message history - onMoveHistoryIndexForward: () => void; + moveHistoryIndexForward: (index: string) => Promise; // Called to initiate editing the user's latest post onEditLatestPost: () => ActionResult; @@ -206,11 +192,11 @@ type State = { uploadsProgressPercent: {[clientID: string]: FilePreviewInfo}; renderScrollbar: boolean; scrollbarWidth: number; - draft?: PostDraft; + draft: PostDraft; rootId?: string; messageInHistory?: string; createPostErrorId?: string; - caretPosition?: number; + caretPosition: number; postError?: React.ReactNode; errorClass: string | null; serverError: (ServerError & {submittedMessage?: string}) | null; @@ -248,7 +234,6 @@ class AdvancedCreateComment extends React.PureComponent { createPostErrorId: props.createPostErrorId, rootId: props.rootId, messageInHistory: props.messageInHistory, - draft: state.draft || {...props.draft, caretPosition: props.draft.message.length, uploadsInProgress: []}, }; const rootChanged = props.rootId !== state.rootId || props.draft.rootId !== state.draft?.rootId; @@ -278,7 +263,8 @@ class AdvancedCreateComment extends React.PureComponent { serverError: null, showFormat: false, isFormattingBarHidden: props.isFormattingBarHidden, - caretPosition: props.draft.caretPosition, + caretPosition: props.draft.message.length, + draft: {...props.draft, uploadsInProgress: []}, }; this.textboxRef = React.createRef(); @@ -295,7 +281,6 @@ class AdvancedCreateComment extends React.PureComponent { this.focusTextbox(); } - document.addEventListener('paste', this.pasteHandler); document.addEventListener('keydown', this.focusTextboxIfNecessary); window.addEventListener('beforeunload', this.saveDraftWithShow); this.getChannelMemberCountsByGroup(); @@ -310,7 +295,6 @@ class AdvancedCreateComment extends React.PureComponent { componentWillUnmount() { this.props.resetCreatePostRequest?.(); - document.removeEventListener('paste', this.pasteHandler); document.removeEventListener('keydown', this.focusTextboxIfNecessary); window.removeEventListener('beforeunload', this.saveDraftWithShow); this.saveDraftOnUnmount(); @@ -351,6 +335,26 @@ class AdvancedCreateComment extends React.PureComponent { } } + fillMessageFromHistory() { + const lastMessage = this.props.messageInHistory; + this.setState((prev) => ({ + draft: { + ...prev.draft, + message: lastMessage || '', + }, + })); + } + + loadPrevMessage = (e: React.KeyboardEvent) => { + e.preventDefault(); + this.props.moveHistoryIndexBack(Posts.MESSAGE_TYPES.COMMENT).then(() => this.fillMessageFromHistory()); + }; + + loadNextMessage = (e: React.KeyboardEvent) => { + e.preventDefault(); + this.props.moveHistoryIndexForward(Posts.MESSAGE_TYPES.COMMENT).then(() => this.fillMessageFromHistory()); + }; + getChannelMemberCountsByGroup = () => { const {useLDAPGroupMentions, useCustomGroupMentions, channelId, isTimezoneEnabled, searchAssociatedGroupsForReference, getChannelMemberCountsByGroup, draft, currentTeamId} = this.props; @@ -438,43 +442,6 @@ class AdvancedCreateComment extends React.PureComponent { }); }; - pasteHandler = (event: ClipboardEvent) => { - const {clipboardData, target} = event; - - if (!clipboardData || !clipboardData.items || !target || (target as TextboxElement)?.id !== 'reply_textbox') { - return; - } - - const {selectionStart, selectionEnd} = target as TextboxElement; - - const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd; - const hasTextUrl = isTextUrl(clipboardData); - const hasHTMLLinks = !this.isNonFormattedPaste && hasHtmlLink(clipboardData); - const htmlTable = getHtmlTable(clipboardData); - const shouldApplyLinkMarkdown = hasSelection && hasTextUrl; - const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className); - - if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) { - return; - } - - event.preventDefault(); - - const message = this.state.draft?.message ?? ''; - - // execCommand's insertText' triggers a 'change' event, hence we need not set respective state explicitly. - if (shouldApplyLinkMarkdown) { - const formattedLink = formatMarkdownLinkMessage({selectionStart, selectionEnd, message, clipboardData}); - execCommandInsertText(formattedLink); - } else if (shouldApplyGithubCodeBlock) { - const {formattedCodeBlock} = formatGithubCodePaste({selectionStart, selectionEnd, message, clipboardData}); - execCommandInsertText(formattedCodeBlock); - } else { - const {formattedMarkdown} = formatMarkdownMessage(clipboardData, message, this.state.caretPosition); - execCommandInsertText(formattedMarkdown); - } - }; - handleNotifyAllConfirmation = () => { this.doSubmit(); }; @@ -841,188 +808,6 @@ class AdvancedCreateComment extends React.PureComponent { }); }; - handleKeyDown = (e: React.KeyboardEvent) => { - const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey; - const lastMessageReactionKeyCombo = ctrlOrMetaKeyPressed && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH); - - const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey; - const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey; - const shiftAltCombo = !Keyboard.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey; - - // fix for FF not capturing the paste without formatting event when using ctrl|cmd + shift + v - if (e.key === KeyCodes.V[0] && ctrlOrMetaKeyPressed) { - if (e.shiftKey) { - this.isNonFormattedPaste = true; - this.timeoutId = window.setTimeout(() => { - this.isNonFormattedPaste = false; - }, 250); - } - } - - // listen for line break key combo and insert new line character - if (Utils.isUnhandledLineBreakKeyCombo(e)) { - this.setState({ - draft: { - ...this.state.draft!, - message: Utils.insertLineBreakFromKeyEvent(e as React.KeyboardEvent), - }, - }); - return; - } - - if ( - (this.props.ctrlSend || this.props.codeBlockOnCtrlEnter) && - Keyboard.isKeyPressed(e, KeyCodes.ENTER) && - (e.ctrlKey || e.metaKey) - ) { - this.setShowPreview(false); - this.commentMsgKeyPress(e); - return; - } - - const draft = this.state.draft!; - const {message} = draft; - const {caretPosition} = this.state; - const caretIsWithinCodeBlock = caretPosition && isWithinCodeBlock(message, caretPosition); - - if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) { - this.textboxRef.current?.blur(); - } - - if ( - !e.ctrlKey && - !e.metaKey && - !e.altKey && - !e.shiftKey && - Keyboard.isKeyPressed(e, KeyCodes.UP) && - message === '' - ) { - e.preventDefault(); - if (this.textboxRef.current) { - this.textboxRef.current.blur(); - } - - const {data: canEditNow} = this.props.onEditLatestPost(); - if (!canEditNow) { - this.focusTextbox(true); - } - } - - const { - selectionStart, - selectionEnd, - value, - } = e.target as TextboxElement; - - if (ctrlKeyCombo && !caretIsWithinCodeBlock) { - if (Keyboard.isKeyPressed(e, KeyCodes.UP)) { - e.preventDefault(); - this.props.onMoveHistoryIndexBack(); - } else if (Keyboard.isKeyPressed(e, KeyCodes.DOWN)) { - e.preventDefault(); - this.props.onMoveHistoryIndexForward(); - } else if (Keyboard.isKeyPressed(e, KeyCodes.B)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'bold', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.I)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'italic', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'link', - selectionStart, - selectionEnd, - message: value, - }); - } - } else if (ctrlAltCombo && !caretIsWithinCodeBlock) { - if (Keyboard.isKeyPressed(e, KeyCodes.K)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'link', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.C)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'code', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.E)) { - e.stopPropagation(); - e.preventDefault(); - this.toggleEmojiPicker(); - } else if (Keyboard.isKeyPressed(e, KeyCodes.T)) { - e.stopPropagation(); - e.preventDefault(); - this.toggleAdvanceTextEditor(); - } else if (Keyboard.isKeyPressed(e, KeyCodes.P) && draft.message.length) { - e.stopPropagation(); - e.preventDefault(); - this.setShowPreview(!this.props.shouldShowPreview); - } - } else if (shiftAltCombo && !caretIsWithinCodeBlock) { - if (Keyboard.isKeyPressed(e, KeyCodes.X)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'strike', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.SEVEN)) { - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'ol', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.EIGHT)) { - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'ul', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.NINE)) { - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'quote', - selectionStart, - selectionEnd, - message: value, - }); - } - } - - if (lastMessageReactionKeyCombo) { - this.reactToLastMessage(e); - } - }; - applyMarkdown = (options: ApplyMarkdownOptions) => { if (this.props.shouldShowPreview) { return; @@ -1221,6 +1006,25 @@ class AdvancedCreateComment extends React.PureComponent { this.lastBlurAt = Date.now(); }; + handleEditLatestPost = () => { + const {data: canEditNow} = this.props.onEditLatestPost(); + if (!canEditNow) { + this.focusTextbox(true); + } + }; + + onMessageChange = (message: string, callback?: (() => void) | undefined) => { + const draft = this.state.draft; + const modifiedDraft = { + ...draft, + message, + }; + this.handleDraftChange(modifiedDraft); + this.setState({ + draft: modifiedDraft, + }, callback); + }; + render() { const draft = this.state.draft!; @@ -1295,7 +1099,6 @@ class AdvancedCreateComment extends React.PureComponent { handlePostError={this.handlePostError} emitTypingEvent={this.emitTypingEvent} handleMouseUpKeyUp={this.handleMouseUpKeyUp} - handleKeyDown={this.handleKeyDown} postMsgKeyPress={this.commentMsgKeyPress} handleChange={this.handleChange} toggleEmojiPicker={this.toggleEmojiPicker} @@ -1312,6 +1115,13 @@ class AdvancedCreateComment extends React.PureComponent { fileUploadRef={this.fileUploadRef} isThreadView={this.props.isThreadView} additionalControls={pluginItems.filter(Boolean)} + codeBlockOnCtrlEnter={this.props.codeBlockOnCtrlEnter} + ctrlSend={this.props.ctrlSend} + loadNextMessage={this.loadNextMessage} + loadPrevMessage={this.loadPrevMessage} + onEditLatestPost={this.handleEditLatestPost} + onMessageChange={this.onMessageChange} + caretPosition={this.state.caretPosition} placeholder={this.props.placeholder} /> diff --git a/webapp/channels/src/components/advanced_create_comment/index.ts b/webapp/channels/src/components/advanced_create_comment/index.ts index e581ffded2..b75eba0cf7 100644 --- a/webapp/channels/src/components/advanced_create_comment/index.ts +++ b/webapp/channels/src/components/advanced_create_comment/index.ts @@ -8,7 +8,7 @@ import type {ActionCreatorsMapObject, Dispatch} from 'redux'; import type {PreferenceType} from '@mattermost/types/preferences'; import {getChannelTimezones, getChannelMemberCountsByGroup} from 'mattermost-redux/actions/channels'; -import {resetCreatePostRequest, resetHistoryIndex} from 'mattermost-redux/actions/posts'; +import {moveHistoryIndexBack, moveHistoryIndexForward, resetCreatePostRequest, resetHistoryIndex} from 'mattermost-redux/actions/posts'; import {savePreferences} from 'mattermost-redux/actions/preferences'; import {Permissions, Preferences, Posts} from 'mattermost-redux/constants'; import {getAllChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from 'mattermost-redux/selectors/entities/channels'; @@ -25,7 +25,6 @@ import {emitShortcutReactToLastPostFrom} from 'actions/post_actions'; import { clearCommentDraftUploads, updateCommentDraft, - makeOnMoveHistoryIndex, makeOnSubmit, makeOnEditLatestPost, } from 'actions/views/create_comment'; @@ -132,8 +131,8 @@ type Actions = { updateCommentDraftWithRootId: (rootID: string, draft: PostDraft, save?: boolean) => void; onSubmit: (draft: PostDraft, options: {ignoreSlash: boolean}) => void; onResetHistoryIndex: () => void; - onMoveHistoryIndexBack: () => void; - onMoveHistoryIndexForward: () => void; + moveHistoryIndexBack: (index: string) => Promise; + moveHistoryIndexForward: (index: string) => Promise; onEditLatestPost: () => ActionResult; resetCreatePostRequest: () => void; getChannelTimezones: (channelId: string) => Promise; @@ -152,14 +151,6 @@ function makeMapDispatchToProps() { draft: PostDraft, options: {ignoreSlash: boolean}, ) => (dispatch: DispatchFunc, getState: () => GlobalState) => Promise | ActionResult; - let onMoveHistoryIndexBack: () => ( - dispatch: DispatchFunc, - getState: () => GlobalState, - ) => Promise | ActionResult; - let onMoveHistoryIndexForward: () => ( - dispatch: DispatchFunc, - getState: () => GlobalState, - ) => Promise | ActionResult; let onEditLatestPost: () => ActionFunc; function onResetHistoryIndex() { @@ -173,8 +164,6 @@ function makeMapDispatchToProps() { return (dispatch: Dispatch, ownProps: OwnProps) => { if (rootId !== ownProps.rootId) { onUpdateCommentDraft = makeOnUpdateCommentDraft(ownProps.rootId, ownProps.channelId); - onMoveHistoryIndexBack = makeOnMoveHistoryIndex(ownProps.rootId, -1); - onMoveHistoryIndexForward = makeOnMoveHistoryIndex(ownProps.rootId, 1); } if (channelId !== ownProps.channelId) { @@ -200,8 +189,8 @@ function makeMapDispatchToProps() { updateCommentDraftWithRootId, onSubmit, onResetHistoryIndex, - onMoveHistoryIndexBack, - onMoveHistoryIndexForward, + moveHistoryIndexBack, + moveHistoryIndexForward, onEditLatestPost, resetCreatePostRequest, getChannelTimezones, diff --git a/webapp/channels/src/components/advanced_create_post/__snapshots__/advanced_create_post.test.tsx.snap b/webapp/channels/src/components/advanced_create_post/__snapshots__/advanced_create_post.test.tsx.snap index 4510585f33..4e87004525 100644 --- a/webapp/channels/src/components/advanced_create_post/__snapshots__/advanced_create_post.test.tsx.snap +++ b/webapp/channels/src/components/advanced_create_post/__snapshots__/advanced_create_post.test.tsx.snap @@ -13,7 +13,9 @@ exports[`components/advanced_create_post Show tutorial 1`] = ` badConnection={false} canPost={true} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -62,7 +64,6 @@ exports[`components/advanced_create_post Show tutorial 1`] = ` handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -71,13 +72,18 @@ exports[`components/advanced_create_post Show tutorial 1`] = ` handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -109,7 +115,9 @@ exports[`components/advanced_create_post should match snapshot for center textbo badConnection={false} canPost={true} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -158,7 +166,6 @@ exports[`components/advanced_create_post should match snapshot for center textbo handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -167,13 +174,18 @@ exports[`components/advanced_create_post should match snapshot for center textbo handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -205,7 +217,9 @@ exports[`components/advanced_create_post should match snapshot when cannot post badConnection={false} canPost={false} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -254,7 +268,6 @@ exports[`components/advanced_create_post should match snapshot when cannot post handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -263,13 +276,18 @@ exports[`components/advanced_create_post should match snapshot when cannot post handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -301,7 +319,9 @@ exports[`components/advanced_create_post should match snapshot when file upload badConnection={false} canPost={true} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -350,7 +370,6 @@ exports[`components/advanced_create_post should match snapshot when file upload handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -359,13 +378,18 @@ exports[`components/advanced_create_post should match snapshot when file upload handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -397,7 +421,9 @@ exports[`components/advanced_create_post should match snapshot, can post; previe badConnection={false} canPost={true} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -446,7 +472,6 @@ exports[`components/advanced_create_post should match snapshot, can post; previe handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -455,13 +480,18 @@ exports[`components/advanced_create_post should match snapshot, can post; previe handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -493,7 +523,9 @@ exports[`components/advanced_create_post should match snapshot, can post; previe badConnection={false} canPost={true} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -542,7 +574,6 @@ exports[`components/advanced_create_post should match snapshot, can post; previe handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -551,13 +582,18 @@ exports[`components/advanced_create_post should match snapshot, can post; previe handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -589,7 +625,9 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre badConnection={false} canPost={false} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -638,7 +676,6 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -647,13 +684,18 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -685,7 +727,9 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre badConnection={false} canPost={false} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -734,7 +778,6 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -743,13 +786,18 @@ exports[`components/advanced_create_post should match snapshot, cannot post; pre handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -781,7 +829,9 @@ exports[`components/advanced_create_post should match snapshot, init 1`] = ` badConnection={false} canPost={true} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -830,7 +880,6 @@ exports[`components/advanced_create_post should match snapshot, init 1`] = ` handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -839,13 +888,18 @@ exports[`components/advanced_create_post should match snapshot, init 1`] = ` handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -877,7 +931,9 @@ exports[`components/advanced_create_post should match snapshot, post priority di badConnection={false} canPost={true} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -931,7 +987,6 @@ exports[`components/advanced_create_post should match snapshot, post priority di handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -940,13 +995,18 @@ exports[`components/advanced_create_post should match snapshot, post priority di handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -986,7 +1046,9 @@ exports[`components/advanced_create_post should match snapshot, post priority en badConnection={false} canPost={true} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -1035,7 +1097,6 @@ exports[`components/advanced_create_post should match snapshot, post priority en handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -1044,13 +1105,18 @@ exports[`components/advanced_create_post should match snapshot, post priority en handleUploadStart={[Function]} hideEmojiPicker={[Function]} isFormattingBarHidden={false} + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} @@ -1095,7 +1161,9 @@ exports[`components/advanced_create_post should match snapshot, post priority en badConnection={false} canPost={true} canUploadFiles={false} + caretPosition={0} channelId="owsyt8n43jfxjpzh9np93mx1wa" + ctrlSend={false} currentChannel={ Object { "create_at": 0, @@ -1149,7 +1217,6 @@ exports[`components/advanced_create_post should match snapshot, post priority en handleFileUploadChange={[Function]} handleFileUploadComplete={[Function]} handleGifClick={[Function]} - handleKeyDown={[Function]} handleMouseUpKeyUp={[Function]} handlePostError={[Function]} handleSubmit={[Function]} @@ -1173,13 +1240,18 @@ exports[`components/advanced_create_post should match snapshot, post priority en } /> } + loadNextMessage={[Function]} + loadPrevMessage={[Function]} location="CENTER" maxPostSize={4000} message="" + onEditLatestPost={[Function]} + onMessageChange={[Function]} postId="" postMsgKeyPress={[Function]} prefillMessage={[Function]} removePreview={[Function]} + replyToLastPost={[Function]} serverError={null} setShowPreview={[Function]} shouldShowPreview={false} diff --git a/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.tsx b/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.tsx index 6250c02491..d503bd7454 100644 --- a/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.tsx +++ b/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.tsx @@ -9,23 +9,16 @@ import type {CommandArgs} from '@mattermost/types/integrations'; import type {Post} from '@mattermost/types/posts'; import {PostPriority} from '@mattermost/types/posts'; -import {Posts} from 'mattermost-redux/constants'; import type {ActionResult} from 'mattermost-redux/types/actions'; -import * as GlobalActions from 'actions/global_actions'; - -import type {Props} from 'components/advanced_create_post/advanced_create_post'; import AdvancedCreatePost from 'components/advanced_create_post/advanced_create_post'; -import AdvanceTextEditor from 'components/advanced_text_editor/advanced_text_editor'; +import type {Props} from 'components/advanced_create_post/advanced_create_post'; import type {TextboxElement} from 'components/textbox'; import {testComponentForLineBreak} from 'tests/helpers/line_break_helpers'; -import {testComponentForMarkdownHotkeys} from 'tests/helpers/markdown_hotkey_helpers.js'; import Constants, {StoragePrefixes, ModalIdentifiers} from 'utils/constants'; import EmojiMap from 'utils/emoji_map'; -import {execCommandInsertText} from 'utils/exec_commands'; import {TestHelper} from 'utils/test_helper'; -import * as Utils from 'utils/utils'; jest.mock('actions/global_actions', () => ({ emitLocalUserTypingEvent: jest.fn(), @@ -993,171 +986,6 @@ describe('components/advanced_create_post', () => { expect(instance.handleFileUploadChange).toHaveBeenCalledTimes(1); }); - it('Should just return as ctrlSend is enabled and its ctrl+enter', () => { - const wrapper = shallow(advancedCreatePost({ - ctrlSend: true, - })); - - const instance: any = wrapper.instance(); - instance.textboxRef.current = {blur: jest.fn()}; - - const target = { - selectionStart: 0, - selectionEnd: 0, - value: 'brown\nfox jumps over lazy dog', - }; - - const event = { - ctrlKey: true, - key: Constants.KeyCodes.ENTER[0], - keyCode: Constants.KeyCodes.ENTER[1], - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - persist: jest.fn(), - target, - } as unknown as React.KeyboardEvent; - - instance.handleKeyDown(event); - setTimeout(() => { - expect(GlobalActions.emitLocalUserTypingEvent).toHaveBeenCalledWith(currentChannelProp.id, ''); - }, 0); - }); - - it('Should call edit action as comment for arrow up', () => { - const setEditingPost = jest.fn(); - const wrapper = shallow(advancedCreatePost({ - actions: { - ...baseProp.actions, - setEditingPost, - }, - })); - const instance = wrapper.instance() as AdvancedCreatePost; - const type = Utils.localizeMessage('create_post.comment', Posts.MESSAGE_TYPES.COMMENT); - - const target = { - selectionStart: 0, - selectionEnd: 0, - value: 'brown\nfox jumps over lazy dog', - }; - - const event = { - key: Constants.KeyCodes.UP[0], - keyCode: Constants.KeyCodes.UP[1], - preventDefault: jest.fn(), - persist: jest.fn(), - target, - } as unknown as React.KeyboardEvent; - - instance.handleKeyDown(event); - expect(setEditingPost).toHaveBeenCalledWith(currentUsersLatestPostProp.id, 'post_textbox', type); - }); - - it('Should call edit action as post for arrow up', () => { - const setEditingPost = jest.fn(); - const wrapper = shallow(advancedCreatePost({ - actions: { - ...baseProp.actions, - setEditingPost, - }, - })); - const instance = wrapper.instance() as AdvancedCreatePost; - - wrapper.setProps({ - currentUsersLatestPost: {id: 'b', channel_id: currentChannelProp.id}, - }); - - const type = Utils.localizeMessage('create_post.post', Posts.MESSAGE_TYPES.POST); - - const target = { - selectionStart: 0, - selectionEnd: 0, - value: 'brown\nfox jumps over lazy dog', - }; - - const event = { - key: Constants.KeyCodes.UP[0], - keyCode: Constants.KeyCodes.UP[1], - preventDefault: jest.fn(), - persist: jest.fn(), - target, - } as unknown as React.KeyboardEvent; - - instance.handleKeyDown(event); - expect(setEditingPost).toHaveBeenCalledWith(currentUsersLatestPostProp.id, 'post_textbox', type); - }); - - it('Should call moveHistoryIndexForward as ctrlKey and down arrow', () => { - const moveHistoryIndexForward = jest.fn( - () => { - return new Promise((resolve) => { - process.nextTick(() => resolve()); - }); - }, - ); - const wrapper = shallow(advancedCreatePost({ - actions: { - ...baseProp.actions, - moveHistoryIndexForward, - }, - })); - const instance = wrapper.instance() as AdvancedCreatePost; - - const target = { - selectionStart: 0, - selectionEnd: 0, - value: 'brown\nfox jumps over lazy dog', - }; - - const event = { - ctrlKey: true, - key: Constants.KeyCodes.DOWN[0], - keyCode: Constants.KeyCodes.DOWN[1], - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - persist: jest.fn(), - target, - } as unknown as React.KeyboardEvent; - - instance.handleKeyDown(event); - expect(moveHistoryIndexForward).toHaveBeenCalled(); - }); - - it('Should call moveHistoryIndexBack as ctrlKey and up arrow', () => { - const moveHistoryIndexBack = jest.fn( - () => { - return new Promise((resolve) => { - process.nextTick(() => resolve()); - }); - }, - ); - const wrapper = shallow(advancedCreatePost({ - actions: { - ...baseProp.actions, - moveHistoryIndexBack, - }, - })); - const instance = wrapper.instance() as AdvancedCreatePost; - - const target = { - selectionStart: 0, - selectionEnd: 0, - value: 'brown\nfox jumps over lazy dog', - }; - - const event = { - ctrlKey: true, - key: Constants.KeyCodes.UP[0], - keyCode: Constants.KeyCodes.UP[1], - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - persist: jest.fn(), - target, - } as unknown as React.KeyboardEvent; - - instance.handleKeyDown(event); - expect(moveHistoryIndexBack).toHaveBeenCalled(); - }); - it('Show tutorial', () => { const wrapper = shallow(advancedCreatePost({ showSendTutorialTip: true, @@ -1300,129 +1128,6 @@ describe('components/advanced_create_post', () => { ); }); - it('should be able to format a pasted markdown table', () => { - const wrapper = shallow(advancedCreatePost()); - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - focus: jest.fn(), - }; - }; - (wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - - const event = { - target: { - id: 'post_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/html'], - getData: () => { - return '
testtest
testtest
'; - }, - }, - } as unknown as ClipboardEvent; - - const markdownTable = '| test | test |\n| --- | --- |\n| test | test |'; - - (wrapper.instance() as AdvancedCreatePost).pasteHandler(event); - expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable); - }); - - it('should be able to format a pasted markdown table without headers', () => { - const wrapper = shallow(advancedCreatePost()); - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - focus: jest.fn(), - }; - }; - (wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - - const event = { - target: { - id: 'post_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/html'], - getData: () => { - return '
testtest
testtest
'; - }, - }, - } as unknown as ClipboardEvent; - - const markdownTable = '| test | test |\n| --- | --- |\n| test | test |\n'; - - (wrapper.instance() as AdvancedCreatePost).pasteHandler(event); - expect(execCommandInsertText).toHaveBeenCalledWith(markdownTable); - }); - - it('should be able to format a pasted hyperlink', () => { - const wrapper = shallow(advancedCreatePost()); - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - focus: jest.fn(), - }; - }; - (wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - - const event = { - target: { - id: 'post_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/html'], - getData: () => { - return 'link text'; - }, - }, - } as unknown as ClipboardEvent; - - const markdownLink = '[link text](https://test.domain)'; - - (wrapper.instance() as AdvancedCreatePost).pasteHandler(event); - expect(execCommandInsertText).toHaveBeenCalledWith(markdownLink); - }); - - it('should be able to format a github codeblock (pasted as a table)', () => { - const wrapper = shallow(advancedCreatePost()); - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - focus: jest.fn(), - }; - }; - (wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - - const event = { - target: { - id: 'post_textbox', - }, - preventDefault: jest.fn(), - clipboardData: { - items: [1], - types: ['text/plain', 'text/html'], - getData: (type: string) => { - if (type === 'text/plain') { - return '// a javascript codeblock example\nif (1 > 0) {\n return \'condition is true\';\n}'; - } - return '
// a javascript codeblock example
 if (1 > 0) {
 console.log(\'condition is true\');
 }
'; - }, - }, - } as unknown as ClipboardEvent; - - const codeBlockMarkdown = "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```"; - - (wrapper.instance() as AdvancedCreatePost).pasteHandler(event); - expect(execCommandInsertText).toHaveBeenCalledWith(codeBlockMarkdown); - }); - /** * TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component * @@ -1471,26 +1176,6 @@ describe('components/advanced_create_post', () => { false, ); - testComponentForMarkdownHotkeys( - (value: string) => advancedCreatePost({draft: {...draftProp, message: value}}), - (wrapper: any, setSelectionRangeFn: any) => { - wrapper.instance().textboxRef = { - current: { - getInputBox: jest.fn(() => { - return { - focus: jest.fn(), - setSelectionRange: setSelectionRangeFn, - }; - }), - }, - }; - }, - (instance: any) => instance.find(AdvanceTextEditor), - (instance: any) => instance.state().message, - false, - 'post_textbox', - ); - it('should match snapshot, can post; preview enabled', () => { const wrapper = shallow(advancedCreatePost({canPost: true})); diff --git a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx index 23bba031da..25c494ae64 100644 --- a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx +++ b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx @@ -3,7 +3,6 @@ /* eslint-disable max-lines */ -import {isNil} from 'lodash'; import React from 'react'; import type {Channel, ChannelMemberCountsByGroup} from '@mattermost/types/channels'; @@ -44,19 +43,9 @@ import Constants, { AdvancedTextEditor as AdvancedTextEditorConst, } from 'utils/constants'; import type EmojiMap from 'utils/emoji_map'; -import {execCommandInsertText} from 'utils/exec_commands'; import * as Keyboard from 'utils/keyboard'; import {applyMarkdown} from 'utils/markdown/apply_markdown'; import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; -import { - getHtmlTable, - hasHtmlLink, - formatMarkdownMessage, - formatGithubCodePaste, - isGitHubCodeBlock, - formatMarkdownLinkMessage, - isTextUrl, -} from 'utils/paste'; import { containsAtChannel, specialMentionsInText, @@ -67,7 +56,6 @@ import { groupsMentionedInText, mentionsMinusSpecialMentionsInText, hasRequestedPersistentNotifications, - isWithinCodeBlock, } from 'utils/post_utils'; import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; @@ -325,7 +313,6 @@ class AdvancedCreatePost extends React.PureComponent { actions.setShowPreview(false); actions.clearDraftUploads(); this.focusTextbox(); - document.addEventListener('paste', this.pasteHandler); document.addEventListener('keydown', this.documentKeyHandler); window.addEventListener('beforeunload', this.unloadHandler); this.setOrientationListeners(); @@ -357,7 +344,6 @@ class AdvancedCreatePost extends React.PureComponent { } componentWillUnmount() { - document.removeEventListener('paste', this.pasteHandler); document.removeEventListener('keydown', this.documentKeyHandler); window.removeEventListener('beforeunload', this.unloadHandler); this.removeOrientationListeners(); @@ -928,43 +914,6 @@ class AdvancedCreatePost extends React.PureComponent { this.draftsForChannel[channelId] = draft; }; - pasteHandler = (event: ClipboardEvent) => { - const {clipboardData, target} = event; - - if (!clipboardData || !clipboardData.items || !target || ((target as TextboxElement)?.id !== 'post_textbox')) { - return; - } - - const {selectionStart, selectionEnd} = target as TextboxElement; - - const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd; - const hasTextUrl = isTextUrl(clipboardData); - const hasHTMLLinks = !this.isNonFormattedPaste && hasHtmlLink(clipboardData); - const htmlTable = getHtmlTable(clipboardData); - const shouldApplyLinkMarkdown = hasSelection && hasTextUrl; - const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className); - - if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) { - return; - } - - event.preventDefault(); - - const message = this.state.message; - - // execCommand's insertText' triggers a 'change' event, hence we need not set respective state explicitly. - if (shouldApplyLinkMarkdown) { - const formattedLink = formatMarkdownLinkMessage({selectionStart, selectionEnd, message, clipboardData}); - execCommandInsertText(formattedLink); - } else if (shouldApplyGithubCodeBlock) { - const {formattedCodeBlock} = formatGithubCodePaste({selectionStart, selectionEnd, message, clipboardData}); - execCommandInsertText(formattedCodeBlock); - } else { - const {formattedMarkdown} = formatMarkdownMessage(clipboardData, message, this.state.caretPosition); - execCommandInsertText(formattedMarkdown); - } - }; - handleFileUploadChange = () => { this.focusTextbox(); }; @@ -1128,11 +1077,9 @@ class AdvancedCreatePost extends React.PureComponent { fillMessageFromHistory() { const lastMessage = this.props.messageInHistoryItem; - if (lastMessage) { - this.setState({ - message: lastMessage, - }); - } + this.setState({ + message: lastMessage || '', + }); } handleMouseUpKeyUp = (e: React.MouseEvent | React.KeyboardEvent) => { @@ -1141,194 +1088,6 @@ class AdvancedCreatePost extends React.PureComponent { }); }; - handleKeyDown = (e: React.KeyboardEvent) => { - const messageIsEmpty = this.state.message.length === 0; - const draftMessageIsEmpty = this.props.draft.message.length === 0; - const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey; - - // fix for FF not capturing the paste without formatting event when using ctrl|cmd + shift + v - if (e.key === KeyCodes.V[0] && ctrlOrMetaKeyPressed) { - if (e.shiftKey) { - this.isNonFormattedPaste = true; - this.timeoutId = window.setTimeout(() => { - this.isNonFormattedPaste = false; - }, 250); - } - } - - const ctrlEnterKeyCombo = (this.props.ctrlSend || this.props.codeBlockOnCtrlEnter) && - Keyboard.isKeyPressed(e, KeyCodes.ENTER) && - ctrlOrMetaKeyPressed; - - const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey; - const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey; - const shiftAltCombo = !Keyboard.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey; - - // listen for line break key combo and insert new line character - if (Utils.isUnhandledLineBreakKeyCombo(e)) { - this.setState({message: Utils.insertLineBreakFromKeyEvent(e)}); - return; - } - - if (ctrlEnterKeyCombo) { - this.setShowPreview(false); - this.postMsgKeyPress(e); - return; - } - - const {message, caretPosition} = this.state; - const caretIsWithinCodeBlock = isWithinCodeBlock(message, caretPosition); - - if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) { - this.textboxRef.current?.blur(); - } - - if ( - !e.ctrlKey && - !e.metaKey && - !e.altKey && - !e.shiftKey && - Keyboard.isKeyPressed(e, KeyCodes.UP) && - message === '' - ) { - e.preventDefault(); - if (this.textboxRef.current) { - this.textboxRef.current.blur(); - } - - this.editLastPost(e); - } - - const { - selectionStart, - selectionEnd, - value, - } = e.target as TextboxElement; - - if (ctrlKeyCombo && !caretIsWithinCodeBlock) { - if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.UP)) { - e.stopPropagation(); - e.preventDefault(); - this.loadPrevMessage(e); - } else if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.DOWN)) { - e.stopPropagation(); - e.preventDefault(); - this.loadNextMessage(e); - } else if (Keyboard.isKeyPressed(e, KeyCodes.B)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'bold', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.I)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'italic', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'link', - selectionStart, - selectionEnd, - message: value, - }); - } - } else if (ctrlAltCombo && !caretIsWithinCodeBlock) { - if (Keyboard.isKeyPressed(e, KeyCodes.K)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'link', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.C)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'code', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.E)) { - e.stopPropagation(); - e.preventDefault(); - this.toggleEmojiPicker(); - } else if (Keyboard.isKeyPressed(e, KeyCodes.T)) { - e.stopPropagation(); - e.preventDefault(); - this.toggleAdvanceTextEditor(); - } else if (Keyboard.isKeyPressed(e, KeyCodes.P) && message.length) { - e.stopPropagation(); - e.preventDefault(); - this.setShowPreview(!this.props.shouldShowPreview); - } - } else if (shiftAltCombo && !caretIsWithinCodeBlock) { - if (Keyboard.isKeyPressed(e, KeyCodes.X)) { - e.stopPropagation(); - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'strike', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.SEVEN)) { - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'ol', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.EIGHT)) { - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'ul', - selectionStart, - selectionEnd, - message: value, - }); - } else if (Keyboard.isKeyPressed(e, KeyCodes.NINE)) { - e.preventDefault(); - this.applyMarkdown({ - markdownMode: 'quote', - selectionStart, - selectionEnd, - message: value, - }); - } - } - const upKeyOnly = !ctrlOrMetaKeyPressed && !e.altKey && !e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP); - const shiftUpKeyCombo = !ctrlOrMetaKeyPressed && !e.altKey && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP); - const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey; - - if (upKeyOnly && messageIsEmpty) { - this.editLastPost(e); - } else if (shiftUpKeyCombo && messageIsEmpty) { - this.replyToLastPost(e); - } else if (ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.E)) { - e.stopPropagation(); - e.preventDefault(); - this.toggleEmojiPicker(); - } else if (((UserAgent.isMac() && ctrlShiftCombo) || (!UserAgent.isMac() && ctrlAltCombo)) && Keyboard.isKeyPressed(e, KeyCodes.P) && this.state.message.length) { - this.setShowPreview(!this.props.shouldShowPreview); - } else if (ctrlAltCombo && Keyboard.isKeyPressed(e, KeyCodes.T)) { - this.toggleAdvanceTextEditor(); - } - }; - editLastPost = (e: React.KeyboardEvent) => { e.preventDefault(); @@ -1580,6 +1339,14 @@ class AdvancedCreatePost extends React.PureComponent { return Object.values(this.getSpecialMentions()).includes(true); }; + onMessageChange = (message: string, callback?: (() => void) | undefined) => { + this.handleDraftChange({ + ...this.props.draft, + message, + }); + this.setState({message}, callback); + }; + render() { const {draft, canPost} = this.props; @@ -1666,7 +1433,6 @@ class AdvancedCreatePost extends React.PureComponent { handlePostError={this.handlePostError} emitTypingEvent={this.emitTypingEvent} handleMouseUpKeyUp={this.handleMouseUpKeyUp} - handleKeyDown={this.handleKeyDown} postMsgKeyPress={this.postMsgKeyPress} handleChange={this.handleChange} toggleEmojiPicker={this.toggleEmojiPicker} @@ -1707,6 +1473,14 @@ class AdvancedCreatePost extends React.PureComponent { ), ...(pluginItems || []), ].filter(Boolean)} + codeBlockOnCtrlEnter={this.props.codeBlockOnCtrlEnter} + ctrlSend={this.props.ctrlSend} + loadNextMessage={this.loadNextMessage} + loadPrevMessage={this.loadPrevMessage} + onEditLatestPost={this.editLastPost} + onMessageChange={this.onMessageChange} + replyToLastPost={this.replyToLastPost} + caretPosition={this.state.caretPosition} /> ); diff --git a/webapp/channels/src/components/advanced_create_post/index.ts b/webapp/channels/src/components/advanced_create_post/index.ts index 47904c6101..66547c9ed4 100644 --- a/webapp/channels/src/components/advanced_create_post/index.ts +++ b/webapp/channels/src/components/advanced_create_post/index.ts @@ -44,7 +44,7 @@ import {searchAssociatedGroupsForReference} from 'actions/views/group'; import {openModal} from 'actions/views/modals'; import {selectPostFromRightHandSideSearchByPostId} from 'actions/views/rhs'; import {setShowPreviewOnCreatePost} from 'actions/views/textbox'; -import {getEmojiMap, getShortcutReactToLastPostEmittedFrom} from 'selectors/emojis'; +import {getEmojiMap} from 'selectors/emojis'; import {getCurrentLocale} from 'selectors/i18n'; import {makeGetChannelDraft, getIsRhsExpanded, getIsRhsOpen} from 'selectors/rhs'; import {connectionErrorCount} from 'selectors/views/system'; @@ -81,7 +81,6 @@ function makeMapStateToProps() { const userIsOutOfOffice = getStatusForUserId(state, currentUserId) === UserStatuses.OUT_OF_OFFICE; const badConnection = connectionErrorCount(state) > 1; const isTimezoneEnabled = config.ExperimentalTimezone === 'true'; - const shortcutReactToLastPostEmittedFrom = getShortcutReactToLastPostEmittedFrom(state); const canPost = haveICurrentChannelPermission(state, Permissions.CREATE_POST); const useChannelMentions = haveICurrentChannelPermission(state, Permissions.USE_CHANNEL_MENTIONS); const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true'; @@ -128,7 +127,6 @@ function makeMapStateToProps() { emojiMap: getEmojiMap(state), badConnection, isTimezoneEnabled, - shortcutReactToLastPostEmittedFrom, canPost, useChannelMentions, shouldShowPreview: showPreviewOnCreatePost(state), 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 new file mode 100644 index 0000000000..10e5fcf8f7 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx @@ -0,0 +1,292 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react'; +import React from 'react'; + +import type {Channel} from '@mattermost/types/channels'; + +import Permissions from 'mattermost-redux/constants/permissions'; + +import type {FileUpload} from 'components/file_upload/file_upload'; +import type Textbox from 'components/textbox/textbox'; + +import WebSocketClient from 'client/web_websocket_client'; +import mergeObjects from 'packages/mattermost-redux/test/merge_objects'; +import {renderWithFullContext, userEvent} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; +import {WebSocketContext} from 'utils/use_websocket'; + +import type {PostDraft} from 'types/store/draft'; + +import AdavancedTextEditor from './advanced_text_editor'; + +global.ResizeObserver = require('resize-observer-polyfill'); + +const currentUserId = 'current_user_id'; +const channelId = 'current_channel_id'; + +const initialState = { + entities: { + general: { + config: { + EnableConfirmNotificationsToChannel: 'false', + EnableCustomGroups: 'false', + PostPriority: 'false', + ExperimentalTimezone: 'false', + EnableCustomEmoji: 'false', + AllowSyncedDrafts: 'false', + }, + license: { + IsLicensed: 'false', + LDAPGroups: 'false', + }, + }, + channels: { + channels: { + current_channel_id: TestHelper.getChannelMock({id: 'current_channel_id', team_id: 'current_team_id'}), + }, + stats: { + current_channel_id: { + member_count: 1, + }, + }, + roles: { + current_channel_id: new Set(['channel_roles']), + }, + }, + teams: { + currentTeamId: 'current_team_id', + teams: { + current_team_id: TestHelper.getTeamMock({id: 'current_team_id'}), + }, + myMembers: { + current_team_id: TestHelper.getTeamMembershipMock({roles: 'team_roles'}), + }, + }, + users: { + currentUserId: 'current_user_id', + profiles: { + current_user_id: TestHelper.getUserMock({id: 'current_user_id', roles: 'user_roles'}), + }, + statuses: { + current_user_id: 'online', + }, + }, + }, + websocket: { + connectionId: 'connection_id', + }, +}; + +const emptyDraft: PostDraft = { + message: '', + uploadsInProgress: [], + fileInfos: [], + channelId, + rootId: '', + createAt: 0, + updateAt: 0, +}; + +const baseProps = { + location: 'CENTER', + message: '', + showEmojiPicker: false, + uploadsProgressPercent: {}, + currentChannel: initialState.entities.channels.channels.current_channel_id as Channel, + channelId, + postId: '', + errorClass: null, + serverError: null, + postError: null, + isFormattingBarHidden: false, + draft: emptyDraft, + badConnection: false, + handleSubmit: jest.fn(), + removePreview: jest.fn(), + showSendTutorialTip: false, + setShowPreview: jest.fn(), + shouldShowPreview: false, + maxPostSize: 100, + canPost: true, + applyMarkdown: jest.fn(), + useChannelMentions: false, + currentChannelTeammateUsername: '', + currentUserId, + canUploadFiles: true, + enableEmojiPicker: true, + enableGifPicker: true, + handleBlur: jest.fn(), + handlePostError: jest.fn(), + emitTypingEvent: jest.fn(), + handleMouseUpKeyUp: jest.fn(), + postMsgKeyPress: jest.fn(), + handleChange: jest.fn(), + toggleEmojiPicker: jest.fn(), + handleGifClick: jest.fn(), + handleEmojiClick: jest.fn(), + hideEmojiPicker: jest.fn(), + toggleAdvanceTextEditor: jest.fn(), + handleUploadProgress: jest.fn(), + handleUploadError: jest.fn(), + handleFileUploadComplete: jest.fn(), + handleUploadStart: jest.fn(), + handleFileUploadChange: jest.fn(), + getFileUploadTarget: jest.fn(), + fileUploadRef: React.createRef(), + prefillMessage: jest.fn(), + textboxRef: React.createRef(), + isThreadView: false, + ctrlSend: true, + codeBlockOnCtrlEnter: true, + onMessageChange: jest.fn(), + onEditLatestPost: jest.fn(), + loadPrevMessage: jest.fn(), + loadNextMessage: jest.fn(), + replyToLastPost: jest.fn(), + caretPosition: 0, +}; + +describe('components/avanced_text_editor/advanced_text_editor', () => { + describe('keyDown behavior', () => { + it('Enter should call postMsgKeyPress', () => { + const postMsgKeyPress = jest.fn(); + renderWithFullContext( + + + , mergeObjects(initialState, { + entities: { + roles: { + roles: { + user_roles: {permissions: [Permissions.CREATE_POST]}, + }, + }, + }, + })); + + userEvent.type(screen.getByTestId('post_textbox'), '{enter}'); + expect(postMsgKeyPress).toHaveBeenCalledTimes(1); + }); + + it('Ctrl+up should call loadPrevMessage', () => { + const loadPrevMessage = jest.fn(); + renderWithFullContext( + + + , mergeObjects(initialState, { + entities: { + roles: { + roles: { + user_roles: {permissions: [Permissions.CREATE_POST]}, + }, + }, + }, + })); + userEvent.type(screen.getByTestId('post_textbox'), '{ctrl}{arrowup}'); + expect(loadPrevMessage).toHaveBeenCalledTimes(1); + }); + + it('up should call onEditLatestPost', () => { + const onEditLatestPost = jest.fn(); + renderWithFullContext( + + + , mergeObjects(initialState, { + entities: { + roles: { + roles: { + user_roles: {permissions: [Permissions.CREATE_POST]}, + }, + }, + }, + })); + userEvent.type(screen.getByTestId('post_textbox'), '{arrowup}'); + expect(onEditLatestPost).toHaveBeenCalledTimes(1); + }); + + it('ESC should blur the input', () => { + renderWithFullContext( + + + , mergeObjects(initialState, { + entities: { + roles: { + roles: { + user_roles: {permissions: [Permissions.CREATE_POST]}, + }, + }, + }, + })); + const textbox = screen.getByTestId('post_textbox'); + userEvent.type(textbox, 'something{esc}'); + expect(textbox).not.toHaveFocus(); + }); + + describe('markdown', () => { + const ttcc = [ + { + input: '{ctrl}b', + markdownMode: 'bold', + }, + { + input: '{ctrl}i', + markdownMode: 'italic', + }, + { + input: '{ctrl}k', + markdownMode: 'link', + }, + { + input: '{ctrl}{alt}k', + markdownMode: 'link', + }, + ]; + for (const tc of ttcc) { + it(`component adds ${tc.markdownMode} markdown`, () => { + const applyMarkdown = jest.fn(); + const message = 'Some markdown text'; + const selectionStart = 5; + const selectionEnd = 10; + + renderWithFullContext( + + + , mergeObjects(initialState, { + entities: { + roles: { + roles: { + user_roles: {permissions: [Permissions.CREATE_POST]}, + }, + }, + }, + })); + const textbox = screen.getByTestId('post_textbox'); + userEvent.type(textbox, tc.input, {initialSelectionStart: selectionStart, initialSelectionEnd: selectionEnd}); + expect(applyMarkdown).toHaveBeenCalledWith({ + markdownMode: tc.markdownMode, + selectionStart, + selectionEnd, + message, + }); + }); + } + }); + }); +}); 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 34d226038b..fbd3f4c344 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 @@ -4,6 +4,7 @@ import classNames from 'classnames'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; import {EmoticonHappyOutlineIcon} from '@mattermost/compass-icons/components'; import type {Channel} from '@mattermost/types/channels'; @@ -11,6 +12,8 @@ import type {Emoji} from '@mattermost/types/emojis'; import type {ServerError} from '@mattermost/types/errors'; import type {FileInfo} from '@mattermost/types/files'; +import {emitShortcutReactToLastPostFrom} from 'actions/post_actions'; + import AutoHeightSwitcher from 'components/common/auto_height_switcher'; import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; import FilePreview from 'components/file_preview'; @@ -29,7 +32,11 @@ import Tooltip from 'components/tooltip'; import {SendMessageTour} from 'components/tours/onboarding_tour'; import Constants, {Locations} from 'utils/constants'; +import * as Keyboard from 'utils/keyboard'; import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; +import {pasteHandler} from 'utils/paste'; +import {isWithinCodeBlock} from 'utils/post_utils'; +import * as UserAgent from 'utils/user_agent'; import * as Utils from 'utils/utils'; import type {PostDraft} from 'types/store/draft'; @@ -44,6 +51,8 @@ import ToggleFormattingBar from './toggle_formatting_bar'; import './advanced_text_editor.scss'; +const KeyCodes = Constants.KeyCodes; + type Props = { /** @@ -78,7 +87,6 @@ type Props = { handlePostError: (postError: React.ReactNode) => void; emitTypingEvent: () => void; handleMouseUpKeyUp: (e: React.MouseEvent | React.KeyboardEvent) => void; - handleKeyDown: (e: React.KeyboardEvent) => void; postMsgKeyPress: (e: React.KeyboardEvent) => void; handleChange: (e: React.ChangeEvent) => void; toggleEmojiPicker: () => void; @@ -101,6 +109,14 @@ type Props = { additionalControls?: React.ReactNodeArray; labels?: React.ReactNode; disableSend?: boolean; + ctrlSend?: boolean; + codeBlockOnCtrlEnter?: boolean; + onMessageChange: (message: string, callback?: () => void) => void; + onEditLatestPost: (e: React.KeyboardEvent) => void; + loadPrevMessage: (e: React.KeyboardEvent) => void; + loadNextMessage: (e: React.KeyboardEvent) => void; + replyToLastPost?: (e: React.KeyboardEvent) => void; + caretPosition: number; placeholder?: string; } @@ -136,7 +152,6 @@ const AdvanceTextEditor = ({ handlePostError, emitTypingEvent, handleMouseUpKeyUp, - handleKeyDown, postMsgKeyPress, handleChange, toggleEmojiPicker, @@ -157,6 +172,14 @@ const AdvanceTextEditor = ({ additionalControls, labels, disableSend = false, + ctrlSend, + codeBlockOnCtrlEnter, + onMessageChange, + onEditLatestPost, + loadPrevMessage, + loadNextMessage, + replyToLastPost, + caretPosition, placeholder, }: Props) => { const readOnlyChannel = !canPost; @@ -173,6 +196,11 @@ const AdvanceTextEditor = ({ const [showFormattingSpacer, setShowFormattingSpacer] = useState(shouldShowPreview); const [keepEditorInFocus, setKeepEditorInFocus] = useState(false); + const isNonFormattedPaste = useRef(false); + const timeoutId = useRef(); + + const dispatch = useDispatch(); + const input = textboxRef.current?.getInputBox(); const handleHeightChange = useCallback((height: number, maxHeight: number) => { @@ -192,6 +220,8 @@ const AdvanceTextEditor = ({ setKeepEditorInFocus(true); }, []); + const isRHS = location === Locations.RHS_COMMENT; + let attachmentPreview = null; if (!readOnlyChannel && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0)) { attachmentPreview = ( @@ -370,6 +400,205 @@ const AdvanceTextEditor = ({ } }, [message, input]); + const handleKeyDown = (e: React.KeyboardEvent) => { + const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey; + const ctrlEnterKeyCombo = (ctrlSend || codeBlockOnCtrlEnter) && + Keyboard.isKeyPressed(e, KeyCodes.ENTER) && + ctrlOrMetaKeyPressed; + + const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey; + const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey; + const shiftAltCombo = !Keyboard.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey; + const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey; + + // fix for FF not capturing the paste without formatting event when using ctrl|cmd + shift + v + if (e.key === KeyCodes.V[0] && ctrlOrMetaKeyPressed) { + if (e.shiftKey) { + isNonFormattedPaste.current = true; + timeoutId.current = window.setTimeout(() => { + isNonFormattedPaste.current = false; + }, 250); + } + } + + // listen for line break key combo and insert new line character + if (Utils.isUnhandledLineBreakKeyCombo(e)) { + onMessageChange(Utils.insertLineBreakFromKeyEvent(e)); + return; + } + + if (ctrlEnterKeyCombo) { + setShowPreview(false); + postMsgKeyPress(e); + return; + } + + if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) { + textboxRef.current?.blur(); + } + + const upKeyOnly = !ctrlOrMetaKeyPressed && !e.altKey && !e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP); + const messageIsEmpty = message.length === 0; + const draftMessageIsEmpty = draft.message.length === 0; + const caretIsWithinCodeBlock = caretPosition && isWithinCodeBlock(message, caretPosition); + + if (upKeyOnly && messageIsEmpty) { + e.preventDefault(); + if (textboxRef.current) { + textboxRef.current.blur(); + } + + onEditLatestPost(e); + } + + const { + selectionStart, + selectionEnd, + value, + } = e.target as TextboxElement; + + if (ctrlKeyCombo && !caretIsWithinCodeBlock) { + if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.UP)) { + e.stopPropagation(); + e.preventDefault(); + loadPrevMessage(e); + } else if (draftMessageIsEmpty && Keyboard.isKeyPressed(e, KeyCodes.DOWN)) { + e.stopPropagation(); + e.preventDefault(); + loadNextMessage(e); + } else if (Keyboard.isKeyPressed(e, KeyCodes.B)) { + e.stopPropagation(); + e.preventDefault(); + applyMarkdown({ + markdownMode: 'bold', + selectionStart, + selectionEnd, + message: value, + }); + } else if (Keyboard.isKeyPressed(e, KeyCodes.I)) { + e.stopPropagation(); + e.preventDefault(); + applyMarkdown({ + markdownMode: 'italic', + selectionStart, + selectionEnd, + message: value, + }); + } else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) { + e.stopPropagation(); + e.preventDefault(); + applyMarkdown({ + markdownMode: 'link', + selectionStart, + selectionEnd, + message: value, + }); + } + } else if (ctrlAltCombo && !caretIsWithinCodeBlock) { + if (Keyboard.isKeyPressed(e, KeyCodes.K)) { + e.stopPropagation(); + e.preventDefault(); + applyMarkdown({ + markdownMode: 'link', + selectionStart, + selectionEnd, + message: value, + }); + } else if (Keyboard.isKeyPressed(e, KeyCodes.C)) { + e.stopPropagation(); + e.preventDefault(); + applyMarkdown({ + markdownMode: 'code', + selectionStart, + selectionEnd, + message: value, + }); + } else if (Keyboard.isKeyPressed(e, KeyCodes.E)) { + e.stopPropagation(); + e.preventDefault(); + toggleEmojiPicker(); + } else if (Keyboard.isKeyPressed(e, KeyCodes.T)) { + e.stopPropagation(); + e.preventDefault(); + toggleAdvanceTextEditor(); + } else if (Keyboard.isKeyPressed(e, KeyCodes.P) && message.length && !UserAgent.isMac()) { + e.stopPropagation(); + e.preventDefault(); + setShowPreview(!shouldShowPreview); + } + } else if (shiftAltCombo && !caretIsWithinCodeBlock) { + if (Keyboard.isKeyPressed(e, KeyCodes.X)) { + e.stopPropagation(); + e.preventDefault(); + applyMarkdown({ + markdownMode: 'strike', + selectionStart, + selectionEnd, + message: value, + }); + } else if (Keyboard.isKeyPressed(e, KeyCodes.SEVEN)) { + e.preventDefault(); + applyMarkdown({ + markdownMode: 'ol', + selectionStart, + selectionEnd, + message: value, + }); + } else if (Keyboard.isKeyPressed(e, KeyCodes.EIGHT)) { + e.preventDefault(); + applyMarkdown({ + markdownMode: 'ul', + selectionStart, + selectionEnd, + message: value, + }); + } else if (Keyboard.isKeyPressed(e, KeyCodes.NINE)) { + e.preventDefault(); + applyMarkdown({ + markdownMode: 'quote', + selectionStart, + selectionEnd, + message: value, + }); + } + } else if (ctrlShiftCombo && !caretIsWithinCodeBlock) { + if (Keyboard.isKeyPressed(e, KeyCodes.P) && message.length && UserAgent.isMac()) { + e.stopPropagation(); + e.preventDefault(); + setShowPreview(!shouldShowPreview); + } else if (Keyboard.isKeyPressed(e, KeyCodes.E)) { + e.stopPropagation(); + e.preventDefault(); + toggleEmojiPicker(); + } + } + + if (isRHS) { + const lastMessageReactionKeyCombo = ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH); + if (lastMessageReactionKeyCombo) { + e.stopPropagation(); + e.preventDefault(); + dispatch(emitShortcutReactToLastPostFrom(Locations.RHS_ROOT)); + } + } else { + const shiftUpKeyCombo = !ctrlOrMetaKeyPressed && !e.altKey && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP); + if (shiftUpKeyCombo && messageIsEmpty) { + replyToLastPost?.(e); + } + } + }; + + useEffect(() => { + function onPaste(event: ClipboardEvent) { + pasteHandler(event, location, message, isNonFormattedPaste.current, caretPosition); + } + + document.addEventListener('paste', onPaste); + return () => { + document.removeEventListener('paste', onPaste); + }; + }, [location, message, caretPosition]); + useEffect(() => { if (!message) { handleWidthChange(0); diff --git a/webapp/channels/src/components/edit_post/edit_post.tsx b/webapp/channels/src/components/edit_post/edit_post.tsx index ef9beedb66..1a3fc0cab1 100644 --- a/webapp/channels/src/components/edit_post/edit_post.tsx +++ b/webapp/channels/src/components/edit_post/edit_post.tsx @@ -326,6 +326,8 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, Keyboard.isKeyPressed(e, KeyCodes.ENTER) && ctrlOrMetaKeyPressed; const markdownLinkKey = Keyboard.isKeyPressed(e, KeyCodes.K); + const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey; + const lastMessageReactionKeyCombo = ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH); // listen for line break key combo and insert new line character if (Utils.isUnhandledLineBreakKeyCombo(e)) { @@ -356,6 +358,10 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, selectionEnd: e.currentTarget.selectionEnd, message: e.currentTarget.value, }); + } else if (lastMessageReactionKeyCombo) { + // Stop document from handling the hotkey and opening the reaction + e.stopPropagation(); + e.preventDefault(); } }; diff --git a/webapp/channels/src/components/post/post_options.tsx b/webapp/channels/src/components/post/post_options.tsx index 65200148f2..6649a2db72 100644 --- a/webapp/channels/src/components/post/post_options.tsx +++ b/webapp/channels/src/components/post/post_options.tsx @@ -66,8 +66,9 @@ const PostOptions = (props: Props): JSX.Element => { const [showActionsMenu, setShowActionsMenu] = useState(false); useEffect(() => { + const locationToUse = props.location === 'RHS_COMMENT' ? Locations.RHS_ROOT : props.location; if (props.isLastPost && - (props.shortcutReactToLastPostEmittedFrom === props.location) && + (props.shortcutReactToLastPostEmittedFrom === locationToUse) && props.isPostHeaderVisible) { toggleEmojiPicker(); props.actions.emitShortcutReactToLastPostFrom(Locations.NO_WHERE); diff --git a/webapp/channels/src/tests/helpers/markdown_hotkey_helpers.js b/webapp/channels/src/tests/helpers/markdown_hotkey_helpers.js deleted file mode 100644 index 1d4316cccf..0000000000 --- a/webapp/channels/src/tests/helpers/markdown_hotkey_helpers.js +++ /dev/null @@ -1,311 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/** - * @module makrdownHotkeyHelpers - * consolidate testing of similar behavior across components - */ - -import {shallow} from 'enzyme'; - -import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; -import Constants from 'utils/constants'; - -/** - * @param {string} [input] text input - * @param {int} [start] selection's start index - * @param {int} [end] selection's end index - * @param {object} [keycode] Keycode constant associated with key press - * @return {object} keydown event object - */ - -export function makeSelectionEvent(input, start, end) { - return { - preventDefault: jest.fn(), - target: { - selectionStart: start, - selectionEnd: end, - value: input, - }, - }; -} - -function makeMarkdownHotkeyEvent(input, start, end, keycode, altKey = false, targetId = 'root') { - return { - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - ctrlKey: true, - altKey, - key: keycode[0], - keyCode: keycode[1], - target: { - selectionStart: start, - selectionEnd: end, - value: input, - id: targetId, - }, - }; -} - -/** - * @param {string} [input] text input - * @param {int} [start] selection's start index - * @param {int} [end] selection's end index - * @return {object} keydown event object - */ -export function makeBoldHotkeyEvent(input, start, end) { - return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.B); -} - -/** - * @param {string} [input] text input - * @param {int} [start] selection's start index - * @param {int} [end] selection's end index - * @return {object} keydown event object - */ -export function makeItalicHotkeyEvent(input, start, end) { - return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.I); -} - -function makeLinkHotKeyWithoutAltKeyEvent(input, start, end, targetId) { - return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.K, false, targetId); -} - -function makeLinkHotKeyEvent(input, start, end) { - return makeMarkdownHotkeyEvent(input, start, end, Constants.KeyCodes.K, true); -} - -/** - * helper to test markdown hotkeys on key down behavior common to many textarea inputs - * @param {function} generateInstance - single paramater "value" of the initial value - * @param {function} initRefs - React Component instance and setSelectionRange function - * @param {function} getValue - single parameter for the React Component instance - * NOTE: runs Jest tests - */ -export function testComponentForMarkdownHotkeys(generateInstance, initRefs, find, getValue, intlInjected = true, targetId) { - const shallowRender = intlInjected ? shallowWithIntl : shallow; - test('component adds bold markdown', () => { - // "Fafda" is selected with ctrl + B hotkey - const input = 'Jalebi Fafda & Sambharo'; - const e = makeBoldHotkeyEvent(input, 7, 12); - - const instance = shallowRender(generateInstance(input)); - - const setSelectionRange = jest.fn(); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi **Fafda** & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - }); - - test('component adds italic markdown', () => { - // "Fafda" is selected with ctrl + I hotkey - const input = 'Jalebi Fafda & Sambharo'; - const e = makeItalicHotkeyEvent(input, 7, 12); - - const instance = shallowRender(generateInstance(input)); - - const setSelectionRange = jest.fn(); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi *Fafda* & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - }); - - test('component starts bold markdown', () => { - // Nothing is selected, caret is just before "Fafde" with ctrl + B - const input = 'Jalebi Fafda & Sambharo'; - const e = makeBoldHotkeyEvent(input, 7, 7); - - const instance = shallowRender(generateInstance(input)); - - const setSelectionRange = jest.fn(); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi ****Fafda & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - }); - - test('component starts italic markdown', () => { - // Nothing is selected, caret is just before "Fafde" with ctrl + B - const input = 'Jalebi Fafda & Sambharo'; - const e = makeItalicHotkeyEvent(input, 7, 7); - - const instance = shallowRender(generateInstance(input)); - - const setSelectionRange = jest.fn(); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi **Fafda & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - }); - - test('component adds link markdown with hitting Ctrl + K when something is selected and event target has specific targetId', () => { - // "Fafda" is selected with ctrl + K hotkey - const input = 'Jalebi Fafda & Sambharo'; - const e = makeLinkHotKeyWithoutAltKeyEvent(input, 7, 12, targetId); - - const instance = shallowRender(generateInstance(input)); - - let selectionStart = -1; - let selectionEnd = -1; - const setSelectionRange = jest.fn((start, end) => { - selectionStart = start; - selectionEnd = end; - }); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - expect(selectionStart).toBe(15); - expect(selectionEnd).toBe(18); - }); - - test('component adds link markdown when something is selected', () => { - // "Fafda" is selected with ctrl + alt + K hotkey - const input = 'Jalebi Fafda & Sambharo'; - const e = makeLinkHotKeyEvent(input, 7, 12); - - const instance = shallowRender(generateInstance(input)); - - let selectionStart = -1; - let selectionEnd = -1; - const setSelectionRange = jest.fn((start, end) => { - selectionStart = start; - selectionEnd = end; - }); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - expect(selectionStart).toBe(15); - expect(selectionEnd).toBe(18); - }); - - test('component adds link markdown when cursor is before a word', () => { - // Cursor is before "Fafda" with ctrl + alt + K hotkey - const input = 'Jalebi Fafda & Sambharo'; - const e = makeLinkHotKeyEvent(input, 7, 7); - - const instance = shallowRender(generateInstance(input)); - - let selectionStart = -1; - let selectionEnd = -1; - const setSelectionRange = jest.fn((start, end) => { - selectionStart = start; - selectionEnd = end; - }); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - expect(selectionStart).toBe(15); - expect(selectionEnd).toBe(18); - }); - - test('component adds link markdown when cursor is in a word', () => { - // Cursor is after "Fafda" with ctrl + alt + K hotkey - const input = 'Jalebi Fafda & Sambharo'; - const e = makeLinkHotKeyEvent(input, 10, 10); - - const instance = shallowRender(generateInstance(input)); - - let selectionStart = -1; - let selectionEnd = -1; - const setSelectionRange = jest.fn((start, end) => { - selectionStart = start; - selectionEnd = end; - }); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - expect(selectionStart).toBe(15); - expect(selectionEnd).toBe(18); - }); - - test('component adds link markdown when cursor is after a word', () => { - // Cursor is after "Fafda" with ctrl + alt + K hotkey - const input = 'Jalebi Fafda & Sambharo'; - const e = makeLinkHotKeyEvent(input, 12, 12); - - const instance = shallowRender(generateInstance(input)); - - let selectionStart = -1; - let selectionEnd = -1; - const setSelectionRange = jest.fn((start, end) => { - selectionStart = start; - selectionEnd = end; - }); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi [Fafda](url) & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - expect(selectionStart).toBe(15); - expect(selectionEnd).toBe(18); - }); - - test('component adds link markdown when cursor is at the end of line', () => { - // Cursor is after "Sambharo" with ctrl + alt + K hotkey - const input = 'Jalebi Fafda & Sambharo'; - const e = makeLinkHotKeyEvent(input, 23, 23); - - const instance = shallowRender(generateInstance(input)); - - let selectionStart = -1; - let selectionEnd = -1; - const setSelectionRange = jest.fn((start, end) => { - selectionStart = start; - selectionEnd = end; - }); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi Fafda & Sambharo [](url)'); - expect(setSelectionRange).toHaveBeenCalled(); - expect(selectionStart).toBe(25); - expect(selectionEnd).toBe(25); - }); - - test('component removes link markdown', () => { - // "Fafda" is selected with ctrl + alt + K hotkey - const input = 'Jalebi [Fafda](url) & Sambharo'; - const e = makeLinkHotKeyEvent(input, 8, 13); - - const instance = shallowRender(generateInstance(input)); - - let selectionStart = -1; - let selectionEnd = -1; - const setSelectionRange = jest.fn((start, end) => { - selectionStart = start; - selectionEnd = end; - }); - initRefs(instance, setSelectionRange); - - find(instance).props().onKeyDown?.(e); - find(instance).props().handleKeyDown?.(e); - expect(getValue(instance)).toBe('Jalebi Fafda & Sambharo'); - expect(setSelectionRange).toHaveBeenCalled(); - expect(selectionStart).toBe(7); - expect(selectionEnd).toBe(12); - }); -} diff --git a/webapp/channels/src/tests/react_testing_utils.tsx b/webapp/channels/src/tests/react_testing_utils.tsx index 6566f2bdb4..c1f75bd08b 100644 --- a/webapp/channels/src/tests/react_testing_utils.tsx +++ b/webapp/channels/src/tests/react_testing_utils.tsx @@ -13,11 +13,12 @@ import type {DeepPartial} from '@mattermost/types/utilities'; import configureStore from 'store'; +import WebSocketClient from 'client/web_websocket_client'; import mergeObjects from 'packages/mattermost-redux/test/merge_objects'; import mockStore from 'tests/test_store'; +import {WebSocketContext} from 'utils/use_websocket'; import type {GlobalState} from 'types/store'; - export * from '@testing-library/react'; export {userEvent}; @@ -63,7 +64,9 @@ export const renderWithFullContext = (component: React.ReactNode | React.ReactNo - {renderState.component} + + {renderState.component} + diff --git a/webapp/channels/src/utils/paste.test.tsx b/webapp/channels/src/utils/paste.test.tsx index 85ce4b6a5f..48479cea9e 100644 --- a/webapp/channels/src/utils/paste.test.tsx +++ b/webapp/channels/src/utils/paste.test.tsx @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Locations} from './constants'; +import {execCommandInsertText} from './exec_commands'; import { parseHtmlTable, getHtmlTable, @@ -10,6 +12,7 @@ import { isTextUrl, hasPlainText, createFileFromClipboardDataItem, + pasteHandler, } from './paste'; const validClipboardData: any = { @@ -231,6 +234,79 @@ describe('isTextUrl', () => { }); }); +jest.mock('utils/exec_commands', () => ({ + execCommandInsertText: jest.fn(), +})); + +describe('pasteHandler', () => { + const testCases = [ + { + testName: 'should be able to format a pasted markdown table', + clipboardData: { + items: [1], + types: ['text/html'], + getData: () => { + return '
testtest
testtest
'; + }, + }, + expectedMarkdown: '| test | test |\n| --- | --- |\n| test | test |', + }, + { + testName: 'should be able to format a pasted markdown table without headers', + clipboardData: { + items: [1], + types: ['text/html'], + getData: () => { + return '
testtest
testtest
'; + }, + }, + expectedMarkdown: '| test | test |\n| --- | --- |\n| test | test |\n', + }, + { + testName: 'should be able to format a pasted hyperlink', + clipboardData: { + items: [1], + types: ['text/html'], + getData: () => { + return 'link text'; + }, + }, + expectedMarkdown: '[link text](https://test.domain)', + }, + { + testName: 'should be able to format a github codeblock (pasted as a table)', + clipboardData: { + items: [1], + types: ['text/plain', 'text/html'], + getData: (type: string) => { + if (type === 'text/plain') { + return '// a javascript codeblock example\nif (1 > 0) {\n return \'condition is true\';\n}'; + } + return '
// a javascript codeblock example
 if (1 > 0) {
 console.log(\'condition is true\');
 }
'; + }, + }, + expectedMarkdown: "```\n// a javascript codeblock example\nif (1 > 0) {\n return 'condition is true';\n}\n```", + }, + ]; + + for (const tc of testCases) { + it(tc.testName, () => { + const location = Locations.RHS_COMMENT; + const event: any = { + target: { + id: 'reply_textbox', + }, + preventDefault: jest.fn(), + clipboardData: tc.clipboardData, + }; + + pasteHandler(event, location, '', false, 0); + + expect(execCommandInsertText).toHaveBeenCalledWith(tc.expectedMarkdown); + }); + } +}); + describe('hasPlainText', () => { test('Should return true when clipboard data has plain text', () => { const clipboardData = { diff --git a/webapp/channels/src/utils/paste.tsx b/webapp/channels/src/utils/paste.tsx index 27bb150689..2761b09b68 100644 --- a/webapp/channels/src/utils/paste.tsx +++ b/webapp/channels/src/utils/paste.tsx @@ -1,6 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {isNil} from 'lodash'; + +import type {TextboxElement} from 'components/textbox'; + +import {Locations} from 'utils/constants'; +import {execCommandInsertText} from 'utils/exec_commands'; import {DEFAULT_PLACEHOLDER_URL} from 'utils/markdown/apply_markdown'; import {splitMessageBasedOnCaretPosition, splitMessageBasedOnTextSelection} from 'utils/post_utils'; import turndownService from 'utils/turndown'; @@ -154,6 +160,43 @@ export function formatMarkdownLinkMessage({message, clipboardData, selectionStar return markdownLink; } +export function pasteHandler(event: ClipboardEvent, location: string, message: string, isNonFormattedPaste?: boolean, caretPosition?: number) { + const {clipboardData, target} = event; + + const textboxId = location === Locations.RHS_COMMENT ? 'reply_textbox' : 'post_textbox'; + + if (!clipboardData || !clipboardData.items || !target || (target as TextboxElement)?.id !== textboxId) { + return; + } + + const {selectionStart, selectionEnd} = target as TextboxElement; + + const hasSelection = !isNil(selectionStart) && !isNil(selectionEnd) && selectionStart < selectionEnd; + const hasTextUrl = isTextUrl(clipboardData); + const hasHTMLLinks = !isNonFormattedPaste && hasHtmlLink(clipboardData); + const htmlTable = getHtmlTable(clipboardData); + const shouldApplyLinkMarkdown = hasSelection && hasTextUrl; + const shouldApplyGithubCodeBlock = htmlTable && isGitHubCodeBlock(htmlTable.className); + + if (!htmlTable && !hasHTMLLinks && !shouldApplyLinkMarkdown) { + return; + } + + event.preventDefault(); + + // execCommand's insertText' triggers a 'change' event, hence we need not set respective state explicitly. + if (shouldApplyLinkMarkdown) { + const formattedLink = formatMarkdownLinkMessage({selectionStart, selectionEnd, message, clipboardData}); + execCommandInsertText(formattedLink); + } else if (shouldApplyGithubCodeBlock) { + const {formattedCodeBlock} = formatGithubCodePaste({selectionStart, selectionEnd, message, clipboardData}); + execCommandInsertText(formattedCodeBlock); + } else { + const {formattedMarkdown} = formatMarkdownMessage(clipboardData, message, caretPosition); + execCommandInsertText(formattedMarkdown); + } +} + export function createFileFromClipboardDataItem(item: DataTransferItem, fileNamePrefixIfNoName: string): File | null { const file = item.getAsFile();