diff --git a/webapp/channels/src/actions/views/create_comment.test.jsx b/webapp/channels/src/actions/views/create_comment.test.jsx index 4296b3260a..24cff670f6 100644 --- a/webapp/channels/src/actions/views/create_comment.test.jsx +++ b/webapp/channels/src/actions/views/create_comment.test.jsx @@ -22,6 +22,7 @@ import {removeDraft, setGlobalDraftSource} from 'actions/views/drafts'; import mockStore from 'tests/test_store'; import {StoragePrefixes} from 'utils/constants'; +import {TestHelper} from 'utils/test_helper'; /* eslint-disable global-require */ @@ -121,13 +122,21 @@ describe('rhs view actions', () => { messages: ['test message'], }, }, + channels: { + channels: { + [channelId]: TestHelper.getChannelMock({id: channelId}), + }, + roles: { + [channelId]: new Set(['channel_roles']), + }, + }, preferences: { myPreferences: {}, }, users: { currentUserId, profiles: { - [currentUserId]: {id: currentUserId}, + [currentUserId]: TestHelper.getUserMock({id: currentUserId}), }, }, teams: { @@ -136,6 +145,13 @@ describe('rhs view actions', () => { emojis: { customEmoji: {}, }, + roles: { + roles: { + channel_roles: { + permissions: '', + }, + }, + }, general: { config: { EnableCustomEmoji: 'true', diff --git a/webapp/channels/src/actions/views/create_comment.tsx b/webapp/channels/src/actions/views/create_comment.tsx index f07a6503c1..e7cf1d6f35 100644 --- a/webapp/channels/src/actions/views/create_comment.tsx +++ b/webapp/channels/src/actions/views/create_comment.tsx @@ -6,12 +6,20 @@ import type {Post} from '@mattermost/types/posts'; import { addMessageIntoHistory, } from 'mattermost-redux/actions/posts'; +import {Permissions} from 'mattermost-redux/constants'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups'; import { + getLatestInteractablePostId, + getLatestPostToEdit, getPost, makeGetPostIdsForThread, } from 'mattermost-redux/selectors/entities/posts'; +import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import type {ActionFunc, ActionFuncAsync} from 'mattermost-redux/types/actions'; @@ -25,6 +33,7 @@ import {updateDraft, removeDraft} from 'actions/views/drafts'; import {Constants, StoragePrefixes} from 'utils/constants'; import EmojiMap from 'utils/emoji_map'; +import {containsAtChannel, groupsMentionedInText} from 'utils/post_utils'; import * as Utils from 'utils/utils'; import type {GlobalState} from 'types/store'; @@ -63,10 +72,30 @@ export function submitPost(channelId: string, rootId: string, draft: PostDraft): pending_post_id: `${userId}:${time}`, user_id: userId, create_at: time, - metadata: {}, + metadata: {...draft.metadata}, props: {...draft.props}, } as unknown as Post; + const channel = getChannel(state, channelId); + if (!channel) { + return {error: new Error('cannot find channel')}; + } + const useChannelMentions = haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_CHANNEL_MENTIONS); + if (!useChannelMentions && containsAtChannel(post.message, {checkAllMentions: true})) { + post.props.mentionHighlightDisabled = true; + } + + const license = getLicense(state); + const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true'; + const useLDAPGroupMentions = isLDAPEnabled && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS); + + const useCustomGroupMentions = isCustomGroupsEnabled(state) && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS); + + const groupsWithAllowReference = useLDAPGroupMentions || useCustomGroupMentions ? getAssociatedGroupsForReferenceByMention(state, channel.team_id, channel.id) : null; + if (!useLDAPGroupMentions && !useCustomGroupMentions && groupsMentionedInText(post.message, groupsWithAllowReference)) { + post.props.disable_group_highlight = true; + } + const hookResult = await dispatch(runMessageWillBePostedHooks(post)); if (hookResult.error) { return {error: hookResult.error}; @@ -146,6 +175,32 @@ export function makeOnSubmit(channelId: string, rootId: string, latestPostId: st }; } +export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean}): ActionFuncAsync { + return async (dispatch, getState) => { + const {message, channelId, rootId} = draft; + const state = getState(); + + dispatch(addMessageIntoHistory(message)); + + const isReaction = Utils.REACTION_PATTERN.exec(message); + + const emojis = getCustomEmojisByName(state); + const emojiMap = new EmojiMap(emojis); + + if (isReaction && emojiMap.has(isReaction[2])) { + const latestPostId = getLatestInteractablePostId(state, channelId, rootId); + if (latestPostId) { + dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2])); + } + } else if (message.indexOf('/') === 0 && !options.ignoreSlash) { + await dispatch(submitCommand(channelId, rootId, draft)); + } else { + await dispatch(submitPost(channelId, rootId, draft)); + } + return {data: true}; + }; +} + function makeGetCurrentUsersLatestReply() { const getPostIdsInThread = makeGetPostIdsForThread(); return createSelector( @@ -211,3 +266,22 @@ export function makeOnEditLatestPost(rootId: string): () => ActionFunc )); }; } + +export function editLatestPost(channelId: string, rootId = ''): ActionFunc { + return (dispatch, getState) => { + const state = getState(); + + const lastPostId = getLatestPostToEdit(state, channelId, rootId); + + if (!lastPostId) { + return {data: false}; + } + + return dispatch(PostActions.setEditingPost( + lastPostId, + rootId ? 'reply_textbox' : 'post_textbox', + '', // title is no longer used + Boolean(rootId), + )); + }; +} diff --git a/webapp/channels/src/actions/views/drafts.ts b/webapp/channels/src/actions/views/drafts.ts index 14079fce2f..db7f1916ba 100644 --- a/webapp/channels/src/actions/views/drafts.ts +++ b/webapp/channels/src/actions/views/drafts.ts @@ -97,7 +97,7 @@ export function updateDraft(key: string, value: PostDraft|null, rootId = '', sav let updatedValue: PostDraft|null = null; if (value) { const timestamp = new Date().getTime(); - const data = getGlobalItem(state, key, {}); + const data = getGlobalItem>(state, key, {}); updatedValue = { ...value, createAt: data.createAt || timestamp, 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 deleted file mode 100644 index f59732a3ea..0000000000 --- a/webapp/channels/src/components/advanced_create_comment/__snapshots__/advanced_create_comment.test.tsx.snap +++ /dev/null @@ -1,530 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/AdvancedCreateComment should match snapshot when cannot post 1`] = ` -
- - -`; - -exports[`components/AdvancedCreateComment should match snapshot, comment with message 1`] = ` -
- - -`; - -exports[`components/AdvancedCreateComment should match snapshot, emoji picker disabled 1`] = ` -
- - - -`; - -exports[`components/AdvancedCreateComment should match snapshot, empty comment 1`] = ` -
- - -`; - -exports[`components/AdvancedCreateComment should match snapshot, non-empty message and uploadsInProgress + fileInfos 1`] = ` -
- - - -`; 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 deleted file mode 100644 index baec8b857d..0000000000 --- a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.tsx +++ /dev/null @@ -1,1257 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; - -import type {ServerError} from '@mattermost/types/errors'; -import type {FileInfo} from '@mattermost/types/files'; - -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 {testComponentForLineBreak} from 'tests/helpers/line_break_helpers'; -import Constants, {ModalIdentifiers} from 'utils/constants'; -import {TestHelper} from 'utils/test_helper'; - -import type {PostDraft} from 'types/store/draft'; - -jest.mock('utils/exec_commands', () => ({ - execCommandInsertText: jest.fn(), -})); - -describe('components/AdvancedCreateComment', () => { - jest.useFakeTimers(); - let spy: jest.SpyInstance; - beforeEach(() => { - spy = jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => setTimeout(cb, 16)); - }); - - afterEach(() => { - spy.mockRestore(); - }); - - const currentTeamId = 'current-team-id'; - const channelId = 'g6139tbospd18cmxroesdk3kkc'; - const rootId = ''; - const latestPostId = '3498nv24823948v23m4nv34'; - const currentUserId = 'zaktnt8bpbgu8mb6ez9k64r7sa'; - - const emptyDraft: PostDraft = TestHelper.getPostDraftMock({message: ''}); - const defaultFileInfo: FileInfo = TestHelper.getFileInfoMock(); - - const baseProps: Props = { - channelId, - currentTeamId, - currentUserId, - rootId, - rootDeleted: false, - channelMembersCount: 3, - draft: TestHelper.getPostDraftMock({ - fileInfos: [defaultFileInfo, defaultFileInfo, defaultFileInfo], - }), - isRemoteDraft: false, - enableAddButton: true, - ctrlSend: false, - latestPostId, - locale: 'en', - clearCommentDraftUploads: jest.fn(), - onUpdateCommentDraft: jest.fn(), - updateCommentDraftWithRootId: jest.fn(), - onSubmit: jest.fn(), - onResetHistoryIndex: jest.fn(), - moveHistoryIndexBack: jest.fn(), - moveHistoryIndexForward: jest.fn(), - onEditLatestPost: jest.fn(), - resetCreatePostRequest: jest.fn(), - setShowPreview: jest.fn(), - searchAssociatedGroupsForReference: jest.fn(), - shouldShowPreview: false, - enableEmojiPicker: true, - enableGifPicker: true, - enableConfirmNotificationsToChannel: true, - maxPostSize: Constants.DEFAULT_CHARACTER_LIMIT, - rhsExpanded: false, - badConnection: false, - getChannelTimezones: jest.fn(() => Promise.resolve({data: [], error: ''})), - selectedPostFocussedAt: 0, - canPost: true, - canUploadFiles: true, - isFormattingBarHidden: false, - useChannelMentions: true, - getChannelMemberCountsByGroup: jest.fn(), - useLDAPGroupMentions: true, - useCustomGroupMentions: true, - openModal: jest.fn(), - postEditorActions: [], - emitShortcutReactToLastPostFrom(): void { - throw new Error('Function not implemented.'); - }, - groupsWithAllowReference: null, - channelMemberCountsByGroup: undefined as any, - savePreferences(): Promise { - throw new Error('Function not implemented.'); - }, - shouldFocusRHS: true, - focusedRHS: jest.fn(), - }; - - const submitEvent = { - preventDefault: jest.fn(), - } as unknown as React.FormEvent; - - test('should match snapshot, empty comment', () => { - const draft: PostDraft = emptyDraft; - const isRemoteDraft = false; - const enableAddButton = false; - const ctrlSend = true; - const props: any = {...baseProps, draft, isRemoteDraft, enableAddButton, ctrlSend}; - - const wrapper = shallow( - , - ); - - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot, comment with message', () => { - const clearCommentDraftUploads = jest.fn(); - const onResetHistoryIndex = jest.fn(); - const getChannelMemberCountsByGroup = jest.fn(); - const draft: PostDraft = TestHelper.getPostDraftMock(); - const isRemoteDraft = false; - const ctrlSend = true; - const props: any = {...baseProps, ctrlSend, draft, isRemoteDraft, clearCommentDraftUploads, onResetHistoryIndex, getChannelMemberCountsByGroup}; - - const wrapper = shallow( - , - ); - - // should clear draft uploads on mount - expect(clearCommentDraftUploads).toHaveBeenCalled(); - - // should reset message history index on mount - expect(onResetHistoryIndex).toHaveBeenCalled(); - - // should load channel member counts on mount - expect(getChannelMemberCountsByGroup).not.toHaveBeenCalled(); - - expect(wrapper).toMatchSnapshot(); - }); - - test('should call searchAssociatedGroupsForReference if there is one mention in the draft', () => { - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: '@group', - }); - - const searchAssociatedGroupsForReference: any = jest.fn(); - const props: any = {...baseProps, draft, searchAssociatedGroupsForReference}; - - shallow(); - - expect(searchAssociatedGroupsForReference).toHaveBeenCalled(); - }); - - test('should call getChannelMemberCountsByGroup if there is more than one mention in the draft', () => { - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: '@group @othergroup', - - }); - const getChannelMemberCountsByGroup = jest.fn(); - const props: any = {...baseProps, draft, getChannelMemberCountsByGroup}; - - shallow(); - - expect(getChannelMemberCountsByGroup).toHaveBeenCalled(); - }); - - test('should not call getChannelMemberCountsByGroup, without group mentions permission or license', () => { - const useLDAPGroupMentions = false; - const useCustomGroupMentions = false; - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: '@group @othergroup', - - }); - - const getChannelMemberCountsByGroup = jest.fn(); - const props: any = {...baseProps, useLDAPGroupMentions, useCustomGroupMentions, getChannelMemberCountsByGroup, draft}; - - shallow(); - - // should not load channel member counts on mount without useGroupmentions - expect(getChannelMemberCountsByGroup).not.toHaveBeenCalled(); - }); - - test('should match snapshot, non-empty message and uploadsInProgress + fileInfos', () => { - const draft: PostDraft = TestHelper.getPostDraftMock(); - - const wrapper = shallow( - , - ); - - wrapper.setState({draft}); - expect(wrapper).toMatchSnapshot(); - }); - - test('should correctly change state when toggleEmojiPicker is called', () => { - const wrapper = shallow( - , - ); - - wrapper.instance().toggleEmojiPicker(); - expect(wrapper.state().showEmojiPicker).toBe(true); - - wrapper.instance().toggleEmojiPicker(); - expect(wrapper.state().showEmojiPicker).toBe(false); - }); - - test('should correctly change state when hideEmojiPicker is called', () => { - const wrapper = shallow( - , - ); - - wrapper.instance().hideEmojiPicker(); - expect(wrapper.state().showEmojiPicker).toBe(false); - }); - - test('should correctly update draft when handleEmojiClick is called', () => { - const onUpdateCommentDraft = jest.fn(); - const draft: PostDraft = emptyDraft; - const enableAddButton = false; - const props: any = {...baseProps, draft, onUpdateCommentDraft, enableAddButton}; - - const wrapper = shallow( - , - ); - - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - getBoundingClientRect: jest.fn(mockTop), - focus: jest.fn(), - }; - }; - - const mockTop = () => { - return document.createElement('div'); - }; - - (wrapper.instance() as any).textboxRef.current = {getInputBox: jest.fn(mockImpl), getBoundingClientRect: jest.fn(), focus: jest.fn()}; - - wrapper.instance().handleEmojiClick(TestHelper.getCustomEmojiMock({name: 'smile'})); - - jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT); - expect(onUpdateCommentDraft).toHaveBeenCalled(); - - // Empty message case - expect(onUpdateCommentDraft.mock.calls[0][0]).toEqual( - expect.objectContaining({message: ':smile: '}), - ); - expect(wrapper.state().draft!.message).toBe(':smile: '); - - wrapper.setState({draft: TestHelper.getPostDraftMock({message: 'test', uploadsInProgress: [], fileInfos: []}), - caretPosition: 'test'.length, // cursor is at the end - }); - wrapper.instance().handleEmojiClick(TestHelper.getCustomEmojiMock({name: 'smile'})); - - // Message with no space at the end - jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT); - expect(onUpdateCommentDraft.mock.calls[1][0]).toEqual( - expect.objectContaining({message: 'test :smile: '}), - ); - expect(wrapper.state().draft!.message).toBe('test :smile: '); - - wrapper.setState({draft: TestHelper.getPostDraftMock({message: 'test ', uploadsInProgress: [], fileInfos: []}), - caretPosition: 'test '.length, // cursor is at the end - }); - wrapper.instance().handleEmojiClick(TestHelper.getCustomEmojiMock({name: 'smile'})); - - // Message with space at the end - jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT); - expect(onUpdateCommentDraft.mock.calls[2][0]).toEqual( - expect.objectContaining({message: 'test :smile: '}), - ); - expect(wrapper.state().draft!.message).toBe('test :smile: '); - - expect(wrapper.state().showEmojiPicker).toBe(false); - }); - - test('handlePostError should update state with the correct error', () => { - const wrapper = shallow( - , - ); - - wrapper.instance().handlePostError('test error 1'); - expect(wrapper.state().postError).toBe('test error 1'); - - wrapper.instance().handlePostError('test error 2'); - expect(wrapper.state().postError).toBe('test error 2'); - }); - - // debug next - test('handleUploadError should update state with the correct error', () => { - const updateCommentDraftWithRootId = jest.fn(); - const fileInfoObject: FileInfo = TestHelper.getFileInfoMock(); - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: 'Test message', - uploadsInProgress: ['1', '2', '3'], - fileInfos: [fileInfoObject, fileInfoObject, fileInfoObject], - }); - const props: any = {...baseProps, draft, updateCommentDraftWithRootId}; - - const wrapper = shallow( - , - ); - - const instance = wrapper.instance(); - - const testError1 = 'test error 1'; - wrapper.setState({draft}); - instance.draftsForPost[props.rootId] = draft; - instance.handleUploadError(testError1, '1', undefined, props.rootId); - - expect(updateCommentDraftWithRootId).toHaveBeenCalled(); - expect(updateCommentDraftWithRootId.mock.calls[0][0]).toEqual(props.rootId); - expect(updateCommentDraftWithRootId.mock.calls[0][1]).toEqual( - expect.objectContaining({uploadsInProgress: ['2', '3']}), - ); - expect(wrapper.state().serverError!.message).toBe(testError1); - expect(wrapper.state().draft!.uploadsInProgress).toEqual(['2', '3']); - - const testError2 = 'test error 2'; - instance.handleUploadError(testError2, '', undefined, props.rootId); - - // should not call onUpdateCommentDraft - expect(updateCommentDraftWithRootId.mock.calls.length).toBe(1); - expect(wrapper.state().serverError!.message).toBe(testError2); - }); - - test('should call openModal when showPostDeletedModal is called', () => { - const wrapper = shallow( - , - ); - - wrapper.instance().showPostDeletedModal(); - - expect(baseProps.openModal).toHaveBeenCalledTimes(1); - }); - - test('handleUploadStart should update comment draft correctly', () => { - const onUpdateCommentDraft = jest.fn(); - const draft: PostDraft = TestHelper.getPostDraftMock({ - uploadsInProgress: ['1', '2', '3'], - fileInfos: [TestHelper.getFileInfoMock(), TestHelper.getFileInfoMock(), TestHelper.getFileInfoMock()], - }); - - const props: any = {...baseProps, onUpdateCommentDraft, draft}; - - const wrapper = shallow( - , - ); - - const focusTextbox = jest.fn(); - wrapper.setState({draft}); - wrapper.instance().focusTextbox = focusTextbox; - wrapper.instance().handleUploadStart(['4', '5']); - - expect(onUpdateCommentDraft).toHaveBeenCalled(); - expect(onUpdateCommentDraft.mock.calls[0][0]).toEqual( - expect.objectContaining({uploadsInProgress: ['1', '2', '3', '4', '5']}), - ); - - expect(wrapper.state().draft!.uploadsInProgress).toEqual(['1', '2', '3', '4', '5']); - expect(focusTextbox).toHaveBeenCalled(); - }); - - test('handleFileUploadComplete should update comment draft correctly', () => { - const updateCommentDraftWithRootId: any = jest.fn(); - const fileInfos = [ - TestHelper.getFileInfoMock({id: '1', name: 'aaa', create_at: 100}), - TestHelper.getFileInfoMock({id: '2', name: 'bbb', create_at: 200}), - ]; - - const draft: PostDraft = TestHelper.getPostDraftMock({ - uploadsInProgress: ['1', '2', '3'], - fileInfos, - }); - const props: any = {...baseProps, updateCommentDraftWithRootId, draft}; - - const wrapper: any = shallow( - , - ); - - const instance: any = wrapper.instance(); - wrapper.setState({draft}); - instance.draftsForPost[props.rootId] = draft; - - const uploadCompleteFileInfo: any = [{id: '3', name: 'ccc', create_at: 300}]; - const expectedNewFileInfos: any = fileInfos.concat(uploadCompleteFileInfo); - instance.handleFileUploadComplete(uploadCompleteFileInfo, ['3'], null as any, props.rootId); - - jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT); - expect(updateCommentDraftWithRootId).toHaveBeenCalled(); - expect(updateCommentDraftWithRootId.mock.calls[0][0]).toEqual(props.rootId); - expect(updateCommentDraftWithRootId.mock.calls[0][1]).toEqual( - expect.objectContaining({uploadsInProgress: ['1', '2'], fileInfos: expectedNewFileInfos}), - ); - - expect(wrapper.state().draft!.uploadsInProgress).toEqual(['1', '2']); - expect(wrapper.state().draft!.fileInfos).toEqual(expectedNewFileInfos); - }); - - test('should open PostDeletedModal when createPostErrorId === api.post.create_post.root_id.app_error', () => { - const onUpdateCommentDraft = jest.fn(); - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: 'Test message', - uploadsInProgress: ['1', '2', '3'], - fileInfos: [ - TestHelper.getFileInfoMock({id: '1', name: 'aaa', create_at: 100}), - TestHelper.getFileInfoMock({id: '2', name: 'bbb', create_at: 200}), - ], - }); - const props: any = {...baseProps, onUpdateCommentDraft, draft}; - - const wrapper = shallow( - , - ); - - wrapper.setProps({createPostErrorId: 'api.post.create_post.root_id.app_error'}); - - expect(props.openModal).toHaveBeenCalledTimes(1); - expect(props.openModal.mock.calls[0][0]).toMatchObject({ - modalId: ModalIdentifiers.POST_DELETED_MODAL, - }); - }); - - test('should open PostDeletedModal when message is submitted to deleted root', () => { - const onUpdateCommentDraft = jest.fn(); - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: 'Test message', - uploadsInProgress: ['1', '2', '3'], - fileInfos: [ - TestHelper.getFileInfoMock({id: '1', name: 'aaa', create_at: 100}), - TestHelper.getFileInfoMock({id: '2', name: 'bbb', create_at: 200}), - ], - }); - const props: any = {...baseProps, onUpdateCommentDraft, draft}; - - const wrapper = shallow( - , - ); - - wrapper.setProps({rootDeleted: true}); - wrapper.instance().handleSubmit(submitEvent); - - expect(props.openModal).toHaveBeenCalledTimes(1); - expect(props.openModal.mock.calls[0][0]).toMatchObject({ - modalId: ModalIdentifiers.POST_DELETED_MODAL, - }); - }); - - describe('focusTextbox', () => { - const draft: PostDraft = TestHelper.getPostDraftMock({ - uploadsInProgress: ['1', '2', '3'], - fileInfos: [ - TestHelper.getFileInfoMock({id: '1', name: 'aaa', create_at: 100}), - TestHelper.getFileInfoMock({id: '2', name: 'bbb', create_at: 200}), - ], - }); - - it('is called when rootId changes', () => { - const props: any = {...baseProps, draft}; - const wrapper = shallow( - , - ); - - const focusTextbox = jest.fn(); - wrapper.instance().focusTextbox = focusTextbox; - - const newProps = { - ...props, - rootId: 'testid123', - }; - - // Note that setProps doesn't actually trigger componentDidUpdate - wrapper.setProps(newProps); - wrapper.instance().componentDidUpdate(props, newProps); - expect(focusTextbox).toHaveBeenCalled(); - }); - - it('is called when selectPostFocussedAt changes', () => { - const props: any = {...baseProps, draft, selectedPostFocussedAt: 1000}; - const wrapper = shallow( - , - ); - - const focusTextbox = jest.fn(); - wrapper.instance().focusTextbox = focusTextbox; - - const newProps = { - ...props, - selectedPostFocussedAt: 2000, - }; - - // Note that setProps doesn't actually trigger componentDidUpdate - wrapper.setProps(newProps); - wrapper.instance().componentDidUpdate(props, props); - expect(focusTextbox).toHaveBeenCalled(); - }); - - it('is not called when rootId and selectPostFocussedAt have not changed', () => { - const props: any = {...baseProps, draft, selectedPostFocussedAt: 1000}; - const wrapper = shallow( - , - ); - - const focusTextbox = jest.fn(); - wrapper.instance().focusTextbox = focusTextbox; - wrapper.instance().handleBlur(); - - // Note that setProps doesn't actually trigger componentDidUpdate - wrapper.setProps(props); - wrapper.instance().componentDidUpdate(props, props); - expect(focusTextbox).not.toHaveBeenCalled(); - }); - }); - - test('handleChange should update comment draft correctly', () => { - const draft: PostDraft = TestHelper.getPostDraftMock({ - uploadsInProgress: ['1', '2', '3'], - fileInfos: [TestHelper.getFileInfoMock(), TestHelper.getFileInfoMock(), TestHelper.getFileInfoMock()], - }); - const scrollToBottom = jest.fn(); - const props: any = {...baseProps, draft, scrollToBottom}; - - const wrapper = shallow( - , - ); - - const testMessage = 'new msg'; - wrapper.instance().handleChange({target: {value: testMessage}} as any); - - // The callback won't we called until after a short delay - expect(baseProps.onUpdateCommentDraft).not.toHaveBeenCalled(); - - jest.runOnlyPendingTimers(); - jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT); - expect(baseProps.onUpdateCommentDraft).toHaveBeenCalled(); - - expect((baseProps.onUpdateCommentDraft as jest.Mock).mock.calls[0][0]).toEqual( - expect.objectContaining({message: testMessage}), - ); - expect(wrapper.state().draft!.message).toBe(testMessage); - expect(scrollToBottom).toHaveBeenCalled(); - }); - - // debug - it('handleChange should throw away invalid command error if user resumes typing', async () => { - const onUpdateCommentDraft = jest.fn(); - - const error: ServerError = {message: 'No command found'}; - - error.server_error_id = 'api.command.execute_command.not_found.app_error'; - const onSubmit = jest.fn(() => Promise.reject(error)); - const defaultFileInfo = TestHelper.getFileInfoMock(); - - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: '/fakecommand other text', - uploadsInProgress: ['1', '2', '3'], - fileInfos: [defaultFileInfo, defaultFileInfo, defaultFileInfo], - }); - const props: any = {...baseProps, onUpdateCommentDraft, draft, onSubmit}; - - const wrapper = shallow( - , - ); - - await wrapper.instance().handleSubmit(submitEvent); - - expect(onSubmit).toHaveBeenCalledWith(TestHelper.getPostDraftMock({ - message: '/fakecommand other text', - uploadsInProgress: [], - fileInfos: [defaultFileInfo, defaultFileInfo, defaultFileInfo], - }), {ignoreSlash: false}); - - wrapper.instance().handleChange({ - target: {value: 'some valid text'}, - } as any); - - wrapper.instance().handleSubmit(submitEvent); - - expect(onSubmit).toHaveBeenCalledWith(TestHelper.getPostDraftMock({ - message: 'some valid text', - uploadsInProgress: [], - fileInfos: [defaultFileInfo, defaultFileInfo, defaultFileInfo], - }), {ignoreSlash: false}); - }); - - test('should scroll to bottom when uploadsInProgress increase', () => { - const draft: PostDraft = TestHelper.getPostDraftMock({ - uploadsInProgress: ['1', '2', '3'], - fileInfos: [TestHelper.getFileInfoMock(), TestHelper.getFileInfoMock(), TestHelper.getFileInfoMock()], - }); - const scrollToBottom = jest.fn(); - const props: any = {...baseProps, draft, scrollToBottom}; - - const wrapper = shallow( - , - ); - - wrapper.setState({draft: {...draft, uploadsInProgress: ['1', '2', '3', '4']}}); - expect(scrollToBottom).toHaveBeenCalled(); - }); - - test('handleSubmit should call onSubmit prop', () => { - const onSubmit = jest.fn(); - const defaultFileInfo = TestHelper.getFileInfoMock(); - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: 'Test message', - uploadsInProgress: [], - fileInfos: [defaultFileInfo, defaultFileInfo, defaultFileInfo], - }); - const props: any = {...baseProps, draft, onSubmit}; - - const wrapper = shallow( - , - ); - - const preventDefault = jest.fn(); - wrapper.instance().handleSubmit({...submitEvent, preventDefault}); - expect(onSubmit).toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - }); - - describe('handleSubmit', () => { - let onSubmit: any; - let preventDefault: any; - - beforeEach(() => { - onSubmit = jest.fn(); - preventDefault = jest.fn(); - submitEvent.preventDefault = preventDefault; - }); - - ['channel', 'all', 'here'].forEach((mention: string) => { - describe(`should not show Confirm Modal for @${mention} mentions`, () => { - it('when channel member count too low', () => { - const props: any = { - ...baseProps, - draft: { - message: `Test message @${mention}`, - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit, - channelMembersCount: 1, - enableConfirmNotificationsToChannel: true, - }; - - const wrapper = shallow( - , - ); - - wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(props.openModal).not.toHaveBeenCalled(); - }); - - it('when feature disabled', () => { - const props: any = { - ...baseProps, - draft: { - message: `Test message @${mention}`, - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit, - channelMembersCount: 8, - enableConfirmNotificationsToChannel: false, - }; - - const wrapper = shallow( - , - ); - - wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(props.openModal).not.toHaveBeenCalled(); - }); - - it('when no mention', () => { - const props: any = { - ...baseProps, - draft: { - message: `Test message ${mention}`, - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit, - channelMembersCount: 8, - enableConfirmNotificationsToChannel: true, - }; - - const wrapper = shallow( - , - ); - - wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(props.openModal).not.toHaveBeenCalled(); - }); - - it('when user has insufficient permissions', () => { - const props: any = { - ...baseProps, - useChannelMentions: false, - draft: { - message: `Test message @${mention}`, - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit, - channelMembersCount: 8, - enableConfirmNotificationsToChannel: true, - }; - - const wrapper = shallow( - , - ); - - wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(props.openModal).not.toHaveBeenCalled(); - }); - }); - - it(`should show Confirm Modal for @${mention} mentions when needed and timezone notification`, async () => { - const props: any = { - ...baseProps, - draft: { - message: `Test message @${mention}`, - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit, - channelMembersCount: 8, - enableConfirmNotificationsToChannel: true, - }; - - const wrapper = shallow( - , - ); - - await wrapper.instance().handleSubmit(submitEvent); - wrapper.setState({channelTimezoneCount: 4} as any); - - expect(onSubmit).not.toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(wrapper.state('channelTimezoneCount')).toBe(4); - expect(baseProps.getChannelTimezones).toHaveBeenCalledTimes(1); - expect(props.openModal).toHaveBeenCalled(); - }); - - it(`should show Confirm Modal for @${mention} mentions when needed and no timezone notification`, async () => { - const props: any = { - ...baseProps, - draft: { - message: `Test message @${mention}`, - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit, - channelMembersCount: 8, - enableConfirmNotificationsToChannel: true, - }; - - const wrapper = shallow( - , - ); - - await wrapper.instance().handleSubmit(submitEvent); - wrapper.setState({channelTimezoneCount: 0} as any); - - expect(onSubmit).not.toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(wrapper.state('channelTimezoneCount')).toBe(0); - expect(baseProps.getChannelTimezones).toHaveBeenCalledTimes(1); - expect(props.openModal).toHaveBeenCalled(); - }); - }); - - it('should show Confirm Modal for @group mention when needed and no timezone notification', async () => { - const props: any = { - ...baseProps, - draft: { - message: 'Test message @developers', - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - groupsWithAllowReference: new Map([ - ['@developers', { - id: 'developers', - name: 'developers', - }], - ]), - channelMemberCountsByGroup: { - developers: { - channel_member_count: 10, - channel_member_timezones_count: 0, - }, - }, - channelMembersCount: 8, - useChannelMentions: true, - enableConfirmNotificationsToChannel: true, - }; - - const wrapper = shallow( - , - ); - const showNotifyAllModal = wrapper.instance().showNotifyAllModal; - wrapper.instance().showNotifyAllModal = jest.fn((mentions, channelTimezoneCount, memberNotifyCount) => showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount)); - - await wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).not.toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(baseProps.getChannelTimezones).toHaveBeenCalledTimes(0); - expect(wrapper.instance().showNotifyAllModal).toHaveBeenCalledWith(['@developers'], 0, 10); - expect(props.openModal).toHaveBeenCalled(); - }); - - it('should show Confirm Modal for @group mentions when needed and no timezone notification', async () => { - const props: any = { - ...baseProps, - draft: { - message: 'Test message @developers @boss @love @you @software-developers', - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - groupsWithAllowReference: new Map([ - ['@developers', { - id: 'developers', - name: 'developers', - }], - ['@boss', { - id: 'boss', - name: 'boss', - }], - ['@love', { - id: 'love', - name: 'love', - }], - ['@you', { - id: 'you', - name: 'you', - }], - ['@software-developers', { - id: 'softwareDevelopers', - name: 'software-developers', - }], - ]), - channelMemberCountsByGroup: { - developers: { - channel_member_count: 10, - channel_member_timezones_count: 0, - }, - boss: { - channel_member_count: 20, - channel_member_timezones_count: 0, - }, - love: { - channel_member_count: 30, - channel_member_timezones_count: 0, - }, - you: { - channel_member_count: 40, - channel_member_timezones_count: 0, - }, - softwareDevelopers: { - channel_member_count: 5, - channel_member_timezones_count: 0, - }, - }, - channelMembersCount: 8, - useChannelMentions: true, - enableConfirmNotificationsToChannel: true, - }; - - const wrapper = shallow( - , - ); - - const showNotifyAllModal = wrapper.instance().showNotifyAllModal; - wrapper.instance().showNotifyAllModal = jest.fn((mentions, channelTimezoneCount, memberNotifyCount) => showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount)); - - await wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).not.toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(baseProps.getChannelTimezones).toHaveBeenCalledTimes(0); - expect(wrapper.instance().showNotifyAllModal).toHaveBeenCalledWith(['@developers', '@boss', '@love', '@you', '@software-developers'], 0, 40); - expect(props.openModal).toHaveBeenCalled(); - }); - - it('should show Confirm Modal for @group mention with timezone enabled', async () => { - const props: any = { - ...baseProps, - draft: { - message: 'Test message @developers', - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - groupsWithAllowReference: new Map([ - ['@developers', { - id: 'developers', - name: 'developers', - }], - ]), - channelMemberCountsByGroup: { - developers: { - channel_member_count: 10, - channel_member_timezones_count: 5, - }, - }, - channelMembersCount: 8, - useChannelMentions: true, - enableConfirmNotificationsToChannel: true, - }; - - const wrapper = shallow( - , - ); - - const showNotifyAllModal = wrapper.instance().showNotifyAllModal; - wrapper.instance().showNotifyAllModal = jest.fn((mentions, channelTimezoneCount, memberNotifyCount) => showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount)); - - await wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).not.toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(baseProps.getChannelTimezones).toHaveBeenCalledTimes(0); - expect(wrapper.instance().showNotifyAllModal).toHaveBeenCalledWith(['@developers'], 5, 10); - expect(props.openModal).toHaveBeenCalled(); - }); - - it('should allow to force send invalid slash command as a message', async () => { - const error: ServerError = {message: 'No command found'}; - error.server_error_id = 'api.command.execute_command.not_found.app_error'; - const onSubmitWithError = jest.fn(() => Promise.reject(error)); - - const props: any = { - ...baseProps, - draft: { - message: '/fakecommand other text', - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit: onSubmitWithError, - }; - - const wrapper = shallow( - , - ); - - await wrapper.instance().handleSubmit(submitEvent); - expect(onSubmitWithError).toHaveBeenCalledWith({ - message: '/fakecommand other text', - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, {ignoreSlash: false}); - expect(preventDefault).toHaveBeenCalled(); - - wrapper.setProps({onSubmit}); - await wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).toHaveBeenCalledWith({ - message: '/fakecommand other text', - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, {ignoreSlash: true}); - expect(wrapper.find('[id="postServerError"]').exists()).toBe(false); - }); - - it('should update global draft state if invalid slash command error occurs', async () => { - const error: ServerError = {message: 'No command found'}; - error.server_error_id = 'api.command.execute_command.not_found.app_error'; - const onSubmitWithError = jest.fn(() => Promise.reject(error)); - - const props: any = { - ...baseProps, - draft: { - message: '/fakecommand other text', - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit: onSubmitWithError, - }; - - const wrapper = shallow( - , - ); - - const submitPromise = wrapper.instance().handleSubmit(submitEvent); - expect(props.onUpdateCommentDraft).not.toHaveBeenCalled(); - - await submitPromise; - expect(props.onUpdateCommentDraft).toHaveBeenCalledWith(props.draft); - }); - ['channel', 'all', 'here'].forEach((mention) => { - it(`should set mentionHighlightDisabled when user does not have permission and message contains channel @${mention}`, async () => { - const props: any = { - ...baseProps, - useChannelMentions: false, - enableConfirmNotificationsToChannel: false, - draft: { - message: `Test message @${mention}`, - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit, - }; - - const wrapper = shallow( - , - ); - - wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).toHaveBeenCalled(); - expect(wrapper.state('draft')!.props.mentionHighlightDisabled).toBe(true); - }); - - it(`should not set mentionHighlightDisabled when user does have permission and message contains channel channel @${mention}`, async () => { - const props: any = { - ...baseProps, - useChannelMentions: true, - enableConfirmNotificationsToChannel: false, - draft: { - message: `Test message @${mention}`, - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit, - }; - - const wrapper = shallow( - , - ); - - wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).toHaveBeenCalled(); - expect(wrapper.state('draft')!.props).toBe(undefined); - }); - }); - - it('should not set mentionHighlightDisabled when user does not have useChannelMentions permission and message contains no mention', async () => { - const props: any = { - ...baseProps, - useChannelMentions: false, - draft: { - message: 'Test message', - uploadsInProgress: [], - fileInfos: [{}, {}, {}], - }, - onSubmit, - }; - - const wrapper = shallow( - , - ); - - wrapper.instance().handleSubmit(submitEvent); - expect(onSubmit).toHaveBeenCalled(); - expect(wrapper.state('draft')!.props).toBe(undefined); - }); - }); - - test('removePreview should remove file info and upload in progress with corresponding id', () => { - const onUpdateCommentDraft = jest.fn(); - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: 'Test message', - uploadsInProgress: ['4', '5', '6'], - fileInfos: [ - TestHelper.getFileInfoMock({id: '1'}), - TestHelper.getFileInfoMock({id: '2'}), - TestHelper.getFileInfoMock({id: '3'}), - ], - }); - const props: any = {...baseProps, draft, onUpdateCommentDraft}; - - const wrapper = shallow( - , - ); - - wrapper.setState({draft}); - - wrapper.instance().removePreview('3'); - - jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT); - expect(onUpdateCommentDraft).toHaveBeenCalled(); - expect(onUpdateCommentDraft.mock.calls[0][0]).toEqual( - expect.objectContaining({fileInfos: [ - TestHelper.getFileInfoMock({id: '1'}), - TestHelper.getFileInfoMock({id: '2'}), - ]}), - ); - expect(wrapper.state().draft!.fileInfos).toEqual([ - TestHelper.getFileInfoMock({id: '1'}), - TestHelper.getFileInfoMock({id: '2'}), - ]); - - wrapper.instance().removePreview('5'); - - jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT); - expect(onUpdateCommentDraft.mock.calls[1][0]).toEqual( - expect.objectContaining({uploadsInProgress: ['4', '6']}), - ); - expect(wrapper.state().draft!.uploadsInProgress).toEqual(['4', '6']); - }); - - test('should match draft state on componentWillReceiveProps with change in messageInHistory', () => { - const draft: PostDraft = TestHelper.getPostDraftMock({ - fileInfos: [defaultFileInfo, defaultFileInfo, defaultFileInfo], - }); - - const wrapper = shallow( - , - ); - expect(wrapper.state('draft')).toEqual(draft); - - const newDraft: PostDraft = TestHelper.getPostDraftMock({...draft, message: 'Test message edited'}); - wrapper.setProps({draft: newDraft, messageInHistory: 'Test message edited'}); - expect(wrapper.state('draft')).toEqual(newDraft); - }); - - test('should match draft state on componentWillReceiveProps with new rootId', () => { - const defaultFileInfo = TestHelper.getFileInfoMock(); - const draft: PostDraft = TestHelper.getPostDraftMock({ - message: 'Test message', - uploadsInProgress: ['4', '5', '6'], - fileInfos: [ - TestHelper.getFileInfoMock({id: '1'}), - TestHelper.getFileInfoMock({id: '2'}), - TestHelper.getFileInfoMock({id: '3'}), - ], - }); - - const wrapper = shallow( - , - ); - wrapper.setState({draft}); - expect(wrapper.state('draft')).toEqual(draft); - - wrapper.setProps({rootId: 'new_root_id'}); - expect(wrapper.state('draft')).toEqual(TestHelper.getPostDraftMock({...draft, uploadsInProgress: [], fileInfos: [defaultFileInfo, defaultFileInfo, defaultFileInfo]})); - }); - - test('should match snapshot when cannot post', () => { - const props: any = {...baseProps, canPost: false}; - const wrapper = shallow( - , - ); - - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot, emoji picker disabled', () => { - const props: any = {...baseProps, enableEmojiPicker: false}; - const wrapper = shallow( - , - ); - - expect(wrapper).toMatchSnapshot(); - }); - - test('check for handleFileUploadChange callback for focus', () => { - const wrapper = shallow( - , - ); - const instance = wrapper.instance(); - instance.focusTextbox = jest.fn(); - - instance.handleFileUploadChange(); - expect(instance.focusTextbox).toHaveBeenCalledTimes(1); - }); - - 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(); - const wrapper = shallow( - , - ); - - expect(scrollToBottom).toBeCalledTimes(0); - expect(wrapper.instance().doInitialScrollToBottom).toEqual(true); - - // should scroll to bottom on first component update - wrapper.setState({draft: {...draft, message: 'new message'}}); - expect(scrollToBottom).toBeCalledTimes(1); - expect(wrapper.instance().doInitialScrollToBottom).toEqual(false); - - // but not after the first update - wrapper.setState({draft: {...draft, message: 'another message'}}); - expect(scrollToBottom).toBeCalledTimes(1); - expect(wrapper.instance().doInitialScrollToBottom).toEqual(false); - }); - - test('should the RHS thread scroll to bottom when state.draft.uploadsInProgress increases but not when it decreases', () => { - const draft: PostDraft = emptyDraft; - const scrollToBottom = jest.fn(); - const wrapper = shallow( - , - ); - - expect(scrollToBottom).toBeCalledTimes(0); - - wrapper.setState({draft: {...draft, uploadsInProgress: ['1']}}); - expect(scrollToBottom).toBeCalledTimes(1); - - wrapper.setState({draft: {...draft, uploadsInProgress: ['1', '2']}}); - expect(scrollToBottom).toBeCalledTimes(2); - - wrapper.setState({draft: {...draft, uploadsInProgress: ['2']}}); - expect(scrollToBottom).toBeCalledTimes(2); - }); - - test('should show preview and edit mode, and return focus on preview disable', () => { - const wrapper = shallow( - , - ); - const instance = wrapper.instance(); - instance.focusTextbox = jest.fn(); - expect(instance.focusTextbox).not.toBeCalled(); - - instance.setShowPreview(true); - - expect(baseProps.setShowPreview).toHaveBeenCalledWith(true); - expect(instance.focusTextbox).not.toBeCalled(); - - wrapper.setProps({shouldShowPreview: true}); - expect(instance.focusTextbox).not.toBeCalled(); - wrapper.setProps({shouldShowPreview: false}); - expect(instance.focusTextbox).toBeCalled(); - }); - - testComponentForLineBreak((value: any) => ( - - ), (instance: any) => instance.state().draft.message, false); -}); 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 6996561ef7..ce1fd99f27 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 @@ -5,1126 +5,37 @@ import React from 'react'; -import type {ChannelMemberCountsByGroup} from '@mattermost/types/channels'; -import type {Emoji} from '@mattermost/types/emojis'; -import type {ServerError} from '@mattermost/types/errors'; -import type {FileInfo} from '@mattermost/types/files'; -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 {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; -import {sortFileInfos} from 'mattermost-redux/utils/file_utils'; - -import * as GlobalActions from 'actions/global_actions'; - import AdvancedTextEditor from 'components/advanced_text_editor/advanced_text_editor'; -import FileLimitStickyBanner from 'components/file_limit_sticky_banner'; -import type {FilePreviewInfo} from 'components/file_preview/file_preview'; -import type {FileUpload as FileUploadClass} from 'components/file_upload/file_upload'; -import NotifyConfirmModal from 'components/notify_confirm_modal'; -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 { - applyMarkdown, -} from 'utils/markdown/apply_markdown'; -import type { - ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; -import { - specialMentionsInText, - postMessageOnKeyPress, - shouldFocusMainTextbox, - isErrorInvalidSlashCommand, - splitMessageBasedOnCaretPosition, - groupsMentionedInText, - mentionsMinusSpecialMentionsInText, -} from 'utils/post_utils'; -import * as UserAgent from 'utils/user_agent'; -import * as Utils from 'utils/utils'; - -import type {ModalData} from 'types/actions'; -import type {PostDraft} from 'types/store/draft'; -import type {PluginComponent} from 'types/store/plugins'; +import {Locations} from 'utils/constants'; export type Props = { - currentTeamId: string; // The channel for which this comment is a part of channelId: string; - // The id of the current user - currentUserId: string; - // The id of the parent post rootId: string; - // The root message is deleted - rootDeleted: boolean; - - // The number of channel members - channelMembersCount: number; - - // The current history message selected - messageInHistory?: string; - - // The current draft of the comment - draft: PostDraft; - - // Data used for knowing if the draft came from a WS event - isRemoteDraft: boolean; - - // Determines if the submit button should be rendered - enableAddButton?: boolean; - - // Force message submission on CTRL/CMD + ENTER - codeBlockOnCtrlEnter?: boolean; - - // Set to force form submission on CTRL/CMD + ENTER instead of just ENTER - ctrlSend?: boolean; - - // The id of the latest post in this channel - latestPostId?: string; - - // The current user locale - locale: string; - - // Error id, if the post creation fails - createPostErrorId?: string; - - // Determines if the current user can edit the post - canPost: boolean; - - // Determines if the user is allowed to upload files - canUploadFiles: boolean; - - // Called to clear file uploads in progress - clearCommentDraftUploads: () => void; - - // Called when comment draft needs to be updated - onUpdateCommentDraft: (draft?: PostDraft, save?: boolean) => void; - - // Called when comment draft needs to be updated for a specific root ID - updateCommentDraftWithRootId: (rootID: string, draft: PostDraft, save?: boolean) => void; - - // Called when submitting the comment - onSubmit: (draft: PostDraft, options: {ignoreSlash: boolean}) => void; - - // Called when resetting comment message history index - onResetHistoryIndex: () => void; - - // Called when navigating back through comment message history - moveHistoryIndexBack: (index: string) => Promise; - - // Called when navigating forward through comment message history - moveHistoryIndexForward: (index: string) => Promise; - - // Called to initiate editing the user's latest post - onEditLatestPost: () => ActionResult; - - // Function to get the users timezones in the channel - getChannelTimezones: (channelId: string) => Promise>; - - // Reset state of createPost request - resetCreatePostRequest: () => void; - - // Determines if @channel should warn in this channel - enableConfirmNotificationsToChannel: boolean; - - // Determines if the emoji picker is enabled - enableEmojiPicker: boolean; - - // Determines if the gif picker is enabled. - enableGifPicker: boolean; - - // Determines if the connection may be bad to warn user - badConnection: boolean; - - // Determines the maximum length of a post - maxPostSize: number; - - // Determines if the RHS is in expanded state - rhsExpanded: boolean; - - // The last time, if any, the selected post changed. Will be 0 if no post is selected. - selectedPostFocussedAt: number; - - // Function to set or unset emoji picker for last message - emitShortcutReactToLastPostFrom: (location: keyof typeof Constants.Locations) => void; - - // Determines if the current user can send special channel mentions - useChannelMentions: boolean; - - // Determines if the current user can send LDAP group mentions - useLDAPGroupMentions: boolean; - - // Set show preview for textbox - setShowPreview: (showPreview: boolean) => void; - - // Determines if the preview should be shown - shouldShowPreview: boolean; - - // Called when parent component should be scrolled to bottom - scrollToBottom?: () => void; - - // Group member mention - getChannelMemberCountsByGroup: (channelID: string) => void; - groupsWithAllowReference: Map | null; - channelMemberCountsByGroup: ChannelMemberCountsByGroup; isThreadView?: boolean; - openModal:

(modalData: ModalData

) => void; - savePreferences: (userId: string, preferences: PreferenceType[]) => Promise; - useCustomGroupMentions: boolean; - isFormattingBarHidden: boolean; - searchAssociatedGroupsForReference: (prefix: string, teamId: string, channelId: string | undefined) => Promise; - postEditorActions: PluginComponent[]; placeholder?: string; - isPlugin?: boolean; - shouldFocusRHS: boolean; - focusedRHS: () => void; } -type State = { - showEmojiPicker: boolean; - uploadsProgressPercent: {[clientID: string]: FilePreviewInfo}; - renderScrollbar: boolean; - scrollbarWidth: number; - draft: PostDraft; - rootId?: string; - messageInHistory?: string; - createPostErrorId?: string; - caretPosition: number; - postError?: React.ReactNode; - errorClass: string | null; - serverError: (ServerError & {submittedMessage?: string}) | null; - showFormat: boolean; - isFormattingBarHidden: boolean; +const AdvancedCreateComment = ({ + channelId, + rootId, + isThreadView, + placeholder, +}: Props) => { + return ( + + ); }; -function isDraftEmpty(draft: PostDraft): boolean { - return !draft || (!draft.message && draft.fileInfos.length === 0); -} - -class AdvancedCreateComment extends React.PureComponent { - // public because accessed in advanced_create_comment.test.tsx - public draftsForPost: {[postID: string]: PostDraft | null} = {}; - public doInitialScrollToBottom = false; - - private readonly textboxRef: React.RefObject; - - private lastBlurAt = 0; - private saveDraftFrame?: number | null; - - private isDraftSubmitting = false; - private isDraftEdited = false; - private isNonFormattedPaste = false; - private timeoutId: number | null = null; - - private readonly fileUploadRef: React.RefObject; - - static defaultProps = { - focusOnMount: true, - }; - - static getDerivedStateFromProps(props: Props, state: State) { - let updatedState: Partial = { - createPostErrorId: props.createPostErrorId, - rootId: props.rootId, - messageInHistory: props.messageInHistory, - }; - - const rootChanged = props.rootId !== state.rootId || props.draft.rootId !== state.draft?.rootId; - const messageInHistoryChanged = props.messageInHistory !== state.messageInHistory; - if (rootChanged || messageInHistoryChanged || (props.isRemoteDraft && props.draft.message !== state.draft?.message)) { - updatedState = { - ...updatedState, - draft: { - ...props.draft, - uploadsInProgress: rootChanged ? [] : props.draft.uploadsInProgress, - }, - }; - } - - return updatedState; - } - - constructor(props: Props) { - super(props); - - this.state = { - showEmojiPicker: false, - uploadsProgressPercent: {}, - renderScrollbar: false, - scrollbarWidth: 0, - errorClass: null, - serverError: null, - showFormat: false, - isFormattingBarHidden: props.isFormattingBarHidden, - caretPosition: props.draft.message.length, - draft: {...props.draft, uploadsInProgress: []}, - }; - - this.textboxRef = React.createRef(); - this.fileUploadRef = React.createRef(); - } - - componentDidMount() { - const {clearCommentDraftUploads, onResetHistoryIndex, setShowPreview, draft} = this.props; - clearCommentDraftUploads(); - onResetHistoryIndex(); - setShowPreview(false); - - if (this.props.shouldFocusRHS) { - this.focusTextbox(); - this.props.focusedRHS(); - } - - document.addEventListener('keydown', this.focusTextboxIfNecessary); - window.addEventListener('beforeunload', this.saveDraftWithShow); - this.getChannelMemberCountsByGroup(); - - // When draft.message is not empty, set doInitialScrollToBottom to true so that - // on next component update, the actual this.scrollToBottom() will be called. - // This is made so that the this.scrollToBottom() will be called only once. - if (draft.message !== '') { - this.doInitialScrollToBottom = true; - } - } - - componentWillUnmount() { - this.props.resetCreatePostRequest?.(); - document.removeEventListener('keydown', this.focusTextboxIfNecessary); - window.removeEventListener('beforeunload', this.saveDraftWithShow); - this.saveDraftOnUnmount(); - if (this.timeoutId !== null) { - clearTimeout(this.timeoutId); - } - } - - componentDidUpdate(prevProps: Props, prevState: State) { - if (prevState.draft!.uploadsInProgress.length < this.state.draft!.uploadsInProgress.length && this.props.scrollToBottom) { - this.props.scrollToBottom(); - } - - // Focus on textbox when emoji picker is closed - if (prevState.showEmojiPicker && !this.state.showEmojiPicker) { - this.focusTextbox(); - } - - // Focus on textbox when returned from preview mode - if (prevProps.shouldShowPreview && !this.props.shouldShowPreview) { - this.focusTextbox(); - } - - if (prevProps.rootId !== this.props.rootId || prevProps.selectedPostFocussedAt !== this.props.selectedPostFocussedAt) { - this.getChannelMemberCountsByGroup(); - this.focusTextbox(); - } - - if (this.doInitialScrollToBottom) { - if (this.props.scrollToBottom) { - this.props.scrollToBottom(); - } - this.doInitialScrollToBottom = false; - } - - if (this.props.createPostErrorId === 'api.post.create_post.root_id.app_error' && this.props.createPostErrorId !== prevProps.createPostErrorId) { - this.showPostDeletedModal(); - } - } - - 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, searchAssociatedGroupsForReference, getChannelMemberCountsByGroup, draft, currentTeamId} = this.props; - - if ((useLDAPGroupMentions || useCustomGroupMentions) && channelId) { - const mentions = mentionsMinusSpecialMentionsInText(draft.message); - - if (mentions.length === 1) { - searchAssociatedGroupsForReference(mentions[0], currentTeamId, channelId); - } else if (mentions.length > 1) { - getChannelMemberCountsByGroup(channelId); - } - } - }; - - saveDraftOnUnmount = () => { - if (!this.isDraftEdited || !this.state.draft || this.props.rootDeleted) { - return; - } - - const updatedDraft = { - ...this.state.draft, - show: !isDraftEmpty(this.state.draft), - } as PostDraft; - - this.props.onUpdateCommentDraft(updatedDraft, true); - }; - - saveDraftWithShow = () => { - this.setState((prev) => { - if (prev.draft) { - return { - draft: { - ...prev.draft, - show: !isDraftEmpty(prev.draft), - } as PostDraft, - }; - } - - return { - draft: prev.draft, - }; - }, () => { - this.saveDraft(true); - }); - }; - - saveDraft = (save = false) => { - if (this.saveDraftFrame) { - clearTimeout(this.saveDraftFrame); - this.props.onUpdateCommentDraft(this.state.draft, save); - this.saveDraftFrame = null; - } - }; - - setShowPreview = (newPreviewValue: boolean) => { - this.props.setShowPreview(newPreviewValue); - }; - - focusTextboxIfNecessary = (e: KeyboardEvent) => { - // Should only focus if RHS is expanded or if thread view - if (!this.props.isThreadView && !this.props.rhsExpanded) { - return; - } - - // A bit of a hack to not steal focus from the channel switch modal if it's open - // This is a special case as the channel switch modal does not enforce focus like - // most modals do - if (document.getElementsByClassName('channel-switch-modal').length) { - return; - } - - if (shouldFocusMainTextbox(e, document.activeElement)) { - this.focusTextbox(); - this.toggleAdvanceTextEditor(); - } - }; - - setCaretPosition = (newCaretPosition: number) => { - const textbox = this.textboxRef.current && this.textboxRef.current.getInputBox(); - - this.setState({ - caretPosition: newCaretPosition, - }, () => { - Utils.setCaretPosition(textbox, newCaretPosition); - }); - }; - - handleNotifyAllConfirmation = () => { - this.doSubmit(); - }; - - showNotifyAllModal = (mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => { - this.props.openModal({ - modalId: ModalIdentifiers.NOTIFY_CONFIRM_MODAL, - dialogType: NotifyConfirmModal, - dialogProps: { - mentions, - channelTimezoneCount, - memberNotifyCount, - onConfirm: () => this.handleNotifyAllConfirmation(), - onExited: () => { - this.isDraftSubmitting = false; - }, - }, - }); - }; - - toggleEmojiPicker = (e?: React.MouseEvent): void => { - e?.stopPropagation(); - const showEmojiPicker = !this.state.showEmojiPicker; - this.setState({showEmojiPicker}); - }; - - hideEmojiPicker = () => { - this.setState({showEmojiPicker: false}); - }; - - handleEmojiClick = (emoji: Emoji) => { - const emojiAlias = getEmojiName(emoji); - - if (!emojiAlias) { - //Oops... There went something wrong - return; - } - - const draft = this.state.draft!; - - let newMessage: string; - if (draft.message === '') { - newMessage = `:${emojiAlias}: `; - this.setCaretPosition(newMessage.length); - } else { - const {message} = draft; - const {firstPiece, lastPiece} = splitMessageBasedOnCaretPosition(this.state.caretPosition || 0, message); - - // check whether the first piece of the message is empty when cursor is placed at beginning of message and avoid adding an empty string at the beginning of the message - newMessage = firstPiece === '' ? `:${emojiAlias}: ${lastPiece} ` : `${firstPiece} :${emojiAlias}: ${lastPiece} `; - - const newCaretPosition = firstPiece === '' ? `:${emojiAlias}: `.length : `${firstPiece} :${emojiAlias}: `.length; - this.setCaretPosition(newCaretPosition); - } - - const modifiedDraft = { - ...draft, - message: newMessage, - }; - - this.handleDraftChange(modifiedDraft); - - this.setState({ - showEmojiPicker: false, - draft: modifiedDraft, - }); - }; - - handleGifClick = (gif: string) => { - const draft = this.state.draft!; - - let newMessage: string; - if (draft.message === '') { - newMessage = gif; - } else if ((/\s+$/).test(draft.message)) { - // Check whether there is already a blank at the end of the current message - newMessage = `${draft.message}${gif} `; - } else { - newMessage = `${draft.message} ${gif} `; - } - - const modifiedDraft = { - ...draft, - message: newMessage, - }; - - this.handleDraftChange(modifiedDraft); - - this.setState({ - showEmojiPicker: false, - draft: modifiedDraft, - }); - - this.focusTextbox(); - }; - - handlePostError = (postError: React.ReactNode) => { - this.setState({postError}); - }; - - handleSubmit = async (e: React.FormEvent | React.MouseEvent) => { - e.preventDefault(); - this.setShowPreview(false); - this.isDraftSubmitting = true; - - const { - channelMembersCount, - enableConfirmNotificationsToChannel, - useChannelMentions, - groupsWithAllowReference, - channelMemberCountsByGroup, - useLDAPGroupMentions, - useCustomGroupMentions, - } = this.props; - const draft = this.state.draft!; - const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions; - let memberNotifyCount = 0; - let channelTimezoneCount = 0; - let mentions: string[] = []; - - const specialMentions = specialMentionsInText(draft.message); - const hasSpecialMentions = Object.values(specialMentions).includes(true); - - if (enableConfirmNotificationsToChannel && !hasSpecialMentions && (useLDAPGroupMentions || useCustomGroupMentions)) { - // Groups mentioned in users text - const mentionGroups = groupsMentionedInText(draft.message, groupsWithAllowReference); - if (mentionGroups.length > 0) { - mentionGroups. - forEach((group) => { - if (group.source === GroupSource.Ldap && !useLDAPGroupMentions) { - return; - } - if (group.source === GroupSource.Custom && !useCustomGroupMentions) { - return; - } - const mappedValue = channelMemberCountsByGroup[group.id]; - if (mappedValue && mappedValue.channel_member_count > Constants.NOTIFY_ALL_MEMBERS && mappedValue.channel_member_count > memberNotifyCount) { - memberNotifyCount = mappedValue.channel_member_count; - channelTimezoneCount = mappedValue.channel_member_timezones_count; - } - mentions.push(`@${group.name}`); - }); - mentions = [...new Set(mentions)]; - } - } - - if (!useLDAPGroupMentions && !useCustomGroupMentions && mentions.length > 0) { - const updatedDraft = { - ...draft, - props: { - ...draft.props, - disable_group_highlight: true, - }, - }; - - this.props.onUpdateCommentDraft(updatedDraft); - this.setState({draft: updatedDraft}); - } - - if (notificationsToChannel && - channelMembersCount > Constants.NOTIFY_ALL_MEMBERS && - hasSpecialMentions) { - memberNotifyCount = channelMembersCount - 1; - for (const k in specialMentions) { - if (specialMentions[k]) { - mentions.push('@' + k); - } - } - - const {data} = await this.props.getChannelTimezones(this.props.channelId); - channelTimezoneCount = data ? data.length : 0; - } - - if (!useChannelMentions && hasSpecialMentions) { - const updatedDraft = { - ...draft, - props: { - ...draft.props, - mentionHighlightDisabled: true, - }, - }; - - this.props.onUpdateCommentDraft(updatedDraft); - this.setState({draft: updatedDraft}); - } - - if (memberNotifyCount > 0) { - this.showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount); - return; - } - - await this.doSubmit(e); - }; - - doSubmit = async (e?: React.FormEvent) => { - if (e) { - e.preventDefault(); - } - - const draft = this.state.draft!; - const enableAddButton = this.shouldEnableAddButton(); - - if (!enableAddButton) { - this.isDraftSubmitting = false; - return; - } - - if (draft.uploadsInProgress.length > 0) { - this.isDraftSubmitting = false; - return; - } - - if (this.state.postError) { - this.setState({errorClass: 'animation--highlight'}); - setTimeout(() => { - this.setState({errorClass: null}); - }, Constants.ANIMATION_TIMEOUT); - this.isDraftSubmitting = false; - return; - } - - if (this.props.rootDeleted) { - this.showPostDeletedModal(); - this.isDraftSubmitting = false; - return; - } - - const fasterThanHumanWillClick = 150; - const forceFocus = (Date.now() - this.lastBlurAt < fasterThanHumanWillClick); - this.focusTextbox(forceFocus); - - const serverError = this.state.serverError; - let ignoreSlash = false; - if (isErrorInvalidSlashCommand(serverError) && draft.message === serverError?.submittedMessage) { - ignoreSlash = true; - } - - const options = {ignoreSlash}; - - try { - await this.props.onSubmit(draft, options); - - this.setState({ - postError: null, - serverError: null, - showFormat: false, - }); - } catch (err: any) { - if (isErrorInvalidSlashCommand(err)) { - this.props.onUpdateCommentDraft(draft); - } - err.submittedMessage = draft.message; - this.setState({serverError: err}); - this.isDraftSubmitting = false; - return; - } - - if (this.saveDraftFrame) { - clearTimeout(this.saveDraftFrame); - } - - this.isDraftSubmitting = false; - this.setState({draft: {...this.props.draft, uploadsInProgress: []}}); - this.draftsForPost[this.props.rootId] = null; - }; - - commentMsgKeyPress = (e: React.KeyboardEvent) => { - const {ctrlSend, codeBlockOnCtrlEnter} = this.props; - - const {allowSending} = postMessageOnKeyPress( - e, - this.state.draft!.message, - Boolean(ctrlSend), - Boolean(codeBlockOnCtrlEnter), - 0, - 0, - this.state.caretPosition, - ); - - if (allowSending) { - e.persist?.(); - - this.isDraftSubmitting = true; - this.textboxRef.current?.blur(); - this.handleSubmit(e); - - this.setShowPreview(false); - setTimeout(() => { - this.focusTextbox(); - }); - } - - this.emitTypingEvent(); - }; - - reactToLastMessage = (e: React.KeyboardEvent) => { - e.preventDefault(); - - const {emitShortcutReactToLastPostFrom} = this.props; - - // Here we are not handling conditions such as check for modals, popups etc. as shortcut is only trigger on - // textbox input focus. Since all of them will already be closed as soon as they loose focus. - emitShortcutReactToLastPostFrom(Locations.RHS_ROOT); - }; - - emitTypingEvent = () => { - const {channelId, rootId} = this.props; - GlobalActions.emitLocalUserTypingEvent(channelId, rootId); - }; - - handleChange = (e: React.ChangeEvent) => { - const message = e.target.value; - - let serverError = this.state.serverError; - if (isErrorInvalidSlashCommand(serverError)) { - serverError = null; - } - - const draft = this.state.draft!; - const show = isDraftEmpty(draft) ? false : draft.show; - const updatedDraft = {...draft, message, show}; - - this.handleDraftChange(updatedDraft); - - this.setState({draft: updatedDraft, serverError}, () => { - if (this.props.scrollToBottom) { - this.props.scrollToBottom(); - } - }); - this.draftsForPost[this.props.rootId] = updatedDraft; - }; - - handleDraftChange = (draft: PostDraft, rootId?: string, save = false, instant = false) => { - this.isDraftEdited = true; - - if (this.saveDraftFrame) { - clearTimeout(this.saveDraftFrame); - } - - const saveDraft = () => { - if (typeof rootId == 'undefined') { - this.props.onUpdateCommentDraft(draft); - } else { - this.props.updateCommentDraftWithRootId(rootId, draft, save); - } - }; - - if (instant) { - saveDraft(); - } else { - this.saveDraftFrame = window.setTimeout(() => { - saveDraft(); - }, Constants.SAVE_DRAFT_TIMEOUT); - } - this.draftsForPost[this.props.rootId] = draft; - }; - - handleMouseUpKeyUp = (e: React.MouseEvent | React.KeyboardEvent) => { - this.setState({ - caretPosition: (e.target as TextboxElement).selectionStart || 0, - }); - }; - - applyMarkdown = (options: ApplyMarkdownOptions) => { - if (this.props.shouldShowPreview) { - return; - } - - const res = applyMarkdown(options); - - const draft = this.state.draft!; - const modifiedDraft = { - ...draft, - message: res.message, - }; - - this.handleDraftChange(modifiedDraft); - - this.setState({ - draft: modifiedDraft, - }, () => { - const textbox = this.textboxRef.current?.getInputBox(); - Utils.setSelectionRange(textbox, res.selectionStart, res.selectionEnd); - }); - }; - - handleFileUploadChange = () => { - this.isDraftEdited = true; - this.focusTextbox(); - }; - - handleUploadStart = (clientIds: string[]) => { - const draft = this.state.draft!; - const uploadsInProgress = [...draft.uploadsInProgress, ...clientIds]; - - const modifiedDraft = { - ...draft, - uploadsInProgress, - }; - this.props.onUpdateCommentDraft(modifiedDraft); - this.setState({draft: modifiedDraft}); - this.draftsForPost[this.props.rootId] = modifiedDraft; - - // this is a bit redundant with the code that sets focus when the file input is clicked, - // but this also resets the focus after a drag and drop - this.focusTextbox(); - }; - - handleUploadProgress = (filePreviewInfo: FilePreviewInfo) => { - const uploadsProgressPercent = {...this.state.uploadsProgressPercent, [filePreviewInfo.clientId]: filePreviewInfo}; - this.setState({uploadsProgressPercent}); - }; - - handleFileUploadComplete = (fileInfos: FileInfo[], clientIds: string[], _: string, rootId?: string) => { - const draft = this.draftsForPost[rootId!]!; - const uploadsInProgress = [...draft.uploadsInProgress]; - const newFileInfos = sortFileInfos([...draft.fileInfos, ...fileInfos], this.props.locale); - - // remove each finished file from uploads - for (let i = 0; i < clientIds.length; i++) { - const index = uploadsInProgress.indexOf(clientIds[i]); - - if (index !== -1) { - uploadsInProgress.splice(index, 1); - } - } - - const modifiedDraft = { - ...draft, - fileInfos: newFileInfos, - uploadsInProgress, - }; - this.handleDraftChange(modifiedDraft, rootId!, true, true); - if (this.props.rootId === rootId) { - this.setState({draft: modifiedDraft}); - } - }; - - handleUploadError = (uploadError: string | ServerError | null, clientId?: string, _?: string, rootId = '') => { - if (clientId) { - const draft = {...this.draftsForPost[rootId]!}; - const uploadsInProgress = [...draft.uploadsInProgress]; - - const index = uploadsInProgress.indexOf(clientId as string); - if (index !== -1) { - uploadsInProgress.splice(index, 1); - } - - const modifiedDraft = { - ...draft, - uploadsInProgress, - }; - this.props.updateCommentDraftWithRootId(rootId, modifiedDraft, true); - this.draftsForPost[rootId] = modifiedDraft; - if (this.props.rootId === rootId) { - this.setState({draft: modifiedDraft}); - } - } - - if (typeof uploadError === 'string') { - if (uploadError.length !== 0) { - this.setState({serverError: new Error(uploadError)}); - } - } else { - this.setState({serverError: uploadError}); - } - }; - - removePreview = (id: string) => { - const draft = this.state.draft!; - const fileInfos = [...draft.fileInfos]; - const uploadsInProgress = [...draft.uploadsInProgress]; - - // Clear previous errors - this.handleUploadError(null); - - // id can either be the id of an uploaded file or the client id of an in progress upload - let index = fileInfos.findIndex((info) => info.id === id); - if (index === -1) { - index = uploadsInProgress.indexOf(id); - - if (index !== -1) { - uploadsInProgress.splice(index, 1); - - if (this.fileUploadRef.current) { - this.fileUploadRef.current.cancelUpload(id); - } - } - } else { - fileInfos.splice(index, 1); - } - - const modifiedDraft = { - ...draft, - fileInfos, - uploadsInProgress, - }; - - this.props.onUpdateCommentDraft(modifiedDraft); - this.setState({draft: modifiedDraft}); - this.draftsForPost[this.props.rootId] = modifiedDraft; - - this.handleFileUploadChange(); - - if (this.saveDraftFrame) { - clearTimeout(this.saveDraftFrame); - } - - this.saveDraftFrame = window.setTimeout(() => {}, Constants.SAVE_DRAFT_TIMEOUT); - }; - - getFileUploadTarget = () => { - return this.textboxRef.current?.getInputBox(); - }; - - toggleAdvanceTextEditor = () => { - this.setState({ - isFormattingBarHidden: - !this.state.isFormattingBarHidden, - }); - this.props.savePreferences(this.props.currentUserId, [{ - category: Preferences.ADVANCED_TEXT_EDITOR, - user_id: this.props.currentUserId, - name: AdvancedTextEditorConst.COMMENT, - value: String(!this.state.isFormattingBarHidden), - }]); - }; - - focusTextbox = (keepFocus = false) => { - if (this.textboxRef.current && (keepFocus || !UserAgent.isMobile())) { - this.textboxRef.current.focus(); - } - }; - - shouldEnableAddButton = () => { - const {draft} = this.state; - if (draft) { - const message = draft.message ? draft.message.trim() : ''; - const fileInfos = draft.fileInfos ? draft.fileInfos : []; - if (message.trim().length !== 0 || fileInfos.length !== 0) { - return true; - } - } - - return isErrorInvalidSlashCommand(this.state.serverError); - }; - - showPostDeletedModal = () => { - this.props.openModal({ - modalId: ModalIdentifiers.POST_DELETED_MODAL, - dialogType: PostDeletedModal, - }); - }; - - handleBlur = () => { - if (!this.isDraftSubmitting) { - this.saveDraftWithShow(); - } - 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!; - - const pluginItems = this.props.postEditorActions?. - map((item) => { - if (!item.component) { - return null; - } - - const Component = item.component as any; - return ( - { - const input = this.textboxRef.current?.getInputBox(); - - return { - start: input.selectionStart, - end: input.selectionEnd, - }; - }} - updateText={(message: string) => { - const draft = this.state.draft!; - const modifiedDraft = { - ...draft, - message, - }; - this.handleDraftChange(modifiedDraft); - this.setState({ - draft: modifiedDraft, - }); - }} - /> - ); - }); - - return ( -

- { - this.props.canPost && - (this.props.draft.fileInfos.length > 0 || this.props.draft.uploadsInProgress.length > 0) && - - } - - - ); - } -} - -export default AdvancedCreateComment; +export default React.memo(AdvancedCreateComment); diff --git a/webapp/channels/src/components/advanced_create_comment/index.ts b/webapp/channels/src/components/advanced_create_comment/index.ts index bc3ff28931..497447e346 100644 --- a/webapp/channels/src/components/advanced_create_comment/index.ts +++ b/webapp/channels/src/components/advanced_create_comment/index.ts @@ -1,189 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import type {Dispatch} from 'redux'; - -import {getChannelTimezones, getChannelMemberCountsByGroup} from 'mattermost-redux/actions/channels'; -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'; -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common'; -import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups'; -import {makeGetMessageInHistoryItem} from 'mattermost-redux/selectors/entities/posts'; -import {getBool, isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences'; -import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; -import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; - -import {emitShortcutReactToLastPostFrom} from 'actions/post_actions'; -import { - clearCommentDraftUploads, - updateCommentDraft, - makeOnSubmit, - makeOnEditLatestPost, -} from 'actions/views/create_comment'; -import {searchAssociatedGroupsForReference} from 'actions/views/group'; -import {openModal} from 'actions/views/modals'; -import {focusedRHS} from 'actions/views/rhs'; -import {setShowPreviewOnCreateComment} from 'actions/views/textbox'; -import {getCurrentLocale} from 'selectors/i18n'; -import {getPostDraft, getIsRhsExpanded, getSelectedPostFocussedAt} from 'selectors/rhs'; -import {getShouldFocusRHS} from 'selectors/views/rhs'; -import {connectionErrorCount} from 'selectors/views/system'; -import {showPreviewOnCreateComment} from 'selectors/views/textbox'; - -import {AdvancedTextEditor, Constants, StoragePrefixes} from 'utils/constants'; -import {canUploadFiles} from 'utils/file_utils'; - -import type {PostDraft} from 'types/store/draft'; -import type {GlobalState} from 'types/store/index.js'; - import AdvancedCreateComment from './advanced_create_comment'; -type OwnProps = { - rootId: string; - channelId: string; - latestPostId: string; - isPlugin?: boolean; -}; - -function makeMapStateToProps() { - const getMessageInHistoryItem = makeGetMessageInHistoryItem(Posts.MESSAGE_TYPES.COMMENT as 'comment'); - - return (state: GlobalState, ownProps: OwnProps) => { - const err = state.requests.posts.createPost.error || {}; - - const draft = getPostDraft(state, StoragePrefixes.COMMENT_DRAFT, ownProps.rootId); - const isRemoteDraft = state.views.drafts.remotes[`${StoragePrefixes.COMMENT_DRAFT}${ownProps.rootId}`] || false; - - const channelMembersCount = getAllChannelStats(state)[ownProps.channelId] ? getAllChannelStats(state)[ownProps.channelId].member_count : 1; - const messageInHistory = getMessageInHistoryItem(state); - - const channel = state.entities.channels.channels[ownProps.channelId] || {}; - - const config = getConfig(state); - const license = getLicense(state); - const currentUserId = getCurrentUserId(state); - const enableConfirmNotificationsToChannel = config.EnableConfirmNotificationsToChannel === 'true'; - const enableEmojiPicker = config.EnableEmojiPicker === 'true'; - const enableGifPicker = config.EnableGifPicker === 'true'; - const badConnection = connectionErrorCount(state) > 1; - const canPost = haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CREATE_POST); - const useChannelMentions = haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_CHANNEL_MENTIONS); - const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true'; - const useCustomGroupMentions = isCustomGroupsEnabled(state) && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS); - const useLDAPGroupMentions = isLDAPEnabled && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS); - const channelMemberCountsByGroup = selectChannelMemberCountsByGroup(state, ownProps.channelId); - const groupsWithAllowReference = useLDAPGroupMentions || useCustomGroupMentions ? getAssociatedGroupsForReferenceByMention(state, channel.team_id, channel.id) : null; - const isFormattingBarHidden = getBool(state, Constants.Preferences.ADVANCED_TEXT_EDITOR, AdvancedTextEditor.COMMENT); - const currentTeamId = getCurrentTeamId(state); - const postEditorActions = state.plugins.components.PostEditorAction; - const shouldFocusRHS = getShouldFocusRHS(state); - - return { - currentTeamId, - draft, - isRemoteDraft, - messageInHistory, - channelMembersCount, - currentUserId, - isFormattingBarHidden, - codeBlockOnCtrlEnter: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true), - ctrlSend: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'), - createPostErrorId: err.server_error_id, - enableConfirmNotificationsToChannel, - enableEmojiPicker, - enableGifPicker, - locale: getCurrentLocale(state), - maxPostSize: parseInt(config.MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT, - rhsExpanded: getIsRhsExpanded(state), - badConnection, - selectedPostFocussedAt: getSelectedPostFocussedAt(state), - canPost, - useChannelMentions, - shouldShowPreview: showPreviewOnCreateComment(state), - groupsWithAllowReference, - useLDAPGroupMentions, - channelMemberCountsByGroup, - useCustomGroupMentions, - canUploadFiles: canUploadFiles(config), - postEditorActions, - shouldFocusRHS, - }; - }; -} - -function makeOnUpdateCommentDraft(rootId: string, channelId: string) { - return (draft?: PostDraft, save = false) => updateCommentDraft(rootId, draft ? {...draft, channelId} : draft, save); -} - -function makeUpdateCommentDraftWithRootId(channelId: string) { - return (rootId: string, draft?: PostDraft, save = false) => updateCommentDraft(rootId, draft ? {...draft, channelId} : draft, save); -} - -function makeMapDispatchToProps() { - let onUpdateCommentDraft: ReturnType; - let updateCommentDraftWithRootId: ReturnType; - let onSubmit: ReturnType; - let onEditLatestPost: ReturnType; - - function onResetHistoryIndex() { - return resetHistoryIndex(Posts.MESSAGE_TYPES.COMMENT); - } - - let rootId: string; - let channelId: string; - let latestPostId: string; - - return (dispatch: Dispatch, ownProps: OwnProps) => { - if (!ownProps.isPlugin) { - if (rootId !== ownProps.rootId) { - onUpdateCommentDraft = makeOnUpdateCommentDraft(ownProps.rootId, ownProps.channelId); - } - - if (channelId !== ownProps.channelId) { - updateCommentDraftWithRootId = makeUpdateCommentDraftWithRootId(ownProps.channelId); - } - - if (rootId !== ownProps.rootId) { - onEditLatestPost = makeOnEditLatestPost(ownProps.rootId); - } - - if (rootId !== ownProps.rootId || channelId !== ownProps.channelId || latestPostId !== ownProps.latestPostId) { - onSubmit = makeOnSubmit(ownProps.channelId, ownProps.rootId, ownProps.latestPostId); - } - } - - rootId = ownProps.rootId; - channelId = ownProps.channelId; - latestPostId = ownProps.latestPostId; - - return bindActionCreators( - { - clearCommentDraftUploads, - onUpdateCommentDraft, - updateCommentDraftWithRootId, - onSubmit, - onResetHistoryIndex, - moveHistoryIndexBack, - moveHistoryIndexForward, - onEditLatestPost, - resetCreatePostRequest, - getChannelTimezones, - emitShortcutReactToLastPostFrom, - setShowPreview: setShowPreviewOnCreateComment, - getChannelMemberCountsByGroup, - openModal, - savePreferences, - searchAssociatedGroupsForReference, - focusedRHS, - }, - dispatch, - ); - }; -} - -export default connect(makeMapStateToProps, makeMapDispatchToProps, null, {forwardRef: true})(AdvancedCreateComment); +export default AdvancedCreateComment; 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 deleted file mode 100644 index 4e87004525..0000000000 --- a/webapp/channels/src/components/advanced_create_post/__snapshots__/advanced_create_post.test.tsx.snap +++ /dev/null @@ -1,1271 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/advanced_create_post Show tutorial 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot for center textbox 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot when cannot post 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot when file upload disabled 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot, can post; preview disabled 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot, can post; preview enabled 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot, cannot post; preview disabled 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot, cannot post; preview enabled 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot, init 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot, post priority disabled, with priority important 1`] = ` -
- - -`; - -exports[`components/advanced_create_post should match snapshot, post priority enabled 1`] = ` -
- , - ] - } - applyMarkdown={[Function]} - badConnection={false} - canPost={true} - canUploadFiles={false} - caretPosition={0} - channelId="owsyt8n43jfxjpzh9np93mx1wa" - ctrlSend={false} - currentChannel={ - Object { - "create_at": 0, - "creator_id": "id", - "delete_at": 0, - "display_name": "name", - "group_constrained": false, - "header": "header", - "id": "owsyt8n43jfxjpzh9np93mx1wa", - "last_post_at": 0, - "last_root_post_at": 0, - "name": "DN", - "purpose": "purpose", - "scheme_id": "id", - "team_id": "team_id", - "type": "O", - "update_at": 0, - } - } - currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" - disableSend={false} - draft={ - Object { - "channelId": "", - "createAt": 0, - "fileInfos": Array [], - "message": "", - "rootId": "", - "updateAt": 0, - "uploadsInProgress": Array [], - } - } - emitTypingEvent={[Function]} - enableEmojiPicker={true} - enableGifPicker={true} - errorClass={null} - fileUploadRef={ - Object { - "current": null, - } - } - getFileUploadTarget={[Function]} - handleBlur={[Function]} - handleChange={[Function]} - handleEmojiClick={[Function]} - handleFileUploadChange={[Function]} - handleFileUploadComplete={[Function]} - handleGifClick={[Function]} - handleMouseUpKeyUp={[Function]} - handlePostError={[Function]} - handleSubmit={[Function]} - handleUploadError={[Function]} - handleUploadProgress={[Function]} - 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} - showEmojiPicker={false} - showSendTutorialTip={false} - textboxRef={ - Object { - "current": null, - } - } - toggleAdvanceTextEditor={[Function]} - toggleEmojiPicker={[Function]} - uploadsProgressPercent={Object {}} - useChannelMentions={true} - /> - -`; - -exports[`components/advanced_create_post should match snapshot, post priority enabled, with priority important 1`] = ` -
- , - ] - } - applyMarkdown={[Function]} - badConnection={false} - canPost={true} - canUploadFiles={false} - caretPosition={0} - channelId="owsyt8n43jfxjpzh9np93mx1wa" - ctrlSend={false} - currentChannel={ - Object { - "create_at": 0, - "creator_id": "id", - "delete_at": 0, - "display_name": "name", - "group_constrained": false, - "header": "header", - "id": "owsyt8n43jfxjpzh9np93mx1wa", - "last_post_at": 0, - "last_root_post_at": 0, - "name": "DN", - "purpose": "purpose", - "scheme_id": "id", - "team_id": "team_id", - "type": "O", - "update_at": 0, - } - } - currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa" - disableSend={false} - draft={ - Object { - "channelId": "", - "createAt": 0, - "fileInfos": Array [], - "message": "", - "metadata": Object { - "priority": Object { - "priority": "important", - }, - }, - "rootId": "", - "updateAt": 0, - "uploadsInProgress": Array [], - } - } - emitTypingEvent={[Function]} - enableEmojiPicker={true} - enableGifPicker={true} - errorClass={null} - fileUploadRef={ - Object { - "current": null, - } - } - getFileUploadTarget={[Function]} - handleBlur={[Function]} - handleChange={[Function]} - handleEmojiClick={[Function]} - handleFileUploadChange={[Function]} - handleFileUploadComplete={[Function]} - handleGifClick={[Function]} - handleMouseUpKeyUp={[Function]} - handlePostError={[Function]} - handleSubmit={[Function]} - handleUploadError={[Function]} - handleUploadProgress={[Function]} - handleUploadStart={[Function]} - hideEmojiPicker={[Function]} - isFormattingBarHidden={false} - labels={ - - } - 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} - showEmojiPicker={false} - showSendTutorialTip={false} - textboxRef={ - Object { - "current": null, - } - } - toggleAdvanceTextEditor={[Function]} - toggleEmojiPicker={[Function]} - uploadsProgressPercent={Object {}} - useChannelMentions={true} - /> - -`; 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 deleted file mode 100644 index 1df6a270f6..0000000000 --- a/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.tsx +++ /dev/null @@ -1,1184 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; - -import type {ChannelMemberCountsByGroup} from '@mattermost/types/channels'; -import type {CommandArgs} from '@mattermost/types/integrations'; -import type {Post} from '@mattermost/types/posts'; -import {PostPriority} from '@mattermost/types/posts'; - -import type {ActionResult} from 'mattermost-redux/types/actions'; - -import AdvancedCreatePost from 'components/advanced_create_post/advanced_create_post'; -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 Constants, {StoragePrefixes, ModalIdentifiers} from 'utils/constants'; -import EmojiMap from 'utils/emoji_map'; -import {TestHelper} from 'utils/test_helper'; - -jest.mock('actions/global_actions', () => ({ - emitLocalUserTypingEvent: jest.fn(), - emitUserPostedEvent: jest.fn(), -})); - -jest.mock('actions/post_actions', () => ({ - createPost: jest.fn(() => { - return new Promise((resolve) => { - process.nextTick(() => resolve()); - }); - }), -})); - -jest.mock('utils/exec_commands', () => ({ - execCommandInsertText: jest.fn(), -})); - -const currentTeamIdProp = 'r7rws4y7ppgszym3pdd5kaibfa'; -const currentUserIdProp = 'zaktnt8bpbgu8mb6ez9k64r7sa'; -const showSendTutorialTipProp = false; -const fullWidthTextBoxProp = true; -const latestReplyablePostIdProp = 'a'; -const localeProp = 'en'; - -const currentChannelProp = TestHelper.getChannelMock({ - id: 'owsyt8n43jfxjpzh9np93mx1wa', - type: 'O', -}); - -const currentChannelMembersCountProp = 9; - -const draftProp = TestHelper.getPostDraftMock({ - fileInfos: [], - message: '', - uploadsInProgress: [], -}); - -const ctrlSendProp = false; - -const currentUsersLatestPostProp = TestHelper.getPostMock({id: 'b', root_id: 'a', channel_id: currentChannelProp.id}); - -const baseProp: Props = { - currentTeamId: currentTeamIdProp, - currentChannelMembersCount: currentChannelMembersCountProp, - currentChannel: currentChannelProp, - currentUserId: currentUserIdProp, - showSendTutorialTip: showSendTutorialTipProp, - fullWidthTextBox: fullWidthTextBoxProp, - draft: draftProp, - isRemoteDraft: false, - latestReplyablePostId: latestReplyablePostIdProp, - locale: localeProp, - actions: { - addMessageIntoHistory: jest.fn(), - moveHistoryIndexBack: jest.fn(), - moveHistoryIndexForward: jest.fn(), - submitReaction: jest.fn(), - addReaction: jest.fn(), - removeReaction: jest.fn(), - clearDraftUploads: jest.fn(), - onSubmitPost: jest.fn(), - selectPostFromRightHandSideSearchByPostId: jest.fn(), - setDraft: jest.fn(), - setEditingPost: jest.fn(), - openModal: jest.fn(), - setShowPreview: jest.fn(), - savePreferences: jest.fn(), - executeCommand: () => { - return Promise.resolve({data: true}); - }, - getChannelTimezones: jest.fn(() => { - return Promise.resolve({data: [], error: ''}); - }), - runMessageWillBePostedHooks: (post: Post) => { - return Promise.resolve({data: post}); - }, - runSlashCommandWillBePostedHooks: (message: string, args: CommandArgs) => { - return Promise.resolve({data: {message, args}}); - }, - scrollPostListToBottom: jest.fn(), - getChannelMemberCountsByGroup: jest.fn(), - emitShortcutReactToLastPostFrom: jest.fn(), - searchAssociatedGroupsForReference: jest.fn(), - }, - ctrlSend: ctrlSendProp, - currentUsersLatestPost: currentUsersLatestPostProp, - canUploadFiles: false, - emojiMap: new EmojiMap(new Map()), - enableEmojiPicker: true, - enableGifPicker: true, - useLDAPGroupMentions: true, - useCustomGroupMentions: true, - canPost: true, - isPostPriorityEnabled: false, - enableConfirmNotificationsToChannel: true, - maxPostSize: Constants.DEFAULT_CHARACTER_LIMIT, - userIsOutOfOffice: false, - rhsExpanded: false, - rhsOpen: false, - badConnection: false, - shouldShowPreview: false, - useChannelMentions: true, - isFormattingBarHidden: false, - groupsWithAllowReference: null, - channelMemberCountsByGroup: [] as unknown as ChannelMemberCountsByGroup, - postEditorActions: [], -}; - -const submitEvent = { - preventDefault: jest.fn(), -} as unknown as React.FormEvent; - -function advancedCreatePost(props?: Partial) { - const allProps: Props = {...baseProp, ...props}; - - return ( - - ); -} - -describe('components/advanced_create_post', () => { - jest.useFakeTimers({legacyFakeTimers: true}); - let spy: jest.SpyInstance; - - beforeEach(() => { - spy = jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => setTimeout(cb, 16)); - }); - - afterEach(() => { - spy.mockRestore(); - }); - - it('should match snapshot, init', () => { - const wrapper = shallow(advancedCreatePost({})); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot for center textbox', () => { - const wrapper = shallow(advancedCreatePost({fullWidthTextBox: false})); - - expect(wrapper.find('#create_post').hasClass('center')).toBe(true); - expect(wrapper).toMatchSnapshot(); - }); - - it('should call clearDraftUploads on mount', () => { - const clearDraftUploads = jest.fn(); - const actions = { - ...baseProp.actions, - clearDraftUploads, - }; - - shallow(advancedCreatePost({actions})); - - expect(clearDraftUploads).toHaveBeenCalled(); - }); - - it('Check for state change on channelId change with useLDAPGroupMentions = true', () => { - const wrapper = shallow(advancedCreatePost({})); - const draft = { - ...draftProp, - message: 'test', - }; - - expect(wrapper.state('message')).toBe(''); - - wrapper.setProps({draft}); - expect(wrapper.state('message')).toBe(''); - - wrapper.setProps({ - currentChannel: { - ...currentChannelProp, - id: 'owsyt8n43jfxjpzh9np93mx1wb', - }, - }); - expect(wrapper.state('message')).toBe('test'); - }); - - it('Check for searchAssociatedGroupsForReference not called on mount when no mentions in the draft', () => { - const searchAssociatedGroupsForReference = jest.fn(); - const draft = { - ...draftProp, - message: 'hello', - }; - const actions = { - ...baseProp.actions, - searchAssociatedGroupsForReference, - }; - const wrapper = shallow(advancedCreatePost({draft, actions})); - expect(searchAssociatedGroupsForReference).not.toHaveBeenCalled(); - wrapper.setProps({ - currentChannel: { - ...currentChannelProp, - id: 'owsyt8n43jfxjpzh9np93mx1wb', - }, - }); - expect(searchAssociatedGroupsForReference).not.toHaveBeenCalled(); - }); - - it('Check for searchAssociatedGroupsForReference called on mount when one @ mention in the draft', () => { - const searchAssociatedGroupsForReference = jest.fn(); - const draft = { - ...draftProp, - message: '@group1 hello', - }; - const actions = { - ...baseProp.actions, - searchAssociatedGroupsForReference, - }; - const wrapper = shallow(advancedCreatePost({draft, actions})); - expect(searchAssociatedGroupsForReference).toHaveBeenCalled(); - wrapper.setProps({ - currentChannel: { - ...currentChannelProp, - id: 'owsyt8n43jfxjpzh9np93mx1wb', - }, - }); - expect(searchAssociatedGroupsForReference).toHaveBeenCalled(); - }); - - it('Check for getChannelMemberCountsByGroup called on mount when more than one @ mention in the draft', () => { - const getChannelMemberCountsByGroup = jest.fn(); - const draft = { - ...draftProp, - message: '@group1 @group2 hello', - }; - const actions = { - ...baseProp.actions, - getChannelMemberCountsByGroup, - }; - const wrapper = shallow(advancedCreatePost({draft, actions})); - expect(getChannelMemberCountsByGroup).toHaveBeenCalled(); - wrapper.setProps({ - currentChannel: { - ...currentChannelProp, - id: 'owsyt8n43jfxjpzh9np93mx1wb', - }, - }); - expect(getChannelMemberCountsByGroup).toHaveBeenCalled(); - }); - - it('Check for getChannelMemberCountsByGroup not called on mount and when channel changed with useLDAPGroupMentions = false', () => { - const getChannelMemberCountsByGroup = jest.fn(); - const useLDAPGroupMentions = false; - const actions = { - ...baseProp.actions, - getChannelMemberCountsByGroup, - }; - const wrapper = shallow(advancedCreatePost({actions, useLDAPGroupMentions})); - expect(getChannelMemberCountsByGroup).not.toHaveBeenCalled(); - wrapper.setProps({ - currentChannel: { - ...currentChannelProp, - id: 'owsyt8n43jfxjpzh9np93mx1wb', - }, - }); - expect(getChannelMemberCountsByGroup).not.toHaveBeenCalled(); - }); - - /** - * TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component - * - * it is not possible to test for this here since we only shallow render - * - * @see: https://mattermost.atlassian.net/browse/MM-44343 - */ - // it('click toggleEmojiPicker', () => { - // const wrapper = shallow(advancedCreatePost()); - // console.log('### debug', wrapper.debug()); - // wrapper.find('button[aria-label="select an emoji"]').simulate('click'); - // expect(wrapper.state('showEmojiPicker')).toBe(true); - // wrapper.find('.emoji-picker__container').simulate('click'); - // wrapper.find('EmojiPickerOverlay').prop('onHide')(); - // expect(wrapper.state('showEmojiPicker')).toBe(false); - // }); - - /** - * TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component - * - * it is not possible to test for this here since we only shallow render - * - * @see: https://mattermost.atlassian.net/browse/MM-44343 - */ - // it('Check for emoji click message states', () => { - // const wrapper = shallow(advancedCreatePost()); - // const mockImpl = () => { - // return { - // setSelectionRange: jest.fn(), - // focus: jest.fn(), - // }; - // }; - // wrapper.instance().textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - // - // wrapper.find('.emoji-picker__container').simulate('click'); - // expect(wrapper.state('showEmojiPicker')).toBe(true); - // - // wrapper.instance().handleEmojiClick({name: 'smile'}); - // expect(wrapper.state('message')).toBe(':smile: '); - // - // wrapper.setState({ - // message: 'test', - // caretPosition: 'test'.length, // cursor is at the end - // }); - // - // wrapper.instance().handleEmojiClick({name: 'smile'}); - // expect(wrapper.state('message')).toBe('test :smile: '); - // - // wrapper.setState({ - // message: 'test ', - // }); - // - // wrapper.instance().handleEmojiClick({name: 'smile'}); - // expect(wrapper.state('message')).toBe('test :smile: '); - // }); - - /** - * TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component - * - * it is not possible to test for this here since we only shallow render - * - * @see: https://mattermost.atlassian.net/browse/MM-44343 - */ - // it('onChange textbox should call setDraft and change message state', () => { - // const setDraft = jest.fn(); - // const draft = { - // ...draftProp, - // message: 'change', - // }; - // - // const wrapper = shallow( - // advancedCreatePost({ - // actions: { - // ...baseProp.actions, - // setDraft, - // }, - // }), - // ); - // - // const postTextbox = wrapper.find('#post_textbox'); - // postTextbox.simulate('change', {target: {value: 'change'}}); - // expect(setDraft).not.toHaveBeenCalled(); - // jest.runOnlyPendingTimers(); - // expect(setDraft).toHaveBeenCalledWith(StoragePrefixes.DRAFT + currentChannelProp.id, draft); - // }); - - /** - * TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component - * - * it is not possible to test for this here since we only shallow render - * - * @see: https://mattermost.atlassian.net/browse/MM-44343 - */ - // it('onKeyPress textbox should call emitLocalUserTypingEvent', () => { - // const wrapper = shallow(advancedCreatePost()); - // wrapper.instance().textboxRef.current = {blur: jest.fn()}; - // - // const postTextbox = wrapper.find('#post_textbox'); - // postTextbox.simulate('KeyPress', {key: Constants.KeyCodes.ENTER[0], preventDefault: jest.fn(), persist: jest.fn()}); - // expect(GlobalActions.emitLocalUserTypingEvent).toHaveBeenCalledWith(currentChannelProp.id, ''); - // }); - - it('onSubmit test for @all', async () => { - const result: ActionResult = { - data: [1, 2, 3, 4], - }; - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - getChannelTimezones: jest.fn(() => Promise.resolve(result)), - }, - currentChannelMembersCount: 9, - }), - ); - - wrapper.setState({ - message: 'test @all', - }); - - const instance = wrapper.instance() as AdvancedCreatePost; - - const showNotifyAllModal = instance.showNotifyAllModal; - instance.showNotifyAllModal = jest.fn((mentions, channelTimezoneCount, memberNotifyCount) => showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount)); - - const form = wrapper.find('#create_post'); - await form.simulate('Submit', {preventDefault: jest.fn()}); - - expect(instance.props.actions.openModal).toHaveBeenCalledTimes(1); - expect(instance.showNotifyAllModal).toHaveBeenCalledWith(['@all'], 4, 8); - - wrapper.setProps({ - currentChannelMembersCount: 2, - }); - - form.simulate('Submit', {preventDefault: jest.fn()}); - expect(instance.props.actions.openModal).toHaveBeenCalledTimes(1); - }); - - it('onSubmit test for @here', async () => { - const result: ActionResult = { - data: [1, 2, 3, 4], - }; - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - getChannelTimezones: jest.fn(() => Promise.resolve(result)), - }, - currentChannelMembersCount: 9, - }), - ); - - wrapper.setState({ - message: 'test @here', - }); - - const instance = wrapper.instance() as AdvancedCreatePost; - - const showNotifyAllModal = instance.showNotifyAllModal; - instance.showNotifyAllModal = jest.fn((mentions, channelTimezoneCount, memberNotifyCount) => showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount)); - - const form = wrapper.find('#create_post'); - await form.simulate('Submit', {preventDefault: jest.fn()}); - - expect(instance.props.actions.openModal).toHaveBeenCalledTimes(1); - expect(instance.showNotifyAllModal).toHaveBeenCalledWith(['@here'], 4, 8); - - wrapper.setProps({ - currentChannelMembersCount: 2, - }); - - form.simulate('Submit', {preventDefault: jest.fn()}); - expect(instance.props.actions.openModal).toHaveBeenCalledTimes(1); - }); - - it('onSubmit test for @groups', () => { - const wrapper = shallow(advancedCreatePost()); - - wrapper.setProps({ - groupsWithAllowReference: new Map([ - ['@developers', { - id: 'developers', - name: 'developers', - }], - ]), - channelMemberCountsByGroup: { - developers: { - channel_member_count: 10, - channel_member_timezones_count: 0, - }, - }, - }); - wrapper.setState({ - message: '@developers', - }); - - const instance = wrapper.instance() as AdvancedCreatePost; - - const showNotifyAllModal = (instance).showNotifyAllModal; - instance.showNotifyAllModal = jest.fn((mentions, channelTimezoneCount, memberNotifyCount) => showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount)); - - const form = wrapper.find('#create_post'); - form.simulate('Submit', {preventDefault: jest.fn()}); - expect(instance.props.actions.openModal).toHaveBeenCalled(); - expect(instance.showNotifyAllModal).toHaveBeenCalledWith(['@developers'], 0, 10); - }); - - it('onSubmit test for several @groups', () => { - const wrapper = shallow(advancedCreatePost()); - - wrapper.setProps({ - groupsWithAllowReference: new Map([ - ['@developers', { - id: 'developers', - name: 'developers', - }], - ['@boss', { - id: 'boss', - name: 'boss', - }], - ['@love', { - id: 'love', - name: 'love', - }], - ['@you', { - id: 'you', - name: 'you', - }], - ['@software-developers', { - id: 'softwareDevelopers', - name: 'software-developers', - }], - ]), - channelMemberCountsByGroup: { - developers: { - channel_member_count: 10, - channel_member_timezones_count: 0, - }, - boss: { - channel_member_count: 20, - channel_member_timezones_count: 0, - }, - love: { - channel_member_count: 30, - channel_member_timezones_count: 0, - }, - you: { - channel_member_count: 40, - channel_member_timezones_count: 0, - }, - softwareDevelopers: { - channel_member_count: 5, - channel_member_timezones_count: 0, - }, - }, - }); - wrapper.setState({ - message: '@developers @boss @love @you @software-developers', - }); - - const instance = wrapper.instance() as AdvancedCreatePost; - - const showNotifyAllModal = instance.showNotifyAllModal; - instance.showNotifyAllModal = jest.fn((mentions, channelTimezoneCount, memberNotifyCount) => showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount)); - - const form = wrapper.find('#create_post'); - form.simulate('Submit', {preventDefault: jest.fn()}); - expect(instance.props.actions.openModal).toHaveBeenCalled(); - expect(instance.showNotifyAllModal).toHaveBeenCalledWith(['@developers', '@boss', '@love', '@you', '@software-developers'], 0, 40); - }); - - it('onSubmit test for several @groups with timezone', () => { - const wrapper = shallow(advancedCreatePost()); - - wrapper.setProps({ - groupsWithAllowReference: new Map([ - ['@developers', { - id: 'developers', - name: 'developers', - }], - ['@boss', { - id: 'boss', - name: 'boss', - }], - ['@love', { - id: 'love', - name: 'love', - }], - ['@you', { - id: 'you', - name: 'you', - }], - ]), - channelMemberCountsByGroup: { - developers: { - channel_member_count: 10, - channel_member_timezones_count: 10, - }, - boss: { - channel_member_count: 20, - channel_member_timezones_count: 130, - }, - love: { - channel_member_count: 30, - channel_member_timezones_count: 2, - }, - you: { - channel_member_count: 40, - channel_member_timezones_count: 5, - }, - }, - }); - wrapper.setState({ - message: '@developers @boss @love @you', - }); - - const instance = wrapper.instance() as AdvancedCreatePost; - - const showNotifyAllModal = instance.showNotifyAllModal; - instance.showNotifyAllModal = jest.fn((mentions, channelTimezoneCount, memberNotifyCount) => showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount)); - - const form = wrapper.find('#create_post'); - form.simulate('Submit', {preventDefault: jest.fn()}); - expect(instance.props.actions.openModal).toHaveBeenCalled(); - expect(instance.showNotifyAllModal).toHaveBeenCalledWith(['@developers', '@boss', '@love', '@you'], 5, 40); - }); - - it('Should set mentionHighlightDisabled prop when useChannelMentions disabled before calling actions.onSubmitPost', async () => { - const onSubmitPost = jest.fn(); - const wrapper = shallow(advancedCreatePost({ - actions: { - ...baseProp.actions, - onSubmitPost, - }, - })); - - wrapper.setProps({ - useChannelMentions: false, - }); - - const post = TestHelper.getPostMock({message: 'message with @here mention'}); - await (wrapper.instance() as AdvancedCreatePost).sendMessage(post); - - expect(onSubmitPost).toHaveBeenCalledTimes(1); - expect(onSubmitPost.mock.calls[0][0]).toEqual({...post, props: {mentionHighlightDisabled: true}}); - }); - - it('Should not set mentionHighlightDisabled prop when useChannelMentions enabled before calling actions.onSubmitPost', async () => { - const onSubmitPost = jest.fn(); - const wrapper = shallow(advancedCreatePost({ - actions: { - ...baseProp.actions, - onSubmitPost, - }, - })); - - wrapper.setProps({ - useChannelMentions: true, - }); - - const post = TestHelper.getPostMock({message: 'message with @here mention'}); - await (wrapper.instance() as AdvancedCreatePost).sendMessage(post); - - expect(onSubmitPost).toHaveBeenCalledTimes(1); - expect(onSubmitPost.mock.calls[0][0]).toEqual(post); - }); - - it('Should not set mentionHighlightDisabled prop when useChannelMentions disabled but message does not contain channel metion before calling actions.onSubmitPost', async () => { - const onSubmitPost = jest.fn(); - const wrapper = shallow(advancedCreatePost({ - actions: { - ...baseProp.actions, - onSubmitPost, - }, - })); - - wrapper.setProps({ - useChannelMentions: false, - }); - - const post = TestHelper.getPostMock({message: 'message with @here mention'}); - await (wrapper.instance() as AdvancedCreatePost).sendMessage(post); - - expect(onSubmitPost).toHaveBeenCalledTimes(1); - expect(onSubmitPost.mock.calls[0][0]).toEqual(post); - }); - - it('onSubmit test for "/header" message', () => { - const openModal = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - openModal, - }, - }), - ); - - wrapper.setState({ - message: '/header', - }); - - const form = wrapper.find('#create_post'); - form.simulate('Submit', {preventDefault: jest.fn()}); - expect(openModal).toHaveBeenCalledTimes(1); - expect(openModal.mock.calls[0][0].modalId).toEqual(ModalIdentifiers.EDIT_CHANNEL_HEADER); - expect(openModal.mock.calls[0][0].dialogProps.channel).toEqual(currentChannelProp); - }); - - it('onSubmit test for "/purpose" message', () => { - const openModal = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - openModal, - }, - }), - ); - - wrapper.setState({ - message: '/purpose', - }); - - const form = wrapper.find('#create_post'); - form.simulate('Submit', {preventDefault: jest.fn()}); - expect(openModal).toHaveBeenCalledTimes(1); - expect(openModal.mock.calls[0][0].modalId).toEqual(ModalIdentifiers.EDIT_CHANNEL_PURPOSE); - expect(openModal.mock.calls[0][0].dialogProps.channel).toEqual(currentChannelProp); - }); - - it('onSubmit test for "/unknown" message ', async () => { - jest.mock('actions/channel_actions', () => ({ - executeCommand: jest.fn((message, _args, resolve) => resolve()), - })); - - const wrapper = shallow(advancedCreatePost()); - - wrapper.setState({ - message: '/unknown', - }); - - await (wrapper.instance() as AdvancedCreatePost).handleSubmit(submitEvent); - expect(wrapper.state('submitting')).toBe(false); - }); - - it('onSubmit test for addReaction message', async () => { - const submitReaction = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - submitReaction, - }, - }), - ); - - wrapper.setState({ - message: '+:smile:', - }); - - await (wrapper.instance() as AdvancedCreatePost).handleSubmit(submitEvent); - expect(submitReaction).toHaveBeenCalledWith('a', '+', 'smile'); - }); - - it('onSubmit test for removeReaction message', async () => { - const submitReaction = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - submitReaction, - }, - }), - ); - - wrapper.setState({ - message: '-:smile:', - }); - - await (wrapper.instance() as AdvancedCreatePost).handleSubmit(submitEvent); - expect(submitReaction).toHaveBeenCalledWith('a', '-', 'smile'); - }); - - /*it('check for postError state on handlePostError callback', () => { - const wrapper = shallow(createPost()); - const textBox = wrapper.find('#post_textbox'); - const form = wrapper.find('#create_post'); - - textBox.prop('handlePostError')(true); - expect(wrapper.state('postError')).toBe(true); - - wrapper.setState({ - message: 'test', - }); - - form.simulate('Submit', {preventDefault: jest.fn()}); - - expect(wrapper.update().find('.post-error .animation--highlight').length).toBe(1); - expect(wrapper.find('#postCreateFooter').hasClass('post-create-footer has-error')).toBe(true); - });*/ - - it('check for handleFileUploadChange callback for focus', () => { - const wrapper = shallow(advancedCreatePost()); - const instance: any = wrapper.instance(); - const mockImpl = () => { - return { - setSelectionRange: jest.fn(), - focus: jest.fn(), - }; - }; - instance.textboxRef.current = {getInputBox: jest.fn(mockImpl), focus: jest.fn(), blur: jest.fn()}; - instance.focusTextbox = jest.fn(); - - instance.handleFileUploadChange(); - expect(instance.focusTextbox).toHaveBeenCalledTimes(1); - }); - - it('check for handleFileUploadStart callback', () => { - const setDraft = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - setDraft, - }, - }), - ); - - const instance = wrapper.instance() as AdvancedCreatePost; - const clientIds = ['a']; - const draft = { - ...draftProp, - uploadsInProgress: [ - ...draftProp.uploadsInProgress, - ...clientIds, - ], - }; - - instance.handleUploadStart(clientIds, currentChannelProp.id); - expect(setDraft).toHaveBeenCalledWith(StoragePrefixes.DRAFT + currentChannelProp.id, draft, currentChannelProp.id); - }); - - it('check for handleFileUploadComplete callback', () => { - const setDraft = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - setDraft, - }, - }), - ); - - const instance: any = wrapper.instance(); - const clientIds = ['a']; - const uploadsInProgressDraft = { - ...draftProp, - uploadsInProgress: [ - ...draftProp.uploadsInProgress, - 'a', - ], - }; - - const channelId = 'another_channel_id'; - - instance.draftsForChannel[channelId] = uploadsInProgressDraft; - - wrapper.setProps({draft: uploadsInProgressDraft}); - const fileInfos = [TestHelper.getFileInfoMock({id: 'a'})]; - const expectedDraft = { - ...draftProp, - fileInfos: [ - ...draftProp.fileInfos, - ...fileInfos, - ], - }; - - instance.handleFileUploadComplete(fileInfos, clientIds, channelId); - - expect(setDraft).toHaveBeenCalledWith(StoragePrefixes.DRAFT + channelId, expectedDraft, channelId); - }); - - it('check for handleUploadError callback', () => { - const setDraft = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - setDraft, - }, - }), - ); - - const instance: any = wrapper.instance(); - const uploadsInProgressDraft = { - ...draftProp, - uploadsInProgress: [ - ...draftProp.uploadsInProgress, - 'a', - ], - }; - - wrapper.setProps({draft: uploadsInProgressDraft}); - - instance.draftsForChannel[currentChannelProp.id] = uploadsInProgressDraft; - instance.handleUploadError('error message', 'a', currentChannelProp.id); - - expect(setDraft).toHaveBeenCalledWith(StoragePrefixes.DRAFT + currentChannelProp.id, draftProp, currentChannelProp.id); - }); - - /** - * TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component - * - * it is not possible to test for this here since we only shallow render - * - * @see: https://mattermost.atlassian.net/browse/MM-44343 - */ - // it('check for uploadsProgressPercent state on handleUploadProgress callback', () => { - // const wrapper = shallow(advancedCreatePost({})); - // wrapper.find(FileUpload).prop('onUploadProgress')({clientId: 'clientId', name: 'name', percent: 10, type: 'type'}); - // - // expect(wrapper.state('uploadsProgressPercent')).toEqual({clientId: {clientId: 'clientId', percent: 10, name: 'name', type: 'type'}}); - // }); - - it('Remove preview from fileInfos', () => { - const setDraft = jest.fn(); - const fileInfos = TestHelper.getFileInfoMock({ - id: 'a', - extension: 'jpg', - name: 'trimmedFilename', - }); - const uploadsInProgressDraft = { - ...draftProp, - fileInfos: [ - ...draftProp.fileInfos, - fileInfos, - ], - }; - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - setDraft, - }, - draft: { - ...draftProp, - ...uploadsInProgressDraft, - }, - }), - ); - - const instance = wrapper.instance() as AdvancedCreatePost; - instance.handleFileUploadChange = jest.fn(); - instance.removePreview('a'); - - jest.advanceTimersByTime(Constants.SAVE_DRAFT_TIMEOUT); - expect(setDraft).toHaveBeenCalledTimes(1); - expect(setDraft).toHaveBeenCalledWith(StoragePrefixes.DRAFT + currentChannelProp.id, draftProp, currentChannelProp.id); - expect(instance.handleFileUploadChange).toHaveBeenCalledTimes(1); - }); - - it('Show tutorial', () => { - const wrapper = shallow(advancedCreatePost({ - showSendTutorialTip: true, - })); - expect(wrapper).toMatchSnapshot(); - }); - - it('Should have called actions.onSubmitPost on sendMessage', async () => { - const onSubmitPost = jest.fn(); - const wrapper = shallow(advancedCreatePost({ - actions: { - ...baseProp.actions, - onSubmitPost, - }, - })); - const post = TestHelper.getPostMock({message: 'message', file_ids: []}); - await (wrapper.instance() as AdvancedCreatePost).sendMessage(post); - - expect(onSubmitPost).toHaveBeenCalledTimes(1); - expect(onSubmitPost.mock.calls[0][0]).toEqual(post); - expect(onSubmitPost.mock.calls[0][1]).toEqual([]); - }); - - it('Should have called actions.selectPostFromRightHandSideSearchByPostId on replyToLastPost', () => { - const selectPostFromRightHandSideSearchByPostId = jest.fn(); - let latestReplyablePostId = ''; - const wrapper = shallow(advancedCreatePost({ - actions: { - ...baseProp.actions, - selectPostFromRightHandSideSearchByPostId, - }, - latestReplyablePostId, - })); - - const event = {preventDefault: jest.fn()} as unknown as React.KeyboardEvent; - - (wrapper.instance() as AdvancedCreatePost).replyToLastPost(event); - expect(selectPostFromRightHandSideSearchByPostId).not.toBeCalled(); - - latestReplyablePostId = 'latest_replyablePost_id'; - wrapper.setProps({latestReplyablePostId}); - (wrapper.instance() as AdvancedCreatePost).replyToLastPost(event); - expect(selectPostFromRightHandSideSearchByPostId).toHaveBeenCalledTimes(1); - expect(selectPostFromRightHandSideSearchByPostId.mock.calls[0][0]).toEqual(latestReplyablePostId); - }); - - it('should match snapshot when cannot post', () => { - const wrapper = shallow(advancedCreatePost({canPost: false})); - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot when file upload disabled', () => { - const wrapper = shallow(advancedCreatePost({canUploadFiles: false})); - expect(wrapper).toMatchSnapshot(); - }); - - it('should allow to force send invalid slash command as a message', async () => { - const error = { - message: 'No command found', - server_error_id: 'api.command.execute_command.not_found.app_error', - }; - const result: ActionResult = { - error, - }; - const executeCommand = jest.fn(() => Promise.resolve(result)); - const onSubmitPost = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - executeCommand, - onSubmitPost, - }, - }), - ); - - wrapper.setState({ - message: '/fakecommand some text', - }); - - await (wrapper.instance() as AdvancedCreatePost).handleSubmit(submitEvent); - expect(executeCommand).toHaveBeenCalled(); - expect(onSubmitPost).not.toHaveBeenCalled(); - - await (wrapper.instance() as AdvancedCreatePost).handleSubmit(submitEvent); - - expect(onSubmitPost).toHaveBeenCalledWith( - expect.objectContaining({ - message: '/fakecommand some text', - }), - expect.anything(), - ); - }); - - it('should throw away invalid command error if user resumes typing', async () => { - const error = { - message: 'No command found', - server_error_id: 'api.command.execute_command.not_found.app_error', - }; - - const executeCommand = jest.fn().mockResolvedValue({error}); - const onSubmitPost = jest.fn(); - - const wrapper = shallow( - advancedCreatePost({ - actions: { - ...baseProp.actions, - executeCommand, - onSubmitPost, - }, - }), - ); - - wrapper.setState({ - message: '/fakecommand some text', - }); - - const instance = wrapper.instance() as AdvancedCreatePost; - - await instance.handleSubmit(submitEvent); - expect(executeCommand).toHaveBeenCalled(); - expect(onSubmitPost).not.toHaveBeenCalled(); - - const event = { - target: { - value: 'some valid text', - }, - } as unknown as React.ChangeEvent; - - instance.handleChange(event); - - await instance.handleSubmit(submitEvent); - - expect(onSubmitPost).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'some valid text', - }), - expect.anything(), - ); - }); - - /** - * TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component - * - * it is not possible to test for this here since we only shallow render - * - * @see: https://mattermost.atlassian.net/browse/MM-44343 - */ - // it('should not enable the save button when message empty', () => { - // const wrapper = shallow(advancedCreatePost()); - // const saveButton = wrapper.find('.post-body__actions .send-button'); - // - // expect(saveButton.hasClass('disabled')).toBe(true); - // }); - - /** - * TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component - * - * it is not possible to test for this here since we only shallow render - * - * @see: https://mattermost.atlassian.net/browse/MM-44343 - */ - // it('should enable the save button when message not empty', () => { - // const wrapper = shallow(advancedCreatePost({draft: {...draftProp, message: 'a message'}})); - // const saveButton = wrapper.find('.post-body__actions .send-button'); - // - // expect(saveButton.hasClass('disabled')).toBe(false); - // }); - - /** - * TODO@all: move this test to advanced_text_editor.test.tsx and rewrite it according to the component - * - * it is not possible to test for this here since we only shallow render - * - * @see: https://mattermost.atlassian.net/browse/MM-44343 - */ - // it('should enable the save button when a file is available for upload', () => { - // const wrapper = shallow(advancedCreatePost({draft: {...draftProp, fileInfos: [{id: '1'}]}})); - // const saveButton = wrapper.find('.post-body__actions .send-button'); - // - // expect(saveButton.hasClass('disabled')).toBe(false); - // }); - - testComponentForLineBreak( - (value: string) => advancedCreatePost({draft: {...draftProp, message: value}}), - (instance: any) => instance.state.message, - false, - ); - - it('should match snapshot, can post; preview enabled', () => { - const wrapper = shallow(advancedCreatePost({canPost: true})); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot, can post; preview disabled', () => { - const wrapper = shallow(advancedCreatePost({canPost: true})); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot, cannot post; preview enabled', () => { - const wrapper = shallow(advancedCreatePost({canPost: false})); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot, cannot post; preview disabled', () => { - const wrapper = shallow(advancedCreatePost({canPost: false})); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot, post priority enabled', () => { - const wrapper = shallow(advancedCreatePost({isPostPriorityEnabled: true})); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot, post priority enabled, with priority important', () => { - const wrapper = shallow(advancedCreatePost({isPostPriorityEnabled: true, draft: {...draftProp, metadata: {priority: {priority: PostPriority.IMPORTANT}}}})); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot, post priority disabled, with priority important', () => { - const wrapper = shallow(advancedCreatePost({isPostPriorityEnabled: false, draft: {...draftProp, metadata: {priority: {priority: PostPriority.IMPORTANT}}}})); - - expect(wrapper).toMatchSnapshot(); - }); -}); 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 3dfac87435..f00efada9c 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 @@ -4,1505 +4,28 @@ /* eslint-disable max-lines */ import React from 'react'; +import {useSelector} from 'react-redux'; -import type {Channel, ChannelMemberCountsByGroup} from '@mattermost/types/channels'; -import type {Emoji} from '@mattermost/types/emojis'; -import type {ServerError} from '@mattermost/types/errors'; -import type {FileInfo} from '@mattermost/types/files'; -import {GroupSource} from '@mattermost/types/groups'; -import type {Group} from '@mattermost/types/groups'; -import type {CommandArgs} from '@mattermost/types/integrations'; -import {PostPriority} from '@mattermost/types/posts'; -import type {Post, PostMetadata, PostPriorityMetadata} from '@mattermost/types/posts'; -import type {PreferenceType} from '@mattermost/types/preferences'; - -import {Posts} from 'mattermost-redux/constants'; -import type {ActionResult} from 'mattermost-redux/types/actions'; -import {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; -import {sortFileInfos} from 'mattermost-redux/utils/file_utils'; - -import * as GlobalActions from 'actions/global_actions'; +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import AdvancedTextEditor from 'components/advanced_text_editor/advanced_text_editor'; -import EditChannelHeaderModal from 'components/edit_channel_header_modal'; -import EditChannelPurposeModal from 'components/edit_channel_purpose_modal'; -import FileLimitStickyBanner from 'components/file_limit_sticky_banner'; -import type {FilePreviewInfo} from 'components/file_preview/file_preview'; -import type {FileUpload as FileUploadClass} from 'components/file_upload/file_upload'; -import NotifyConfirmModal from 'components/notify_confirm_modal'; -import PersistNotificationConfirmModal from 'components/persist_notification_confirm_modal'; -import PostPriorityPickerOverlay from 'components/post_priority/post_priority_picker_overlay'; -import ResetStatusModal from 'components/reset_status_modal'; -import type TextboxClass from 'components/textbox/textbox'; -import Constants, { - StoragePrefixes, - ModalIdentifiers, - Locations, - A11yClassNames, - Preferences, - AdvancedTextEditor as AdvancedTextEditorConst, -} from 'utils/constants'; -import type EmojiMap from 'utils/emoji_map'; -import * as Keyboard from 'utils/keyboard'; -import {applyMarkdown} from 'utils/markdown/apply_markdown'; -import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; -import { - containsAtChannel, - specialMentionsInText, - postMessageOnKeyPress, - shouldFocusMainTextbox, - isErrorInvalidSlashCommand, - splitMessageBasedOnCaretPosition, - groupsMentionedInText, - mentionsMinusSpecialMentionsInText, - hasRequestedPersistentNotifications, -} from 'utils/post_utils'; -import * as UserAgent from 'utils/user_agent'; -import * as Utils from 'utils/utils'; +import {Locations} from 'utils/constants'; -import type {ModalData} from 'types/actions'; -import type {PostDraft} from 'types/store/draft'; -import type {PluginComponent} from 'types/store/plugins'; +const AdvancedCreatePost = () => { + const currentChannelId = useSelector(getCurrentChannelId); -import PriorityLabels from './priority_labels'; + if (!currentChannelId) { + return null; + } -const KeyCodes = Constants.KeyCodes; - -function isDraftEmpty(draft: PostDraft): boolean { - return !draft || (!draft.message && draft.fileInfos.length === 0); -} - -type TextboxElement = HTMLInputElement | HTMLTextAreaElement; - -export type Props = { - - // ref passed from channelView for EmojiPickerOverlay - getChannelView?: () => void; - - // Data used in notifying user for @all and @channel - currentChannelMembersCount: number; - - // Data used in multiple places of the component - currentChannel?: Channel; - - //Data used for DM prewritten messages - currentChannelTeammateUsername?: string; - - //Data used in executing commands for channel actions passed down to client4 function - currentTeamId: string; - - //Data used for posting message - currentUserId: string; - - //Force message submission on CTRL/CMD + ENTER - codeBlockOnCtrlEnter?: boolean; - - //Flag used for handling submit - ctrlSend?: boolean; - - //Flag used for adding a class center to Postbox based on user pref - fullWidthTextBox?: boolean; - - // Data used for deciding if tutorial tip is to be shown - showSendTutorialTip: boolean; - - // Data used populating message state when triggered by shortcuts - messageInHistoryItem?: string; - - // Data used for populating message state from previous draft - draft: PostDraft; - - // Data used for knowing if the draft came from a WS event - isRemoteDraft: boolean; - - // Data used dispatching handleViewAction ex: edit post - latestReplyablePostId?: string; - locale: string; - - // Data used for calling edit of post - currentUsersLatestPost?: Post | null; - - //Whether or not file upload is allowed. - canUploadFiles: boolean; - - //Whether to show the emoji picker. - enableEmojiPicker: boolean; - - //Whether to show the gif picker. - enableGifPicker: boolean; - - //Whether to check with the user before notifying the whole channel. - enableConfirmNotificationsToChannel: boolean; - - //The maximum length of a post - maxPostSize: number; - emojiMap: EmojiMap; - - //If our connection is bad - badConnection: boolean; - - //Whether to display a confirmation modal to reset status. - userIsOutOfOffice: boolean; - rhsExpanded: boolean; - - //If RHS open - rhsOpen: boolean; - - canPost: boolean; - - //To determine if the current user can send special channel mentions - useChannelMentions: boolean; - - //Should preview be showed - shouldShowPreview: boolean; - - isFormattingBarHidden: boolean; - - isPostPriorityEnabled: boolean; - - actions: { - - //Set show preview for textbox - setShowPreview: (showPreview: boolean) => void; - - // func called after message submit. - addMessageIntoHistory: (message: string) => void; - - // func called for navigation through messages by Up arrow - moveHistoryIndexBack: (index: string) => Promise; - - // func called for navigation through messages by Down arrow - moveHistoryIndexForward: (index: string) => Promise; - - submitReaction: (postId: string, action: string, emojiName: string) => void; - - // func called for adding a reaction - addReaction: (postId: string, emojiName: string) => void; - - // func called for posting message - onSubmitPost: (post: Post, fileInfos: FileInfo[]) => void; - - // func called for removing a reaction - removeReaction: (postId: string, emojiName: string) => void; - - // func called on load of component to clear drafts - clearDraftUploads: () => void; - - //hooks called before a message is sent to the server - runMessageWillBePostedHooks: (originalPost: Post) => Promise>; - - //hooks called before a slash command is sent to the server - runSlashCommandWillBePostedHooks: (originalMessage: string, originalArgs: CommandArgs) => Promise; - - // func called for setting drafts - setDraft: (name: string, value: PostDraft | null, draftChannelId: string, save?: boolean) => void; - - // func called for editing posts - setEditingPost: (postId?: string, refocusId?: string, title?: string, isRHS?: boolean) => void; - - // func called for opening the last replayable post in the RHS - selectPostFromRightHandSideSearchByPostId: (postId: string) => void; - - //Function to open a modal - openModal:

(modalData: ModalData

) => void; - - executeCommand: (message: string, args: CommandArgs) => Promise; - - //Function to get the users timezones in the channel - getChannelTimezones: (channelId: string) => Promise>; - scrollPostListToBottom: () => void; - - //Function to set or unset emoji picker for last message - emitShortcutReactToLastPostFrom: (emittedFrom: 'CENTER' | 'RHS_ROOT' | 'NO_WHERE') => void; - - getChannelMemberCountsByGroup: (channelId: string) => void; - - //Function used to advance the tutorial forward - savePreferences: (userId: string, preferences: PreferenceType[]) => Promise; - - searchAssociatedGroupsForReference: (prefix: string, teamId: string, channelId: string | undefined) => Promise<{ data: any }>; - }; - - groupsWithAllowReference: Map | null; - channelMemberCountsByGroup: ChannelMemberCountsByGroup; - useLDAPGroupMentions: boolean; - useCustomGroupMentions: boolean; - postEditorActions: PluginComponent[]; -} - -type State = { - message: string; - caretPosition: number; - submitting: boolean; - showEmojiPicker: boolean; - uploadsProgressPercent: {[clientID: string]: FilePreviewInfo}; - renderScrollbar: boolean; - scrollbarWidth: number; - currentChannel?: Channel; - errorClass: string | null; - serverError: (ServerError & {submittedMessage?: string}) | null; - postError?: React.ReactNode; - showFormat: boolean; - isFormattingBarHidden: boolean; - showPostPriorityPicker: boolean; + return ( + + ); }; -class AdvancedCreatePost extends React.PureComponent { - static defaultProps = { - latestReplyablePostId: '', - }; - - private lastBlurAt = 0; - private lastChannelSwitchAt = 0; - private draftsForChannel: {[channelID: string]: PostDraft | null} = {}; - private lastOrientation?: string; - private saveDraftFrame?: number | null; - private isDraftSubmitting = false; - private isNonFormattedPaste = false; - private timeoutId: number | null = null; - - private topDiv: React.RefObject; - private textboxRef: React.RefObject; - private fileUploadRef: React.RefObject; - - static getDerivedStateFromProps(props: Props, state: State): Partial { - let updatedState: Partial = { - currentChannel: props.currentChannel, - }; - if ( - props.currentChannel?.id !== state.currentChannel?.id || - (props.isRemoteDraft && props.draft.message !== state.message) - ) { - updatedState = { - ...updatedState, - message: props.draft.message, - submitting: false, - serverError: null, - }; - } - return updatedState; - } - - constructor(props: Props) { - super(props); - this.state = { - message: props.draft.message, - caretPosition: props.draft.message.length, - submitting: false, - showEmojiPicker: false, - uploadsProgressPercent: {}, - renderScrollbar: false, - scrollbarWidth: 0, - currentChannel: props.currentChannel, - errorClass: null, - serverError: null, - showFormat: false, - isFormattingBarHidden: props.isFormattingBarHidden, - showPostPriorityPicker: false, - }; - - this.topDiv = React.createRef(); - this.textboxRef = React.createRef(); - this.fileUploadRef = React.createRef(); - } - - componentDidMount() { - const {actions} = this.props; - this.onOrientationChange(); - actions.setShowPreview(false); - actions.clearDraftUploads(); - this.focusTextbox(); - document.addEventListener('keydown', this.documentKeyHandler); - window.addEventListener('beforeunload', this.unloadHandler); - this.setOrientationListeners(); - this.getChannelMemberCountsByGroup(); - } - - componentDidUpdate(prevProps: Props, prevState: State) { - const {currentChannel, actions} = this.props; - if (prevProps.currentChannel?.id !== currentChannel?.id) { - this.lastChannelSwitchAt = Date.now(); - this.focusTextbox(); - this.saveDraftWithShow(prevProps); - this.getChannelMemberCountsByGroup(); - } - - if (currentChannel?.id !== prevProps.currentChannel?.id) { - actions.setShowPreview(false); - } - - // Focus on textbox when emoji picker is closed - if (prevState.showEmojiPicker && !this.state.showEmojiPicker) { - this.focusTextbox(); - } - - // Focus on textbox when returned from preview mode - if (prevProps.shouldShowPreview && !this.props.shouldShowPreview) { - this.focusTextbox(); - } - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.documentKeyHandler); - window.removeEventListener('beforeunload', this.unloadHandler); - this.removeOrientationListeners(); - this.saveDraftWithShow(); - if (this.timeoutId !== null) { - clearTimeout(this.timeoutId); - } - } - - getChannelMemberCountsByGroup = () => { - const {useLDAPGroupMentions, useCustomGroupMentions, currentChannel, actions, draft} = this.props; - - if ((useLDAPGroupMentions || useCustomGroupMentions) && currentChannel?.id) { - const mentions = mentionsMinusSpecialMentionsInText(draft.message); - - if (mentions.length === 1) { - actions.searchAssociatedGroupsForReference(mentions[0], this.props.currentTeamId, currentChannel.id); - } else if (mentions.length > 1) { - actions.getChannelMemberCountsByGroup(currentChannel.id); - } - } - }; - - unloadHandler = () => { - this.saveDraftWithShow(); - }; - - saveDraftWithShow = (props = this.props) => { - if (this.saveDraftFrame && props.currentChannel) { - const channelId = props.currentChannel.id; - const draft = this.draftsForChannel[channelId]; - - if (draft) { - this.draftsForChannel[channelId] = { - ...draft, - show: !isDraftEmpty(draft), - } as PostDraft; - } - } - - this.saveDraft(props, true); - }; - - saveDraft = (props = this.props, save = false) => { - if (this.saveDraftFrame && props.currentChannel) { - const channelId = props.currentChannel.id; - props.actions.setDraft(StoragePrefixes.DRAFT + channelId, this.draftsForChannel[channelId], channelId, save); - clearTimeout(this.saveDraftFrame); - this.saveDraftFrame = null; - } - }; - - setShowPreview = (newPreviewValue: boolean) => { - this.props.actions.setShowPreview(newPreviewValue); - }; - - setOrientationListeners = () => { - if (window.screen.orientation && 'onchange' in window.screen.orientation) { - window.screen.orientation.addEventListener('change', this.onOrientationChange); - } else if ('onorientationchange' in window) { - window.addEventListener('orientationchange', this.onOrientationChange); - } - }; - - removeOrientationListeners = () => { - if (window.screen.orientation && 'onchange' in window.screen.orientation) { - window.screen.orientation.removeEventListener('change', this.onOrientationChange); - } else if ('onorientationchange' in window) { - window.removeEventListener('orientationchange', this.onOrientationChange); - } - }; - - onOrientationChange = () => { - if (!UserAgent.isIosWeb()) { - return; - } - - const LANDSCAPE_ANGLE = 90; - let orientation = 'portrait'; - if (window.orientation) { - orientation = Math.abs(window.orientation as number) === LANDSCAPE_ANGLE ? 'landscape' : 'portrait'; - } - - if (window.screen.orientation) { - orientation = window.screen.orientation.type.split('-')[0]; - } - - if ( - this.lastOrientation && - orientation !== this.lastOrientation && - (document.activeElement || {}).id === 'post_textbox' - ) { - this.textboxRef.current?.blur(); - } - - this.lastOrientation = orientation; - }; - - handlePostError = (postError: React.ReactNode) => { - if (this.state.postError !== postError) { - this.setState({postError}); - } - }; - - toggleEmojiPicker = (e?: React.MouseEvent): void => { - e?.stopPropagation(); - this.setState({showEmojiPicker: !this.state.showEmojiPicker}); - }; - - hideEmojiPicker = () => { - this.handleEmojiClose(); - }; - - doSubmit = async (e?: React.FormEvent) => { - const channelId = this.props.currentChannel?.id; - if (!channelId) { - return; - } - - if (e) { - e.preventDefault(); - } - - if (this.props.draft.uploadsInProgress.length > 0 || this.state.submitting) { - return; - } - - let message = this.state.message; - - let ignoreSlash = false; - const serverError = this.state.serverError; - - if (serverError && isErrorInvalidSlashCommand(serverError) && serverError.submittedMessage === message) { - message = serverError.submittedMessage; - ignoreSlash = true; - } - - const post = {} as Post; - post.file_ids = []; - post.message = message; - post.props = this.props.draft.props || {}; - post.metadata = (this.props.draft.metadata || {}) as PostMetadata; - - if (post.message.trim().length === 0 && this.props.draft.fileInfos.length === 0) { - return; - } - - if (this.state.postError) { - this.setState({errorClass: 'animation--highlight'}); - setTimeout(() => { - this.setState({errorClass: null}); - }, Constants.ANIMATION_TIMEOUT); - return; - } - - this.props.actions.addMessageIntoHistory(this.state.message); - - this.setState({submitting: true, serverError: null}); - - const fasterThanHumanWillClick = 150; - const forceFocus = Date.now() - this.lastBlurAt < fasterThanHumanWillClick; - this.focusTextbox(forceFocus); - - const isReaction = Utils.REACTION_PATTERN.exec(post.message); - if (post.message.indexOf('/') === 0 && !ignoreSlash) { - this.setState({message: '', postError: null}); - let args: CommandArgs = { - channel_id: channelId, - team_id: this.props.currentTeamId, - }; - - const hookResult = await this.props.actions.runSlashCommandWillBePostedHooks(post.message, args); - - if (hookResult.error) { - this.setState({ - serverError: { - ...hookResult.error, - submittedMessage: post.message, - }, - message: post.message, - }); - } else if (!hookResult.data.message && !hookResult.data.args) { - // do nothing with an empty return from a hook - } else { - post.message = hookResult.data.message; - args = hookResult.data.args; - - const {error} = await this.props.actions.executeCommand(post.message, args); - - if (error) { - if (error.sendMessage) { - await this.sendMessage(post); - } else { - this.setState({ - serverError: { - ...error, - submittedMessage: post.message, - }, - message: post.message, - }); - } - } - } - } else if (isReaction && this.props.emojiMap.has(isReaction[2])) { - this.sendReaction(isReaction); - - this.setState({message: ''}); - } else { - const {error} = await this.sendMessage(post); - - if (!error) { - this.setState({message: ''}); - } - } - - this.setState({ - submitting: false, - postError: null, - showFormat: false, - }); - - if (this.saveDraftFrame) { - clearTimeout(this.saveDraftFrame); - } - - this.isDraftSubmitting = false; - this.removeDraft(channelId); - }; - - handleNotifyAllConfirmation = () => { - this.doSubmit(); - }; - - showNotifyAllModal = (mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => { - this.props.actions.openModal({ - modalId: ModalIdentifiers.NOTIFY_CONFIRM_MODAL, - dialogType: NotifyConfirmModal, - dialogProps: { - mentions, - channelTimezoneCount, - memberNotifyCount, - onConfirm: () => this.handleNotifyAllConfirmation(), - onExited: () => { - this.isDraftSubmitting = false; - }, - }, - }); - }; - - showPersistNotificationModal = (message: string, specialMentions: {[key: string]: boolean}, channelType: Channel['type']) => { - this.props.actions.openModal({ - modalId: ModalIdentifiers.PERSIST_NOTIFICATION_CONFIRM_MODAL, - dialogType: PersistNotificationConfirmModal, - dialogProps: { - currentChannelTeammateUsername: this.props.currentChannelTeammateUsername, - specialMentions, - channelType, - message, - onConfirm: this.handleNotifyAllConfirmation, - }, - }); - }; - - getStatusFromSlashCommand = () => { - const {message} = this.state; - const tokens = message.split(' '); - - if (tokens.length > 0) { - return tokens[0].substring(1); - } - return ''; - }; - - isStatusSlashCommand = (command: string) => { - return command === 'online' || command === 'away' || command === 'dnd' || command === 'offline'; - }; - - handleSubmit = async (e: React.FormEvent) => { - const { - currentChannel: updateChannel, - userIsOutOfOffice, - groupsWithAllowReference, - channelMemberCountsByGroup, - currentChannelMembersCount, - useLDAPGroupMentions, - useCustomGroupMentions, - } = this.props; - - if (!updateChannel) { - return; - } - - this.setShowPreview(false); - this.isDraftSubmitting = true; - - const notificationsToChannel = this.props.enableConfirmNotificationsToChannel && this.props.useChannelMentions; - let memberNotifyCount = 0; - let channelTimezoneCount = 0; - let mentions: string[] = []; - - const specialMentions = specialMentionsInText(this.state.message); - const hasSpecialMentions = Object.values(specialMentions).includes(true); - - if (this.props.enableConfirmNotificationsToChannel && !hasSpecialMentions && (useLDAPGroupMentions || useCustomGroupMentions)) { - // Groups mentioned in users text - const mentionGroups = groupsMentionedInText(this.state.message, groupsWithAllowReference); - if (mentionGroups.length > 0) { - mentionGroups. - forEach((group) => { - if (group.source === GroupSource.Ldap && !useLDAPGroupMentions) { - return; - } - if (group.source === GroupSource.Custom && !useCustomGroupMentions) { - return; - } - const mappedValue = channelMemberCountsByGroup[group.id]; - if (mappedValue && mappedValue.channel_member_count > Constants.NOTIFY_ALL_MEMBERS && mappedValue.channel_member_count > memberNotifyCount) { - memberNotifyCount = mappedValue.channel_member_count; - channelTimezoneCount = mappedValue.channel_member_timezones_count; - } - mentions.push(`@${group.name}`); - }); - mentions = [...new Set(mentions)]; - } - } - - if (notificationsToChannel && currentChannelMembersCount > Constants.NOTIFY_ALL_MEMBERS && hasSpecialMentions) { - memberNotifyCount = currentChannelMembersCount - 1; - - for (const k in specialMentions) { - if (specialMentions[k]) { - mentions.push('@' + k); - } - } - - const {data} = await this.props.actions.getChannelTimezones(updateChannel.id); - channelTimezoneCount = data ? data.length : 0; - } - - const isDirectOrGroup = - updateChannel.type === Constants.DM_CHANNEL || updateChannel.type === Constants.GM_CHANNEL; - - if ( - this.props.isPostPriorityEnabled && - hasRequestedPersistentNotifications(this.props.draft?.metadata?.priority) - ) { - this.showPersistNotificationModal(this.state.message, specialMentions, updateChannel.type); - this.isDraftSubmitting = false; - return; - } else if (memberNotifyCount > 0) { - this.showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount); - return; - } - - const status = this.getStatusFromSlashCommand(); - if (userIsOutOfOffice && this.isStatusSlashCommand(status)) { - const resetStatusModalData = { - modalId: ModalIdentifiers.RESET_STATUS, - dialogType: ResetStatusModal, - dialogProps: {newStatus: status}, - }; - - this.props.actions.openModal(resetStatusModalData); - - this.setState({message: ''}); - this.isDraftSubmitting = false; - return; - } - - if (this.state.message.trimEnd() === '/header') { - const editChannelHeaderModalData = { - modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER, - dialogType: EditChannelHeaderModal, - dialogProps: {channel: updateChannel}, - }; - - this.props.actions.openModal(editChannelHeaderModalData); - - this.setState({message: ''}); - this.isDraftSubmitting = false; - return; - } - - if (!isDirectOrGroup && this.state.message.trimEnd() === '/purpose') { - const editChannelPurposeModalData = { - modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE, - dialogType: EditChannelPurposeModal, - dialogProps: {channel: updateChannel}, - }; - - this.props.actions.openModal(editChannelPurposeModalData); - - this.setState({message: ''}); - this.isDraftSubmitting = false; - return; - } - - await this.doSubmit(e); - }; - - sendMessage = async (originalPost: Post): Promise => { - const { - actions, - currentChannel, - currentUserId, - draft, - useLDAPGroupMentions, - useChannelMentions, - groupsWithAllowReference, - useCustomGroupMentions, - } = this.props; - - if (!currentChannel) { - return {data: false}; - } - - let post = originalPost; - - post.channel_id = currentChannel.id; - - const time = Utils.getTimestamp(); - const userId = currentUserId; - post.pending_post_id = `${userId}:${time}`; - post.user_id = userId; - post.create_at = time; - post.metadata = { - ...originalPost.metadata, - } as PostMetadata; - - post.props = { - ...originalPost.props, - }; - - if (!useChannelMentions && containsAtChannel(post.message, {checkAllMentions: true})) { - post.props.mentionHighlightDisabled = true; - } - if (!useLDAPGroupMentions && !useCustomGroupMentions && groupsMentionedInText(post.message, groupsWithAllowReference)) { - post.props.disable_group_highlight = true; - } - - const hookResult = await actions.runMessageWillBePostedHooks(post); - - if (hookResult.error) { - this.setState({ - serverError: hookResult.error, - submitting: false, - }); - - this.isDraftSubmitting = false; - return hookResult; - } - - post = hookResult.data!; - - actions.onSubmitPost(post, draft.fileInfos); - actions.scrollPostListToBottom(); - - this.setState({submitting: false}); - this.isDraftSubmitting = false; - - return {data: true}; - }; - - sendReaction(isReaction: RegExpExecArray) { - const action = isReaction[1]; - const emojiName = isReaction[2]; - const postId = this.props.latestReplyablePostId; - - if (postId) { - this.props.actions.submitReaction(postId, action, emojiName); - } - - this.removeDraft(); - } - - focusTextbox = (keepFocus = false) => { - const postTextboxDisabled = !this.props.canPost; - if (this.textboxRef.current && postTextboxDisabled) { - this.textboxRef.current.blur(); // Fixes Firefox bug which causes keyboard shortcuts to be ignored (MM-22482) - return; - } - if (this.textboxRef.current && (keepFocus || !UserAgent.isMobile())) { - this.textboxRef.current.focus(); - } - }; - - postMsgKeyPress = (e: React.KeyboardEvent) => { - const {ctrlSend, codeBlockOnCtrlEnter} = this.props; - - const {allowSending, withClosedCodeBlock, ignoreKeyPress, message} = postMessageOnKeyPress( - e, - this.state.message, - Boolean(ctrlSend), - Boolean(codeBlockOnCtrlEnter), - Date.now(), - this.lastChannelSwitchAt, - this.state.caretPosition, - ) as { - allowSending: boolean; - withClosedCodeBlock?: boolean; - ignoreKeyPress?: boolean; - message?: string; - }; - - if (ignoreKeyPress) { - e.preventDefault(); - e.stopPropagation(); - return; - } - - if (allowSending && this.isValidPersistentNotifications()) { - if (e.persist) { - e.persist(); - } - if (this.textboxRef.current) { - this.isDraftSubmitting = true; - this.textboxRef.current.blur(); - } - - if (withClosedCodeBlock && message) { - this.setState({message}, () => this.handleSubmit(e)); - } else { - this.handleSubmit(e); - } - - this.setShowPreview(false); - } - - this.emitTypingEvent(); - }; - - emitTypingEvent = () => { - const channelId = this.props.currentChannel?.id; - if (channelId) { - GlobalActions.emitLocalUserTypingEvent(channelId, ''); - } - }; - - handleChange = (e: React.ChangeEvent) => { - const message = e.target.value; - - let serverError = this.state.serverError; - if (isErrorInvalidSlashCommand(serverError)) { - serverError = null; - } - - this.setState({ - message, - serverError, - }); - - const draft = { - ...this.props.draft, - message, - }; - - this.handleDraftChange(draft); - }; - - handleDraftChange = (draft: PostDraft, channelId = this.props.currentChannel?.id, instant = false) => { - if (this.saveDraftFrame) { - clearTimeout(this.saveDraftFrame); - } - - if (!channelId) { - return; - } - - if (instant) { - this.props.actions.setDraft(StoragePrefixes.DRAFT + channelId, draft, channelId); - } else { - this.saveDraftFrame = window.setTimeout(() => { - this.props.actions.setDraft(StoragePrefixes.DRAFT + channelId, draft, channelId); - }, Constants.SAVE_DRAFT_TIMEOUT); - } - - this.draftsForChannel[channelId] = draft; - }; - - removeDraft = (channelId = this.props.currentChannel?.id) => { - if (!channelId) { - return; - } - this.props.actions.setDraft(StoragePrefixes.DRAFT + channelId, null, channelId); - this.draftsForChannel[channelId] = null; - }; - - handleFileUploadChange = () => { - this.focusTextbox(); - }; - - handleUploadStart = (clientIds: string[], channelId: string) => { - const uploadsInProgress = [...this.props.draft.uploadsInProgress, ...clientIds]; - - const draft = { - ...this.props.draft, - uploadsInProgress, - }; - - this.handleDraftChange(draft, channelId, true); - - // this is a bit redundant with the code that sets focus when the file input is clicked, - // but this also resets the focus after a drag and drop - this.focusTextbox(); - }; - - handleUploadProgress = (filePreviewInfo: FilePreviewInfo) => { - const uploadsProgressPercent = { - ...this.state.uploadsProgressPercent, - [filePreviewInfo.clientId]: filePreviewInfo, - }; - this.setState({uploadsProgressPercent}); - }; - - handleFileUploadComplete = (fileInfos: FileInfo[], clientIds: string[], channelId: string) => { - const draft = {...this.draftsForChannel[channelId]!}; - - // remove each finished file from uploads - for (let i = 0; i < clientIds.length; i++) { - if (draft.uploadsInProgress) { - const index = draft.uploadsInProgress.indexOf(clientIds[i]); - - if (index !== -1) { - draft.uploadsInProgress = draft.uploadsInProgress.filter((item, itemIndex) => index !== itemIndex); - } - } - } - - if (draft.fileInfos) { - draft.fileInfos = sortFileInfos(draft.fileInfos.concat(fileInfos), this.props.locale); - } - - this.handleDraftChange(draft, channelId, true); - }; - - handleUploadError = (uploadError: string | ServerError | null, clientId?: string, channelId?: string) => { - if (clientId && channelId) { - const draft = {...this.draftsForChannel[channelId]!}; - - if (draft.uploadsInProgress) { - const index = draft.uploadsInProgress.indexOf(clientId); - - if (index !== -1) { - const uploadsInProgress = draft.uploadsInProgress.filter((item, itemIndex) => index !== itemIndex); - const modifiedDraft = { - ...draft, - uploadsInProgress, - }; - this.handleDraftChange(modifiedDraft, channelId, true); - } - } - } - - if (typeof uploadError === 'string') { - if (uploadError.length !== 0) { - this.setState({serverError: new Error(uploadError)}); - } - } else { - this.setState({serverError: uploadError}); - } - }; - - removePreview = (id: string) => { - if (!this.props.currentChannel) { - return; - } - let modifiedDraft = {} as PostDraft; - const draft = {...this.props.draft}; - - // Clear previous errors - this.setState({serverError: null}); - - // id can either be the id of an uploaded file or the client id of an in progress upload - let index = draft.fileInfos.findIndex((info) => info.id === id); - if (index === -1) { - index = draft.uploadsInProgress.indexOf(id); - - if (index !== -1) { - const uploadsInProgress = draft.uploadsInProgress.filter((item, itemIndex) => index !== itemIndex); - - modifiedDraft = { - ...draft, - uploadsInProgress, - }; - - if (this.fileUploadRef.current && this.fileUploadRef.current) { - this.fileUploadRef.current.cancelUpload(id); - } - } - } else { - const fileInfos = draft.fileInfos.filter((item, itemIndex) => index !== itemIndex); - - modifiedDraft = { - ...draft, - fileInfos, - }; - } - - this.handleDraftChange(modifiedDraft, this.props.currentChannel.id, true); - this.handleFileUploadChange(); - - if (this.saveDraftFrame) { - clearTimeout(this.saveDraftFrame); - } - - this.saveDraftFrame = window.setTimeout(() => {}, Constants.SAVE_DRAFT_TIMEOUT); - }; - - focusTextboxIfNecessary = (e: KeyboardEvent) => { - // Focus should go to the RHS when it is expanded - if (this.props.rhsExpanded) { - return; - } - - // Hacky fix to avoid cursor jumping textbox sometimes - if (this.props.rhsOpen && document.activeElement?.tagName === 'BODY') { - return; - } - - // Bit of a hack to not steal focus from the channel switch modal if it's open - // This is a special case as the channel switch modal does not enforce focus like - // most modals do - if (document.getElementsByClassName('channel-switch-modal').length) { - return; - } - - if (shouldFocusMainTextbox(e, document.activeElement)) { - this.focusTextbox(); - } - }; - - documentKeyHandler = (e: KeyboardEvent) => { - const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey; - const lastMessageReactionKeyCombo = ctrlOrMetaKeyPressed && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH); - if (lastMessageReactionKeyCombo) { - this.reactToLastMessage(e); - return; - } - - this.focusTextboxIfNecessary(e); - }; - - getFileUploadTarget = () => { - return this.textboxRef.current?.getInputBox(); - }; - - fillMessageFromHistory() { - const lastMessage = this.props.messageInHistoryItem; - this.setState({ - message: lastMessage || '', - }); - } - - handleMouseUpKeyUp = (e: React.MouseEvent | React.KeyboardEvent) => { - this.setState({ - caretPosition: (e.target as HTMLInputElement).selectionStart || 0, - }); - }; - - editLastPost = (e: React.KeyboardEvent) => { - e.preventDefault(); - - const lastPost = this.props.currentUsersLatestPost; - if (!lastPost) { - return; - } - - let type; - if (lastPost.root_id && lastPost.root_id.length > 0) { - type = Utils.localizeMessage('create_post.comment', Posts.MESSAGE_TYPES.COMMENT); - } else { - type = Utils.localizeMessage('create_post.post', Posts.MESSAGE_TYPES.POST); - } - if (this.textboxRef.current) { - this.textboxRef.current.blur(); - } - this.props.actions.setEditingPost(lastPost.id, 'post_textbox', type); - }; - - replyToLastPost = (e: React.KeyboardEvent) => { - e.preventDefault(); - const latestReplyablePostId = this.props.latestReplyablePostId; - const replyBox = document.getElementById('reply_textbox'); - if (replyBox) { - replyBox.focus(); - } - if (latestReplyablePostId) { - this.props.actions.selectPostFromRightHandSideSearchByPostId(latestReplyablePostId); - } - }; - - loadPrevMessage = (e: React.KeyboardEvent) => { - e.preventDefault(); - this.props.actions.moveHistoryIndexBack(Posts.MESSAGE_TYPES.POST).then(() => this.fillMessageFromHistory()); - }; - - loadNextMessage = (e: React.KeyboardEvent) => { - e.preventDefault(); - this.props.actions.moveHistoryIndexForward(Posts.MESSAGE_TYPES.POST).then(() => this.fillMessageFromHistory()); - }; - - applyMarkdown = (params: ApplyMarkdownOptions) => { - if (this.props.shouldShowPreview) { - return; - } - - const res = applyMarkdown(params); - - this.setState({ - message: res.message, - }, () => { - const textbox = this.textboxRef.current?.getInputBox(); - Utils.setSelectionRange(textbox, res.selectionStart, res.selectionEnd); - - const draft = { - ...this.props.draft, - message: this.state.message, - }; - - this.handleDraftChange(draft); - }); - }; - - reactToLastMessage = (e: KeyboardEvent) => { - e.preventDefault(); - - const {rhsExpanded, actions: {emitShortcutReactToLastPostFrom}} = this.props; - const noModalsAreOpen = document.getElementsByClassName(A11yClassNames.MODAL).length === 0; - const noPopupsDropdownsAreOpen = document.getElementsByClassName(A11yClassNames.POPUP).length === 0; - - // Block keyboard shortcut react to last message when : - // - RHS is completely expanded - // - Any dropdown/popups are open - // - Any modals are open - if (!rhsExpanded && noModalsAreOpen && noPopupsDropdownsAreOpen) { - emitShortcutReactToLastPostFrom(Locations.CENTER); - } - }; - - handleBlur = () => { - if (!this.isDraftSubmitting) { - this.saveDraftWithShow(); - } - - this.lastBlurAt = Date.now(); - }; - - handleEmojiClose = () => { - this.setState({showEmojiPicker: false}); - }; - - setMessageAndCaretPosition = (newMessage: string, newCaretPosition: number) => { - const textbox = this.textboxRef.current?.getInputBox(); - - this.setState({ - message: newMessage, - caretPosition: newCaretPosition, - }, () => { - Utils.setCaretPosition(textbox, newCaretPosition); - - const draft = { - ...this.props.draft, - message: this.state.message, - }; - - this.handleDraftChange(draft); - }); - }; - - prefillMessage = (message: string, shouldFocus?: boolean) => { - this.setMessageAndCaretPosition(message, message.length); - - if (shouldFocus) { - const inputBox = this.textboxRef.current?.getInputBox(); - if (inputBox) { - // programmatic click needed to close the create post tip - inputBox.click(); - } - this.focusTextbox(true); - } - }; - - handleEmojiClick = (emoji: Emoji) => { - const emojiAlias = getEmojiName(emoji); - - if (!emojiAlias) { - //Oops.. There went something wrong - return; - } - - if (this.state.message === '') { - const newMessage = ':' + emojiAlias + ': '; - this.setMessageAndCaretPosition(newMessage, newMessage.length); - } else { - const {message} = this.state; - const {firstPiece, lastPiece} = splitMessageBasedOnCaretPosition(this.state.caretPosition, message); - - // check whether the first piece of the message is empty when cursor is placed at beginning of message and avoid adding an empty string at the beginning of the message - const newMessage = - firstPiece === '' ? `:${emojiAlias}: ${lastPiece}` : `${firstPiece} :${emojiAlias}: ${lastPiece}`; - - const newCaretPosition = - firstPiece === '' ? `:${emojiAlias}: `.length : `${firstPiece} :${emojiAlias}: `.length; - this.setMessageAndCaretPosition(newMessage, newCaretPosition); - } - - this.handleEmojiClose(); - }; - - handleGifClick = (gif: string) => { - if (this.state.message === '') { - this.setState({message: gif}); - } else { - const newMessage = (/\s+$/).test(this.state.message) ? this.state.message + gif : this.state.message + ' ' + gif; - this.setState({message: newMessage}); - - const draft = { - ...this.props.draft, - message: newMessage, - }; - - this.handleDraftChange(draft); - } - this.handleEmojiClose(); - }; - - toggleAdvanceTextEditor = () => { - this.setState({ - isFormattingBarHidden: - !this.state.isFormattingBarHidden, - }); - this.props.actions.savePreferences(this.props.currentUserId, [{ - category: Preferences.ADVANCED_TEXT_EDITOR, - user_id: this.props.currentUserId, - name: AdvancedTextEditorConst.POST, - value: String(!this.state.isFormattingBarHidden), - }]); - }; - - handleRemovePriority = () => { - this.handlePostPriorityApply(); - }; - - handlePostPriorityApply = (settings?: PostPriorityMetadata) => { - if (!this.props.currentChannel) { - return; - } - const updatedDraft = { - ...this.props.draft, - }; - - if (settings?.priority || settings?.requested_ack) { - updatedDraft.metadata = { - priority: { - ...settings, - priority: settings!.priority || '', - requested_ack: settings!.requested_ack, - }, - }; - } else { - updatedDraft.metadata = {}; - } - - this.handleDraftChange(updatedDraft, this.props.currentChannel.id, true); - this.focusTextbox(); - }; - - handlePostPriorityHide = () => { - this.focusTextbox(true); - }; - - hasPrioritySet = () => { - return ( - this.props.isPostPriorityEnabled && - this.props.draft.metadata?.priority && ( - this.props.draft.metadata.priority.priority || - this.props.draft.metadata.priority.requested_ack - ) - ); - }; - - isValidPersistentNotifications = (): boolean => { - if (!this.hasPrioritySet()) { - return true; - } - - const {currentChannel} = this.props; - const {priority, persistent_notifications: persistentNotifications} = this.props.draft.metadata!.priority!; - if (priority !== PostPriority.URGENT || !persistentNotifications) { - return true; - } - - if (currentChannel?.type === Constants.DM_CHANNEL) { - return true; - } - - if (this.hasSpecialMentions()) { - return false; - } - - const mentions = mentionsMinusSpecialMentionsInText(this.state.message); - - return mentions.length > 0; - }; - - getSpecialMentions = (): {[key: string]: boolean} => { - return specialMentionsInText(this.state.message); - }; - - hasSpecialMentions = (): boolean => { - 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; - - const pluginItems = this.props.postEditorActions?. - map((item) => { - if (!item.component) { - return null; - } - - const Component = item.component as any; - return ( - { - const input = this.textboxRef.current?.getInputBox(); - - return { - start: input.selectionStart, - end: input.selectionEnd, - }; - }} - updateText={(message: string) => { - this.setState({ - message, - }); - this.handleDraftChange({ - ...this.props.draft, - message, - }); - }} - /> - ); - }); - - let centerClass = ''; - if (!this.props.fullWidthTextBox) { - centerClass = 'center'; - } - - if (!this.props.currentChannel || !this.props.currentChannel.id) { - return null; - } - - return ( -

- {canPost && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0) && ( - - )} - - ) : undefined} - additionalControls={[ - this.props.isPostPriorityEnabled && ( - - ), - ...(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} - /> - - ); - } -} - -export default AdvancedCreatePost; +export default React.memo(AdvancedCreatePost); diff --git a/webapp/channels/src/components/advanced_create_post/index.ts b/webapp/channels/src/components/advanced_create_post/index.ts index 0644a71237..fe53f7031d 100644 --- a/webapp/channels/src/components/advanced_create_post/index.ts +++ b/webapp/channels/src/components/advanced_create_post/index.ts @@ -1,201 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import type {Dispatch} from 'redux'; - -import type {FileInfo} from '@mattermost/types/files'; -import type {Post} from '@mattermost/types/posts'; - -import {getChannelTimezones, getChannelMemberCountsByGroup} from 'mattermost-redux/actions/channels'; -import { - addMessageIntoHistory, - moveHistoryIndexBack, - moveHistoryIndexForward, - removeReaction, -} from 'mattermost-redux/actions/posts'; -import {savePreferences} from 'mattermost-redux/actions/preferences'; -import {Permissions, Posts, Preferences as PreferencesRedux} from 'mattermost-redux/constants'; -import {getCurrentChannelId, getCurrentChannel, getCurrentChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from 'mattermost-redux/selectors/entities/channels'; -import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups'; -import { - getCurrentUsersLatestPost, - getLatestReplyablePostId, - makeGetMessageInHistoryItem, - isPostPriorityEnabled, -} from 'mattermost-redux/selectors/entities/posts'; -import {get, getInt, getBool, isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences'; -import {haveICurrentChannelPermission} from 'mattermost-redux/selectors/entities/roles'; -import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import {getCurrentUserId, getStatusForUserId, getUser, isCurrentUserGuestUser} from 'mattermost-redux/selectors/entities/users'; -import type {ActionFuncAsync} from 'mattermost-redux/types/actions.js'; - -import {executeCommand} from 'actions/command'; -import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks'; -import {addReaction, createPost, setEditingPost, emitShortcutReactToLastPostFrom, submitReaction} from 'actions/post_actions'; -import {actionOnGlobalItemsWithPrefix} from 'actions/storage'; -import {scrollPostListToBottom} from 'actions/views/channel'; -import {removeDraft, updateDraft} from 'actions/views/drafts'; -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} from 'selectors/emojis'; -import {getCurrentLocale} from 'selectors/i18n'; -import {makeGetChannelDraft, getIsRhsExpanded, getIsRhsOpen} from 'selectors/rhs'; -import {connectionErrorCount} from 'selectors/views/system'; -import {showPreviewOnCreatePost} from 'selectors/views/textbox'; - -import {OnboardingTourSteps, TutorialTourName, OnboardingTourStepsForGuestUsers} from 'components/tours'; - -import {AdvancedTextEditor, Constants, Preferences, StoragePrefixes, UserStatuses} from 'utils/constants'; -import {canUploadFiles} from 'utils/file_utils'; - -import type {PostDraft} from 'types/store/draft'; -import type {GlobalState} from 'types/store/index.js'; - import AdvancedCreatePost from './advanced_create_post'; -function makeMapStateToProps() { - const getMessageInHistoryItem = makeGetMessageInHistoryItem(Posts.MESSAGE_TYPES.POST as any); - const getChannelDraft = makeGetChannelDraft(); - - return (state: GlobalState) => { - const config = getConfig(state); - const license = getLicense(state); - const currentChannel = getCurrentChannel(state); - const currentChannelTeammateUsername = currentChannel ? getUser(state, currentChannel.teammate_id || '')?.username : undefined; - const draft = getChannelDraft(state, currentChannel?.id || ''); - const isRemoteDraft = (currentChannel && state.views.drafts.remotes[`${StoragePrefixes.DRAFT}${currentChannel.id}`]) || false; - const latestReplyablePostId = getLatestReplyablePostId(state); - const currentChannelMembersCount = getCurrentChannelStats(state)?.member_count ?? 1; - const enableEmojiPicker = config.EnableEmojiPicker === 'true'; - const enableGifPicker = config.EnableGifPicker === 'true'; - const enableConfirmNotificationsToChannel = config.EnableConfirmNotificationsToChannel === 'true'; - const currentUserId = getCurrentUserId(state); - const userIsOutOfOffice = getStatusForUserId(state, currentUserId) === UserStatuses.OUT_OF_OFFICE; - const badConnection = connectionErrorCount(state) > 1; - const canPost = haveICurrentChannelPermission(state, Permissions.CREATE_POST); - const useChannelMentions = haveICurrentChannelPermission(state, Permissions.USE_CHANNEL_MENTIONS); - const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true'; - const useCustomGroupMentions = isCustomGroupsEnabled(state) && haveICurrentChannelPermission(state, Permissions.USE_GROUP_MENTIONS); - const useLDAPGroupMentions = isLDAPEnabled && haveICurrentChannelPermission(state, Permissions.USE_GROUP_MENTIONS); - const channelMemberCountsByGroup = currentChannel ? selectChannelMemberCountsByGroup(state, currentChannel.id) : {}; - const currentTeamId = getCurrentTeamId(state); - const groupsWithAllowReference = (currentChannel && (useLDAPGroupMentions || useCustomGroupMentions)) ? - getAssociatedGroupsForReferenceByMention(state, currentTeamId, currentChannel.id) : - null; - const enableTutorial = config.EnableTutorial === 'true'; - const tutorialStep = getInt(state, TutorialTourName.ONBOARDING_TUTORIAL_STEP, currentUserId, 0); - - // guest validation to see which point the messaging tour tip starts - const isGuestUser = isCurrentUserGuestUser(state); - const tourStep = isGuestUser ? OnboardingTourStepsForGuestUsers.SEND_MESSAGE : OnboardingTourSteps.SEND_MESSAGE; - const showSendTutorialTip = enableTutorial && tutorialStep === tourStep; - const isFormattingBarHidden = getBool(state, Preferences.ADVANCED_TEXT_EDITOR, AdvancedTextEditor.POST); - const postEditorActions = state.plugins.components.PostEditorAction; - - return { - currentTeamId, - currentChannel, - currentChannelTeammateUsername, - currentChannelMembersCount, - currentUserId, - isFormattingBarHidden, - codeBlockOnCtrlEnter: getBool(state, PreferencesRedux.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true), - ctrlSend: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'), - fullWidthTextBox: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN, - showSendTutorialTip, - messageInHistoryItem: getMessageInHistoryItem(state), - draft, - isRemoteDraft, - latestReplyablePostId, - locale: getCurrentLocale(state), - currentUsersLatestPost: getCurrentUsersLatestPost(state, ''), - canUploadFiles: canUploadFiles(config), - enableEmojiPicker, - enableGifPicker, - enableConfirmNotificationsToChannel, - maxPostSize: parseInt(config.MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT, - userIsOutOfOffice, - rhsExpanded: getIsRhsExpanded(state), - rhsOpen: getIsRhsOpen(state), - emojiMap: getEmojiMap(state), - badConnection, - canPost, - useChannelMentions, - shouldShowPreview: showPreviewOnCreatePost(state), - groupsWithAllowReference, - useLDAPGroupMentions, - channelMemberCountsByGroup, - isLDAPEnabled, - useCustomGroupMentions, - isPostPriorityEnabled: isPostPriorityEnabled(state), - postEditorActions, - }; - }; -} - -function onSubmitPost(post: Post, fileInfos: FileInfo[]) { - return (dispatch: Dispatch) => { - dispatch(createPost(post, fileInfos) as any); - }; -} - -function setDraft(key: string, value: PostDraft | null, draftChannelId: string, save = false): ActionFuncAsync { - return (dispatch, getState) => { - const channelId = draftChannelId || getCurrentChannelId(getState()); - let updatedValue = null; - if (value) { - updatedValue = {...value, channelId}; - } - if (updatedValue) { - return dispatch(updateDraft(key, updatedValue, '', save)); - } - - return dispatch(removeDraft(key, channelId)); - }; -} - -function clearDraftUploads() { - return actionOnGlobalItemsWithPrefix(StoragePrefixes.DRAFT, (_key: string, draft: PostDraft) => { - if (!draft || !draft.uploadsInProgress || draft.uploadsInProgress.length === 0) { - return draft; - } - - return {...draft, uploadsInProgress: []}; - }); -} - -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators({ - addMessageIntoHistory, - onSubmitPost, - moveHistoryIndexBack, - moveHistoryIndexForward, - submitReaction, - addReaction, - removeReaction, - setDraft, - clearDraftUploads, - selectPostFromRightHandSideSearchByPostId, - setEditingPost, - emitShortcutReactToLastPostFrom, - openModal, - executeCommand, - getChannelTimezones, - runMessageWillBePostedHooks, - runSlashCommandWillBePostedHooks, - scrollPostListToBottom, - setShowPreview: setShowPreviewOnCreatePost, - getChannelMemberCountsByGroup, - savePreferences, - searchAssociatedGroupsForReference, - }, dispatch), - }; -} - -export default connect(makeMapStateToProps, mapDispatchToProps)(AdvancedCreatePost); +export default AdvancedCreatePost; diff --git a/webapp/channels/src/components/advanced_create_post/prewritten_chips.tsx b/webapp/channels/src/components/advanced_create_post/prewritten_chips.tsx index a16f0ddf5a..aef7a60dee 100644 --- a/webapp/channels/src/components/advanced_create_post/prewritten_chips.tsx +++ b/webapp/channels/src/components/advanced_create_post/prewritten_chips.tsx @@ -3,19 +3,24 @@ import React, {useMemo, memo} from 'react'; import {defineMessage, useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; import styled from 'styled-components'; -import type {Channel} from '@mattermost/types/channels'; +import {getChannel, getDirectTeammate} from 'mattermost-redux/selectors/entities/channels'; +import {getUser} from 'mattermost-redux/selectors/entities/users'; import {trackEvent} from 'actions/telemetry_actions'; import Chip from 'components/common/chip/chip'; +import Constants from 'utils/constants'; + +import type {GlobalState} from 'types/store'; + type Props = { prefillMessage: (msg: string, shouldFocus: boolean) => void; - currentChannel: Channel; + channelId: string; currentUserId: string; - currentChannelTeammateUsername?: string; } const UsernameMention = styled.span` @@ -28,8 +33,11 @@ const ChipContainer = styled.div` flex-wrap: wrap; `; -const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateUsername, prefillMessage}: Props) => { +const PrewrittenChips = ({channelId, currentUserId, prefillMessage}: Props) => { const {formatMessage} = useIntl(); + const channelType = useSelector((state: GlobalState) => getChannel(state, channelId)?.type || Constants.OPEN_CHANNEL); + const channelTeammateId = useSelector((state: GlobalState) => getDirectTeammate(state, channelId)?.id || ''); + const channelTeammateUsername = useSelector((state: GlobalState) => getUser(state, channelTeammateId)?.username || ''); const chips = useMemo(() => { const customChip = { @@ -45,7 +53,11 @@ const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateU leadingIcon: '', }; - if (currentChannel.type === 'O' || currentChannel.type === 'P' || currentChannel.type === 'G') { + if ( + channelType === Constants.OPEN_CHANNEL || + channelType === Constants.PRIVATE_CHANNEL || + channelType === Constants.GM_CHANNEL + ) { return [ { event: 'prefilled_message_selected_team_hi', @@ -87,7 +99,7 @@ const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateU ]; } - if (currentChannel.teammate_id === currentUserId) { + if (channelTeammateId === currentUserId) { return [ { event: 'prefilled_message_selected_self_note', @@ -144,12 +156,12 @@ const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateU }, customChip, ]; - }, [currentChannel, currentUserId]); + }, [channelType, channelTeammateId, currentUserId]); return ( {chips.map(({event, message, display, leadingIcon}) => { - const values = {username: currentChannelTeammateUsername}; + const values = {username: channelTeammateUsername}; const messageToPrefill = message.id ? formatMessage( message, values, @@ -157,15 +169,14 @@ const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateU const additionalMarkup = message.id === 'create_post.prewritten.tip.dm_hey' ? ( - {'@'}{currentChannelTeammateUsername} + {'@'}{channelTeammateUsername} ) : null; return ( { diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx index a3647891bd..b1a010c711 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx @@ -1,7 +1,6 @@ // 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'; @@ -12,7 +11,7 @@ import type {FileUpload} from 'components/file_upload/file_upload'; import type Textbox from 'components/textbox/textbox'; import mergeObjects from 'packages/mattermost-redux/test/merge_objects'; -import {renderWithContext, userEvent} from 'tests/react_testing_utils'; +import {renderWithContext, userEvent, screen} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; import type {PostDraft} from 'types/store/draft'; @@ -146,71 +145,6 @@ const baseProps = { describe('components/avanced_text_editor/advanced_text_editor', () => { describe('keyDown behavior', () => { - it('Enter should call postMsgKeyPress', () => { - const postMsgKeyPress = jest.fn(); - renderWithContext( - , - 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(); - renderWithContext( - , - 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(); - renderWithContext( - , - 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', () => { renderWithContext( { 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; - - renderWithContext( - , - 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 61fa15762b..1bc3f4133a 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -6,43 +6,41 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; -import {EmoticonHappyOutlineIcon} from '@mattermost/compass-icons/components'; -import type {Channel} from '@mattermost/types/channels'; -import type {Emoji} from '@mattermost/types/emojis'; import type {ServerError} from '@mattermost/types/errors'; -import type {FileInfo} from '@mattermost/types/files'; -import {getDirectChannel} from 'mattermost-redux/selectors/entities/channels'; -import {getPost} from 'mattermost-redux/selectors/entities/posts'; -import {getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users'; +import {savePreferences} from 'mattermost-redux/actions/preferences'; +import {Permissions} from 'mattermost-redux/constants'; +import {getChannel, makeGetChannel, getDirectChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {get, getBool, getInt} from 'mattermost-redux/selectors/entities/preferences'; +import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; +import {getCurrentUserId, isCurrentUserGuestUser, getStatusForUserId, makeGetDisplayName} from 'mattermost-redux/selectors/entities/users'; -import {emitShortcutReactToLastPostFrom} from 'actions/post_actions'; +import * as GlobalActions from 'actions/global_actions'; +import {actionOnGlobalItemsWithPrefix} from 'actions/storage'; +import {removeDraft, updateDraft} from 'actions/views/drafts'; +import {makeGetDraft} from 'selectors/rhs'; +import {connectionErrorCount} from 'selectors/views/system'; import LocalStorageStore from 'stores/local_storage_store'; import AutoHeightSwitcher from 'components/common/auto_height_switcher'; -import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; -import FilePreview from 'components/file_preview'; -import type {FilePreviewInfo} from 'components/file_preview/file_preview'; -import FileUpload from 'components/file_upload'; -import type {FileUpload as FileUploadClass} from 'components/file_upload/file_upload'; -import KeyboardShortcutSequence, {KEYBOARD_SHORTCUTS} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence'; +import useDidUpdate from 'components/common/hooks/useDidUpdate'; +import FileLimitStickyBanner from 'components/file_limit_sticky_banner'; import MessageSubmitError from 'components/message_submit_error'; import MsgTyping from 'components/msg_typing'; -import OverlayTrigger from 'components/overlay_trigger'; import RhsSuggestionList from 'components/suggestion/rhs_suggestion_list'; import SuggestionList from 'components/suggestion/suggestion_list'; import Textbox from 'components/textbox'; import type {TextboxElement} from 'components/textbox'; import type TextboxClass from 'components/textbox/textbox'; -import Tooltip from 'components/tooltip'; +import {OnboardingTourSteps, OnboardingTourStepsForGuestUsers, TutorialTourName} from 'components/tours/constant'; import {SendMessageTour} from 'components/tours/onboarding_tour'; -import Constants, {Locations, UserStatuses} from 'utils/constants'; -import * as Keyboard from 'utils/keyboard'; +import Constants, {Locations, StoragePrefixes, Preferences, AdvancedTextEditor as AdvancedTextEditorConst, UserStatuses} from 'utils/constants'; +import {canUploadFiles as canUploadFilesAccordingToConfig} from 'utils/file_utils'; +import {applyMarkdown as applyMarkdownUtil} from 'utils/markdown/apply_markdown'; import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; -import {pasteHandler} from 'utils/paste'; -import {isWithinCodeBlock} from 'utils/post_utils'; -import * as UserAgent from 'utils/user_agent'; +import {isErrorInvalidSlashCommand} from 'utils/post_utils'; import * as Utils from 'utils/utils'; import type {GlobalState} from 'types/store'; @@ -51,16 +49,25 @@ import type {PostDraft} from 'types/store/draft'; import DoNotDisturbWarning from './do_not_disturb_warning'; import FormattingBar from './formatting_bar'; import {FormattingBarSpacer, Separator} from './formatting_bar/formatting_bar'; -import {IconContainer} from './formatting_bar/formatting_icon'; import RemoteUserHour from './remote_user_hour'; import SendButton from './send_button'; import ShowFormat from './show_formatting'; import TexteditorActions from './texteditor_actions'; import ToggleFormattingBar from './toggle_formatting_bar'; +import useEmojiPicker from './use_emoji_picker'; +import useKeyHandler from './use_key_handler'; +import useOrientationHandler from './use_orientation_handler'; +import usePluginItems from './use_plugin_items'; +import usePriority from './use_priority'; +import useSubmit from './use_submit'; +import useTextboxFocus from './use_textbox_focus'; +import useUploadFiles from './use_upload_files'; import './advanced_text_editor.scss'; -const KeyCodes = Constants.KeyCodes; +function isDraftEmpty(draft: PostDraft) { + return draft.message === '' && draft.fileInfos.length === 0 && draft.uploadsInProgress.length === 0; +} type Props = { @@ -68,282 +75,344 @@ type Props = { * location of the advanced text editor in the UI (center channel / RHS) */ location: string; - currentUserId: string; - message: string; - showEmojiPicker: boolean; - uploadsProgressPercent: { [clientID: string]: FilePreviewInfo }; - currentChannel?: Channel; - errorClass: string | null; - serverError: (ServerError & { submittedMessage?: string }) | null; - postError?: React.ReactNode; - isFormattingBarHidden: boolean; - draft: PostDraft; - showSendTutorialTip?: boolean; - handleSubmit: (e: React.FormEvent) => void; - removePreview: (id: string) => void; - setShowPreview: (newPreviewValue: boolean) => void; - shouldShowPreview: boolean; - maxPostSize: number; - canPost: boolean; - applyMarkdown: (params: ApplyMarkdownOptions) => void; - useChannelMentions: boolean; - badConnection: boolean; - currentChannelTeammateUsername?: string; - canUploadFiles: boolean; - enableEmojiPicker: boolean; - enableGifPicker: boolean; - handleBlur: () => void; - handlePostError: (postError: React.ReactNode) => void; - emitTypingEvent: () => void; - handleMouseUpKeyUp: (e: React.MouseEvent | React.KeyboardEvent) => void; - postMsgKeyPress: (e: React.KeyboardEvent) => void; - handleChange: (e: React.ChangeEvent) => void; - toggleEmojiPicker: () => void; - handleGifClick: (gif: string) => void; - handleEmojiClick: (emoji: Emoji) => void; - hideEmojiPicker: () => void; - toggleAdvanceTextEditor: () => void; - handleUploadProgress: (filePreviewInfo: FilePreviewInfo) => void; - handleUploadError: (err: string | ServerError | null, clientId?: string, channelId?: string) => void; - handleFileUploadComplete: (fileInfos: FileInfo[], clientIds: string[], channelId: string, rootId?: string) => void; - handleUploadStart: (clientIds: string[], channelId: string) => void; - handleFileUploadChange: () => void; - getFileUploadTarget: () => HTMLInputElement | null; - fileUploadRef: React.RefObject; - prefillMessage?: (message: string, shouldFocus?: boolean) => void; channelId: string; postId: string; - textboxRef: React.RefObject; isThreadView?: boolean; - 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; } const AdvanceTextEditor = ({ location, - message, - showEmojiPicker, - uploadsProgressPercent, - currentChannel, channelId, postId, - errorClass, - serverError, - postError, - isFormattingBarHidden, - draft, - badConnection, - handleSubmit, - removePreview, - showSendTutorialTip, - setShowPreview, - shouldShowPreview, - maxPostSize, - canPost, - applyMarkdown, - useChannelMentions, - currentChannelTeammateUsername, - currentUserId, - canUploadFiles, - enableEmojiPicker, - enableGifPicker, - handleBlur: onBlur, - handlePostError, - emitTypingEvent, - handleMouseUpKeyUp, - postMsgKeyPress, - handleChange, - toggleEmojiPicker, - handleGifClick, - handleEmojiClick, - hideEmojiPicker, - toggleAdvanceTextEditor, - handleUploadProgress, - handleUploadError, - handleFileUploadComplete, - handleUploadStart, - handleFileUploadChange, - getFileUploadTarget, - fileUploadRef, - prefillMessage, - textboxRef, - isThreadView, - additionalControls, - labels, - disableSend = false, - ctrlSend, - codeBlockOnCtrlEnter, - onMessageChange, - onEditLatestPost, - loadPrevMessage, - loadNextMessage, - replyToLastPost, - caretPosition, + isThreadView = false, placeholder, }: Props) => { - const readOnlyChannel = !canPost; const {formatMessage} = useIntl(); - const ariaLabelMessageInput = Utils.localizeMessage( - 'accessibility.sections.centerFooter', - 'message input complimentary region', - ); - const emojiPickerRef = useRef(null); - const editorActionsRef = useRef(null); - const editorBodyRef = useRef(null); - const timeout = useRef(); - - const [renderScrollbar, setRenderScrollbar] = useState(false); - const [showFormattingSpacer, setShowFormattingSpacer] = useState(shouldShowPreview); - const [keepEditorInFocus, setKeepEditorInFocus] = useState(false); - - let showDndWarning = false; - let showRemoteUserHour = false; - let teammateId = ''; - const post = useSelector((state: GlobalState) => getPost(state, postId)); - const postChannel = useSelector((state: GlobalState) => getDirectChannel(state, post?.channel_id)); - let channel = currentChannel; - if (postChannel) { - channel = postChannel; - } - if (channel && channel.type === 'D') { - teammateId = channel.teammate_id || ''; - } - const teammateStatus = useSelector((state: GlobalState) => getStatusForUserId(state, teammateId)); - const teammate = useSelector((state: GlobalState) => getUser(state, teammateId)); - - if (teammate && teammateId !== '' && teammateStatus === UserStatuses.DND) { - showDndWarning = true; - } - if (!showDndWarning && teammate && teammateId !== '') { - showRemoteUserHour = true; - } - - const isNonFormattedPaste = useRef(false); - const timeoutId = useRef(); const dispatch = useDispatch(); - const input = textboxRef.current?.getInputBox(); + const getChannelSelector = useMemo(makeGetChannel, []); + const getDraftSelector = useMemo(makeGetDraft, []); + const getDisplayName = useMemo(makeGetDisplayName, []); + + const isRHS = Boolean(postId && !isThreadView); + + const currentUserId = useSelector(getCurrentUserId); + const channelDisplayName = useSelector((state: GlobalState) => getChannelSelector(state, {id: channelId})?.display_name || ''); + const draftFromStore = useSelector((state: GlobalState) => getDraftSelector(state, channelId, postId)); + const badConnection = useSelector((state: GlobalState) => connectionErrorCount(state) > 1); + const maxPostSize = useSelector((state: GlobalState) => parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT); + const canUploadFiles = useSelector((state: GlobalState) => canUploadFilesAccordingToConfig(getConfig(state))); + const fullWidthTextBox = useSelector((state: GlobalState) => get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN); + const isFormattingBarHidden = useSelector((state: GlobalState) => getBool(state, Preferences.ADVANCED_TEXT_EDITOR, isRHS ? AdvancedTextEditorConst.COMMENT : AdvancedTextEditorConst.POST)); + const teammateId = useSelector((state: GlobalState) => getDirectChannel(state, channelId)?.teammate_id || ''); + const teammateDisplayName = useSelector((state: GlobalState) => (teammateId ? getDisplayName(state, teammateId) : '')); + const showDndWarning = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false)); + const showRemoteUserHour = useSelector((state: GlobalState) => !showDndWarning && Boolean(getDirectChannel(state, channelId)?.teammate_id)); + + const canPost = useSelector((state: GlobalState) => { + const channel = getChannel(state, channelId); + return channel ? haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CREATE_POST) : false; + }); + const useChannelMentions = useSelector((state: GlobalState) => { + const channel = getChannel(state, channelId); + return channel ? haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_CHANNEL_MENTIONS) : false; + }); + const showSendTutorialTip = useSelector((state: GlobalState) => { + // We don't show the tutorial tip neither on RHS nor Thread view + if (postId) { + return false; + } + const config = getConfig(state); + const enableTutorial = config.EnableTutorial === 'true'; + + const tutorialStep = getInt(state, TutorialTourName.ONBOARDING_TUTORIAL_STEP, currentUserId, 0); + + // guest validation to see which point the messaging tour tip starts + const isGuestUser = isCurrentUserGuestUser(state); + const tourStep = isGuestUser ? OnboardingTourStepsForGuestUsers.SEND_MESSAGE : OnboardingTourSteps.SEND_MESSAGE; + + return enableTutorial && (tutorialStep === tourStep); + }); + + const editorActionsRef = useRef(null); + const editorBodyRef = useRef(null); + const textboxRef = useRef(null); + const loggedInAriaLabelTimeout = useRef(); + const saveDraftFrame = useRef(); + const previousDraft = useRef(draftFromStore); + const storedDrafts = useRef>({}); + const lastBlurAt = useRef(0); + + const [draft, setDraft] = useState(draftFromStore); + const [caretPosition, setCaretPosition] = useState(draft.message.length); + const [serverError, setServerError] = useState<(ServerError & { submittedMessage?: string }) | null>(null); + const [postError, setPostError] = useState(null); + const [showPreview, setShowPreview] = useState(false); + const [isMessageLong, setIsMessageLong] = useState(false); + const [renderScrollbar, setRenderScrollbar] = useState(false); + const [keepEditorInFocus, setKeepEditorInFocus] = useState(false); + + const readOnlyChannel = !canPost; + const hasDraftMessage = Boolean(draft.message); + + const handleShowPreview = useCallback(() => { + setShowPreview((prev) => !prev); + }, []); + + const emitTypingEvent = useCallback(() => { + GlobalActions.emitLocalUserTypingEvent(channelId, postId); + }, [channelId, postId]); + + const handleDraftChange = useCallback((draftToChange: PostDraft, options: {instant?: boolean; show?: boolean} = {instant: false, show: false}) => { + if (saveDraftFrame.current) { + clearTimeout(saveDraftFrame.current); + } + + setDraft(draftToChange); + + const saveDraft = () => { + let key = `${StoragePrefixes.DRAFT}${draftToChange.channelId}`; + if (draftToChange.rootId) { + key = `${StoragePrefixes.COMMENT_DRAFT}${draftToChange.rootId}`; + } + + if (isDraftEmpty(draftToChange)) { + dispatch(removeDraft(key, draftToChange.channelId, draftToChange.rootId)); + return; + } + + if (options.show) { + dispatch(updateDraft(key, {...draftToChange, show: true}, draftToChange.rootId)); + return; + } + + dispatch(updateDraft(key, draftToChange, draftToChange.rootId)); + }; + + if (options.instant) { + saveDraft(); + } else { + saveDraftFrame.current = setTimeout(() => { + saveDraft(); + }, Constants.SAVE_DRAFT_TIMEOUT); + } + + storedDrafts.current[draftToChange.rootId || draftToChange.channelId] = draftToChange; + }, [dispatch]); + + const applyMarkdown = useCallback((params: ApplyMarkdownOptions) => { + if (showPreview) { + return; + } + + const res = applyMarkdownUtil(params); + + handleDraftChange({ + ...draft, + message: res.message, + }); + + setTimeout(() => { + const textbox = textboxRef.current?.getInputBox(); + Utils.setSelectionRange(textbox, res.selectionStart, res.selectionEnd); + }); + }, [showPreview, handleDraftChange, draft]); + + const toggleAdvanceTextEditor = useCallback(() => { + dispatch(savePreferences(currentUserId, [{ + category: Preferences.ADVANCED_TEXT_EDITOR, + user_id: currentUserId, + name: isRHS ? AdvancedTextEditorConst.COMMENT : AdvancedTextEditorConst.POST, + value: String(!isFormattingBarHidden), + }])); + }, [currentUserId, isRHS, isFormattingBarHidden, dispatch]); + + useOrientationHandler(textboxRef, postId); + const pluginItems = usePluginItems(draft, textboxRef, handleDraftChange); + const focusTextbox = useTextboxFocus(textboxRef, channelId, isRHS, canPost); + const [attachmentPreview, fileUploadJSX] = useUploadFiles(draft, postId, channelId, isThreadView, storedDrafts, readOnlyChannel, textboxRef, handleDraftChange, focusTextbox, setServerError); + const { + emojiPicker, + enableEmojiPicker, + toggleEmojiPicker, + } = useEmojiPicker(readOnlyChannel, draft, caretPosition, setCaretPosition, handleDraftChange, showPreview, focusTextbox); + const { + labels, + additionalControl: priorityAdditionalControl, + isValidPersistentNotifications, + onSubmitCheck: prioritySubmitCheck, + } = usePriority(draft, handleDraftChange, focusTextbox, showPreview); + const [handleSubmit, errorClass] = useSubmit(draft, postError, channelId, postId, serverError, lastBlurAt, focusTextbox, setServerError, setPostError, setShowPreview, handleDraftChange, prioritySubmitCheck); + const [handleKeyDown, postMsgKeyPress] = useKeyHandler( + draft, + channelId, + postId, + caretPosition, + isValidPersistentNotifications, + location, + textboxRef, + focusTextbox, + applyMarkdown, + handleDraftChange, + handleSubmit, + emitTypingEvent, + handleShowPreview, + toggleAdvanceTextEditor, + toggleEmojiPicker, + ); + + const handlePostError = useCallback((err: React.ReactNode) => { + setPostError(err); + }, []); const handleHeightChange = useCallback((height: number, maxHeight: number) => { setRenderScrollbar(height > maxHeight); }, []); - const handleShowFormat = useCallback(() => { - setShowPreview(!shouldShowPreview); - }, [shouldShowPreview, setShowPreview]); - const handleBlur = useCallback(() => { - onBlur?.(); + lastBlurAt.current = Date.now(); setKeepEditorInFocus(false); - }, [onBlur]); + }, []); const handleFocus = useCallback(() => { setKeepEditorInFocus(true); }, []); - const isRHS = location === Locations.RHS_COMMENT; + const handleChange = useCallback((e: React.ChangeEvent) => { + const message = e.target.value; - let attachmentPreview = null; - if (!readOnlyChannel && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0)) { - attachmentPreview = ( - - ); - } + if (!isErrorInvalidSlashCommand(serverError)) { + setServerError(null); + } - const getFileCount = () => { - return draft.fileInfos.length + draft.uploadsInProgress.length; - }; + handleDraftChange({ + ...draft, + message, + }); + }, [draft, handleDraftChange, serverError]); - let postType = 'post'; - if (postId) { - postType = isThreadView ? 'thread' : 'comment'; - } + /** + * by getting the value directly from the textbox we eliminate all unnecessary + * re-renders for the FormattingBar component. The previous method of always passing + * down the current message value that came from the parents state was not optimal, + * although still working as expected + */ + const getCurrentValue = useCallback(() => textboxRef.current?.getInputBox().value, [textboxRef]); - const fileUploadJSX = readOnlyChannel ? null : ( - - ); + const getCurrentSelection = useCallback(() => { + const input = textboxRef.current?.getInputBox(); - const getEmojiPickerRef = () => { - return emojiPickerRef.current; - }; + return { + start: input.selectionStart, + end: input.selectionEnd, + }; + }, [textboxRef]); - let emojiPicker = null; + const handleWidthChange = useCallback((width: number) => { + const input = textboxRef.current?.getInputBox(); + if (!editorBodyRef.current || !editorActionsRef.current || !input) { + return; + } - if (enableEmojiPicker && !readOnlyChannel) { - const emojiPickerTooltip = ( - - - - ); - emojiPicker = ( - <> - - - - - - - - ); - } + const maxWidth = editorBodyRef.current.offsetWidth - editorActionsRef.current.offsetWidth; - const disableSendButton = Boolean(readOnlyChannel || (!message.trim().length && !draft.fileInfos.length)) || disableSend; + if (!hasDraftMessage) { + // if we do not have a message we can just render the default state + setIsMessageLong(false); + return; + } + + if (width >= maxWidth) { + setIsMessageLong(true); + } else { + setIsMessageLong(false); + } + }, [hasDraftMessage]); + + const handleMouseUpKeyUp = useCallback((e: React.MouseEvent | React.KeyboardEvent) => { + setCaretPosition((e.target as TextboxElement).selectionStart || 0); + }, []); + + const prefillMessage = useCallback((message: string, shouldFocus?: boolean) => { + handleDraftChange({ + ...draft, + message, + }); + setCaretPosition(message.length); + + if (shouldFocus) { + const inputBox = textboxRef.current?.getInputBox(); + inputBox?.click(); + focusTextbox(true); + } + }, [handleDraftChange, focusTextbox, draft, textboxRef]); + + // Update the caret position in the input box when changed by a side effect + useEffect(() => { + const textbox: HTMLInputElement | HTMLTextAreaElement | undefined = textboxRef.current?.getInputBox(); + if (textbox && textbox.selectionStart !== caretPosition) { + Utils.setCaretPosition(textbox, caretPosition); + } + }, [caretPosition]); + + // Handle width change when there is no message. + useEffect(() => { + if (!hasDraftMessage) { + handleWidthChange(0); + } + }, [hasDraftMessage, handleWidthChange]); + + // Clear timeout on unmount + useEffect(() => { + return () => loggedInAriaLabelTimeout.current && clearTimeout(loggedInAriaLabelTimeout.current); + }, []); + + // Focus textbox when we stop showing the preview + useDidUpdate(() => { + if (!showPreview) { + focusTextbox(); + } + }, [showPreview]); + + // Remove show preview when we switch channels or posts + useEffect(() => { + setShowPreview(false); + }, [channelId, postId]); + + // Remove uploads in progress on mount + useEffect(() => { + dispatch(actionOnGlobalItemsWithPrefix(postId ? StoragePrefixes.COMMENT_DRAFT : StoragePrefixes.DRAFT, (_key: string, draft: PostDraft) => { + if (!draft || !draft.uploadsInProgress || draft.uploadsInProgress.length === 0) { + return draft; + } + + return {...draft, uploadsInProgress: []}; + })); + }, []); + + // Register listener to store the draft when the page unloads + useEffect(() => { + const callback = () => handleDraftChange(draft, {instant: true, show: true}); + window.addEventListener('beforeunload', callback); + return () => { + window.removeEventListener('beforeunload', callback); + }; + }, [handleDraftChange, draft]); + + // Set the draft from store when changing post or channels, and store the previus one + useEffect(() => { + setDraft(draftFromStore); + return () => handleDraftChange(previousDraft.current, {instant: true, show: true}); + }, [channelId, postId]); + + // Keep track of the previous draft + useEffect(() => { + previousDraft.current = draft; + }, [draft]); + + const disableSendButton = Boolean(readOnlyChannel || (!draft.message.trim().length && !draft.fileInfos.length)) || !isValidPersistentNotifications; const sendButton = readOnlyChannel ? null : ( ); let createMessage; if (placeholder) { createMessage = placeholder; - } else if (currentChannel && !readOnlyChannel) { + } else if (!postId && !readOnlyChannel) { createMessage = formatMessage( { id: 'create_post.write', defaultMessage: 'Write to {channelDisplayName}', }, - {channelDisplayName: currentChannel.display_name}, + {channelDisplayName}, ); } else if (readOnlyChannel) { - createMessage = Utils.localizeMessage( - 'create_post.read_only', - 'This channel is read-only. Only members with permission can post here.', + createMessage = formatMessage( + { + id: 'create_post.read_only', + defaultMessage: 'This channel is read-only. Only members with permission can post here.', + }, ); } else { - createMessage = Utils.localizeMessage('create_comment.addComment', 'Reply to this thread...'); + createMessage = formatMessage({id: 'create_comment.addComment', defaultMessage: 'Reply to this thread...'}); } - const messageValue = readOnlyChannel ? '' : message; - - /** - * by getting the value directly from the textbox we eliminate all unnecessary - * re-renders for the FormattingBar component. The previous method of always passing - * down the current message value that came from the parents state was not optimal, - * although still working as expected - */ - const getCurrentValue = useCallback(() => textboxRef.current?.getInputBox().value, [textboxRef]); - const getCurrentSelection = useCallback(() => { - const input = textboxRef.current?.getInputBox(); - - return { - start: input.selectionStart, - end: input.selectionEnd, - }; - }, [textboxRef]); + const messageValue = readOnlyChannel ? '' : draft.message; let textboxId = 'textbox'; @@ -412,253 +467,37 @@ const AdvanceTextEditor = ({ const showFormattingBar = !isFormattingBarHidden && !readOnlyChannel; - const handleWidthChange = useCallback((width: number) => { - if (!editorBodyRef.current || !editorActionsRef.current || !input) { - return; - } - - const maxWidth = editorBodyRef.current.offsetWidth - editorActionsRef.current.offsetWidth; - - if (!message) { - // if we do not have a message we can just render the default state - setShowFormattingSpacer(false); - return; - } - - if (width >= maxWidth) { - setShowFormattingSpacer(true); - } else { - setShowFormattingSpacer(false); - } - }, [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.nativeEvent)); - 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); - } - }, [handleWidthChange, message]); - - useEffect(() => { - return () => timeout.current && clearTimeout(timeout.current); - }, []); - const wasNotifiedOfLogIn = LocalStorageStore.getWasNotifiedOfLogIn(); - const ariaLabel = useMemo(() => { - let label; - if (!wasNotifiedOfLogIn) { - label = Utils.localizeMessage( - 'channelView.login.successfull', - 'Login Successful', - ); + let loginSuccessfulLabel; + if (!wasNotifiedOfLogIn) { + loginSuccessfulLabel = formatMessage({ + id: 'channelView.login.successfull', + defaultMessage: 'Login Successful', + }); - // set timeout to make sure aria-label is read by a screen reader, - // and then set the flag to "true" to make sure it's not read again until a user logs back in - timeout.current = setTimeout(() => { + // set timeout to make sure aria-label is read by a screen reader, + // and then set the flag to "true" to make sure it's not read again until a user logs back in + if (!loggedInAriaLabelTimeout.current) { + loggedInAriaLabelTimeout.current = setTimeout(() => { LocalStorageStore.setWasNotifiedOfLogIn(true); }, 3000); } - return label ? `${label} ${ariaLabelMessageInput}` : ariaLabelMessageInput; - }, [ariaLabelMessageInput, wasNotifiedOfLogIn]); + } + + const ariaLabelMessageInput = formatMessage({ + id: 'accessibility.sections.centerFooter', + defaultMessage: 'message input complimentary region', + }); + + const ariaLabel = loginSuccessfulLabel ? `${loginSuccessfulLabel} ${ariaLabelMessageInput}` : ariaLabelMessageInput; + + const additionalControls = useMemo(() => + [ + priorityAdditionalControl, + ...(pluginItems || []), + ].filter(Boolean), + [pluginItems, priorityAdditionalControl]); const formattingBar = ( @@ -678,13 +517,22 @@ const AdvanceTextEditor = ({ /> ); + const showFormattingSpacer = isMessageLong || showPreview || attachmentPreview || isRHS || isThreadView; return ( - <> - {showDndWarning && } +
+ {canPost && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0) && ( + + )} + {showDndWarning && } {showRemoteUserHour && ( )}
{attachmentPreview} - {!readOnlyChannel && (showFormattingBar || shouldShowPreview) && ( + {!readOnlyChannel && (showFormattingBar || showPreview) && ( )} - {showFormattingSpacer || shouldShowPreview || attachmentPreview || isRHS ? ( + {showFormattingSpacer ? ( {formattingBar} @@ -768,7 +616,7 @@ const AdvanceTextEditor = ({ {fileUploadJSX} @@ -777,12 +625,11 @@ const AdvanceTextEditor = ({ )}
- {showSendTutorialTip && currentChannel && prefillMessage && ( + {showSendTutorialTip && ( )} @@ -809,7 +656,7 @@ const AdvanceTextEditor = ({ postId={postId} /> - + ); }; diff --git a/webapp/channels/src/components/advanced_create_post/priority_labels.tsx b/webapp/channels/src/components/advanced_text_editor/priority_labels.tsx similarity index 100% rename from webapp/channels/src/components/advanced_create_post/priority_labels.tsx rename to webapp/channels/src/components/advanced_text_editor/priority_labels.tsx diff --git a/webapp/channels/src/components/advanced_text_editor/remote_user_hour.tsx b/webapp/channels/src/components/advanced_text_editor/remote_user_hour.tsx index 3e4acea117..3dba15fe6b 100644 --- a/webapp/channels/src/components/advanced_text_editor/remote_user_hour.tsx +++ b/webapp/channels/src/components/advanced_text_editor/remote_user_hour.tsx @@ -4,11 +4,13 @@ import {DateTime} from 'luxon'; import React, {useState, useEffect} from 'react'; import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; import styled from 'styled-components'; -import type {UserProfile} from '@mattermost/types/users'; +import type {GlobalState} from '@mattermost/types/store'; import {getTimezoneForUserProfile} from 'mattermost-redux/selectors/entities/timezone'; +import {getUser} from 'mattermost-redux/selectors/entities/users'; import Moon from 'components/common/svg_images_components/moon_svg'; import Timestamp from 'components/timestamp'; @@ -43,15 +45,26 @@ const Icon = styled(Moon)` `; type Props = { - teammate: UserProfile; + teammateId: string; displayName: string; } -const RemoteUserHour = ({teammate, displayName}: Props) => { +const DEFAULT_TIMEZONE = { + useAutomaticTimezone: true, + automaticTimezone: '', + manualTimezone: '', +}; + +const RemoteUserHour = ({teammateId, displayName}: Props) => { const [timestamp, setTimestamp] = useState(0); const [showIt, setShowIt] = useState(false); - const teammateTimezone = getTimezoneForUserProfile(teammate); + const teammateTimezone = useSelector((state: GlobalState) => { + const teammate = teammateId ? getUser(state, teammateId) : undefined; + return teammate ? getTimezoneForUserProfile(teammate) : DEFAULT_TIMEZONE; + }, (a, b) => a.automaticTimezone === b.automaticTimezone && + a.manualTimezone === b.manualTimezone && + a.useAutomaticTimezone === b.useAutomaticTimezone); useEffect(() => { const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone); diff --git a/webapp/channels/src/components/advanced_text_editor/use_emoji_picker.tsx b/webapp/channels/src/components/advanced_text_editor/use_emoji_picker.tsx new file mode 100644 index 0000000000..d5a32dc039 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_emoji_picker.tsx @@ -0,0 +1,170 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {useCallback, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {EmoticonHappyOutlineIcon} from '@mattermost/compass-icons/components'; +import type {Emoji} from '@mattermost/types/emojis'; + +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; + +import useDidUpdate from 'components/common/hooks/useDidUpdate'; +import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; +import KeyboardShortcutSequence, {KEYBOARD_SHORTCUTS} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence'; +import OverlayTrigger from 'components/overlay_trigger'; +import Tooltip from 'components/tooltip'; + +import Constants from 'utils/constants'; +import {splitMessageBasedOnCaretPosition} from 'utils/post_utils'; + +import type {GlobalState} from 'types/store'; +import type {PostDraft} from 'types/store/draft'; + +import {IconContainer} from './formatting_bar/formatting_icon'; + +const useEmojiPicker = ( + readOnlyChannel: boolean, + draft: PostDraft, + caretPosition: number, + setCaretPosition: (pos: number) => void, + handleDraftChange: (draft: PostDraft) => void, + shouldShowPreview: boolean, + focusTextbox: () => void, +) => { + const intl = useIntl(); + + const enableEmojiPicker = useSelector((state: GlobalState) => getConfig(state).EnableEmojiPicker === 'true'); + const enableGifPicker = useSelector((state: GlobalState) => getConfig(state).EnableGifPicker === 'true'); + + const emojiPickerRef = useRef(null); + + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + + const toggleEmojiPicker = useCallback((e?: React.MouseEvent): void => { + e?.stopPropagation(); + setShowEmojiPicker((prev) => !prev); + }, []); + + const hideEmojiPicker = useCallback(() => { + setShowEmojiPicker(false); + }, []); + + const getEmojiPickerRef = useCallback(() => { + return emojiPickerRef.current; + }, []); + + const handleEmojiClick = useCallback((emoji: Emoji) => { + const emojiAlias = getEmojiName(emoji); + + if (!emojiAlias) { + //Oops.. There went something wrong + return; + } + + let newMessage; + if (draft.message === '') { + newMessage = `:${emojiAlias}: `; + setCaretPosition(newMessage.length); + } else { + const {message} = draft; + const {firstPiece, lastPiece} = splitMessageBasedOnCaretPosition(caretPosition, message); + + // check whether the first piece of the message is empty when cursor is placed at beginning of message and avoid adding an empty string at the beginning of the message + newMessage = + firstPiece === '' ? `:${emojiAlias}: ${lastPiece}` : `${firstPiece} :${emojiAlias}: ${lastPiece}`; + + const newCaretPosition = + firstPiece === '' ? `:${emojiAlias}: `.length : `${firstPiece} :${emojiAlias}: `.length; + setCaretPosition(newCaretPosition); + } + + handleDraftChange({ + ...draft, + message: newMessage, + }); + + setShowEmojiPicker(false); + }, [draft, caretPosition, handleDraftChange, setCaretPosition]); + + const handleGifClick = useCallback((gif: string) => { + let newMessage: string; + if (draft.message === '') { + newMessage = gif; + } else if ((/\s+$/).test(draft.message)) { + // Check whether there is already a blank at the end of the current message + newMessage = `${draft.message}${gif} `; + } else { + newMessage = `${draft.message} ${gif} `; + } + + handleDraftChange({ + ...draft, + message: newMessage, + }); + + setShowEmojiPicker(false); + }, [draft, handleDraftChange]); + + // Focus textbox when the emoji picker closes + useDidUpdate(() => { + if (!showEmojiPicker) { + focusTextbox(); + } + }, [showEmojiPicker]); + + let emojiPicker = null; + + if (enableEmojiPicker && !readOnlyChannel) { + const emojiPickerTooltip = ( + + + + ); + emojiPicker = ( + <> + + + + + + + + ); + } + + return {emojiPicker, enableEmojiPicker, toggleEmojiPicker}; +}; + +export default useEmojiPicker; diff --git a/webapp/channels/src/components/advanced_text_editor/use_groups.tsx b/webapp/channels/src/components/advanced_text_editor/use_groups.tsx new file mode 100644 index 0000000000..cee9837aa6 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_groups.tsx @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useEffect} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {GroupSource} from '@mattermost/types/groups'; + +import {getChannelMemberCountsByGroup} from 'mattermost-redux/actions/channels'; +import {Permissions} from 'mattermost-redux/constants'; +import {getChannel, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from 'mattermost-redux/selectors/entities/channels'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups'; +import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +import {searchAssociatedGroupsForReference} from 'actions/views/group'; + +import Constants from 'utils/constants'; +import {groupsMentionedInText, mentionsMinusSpecialMentionsInText} from 'utils/post_utils'; + +import type {GlobalState} from 'types/store'; + +const useGroups = ( + channelId: string, + message: string, +) => { + const dispatch = useDispatch(); + + const teamId = useSelector((state: GlobalState) => { + const channel = getChannel(state, channelId); + return channel?.team_id || getCurrentTeamId(state); + }); + + const canUseLDAPGroupMentions = useSelector((state: GlobalState) => { + const channel = getChannel(state, channelId); + if (!channel) { + return false; + } + const license = getLicense(state); + const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true'; + return isLDAPEnabled && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS); + }); + + const canUseCustomGroupMentions = useSelector((state: GlobalState) => { + const channel = getChannel(state, channelId); + if (!channel) { + return false; + } + return isCustomGroupsEnabled(state) && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS); + }); + + const groupsWithAllowReference = useSelector((state: GlobalState) => { + const channel = getChannel(state, channelId); + if (!channel) { + return null; + } + return canUseLDAPGroupMentions || canUseCustomGroupMentions ? getAssociatedGroupsForReferenceByMention(state, channel.team_id, channel.id) : null; + }); + + const channelMemberCountsByGroup = useSelector((state: GlobalState) => selectChannelMemberCountsByGroup(state, channelId)); + + const getGroupMentions = useCallback((message: string) => { + let memberNotifyCount = 0; + let channelTimezoneCount = 0; + let mentions: string[] = []; + if (canUseLDAPGroupMentions || canUseCustomGroupMentions) { + const mentionGroups = groupsMentionedInText(message, groupsWithAllowReference); + if (mentionGroups.length > 0) { + mentionGroups. + forEach((group) => { + if (group.source === GroupSource.Ldap && !canUseLDAPGroupMentions) { + return; + } + if (group.source === GroupSource.Custom && !canUseCustomGroupMentions) { + return; + } + const mappedValue = channelMemberCountsByGroup[group.id]; + if (mappedValue && mappedValue.channel_member_count > Constants.NOTIFY_ALL_MEMBERS && mappedValue.channel_member_count > memberNotifyCount) { + memberNotifyCount = mappedValue.channel_member_count; + channelTimezoneCount = mappedValue.channel_member_timezones_count; + } + mentions.push(`@${group.name}`); + }); + mentions = [...new Set(mentions)]; + } + } + return {mentions, memberNotifyCount, channelTimezoneCount}; + }, [channelMemberCountsByGroup, groupsWithAllowReference, canUseCustomGroupMentions, canUseLDAPGroupMentions]); + + // Get channel member counts by group on channel switch + useEffect(() => { + if (canUseLDAPGroupMentions || canUseCustomGroupMentions) { + const mentions = mentionsMinusSpecialMentionsInText(message); + + if (mentions.length === 1) { + dispatch(searchAssociatedGroupsForReference(mentions[0], teamId, channelId)); + } else if (mentions.length > 1) { + dispatch(getChannelMemberCountsByGroup(channelId)); + } + } + }, [channelId]); + + return getGroupMentions; +}; + +export default useGroups; diff --git a/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx new file mode 100644 index 0000000000..97d2c93d32 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_key_handler.tsx @@ -0,0 +1,385 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type React from 'react'; +import {useCallback, useEffect, useRef} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {getLatestReplyablePostId} from 'mattermost-redux/selectors/entities/posts'; +import {getBool} from 'mattermost-redux/selectors/entities/preferences'; + +import {emitShortcutReactToLastPostFrom} from 'actions/post_actions'; +import {editLatestPost} from 'actions/views/create_comment'; +import {selectPostFromRightHandSideSearchByPostId} from 'actions/views/rhs'; + +import type {TextboxElement} from 'components/textbox'; +import type TextboxClass from 'components/textbox/textbox'; + +import Constants, {Locations, Preferences} from 'utils/constants'; +import * as Keyboard from 'utils/keyboard'; +import {type ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; +import {pasteHandler} from 'utils/paste'; +import {isWithinCodeBlock, postMessageOnKeyPress} from 'utils/post_utils'; +import * as UserAgent from 'utils/user_agent'; +import * as Utils from 'utils/utils'; + +import type {GlobalState} from 'types/store'; +import type {PostDraft} from 'types/store/draft'; + +const KeyCodes = Constants.KeyCodes; + +const useKeyHandler = ( + draft: PostDraft, + channelId: string, + postId: string, + caretPosition: number, + isValidPersistentNotifications: boolean, + location: string, + textboxRef: React.RefObject, + focusTextbox: (forceFocus?: boolean) => void, + applyMarkdown: (params: ApplyMarkdownOptions) => void, + handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void, + handleSubmit: (e: React.FormEvent, submittingDraft?: PostDraft) => void, + emitTypingEvent: () => void, + toggleShowPreview: () => void, + toggleAdvanceTextEditor: () => void, + toggleEmojiPicker: () => void, +): [ + (e: React.KeyboardEvent) => void, + (e: React.KeyboardEvent) => void, + ] => { + const dispatch = useDispatch(); + + const ctrlSend = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter')); + const codeBlockOnCtrlEnter = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true)); + const messageHistory = useSelector((state: GlobalState) => state.entities.posts.messagesHistory.messages); + + const timeoutId = useRef(); + const messageHistoryIndex = useRef(messageHistory.length); + const lastChannelSwitchAt = useRef(0); + const isNonFormattedPaste = useRef(false); + + const latestReplyablePostId = useSelector((state: GlobalState) => (postId ? '' : getLatestReplyablePostId(state))); + const replyToLastPost = useCallback((e: React.KeyboardEvent) => { + if (postId) { + return; + } + + e.preventDefault(); + const replyBox = document.getElementById('reply_textbox'); + if (replyBox) { + replyBox.focus(); + } + if (latestReplyablePostId) { + dispatch(selectPostFromRightHandSideSearchByPostId(latestReplyablePostId)); + } + }, [latestReplyablePostId, dispatch, postId]); + + const onEditLatestPost = useCallback((e: React.KeyboardEvent) => { + e.preventDefault(); + const {data: canEditNow} = dispatch(editLatestPost(channelId, postId)); + if (!canEditNow) { + focusTextbox(true); + } + }, [focusTextbox, channelId, postId, dispatch]); + + const loadPrevMessage = useCallback((e: React.KeyboardEvent) => { + e.preventDefault(); + if (messageHistoryIndex.current === 0) { + return; + } + messageHistoryIndex.current -= 1; + handleDraftChange({ + ...draft, + message: messageHistory[messageHistoryIndex.current] || '', + }); + }, [draft, handleDraftChange, messageHistory]); + + const loadNextMessage = useCallback((e: React.KeyboardEvent) => { + e.preventDefault(); + if (messageHistoryIndex.current >= messageHistory.length) { + return; + } + messageHistoryIndex.current += 1; + handleDraftChange({ + ...draft, + message: messageHistory[messageHistoryIndex.current] || '', + }); + }, [draft, handleDraftChange, messageHistory]); + + const postMsgKeyPress = useCallback((e: React.KeyboardEvent) => { + const {allowSending, withClosedCodeBlock, ignoreKeyPress, message} = postMessageOnKeyPress( + e, + draft.message, + ctrlSend, + codeBlockOnCtrlEnter, + postId ? 0 : Date.now(), + postId ? 0 : lastChannelSwitchAt.current, + caretPosition, + ); + + if (ignoreKeyPress) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (allowSending && isValidPersistentNotifications) { + e.persist?.(); + + // textboxRef.current?.blur(); + + if (withClosedCodeBlock && message) { + handleSubmit(e, {...draft, message}); + } else { + handleSubmit(e); + } + + // setTimeout(() => { + // focusTextbox(); + // }); + } + + emitTypingEvent(); + }, [draft, ctrlSend, codeBlockOnCtrlEnter, caretPosition, postId, emitTypingEvent, handleSubmit, isValidPersistentNotifications]); + + const handleKeyDown = useCallback((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)) { + handleDraftChange({ + ...draft, + message: Utils.insertLineBreakFromKeyEvent(e.nativeEvent), + }); + return; + } + + if (ctrlEnterKeyCombo) { + 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 = draft.message.length === 0; + const allowHistoryNavigation = draft.message.length === 0 || draft.message === messageHistory[messageHistoryIndex.current]; + const caretIsWithinCodeBlock = caretPosition && isWithinCodeBlock(draft.message, caretPosition); // REVIEW + + 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 (allowHistoryNavigation && Keyboard.isKeyPressed(e, KeyCodes.UP)) { + e.stopPropagation(); + e.preventDefault(); + loadPrevMessage(e); + } else if (allowHistoryNavigation && 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) && draft.message.length && !UserAgent.isMac()) { + e.stopPropagation(); + e.preventDefault(); + toggleShowPreview(); + } + } 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) && draft.message.length && UserAgent.isMac()) { // REVIEW + e.stopPropagation(); + e.preventDefault(); + toggleShowPreview(); + } else if (Keyboard.isKeyPressed(e, KeyCodes.E)) { + e.stopPropagation(); + e.preventDefault(); + toggleEmojiPicker(); + } + } + + const lastMessageReactionKeyCombo = ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH); + if (lastMessageReactionKeyCombo) { + e.stopPropagation(); + e.preventDefault(); + dispatch(emitShortcutReactToLastPostFrom(postId ? Locations.RHS_ROOT : Locations.CENTER)); + } + + if (!postId) { + const shiftUpKeyCombo = !ctrlOrMetaKeyPressed && !e.altKey && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP); + if (shiftUpKeyCombo && messageIsEmpty) { + replyToLastPost?.(e); + } + } + }, [ + applyMarkdown, + caretPosition, + codeBlockOnCtrlEnter, + ctrlSend, + dispatch, + draft, + handleDraftChange, + loadNextMessage, + loadPrevMessage, + messageHistory, + onEditLatestPost, + postId, + postMsgKeyPress, + replyToLastPost, + textboxRef, + toggleAdvanceTextEditor, + toggleEmojiPicker, + toggleShowPreview, + ]); + + // Register paste events + useEffect(() => { + function onPaste(event: ClipboardEvent) { + pasteHandler(event, location, draft.message, isNonFormattedPaste.current, caretPosition); + } + + document.addEventListener('paste', onPaste); + return () => { + document.removeEventListener('paste', onPaste); + }; + }, [location, draft.message, caretPosition]); + + // Reset history index + useEffect(() => { + if (messageHistoryIndex.current === messageHistory.length) { + return; + } + if (draft.message !== messageHistory[messageHistoryIndex.current]) { + messageHistoryIndex.current = messageHistory.length; + } + }, [draft.message]); + + // Update last channel switch at + useEffect(() => { + lastChannelSwitchAt.current = Date.now(); + }, [channelId]); + + return [handleKeyDown, postMsgKeyPress]; +}; + +export default useKeyHandler; diff --git a/webapp/channels/src/components/advanced_text_editor/use_orientation_handler.tsx b/webapp/channels/src/components/advanced_text_editor/use_orientation_handler.tsx new file mode 100644 index 0000000000..6e03127b11 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_orientation_handler.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useEffect, useRef} from 'react'; + +import type TextboxClass from 'components/textbox/textbox'; + +import * as UserAgent from 'utils/user_agent'; + +const useOrientationHandler = ( + textboxRef: React.RefObject, + postId: string, +) => { + const lastOrientation = useRef(''); + + const onOrientationChange = useCallback(() => { + if (!UserAgent.isIosWeb()) { + return; + } + + const LANDSCAPE_ANGLE = 90; + let orientation = 'portrait'; + if (window.orientation) { + orientation = Math.abs(window.orientation as number) === LANDSCAPE_ANGLE ? 'landscape' : 'portrait'; + } + + if (window.screen.orientation) { + orientation = window.screen.orientation.type.split('-')[0]; + } + + if ( + lastOrientation.current && + orientation !== lastOrientation.current && + (document.activeElement || {}).id === 'post_textbox' + ) { + textboxRef.current?.blur(); + } + + lastOrientation.current = orientation; + }, [textboxRef]); + + useEffect(() => { + if (!postId && UserAgent.isIosWeb()) { + onOrientationChange(); + if (window.screen.orientation && 'onchange' in window.screen.orientation) { + window.screen.orientation.addEventListener('change', onOrientationChange); + } else if ('onorientationchange' in window) { + window.addEventListener('orientationchange', onOrientationChange); + } + } + return () => { + if (!postId) { + if (window.screen.orientation && 'onchange' in window.screen.orientation) { + window.screen.orientation.removeEventListener('change', onOrientationChange); + } else if ('onorientationchange' in window) { + window.removeEventListener('orientationchange', onOrientationChange); + } + } + }; + }, []); +}; + +export default useOrientationHandler; diff --git a/webapp/channels/src/components/advanced_text_editor/use_plugin_items.tsx b/webapp/channels/src/components/advanced_text_editor/use_plugin_items.tsx new file mode 100644 index 0000000000..c91d0ae265 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_plugin_items.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {useSelector} from 'react-redux'; + +import type TextboxClass from 'components/textbox/textbox'; + +import type {GlobalState} from 'types/store'; +import type {PostDraft} from 'types/store/draft'; + +const usePluginItems = ( + draft: PostDraft, + textboxRef: React.RefObject, + handleDraftChange: (draft: PostDraft) => void, +) => { + const postEditorActions = useSelector((state: GlobalState) => state.plugins.components.PostEditorAction); + + const getSelectedText = useCallback(() => { + const input = textboxRef.current?.getInputBox(); + + return { + start: input?.selectionStart, + end: input?.selectionEnd, + }; + }, [textboxRef]); + + const updateText = useCallback((message: string) => { + handleDraftChange({ + ...draft, + message, + }); + + // Missing setting the state eventually? + }, [handleDraftChange, draft]); + + const items = useMemo(() => postEditorActions?.map((item) => { + if (!item.component) { + return null; + } + + const Component = item.component as any; + return ( + + ); + }), [postEditorActions, draft, getSelectedText, updateText]); + + return items; +}; + +export default usePluginItems; diff --git a/webapp/channels/src/components/advanced_text_editor/use_priority.tsx b/webapp/channels/src/components/advanced_text_editor/use_priority.tsx new file mode 100644 index 0000000000..4c67777d03 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_priority.tsx @@ -0,0 +1,168 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {Channel} from '@mattermost/types/channels'; +import type {PostPriorityMetadata} from '@mattermost/types/posts'; +import {PostPriority} from '@mattermost/types/posts'; + +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; +import {isPostPriorityEnabled as isPostPriorityEnabledSelector} from 'mattermost-redux/selectors/entities/posts'; +import {getUser} from 'mattermost-redux/selectors/entities/users'; + +import {openModal} from 'actions/views/modals'; + +import PersistNotificationConfirmModal from 'components/persist_notification_confirm_modal'; +import PostPriorityPickerOverlay from 'components/post_priority/post_priority_picker_overlay'; + +import Constants, {ModalIdentifiers} from 'utils/constants'; +import {hasRequestedPersistentNotifications, mentionsMinusSpecialMentionsInText, specialMentionsInText} from 'utils/post_utils'; + +import type {GlobalState} from 'types/store'; +import type {PostDraft} from 'types/store/draft'; + +import PriorityLabels from './priority_labels'; + +const usePriority = ( + draft: PostDraft, + handleDraftChange: (draft: PostDraft, options: {instant?: boolean; show?: boolean}) => void, + focusTextbox: (keepFocus?: boolean) => void, + shouldShowPreview: boolean, +) => { + const dispatch = useDispatch(); + + const isPostPriorityEnabled = useSelector(isPostPriorityEnabledSelector); + const channelType = useSelector((state: GlobalState) => getChannel(state, draft.channelId)?.type || 'O'); + const channelTeammateUsername = useSelector((state: GlobalState) => { + const channel = getChannel(state, draft.channelId); + return getUser(state, channel?.teammate_id || '')?.username || ''; + }); + + const hasPrioritySet = isPostPriorityEnabled && + draft.metadata?.priority && + ( + draft.metadata.priority.priority || + draft.metadata.priority.requested_ack + ); + + const specialMentions = useMemo(() => { + return specialMentionsInText(draft.message); + }, [draft.message]); + + const hasSpecialMentions = useMemo(() => { + return Object.values(specialMentions).includes(true); + }, [specialMentions]); + + const isValidPersistentNotifications = useMemo(() => { + if (!hasPrioritySet) { + return true; + } + + const {priority, persistent_notifications: persistentNotifications} = draft.metadata!.priority!; + if (priority !== PostPriority.URGENT || !persistentNotifications) { + return true; + } + + if (channelType === Constants.DM_CHANNEL) { + return true; + } + + if (hasSpecialMentions) { + return false; + } + + const mentions = mentionsMinusSpecialMentionsInText(draft.message); + + return mentions.length > 0; + }, [hasPrioritySet, draft, channelType, hasSpecialMentions]); + + const handlePostPriorityApply = useCallback((settings?: PostPriorityMetadata) => { + const updatedDraft = { + ...draft, + }; + + if (settings?.priority || settings?.requested_ack) { + updatedDraft.metadata = { + priority: { + ...settings, + priority: settings!.priority || '', + requested_ack: settings!.requested_ack, + }, + }; + } else { + updatedDraft.metadata = {}; + } + + handleDraftChange(updatedDraft, {instant: true}); + focusTextbox(); + }, [focusTextbox, draft, handleDraftChange]); + + const handlePostPriorityHide = useCallback(() => { + focusTextbox(true); + }, [focusTextbox]); + + const handleRemovePriority = useCallback(() => { + handlePostPriorityApply(); + }, [handlePostPriorityApply]); + + const showPersistNotificationModal = useCallback((message: string, specialMentions: {[key: string]: boolean}, channelType: Channel['type'], onConfirm: () => void) => { + dispatch(openModal({ + modalId: ModalIdentifiers.PERSIST_NOTIFICATION_CONFIRM_MODAL, + dialogType: PersistNotificationConfirmModal, + dialogProps: { + currentChannelTeammateUsername: channelTeammateUsername, + specialMentions, + channelType, + message, + onConfirm, + }, + })); + }, [channelTeammateUsername, dispatch]); + + const onSubmitCheck = useCallback((onConfirm: () => void) => { + if ( + isPostPriorityEnabled && + hasRequestedPersistentNotifications(draft?.metadata?.priority) + ) { + showPersistNotificationModal(draft.message, specialMentions, channelType, onConfirm); + return true; + } + return false; + }, [isPostPriorityEnabled, showPersistNotificationModal, draft, channelType, specialMentions]); + + const labels = useMemo(() => ( + (hasPrioritySet && !draft.rootId) ? ( + + ) : undefined + ), [shouldShowPreview, draft, hasPrioritySet, isValidPersistentNotifications, specialMentions, handleRemovePriority]); + + const additionalControl = useMemo(() => + !draft.rootId && isPostPriorityEnabled && ( + + ), [draft.rootId, isPostPriorityEnabled, draft.metadata?.priority, handlePostPriorityApply, handlePostPriorityHide, shouldShowPreview]); + + return { + labels, + additionalControl, + isValidPersistentNotifications, + onSubmitCheck, + }; +}; + +export default usePriority; diff --git a/webapp/channels/src/components/advanced_text_editor/use_submit.tsx b/webapp/channels/src/components/advanced_text_editor/use_submit.tsx new file mode 100644 index 0000000000..754725ea81 --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_submit.tsx @@ -0,0 +1,324 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type React from 'react'; +import {useCallback, useRef, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {ServerError} from '@mattermost/types/errors'; + +import {getChannelTimezones} from 'mattermost-redux/actions/channels'; +import {Permissions} from 'mattermost-redux/constants'; +import {getChannel, getAllChannelStats} from 'mattermost-redux/selectors/entities/channels'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getPost} from 'mattermost-redux/selectors/entities/posts'; +import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; +import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; + +import {scrollPostListToBottom} from 'actions/views/channel'; +import {onSubmit} from 'actions/views/create_comment'; +import {openModal} from 'actions/views/modals'; + +import EditChannelHeaderModal from 'components/edit_channel_header_modal'; +import EditChannelPurposeModal from 'components/edit_channel_purpose_modal'; +import NotifyConfirmModal from 'components/notify_confirm_modal'; +import PostDeletedModal from 'components/post_deleted_modal'; +import ResetStatusModal from 'components/reset_status_modal'; + +import Constants, {ModalIdentifiers, UserStatuses} from 'utils/constants'; +import {isErrorInvalidSlashCommand, isServerError, specialMentionsInText} from 'utils/post_utils'; + +import type {GlobalState} from 'types/store'; +import type {PostDraft} from 'types/store/draft'; + +import useGroups from './use_groups'; + +function getStatusFromSlashCommand(message: string) { + const tokens = message.split(' '); + const command = tokens[0] || ''; + if (command[0] !== '/') { + return ''; + } + const status = command.substring(1); + if (status === 'online' || status === 'away' || status === 'dnd' || status === 'offline') { + return status; + } + + return ''; +} + +const useSubmit = ( + draft: PostDraft, + postError: React.ReactNode, + channelId: string, + postId: string, + serverError: (ServerError & { submittedMessage?: string }) | null, + lastBlurAt: React.MutableRefObject, + focusTextbox: (forceFocust?: boolean) => void, + setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void, + setPostError: (err: React.ReactNode) => void, + setShowPreview: (showPreview: boolean) => void, + handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void, + prioritySubmitCheck: (onConfirm: () => void) => boolean, +): [ + (e: React.FormEvent, submittingDraft?: PostDraft) => void, + string | null, + ] => { + const getGroupMentions = useGroups(channelId, draft.message); + + const dispatch = useDispatch(); + + const isDraftSubmitting = useRef(false); + const [errorClass, setErrorClass] = useState(null); + const isDirectOrGroup = useSelector((state: GlobalState) => { + const channel = getChannel(state, channelId); + if (!channel) { + return false; + } + return channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL; + }); + + const channel = useSelector((state: GlobalState) => { + return getChannel(state, channelId); + }); + + const isRootDeleted = useSelector((state: GlobalState) => { + if (!postId) { + return false; + } + const post = getPost(state, postId); + if (!post || post.delete_at) { + return true; + } + + return false; + }); + + const enableConfirmNotificationsToChannel = useSelector((state: GlobalState) => getConfig(state).EnableConfirmNotificationsToChannel === 'true'); + const channelMembersCount = useSelector((state: GlobalState) => getAllChannelStats(state)[channelId]?.member_count ?? 1); + const userIsOutOfOffice = useSelector((state: GlobalState) => { + const currentUserId = getCurrentUserId(state); + return getStatusForUserId(state, currentUserId) === UserStatuses.OUT_OF_OFFICE; + }); + const useChannelMentions = useSelector((state: GlobalState) => { + const channel = getChannel(state, channelId); + if (!channel) { + return false; + } + return haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_CHANNEL_MENTIONS); + }); + + const showPostDeletedModal = useCallback(() => { + dispatch(openModal({ + modalId: ModalIdentifiers.POST_DELETED_MODAL, + dialogType: PostDeletedModal, + })); + }, [dispatch]); + + const doSubmit = useCallback(async (e?: React.FormEvent, submittingDraft = draft) => { + e?.preventDefault(); + + if (submittingDraft.uploadsInProgress.length > 0) { + isDraftSubmitting.current = false; + return; + } + + if (postError) { + setErrorClass('animation--highlight'); + setTimeout(() => { + setErrorClass(null); + }, Constants.ANIMATION_TIMEOUT); + isDraftSubmitting.current = false; + return; + } + + if (submittingDraft.message.trim().length === 0 && submittingDraft.fileInfos.length === 0) { + return; + } + + if (isRootDeleted) { + showPostDeletedModal(); + isDraftSubmitting.current = false; + return; + } + + if (serverError && !isErrorInvalidSlashCommand(serverError)) { + return; + } + + const fasterThanHumanWillClick = 150; + const forceFocus = Date.now() - lastBlurAt.current < fasterThanHumanWillClick; + focusTextbox(forceFocus); + + setServerError(null); + + const ignoreSlash = isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message; + const options = {ignoreSlash}; + + try { + await dispatch(onSubmit(submittingDraft, options)); + + setPostError(null); + setServerError(null); + handleDraftChange({ + message: '', + fileInfos: [], + uploadsInProgress: [], + createAt: 0, + updateAt: 0, + channelId, + rootId: postId, + }, {instant: true}); + } catch (err: unknown) { + if (isServerError(err)) { + if (isErrorInvalidSlashCommand(err)) { + handleDraftChange(submittingDraft, {instant: true}); + } + setServerError({ + ...err, + submittedMessage: submittingDraft.message, + }); + } + isDraftSubmitting.current = false; + return; + } + + if (!postId) { + dispatch(scrollPostListToBottom()); + } + + isDraftSubmitting.current = false; + }, [handleDraftChange, dispatch, draft, focusTextbox, isRootDeleted, postError, serverError, showPostDeletedModal, channelId, postId, lastBlurAt, setPostError, setServerError]); + + const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => { + dispatch(openModal({ + modalId: ModalIdentifiers.NOTIFY_CONFIRM_MODAL, + dialogType: NotifyConfirmModal, + dialogProps: { + mentions, + channelTimezoneCount, + memberNotifyCount, + onConfirm: () => doSubmit(), + }, + })); + }, [doSubmit, dispatch]); + + const handleSubmit = useCallback(async (e: React.FormEvent, submittingDraft = draft) => { + if (!channel) { + return; + } + e.preventDefault(); + setShowPreview(false); + isDraftSubmitting.current = true; + + const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions; + let memberNotifyCount = 0; + let channelTimezoneCount = 0; + let mentions: string[] = []; + + const specialMentions = specialMentionsInText(submittingDraft.message); + const hasSpecialMentions = Object.values(specialMentions).includes(true); + + if (enableConfirmNotificationsToChannel && !hasSpecialMentions) { + ({memberNotifyCount, channelTimezoneCount, mentions} = getGroupMentions(submittingDraft.message)); + } + + if (notificationsToChannel && channelMembersCount > Constants.NOTIFY_ALL_MEMBERS && hasSpecialMentions) { + memberNotifyCount = channelMembersCount - 1; + + for (const k in specialMentions) { + if (specialMentions[k]) { + mentions.push('@' + k); + } + } + + const {data} = await dispatch(getChannelTimezones(channelId)); + channelTimezoneCount = data ? data.length : 0; + } + + if (prioritySubmitCheck(doSubmit)) { + isDraftSubmitting.current = false; + return; + } + + if (memberNotifyCount > 0) { + showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount); + isDraftSubmitting.current = false; + return; + } + + const status = getStatusFromSlashCommand(submittingDraft.message); + if (userIsOutOfOffice && status) { + const resetStatusModalData = { + modalId: ModalIdentifiers.RESET_STATUS, + dialogType: ResetStatusModal, + dialogProps: {newStatus: status}, + }; + + dispatch(openModal(resetStatusModalData)); + + handleDraftChange({ + ...submittingDraft, + message: '', + }); + isDraftSubmitting.current = false; + return; + } + + if (submittingDraft.message.trimEnd() === '/header') { + const editChannelHeaderModalData = { + modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER, + dialogType: EditChannelHeaderModal, + dialogProps: {channel}, + }; + + dispatch(openModal(editChannelHeaderModalData)); + + handleDraftChange({ + ...submittingDraft, + message: '', + }); + isDraftSubmitting.current = false; + return; + } + + if (!isDirectOrGroup && submittingDraft.message.trimEnd() === '/purpose') { + const editChannelPurposeModalData = { + modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE, + dialogType: EditChannelPurposeModal, + dialogProps: {channel}, + }; + + dispatch(openModal(editChannelPurposeModalData)); + + handleDraftChange({ + ...submittingDraft, + message: '', + }); + isDraftSubmitting.current = false; + return; + } + + await doSubmit(e, submittingDraft); + }, [ + doSubmit, + draft, + isDirectOrGroup, + channel, + channelId, + channelMembersCount, + dispatch, + enableConfirmNotificationsToChannel, + handleDraftChange, + showNotifyAllModal, + useChannelMentions, + userIsOutOfOffice, + getGroupMentions, + setShowPreview, + prioritySubmitCheck, + ]); + + return [handleSubmit, errorClass]; +}; + +export default useSubmit; diff --git a/webapp/channels/src/components/advanced_text_editor/use_textbox_focus.tsx b/webapp/channels/src/components/advanced_text_editor/use_textbox_focus.tsx new file mode 100644 index 0000000000..7ea038f01d --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_textbox_focus.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type React from 'react'; +import {useCallback, useEffect} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {focusedRHS} from 'actions/views/rhs'; +import {getIsRhsExpanded, getIsRhsOpen} from 'selectors/rhs'; +import {getShouldFocusRHS} from 'selectors/views/rhs'; + +import useDidUpdate from 'components/common/hooks/useDidUpdate'; +import type TextboxClass from 'components/textbox/textbox'; + +import {shouldFocusMainTextbox} from 'utils/post_utils'; +import * as UserAgent from 'utils/user_agent'; + +const useTextboxFocus = ( + textboxRef: React.RefObject, + channelId: string, + isRHS: boolean, + canPost: boolean, +) => { + const dispatch = useDispatch(); + + const rhsExpanded = useSelector(getIsRhsExpanded); + const rhsOpen = useSelector(getIsRhsOpen); + + // We force the selector to always think it is the same value to avoid re-renders + // because we only use this value during mount. + const shouldFocusRHS = useSelector(getShouldFocusRHS, () => true); + + const focusTextbox = useCallback((keepFocus = false) => { + const postTextboxDisabled = !canPost; + if (textboxRef.current && postTextboxDisabled) { + textboxRef.current.blur(); // Fixes Firefox bug which causes keyboard shortcuts to be ignored (MM-22482) + return; + } + if (textboxRef.current && (keepFocus || !UserAgent.isMobile())) { + textboxRef.current.focus(); + } + }, [canPost, textboxRef]); + + const focusTextboxIfNecessary = useCallback((e: KeyboardEvent) => { + // Do not focus if the rhs is expanded and this is not the RHS + if (!isRHS && rhsExpanded) { + return; + } + + // Do not focus if the rhs is not expanded and this is the RHS + if (isRHS && !rhsExpanded) { + return; + } + + // Do not focus the main textbox when the RHS is open as a hacky fix to avoid cursor jumping textbox sometimes + if (isRHS && rhsOpen && document.activeElement?.tagName === 'BODY') { + return; + } + + // Bit of a hack to not steal focus from the channel switch modal if it's open + // This is a special case as the channel switch modal does not enforce focus like + // most modals do + if (document.getElementsByClassName('channel-switch-modal').length) { + return; + } + + if (shouldFocusMainTextbox(e, document.activeElement)) { + focusTextbox(); + } + }, [focusTextbox, rhsExpanded, rhsOpen, isRHS]); + + // Register events for onkeydown + useEffect(() => { + document.addEventListener('keydown', focusTextboxIfNecessary); + return () => { + document.removeEventListener('keydown', focusTextboxIfNecessary); + }; + }, [focusTextboxIfNecessary]); + + // Focus on textbox on channel switch + useDidUpdate(() => { + focusTextbox(); + }, [channelId]); + + // Focus on mount + useEffect(() => { + if (isRHS && shouldFocusRHS) { + focusTextbox(); + dispatch(focusedRHS()); + } + }, []); + + return focusTextbox; +}; + +export default useTextboxFocus; diff --git a/webapp/channels/src/components/advanced_text_editor/use_upload_files.tsx b/webapp/channels/src/components/advanced_text_editor/use_upload_files.tsx new file mode 100644 index 0000000000..83542aa5ed --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/use_upload_files.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useRef, useState} from 'react'; +import {useSelector} from 'react-redux'; + +import type {ServerError} from '@mattermost/types/errors'; +import type {FileInfo} from '@mattermost/types/files'; + +import {sortFileInfos} from 'mattermost-redux/utils/file_utils'; + +import {getCurrentLocale} from 'selectors/i18n'; + +import FilePreview from 'components/file_preview'; +import type {FilePreviewInfo} from 'components/file_preview/file_preview'; +import FileUpload from 'components/file_upload'; +import type {FileUpload as FileUploadClass} from 'components/file_upload/file_upload'; +import type TextboxClass from 'components/textbox/textbox'; + +import type {PostDraft} from 'types/store/draft'; + +const getFileCount = (draft: PostDraft) => { + return draft.fileInfos.length + draft.uploadsInProgress.length; +}; + +const useUploadFiles = ( + draft: PostDraft, + postId: string, + channelId: string, + isThreadView: boolean, + storedDrafts: React.MutableRefObject>, + readOnlyChannel: boolean, + textboxRef: React.RefObject, + handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void, + focusTextbox: (forceFocust?: boolean) => void, + setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void, +): [React.ReactNode, React.ReactNode] => { + const locale = useSelector(getCurrentLocale); + + const [uploadsProgressPercent, setUploadsProgressPercent] = useState<{ [clientID: string]: FilePreviewInfo }>({}); + + const fileUploadRef = useRef(null); + + const handleFileUploadChange = useCallback(() => { + focusTextbox(); + }, [focusTextbox]); + + const getFileUploadTarget = useCallback(() => { + return textboxRef.current?.getInputBox(); + }, [textboxRef]); + + const handleUploadProgress = useCallback((filePreviewInfo: FilePreviewInfo) => { + setUploadsProgressPercent((prev) => ({ + ...prev, + [filePreviewInfo.clientId]: filePreviewInfo, + })); + }, []); + + const handleFileUploadComplete = useCallback((fileInfos: FileInfo[], clientIds: string[], channelId: string, rootId?: string) => { + const key = rootId || channelId; + const draftToUpdate = storedDrafts.current[key]; + if (!draftToUpdate) { + return; + } + + const newFileInfos = sortFileInfos([...draftToUpdate.fileInfos || [], ...fileInfos], locale); + + const clientIdsSet = new Set(clientIds); + const uploadsInProgress = (draftToUpdate.uploadsInProgress || []).filter((v) => !clientIdsSet.has(v)); + + const modifiedDraft = { + ...draftToUpdate, + fileInfos: newFileInfos, + uploadsInProgress, + }; + + handleDraftChange(modifiedDraft, {instant: true}); + }, [locale, handleDraftChange, storedDrafts]); + + const handleUploadStart = useCallback((clientIds: string[]) => { + const uploadsInProgress = [...draft.uploadsInProgress, ...clientIds]; + + const updatedDraft = { + ...draft, + uploadsInProgress, + }; + + handleDraftChange(updatedDraft, {instant: true}); + + focusTextbox(); + }, [draft, handleDraftChange, focusTextbox]); + + const handleUploadError = useCallback((uploadError: string | ServerError | null, clientId?: string, channelId = '', rootId = '') => { + if (clientId) { + const id = rootId || channelId; + const storedDraft = storedDrafts.current[id]; + if (storedDraft) { + const modifiedDraft = {...storedDraft}; + const index = modifiedDraft.uploadsInProgress.indexOf(clientId) ?? -1; + if (index !== -1) { + modifiedDraft.uploadsInProgress = [...modifiedDraft.uploadsInProgress]; + modifiedDraft.uploadsInProgress.splice(index, 1); + handleDraftChange(modifiedDraft, {instant: true}); + } + } + } + + if (typeof uploadError === 'string') { + if (uploadError) { + setServerError(new Error(uploadError)); + } + } else { + setServerError(uploadError); + } + }, [handleDraftChange, setServerError, storedDrafts]); + + const removePreview = useCallback((clientId: string) => { + handleUploadError(null, clientId, draft.channelId, draft.rootId); + + const modifiedDraft = {...draft}; + let index = draft.fileInfos.findIndex((info) => info.id === clientId); + if (index === -1) { + index = draft.uploadsInProgress.indexOf(clientId); + + if (index >= 0) { + modifiedDraft.uploadsInProgress = [...draft.uploadsInProgress]; + modifiedDraft.uploadsInProgress.splice(index, 1); + + fileUploadRef.current?.cancelUpload(clientId); + } else { + // No modification + return; + } + } else { + modifiedDraft.fileInfos = [...draft.fileInfos]; + modifiedDraft.fileInfos.splice(index, 1); + } + + handleDraftChange(modifiedDraft, {instant: true}); + handleFileUploadChange(); + }, [draft, fileUploadRef, handleDraftChange, handleUploadError, handleFileUploadChange]); + + let attachmentPreview = null; + if (!readOnlyChannel && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0)) { + attachmentPreview = ( + + ); + } + + let postType = 'post'; + if (postId) { + postType = isThreadView ? 'thread' : 'comment'; + } + + const fileUploadJSX = readOnlyChannel ? null : ( + + ); + + return [attachmentPreview, fileUploadJSX]; +}; + +export default useUploadFiles; diff --git a/webapp/channels/src/components/analytics/team_analytics/index.ts b/webapp/channels/src/components/analytics/team_analytics/index.ts index 1ea66ea62d..c3a99c7c89 100644 --- a/webapp/channels/src/components/analytics/team_analytics/index.ts +++ b/webapp/channels/src/components/analytics/team_analytics/index.ts @@ -21,7 +21,7 @@ const LAST_ANALYTICS_TEAM = 'last_analytics_team'; function mapStateToProps(state: GlobalState) { const teams = getTeamsList(state); - const teamId = makeGetGlobalItem(LAST_ANALYTICS_TEAM, null)(state); + const teamId = makeGetGlobalItem(LAST_ANALYTICS_TEAM, '')(state); const initialTeam = state.entities.teams.teams[teamId] || (teams.length > 0 ? teams[0] : null); return { diff --git a/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap b/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap index 6cbd67425e..39c2601244 100644 --- a/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap +++ b/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap @@ -158,9 +158,7 @@ exports[`components/channel_view Should match snapshot with base props 1`] = ` data-testid="post-create" id="post-create" > - + `; diff --git a/webapp/channels/src/components/channel_view/channel_view.tsx b/webapp/channels/src/components/channel_view/channel_view.tsx index a40c4c45e5..7cca3c53c2 100644 --- a/webapp/channels/src/components/channel_view/channel_view.tsx +++ b/webapp/channels/src/components/channel_view/channel_view.tsx @@ -80,10 +80,6 @@ export default class ChannelView extends React.PureComponent { this.channelViewRef = React.createRef(); } - getChannelView = () => { - return this.channelViewRef.current; - }; - onClickCloseChannel = () => { this.props.goToLastViewedChannel(); }; @@ -160,7 +156,7 @@ export default class ChannelView extends React.PureComponent { data-testid='post-create' className='post-create__container AdvancedTextEditor__ctr' > - + ); } diff --git a/webapp/channels/src/components/common/chip/chip.tsx b/webapp/channels/src/components/common/chip/chip.tsx index a0ba19c647..56528ffad2 100644 --- a/webapp/channels/src/components/common/chip/chip.tsx +++ b/webapp/channels/src/components/common/chip/chip.tsx @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; +import type {MessageDescriptor} from 'react-intl'; import {FormattedMessage} from 'react-intl'; import styled from 'styled-components'; @@ -11,8 +12,7 @@ import RenderEmoji from 'components/emoji/render_emoji'; type Props = { onClick?: () => void; - id?: string; - defaultMessage?: string; + display?: MessageDescriptor; values?: Record; className?: string; @@ -59,8 +59,7 @@ const Chip = ({ otherOption, className, leadingIcon, - id, - defaultMessage, + display, values, additionalMarkup, }: Props) => { @@ -81,10 +80,9 @@ const Chip = ({ emojiStyle={emojiStyles} /> )} - {(id && defaultMessage && values) && ( + {(display && values) && ( )} diff --git a/webapp/channels/src/components/drafts/panel/panel_body.tsx b/webapp/channels/src/components/drafts/panel/panel_body.tsx index 666d8e570e..2d50426c9d 100644 --- a/webapp/channels/src/components/drafts/panel/panel_body.tsx +++ b/webapp/channels/src/components/drafts/panel/panel_body.tsx @@ -9,7 +9,7 @@ import type {UserProfile, UserStatus} from '@mattermost/types/users'; import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams'; -import PriorityLabels from 'components/advanced_create_post/priority_labels'; +import PriorityLabels from 'components/advanced_text_editor/priority_labels'; import FilePreview from 'components/file_preview'; import Markdown from 'components/markdown'; import ProfilePicture from 'components/profile_picture'; diff --git a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx index 3f092a876f..ce3afe037f 100644 --- a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx +++ b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx @@ -5,10 +5,8 @@ import React, {memo, forwardRef, useMemo} from 'react'; import {useSelector} from 'react-redux'; import {ArchiveOutlineIcon} from '@mattermost/compass-icons/components'; -import type {Post} from '@mattermost/types/posts'; import type {UserProfile} from '@mattermost/types/users'; -import {Posts} from 'mattermost-redux/constants'; import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; import {getPost, getLimitedViews} from 'mattermost-redux/selectors/entities/posts'; @@ -23,7 +21,6 @@ import type {GlobalState} from 'types/store'; type Props = { teammate?: UserProfile; threadId: string; - latestPostId: Post['id']; isThreadView?: boolean; placeholder?: string; }; @@ -31,7 +28,6 @@ type Props = { const CreateComment = forwardRef(({ teammate, threadId, - latestPostId, isThreadView, placeholder, }: Props, ref) => { @@ -47,7 +43,6 @@ const CreateComment = forwardRef(({ if (!channel || threadIsLimited) { return null; } - const rootDeleted = (rootPost as Post).state === Posts.POST_DELETED; const isFakeDeletedPost = rootPost.type === Constants.PostTypes.FAKE_PARENT_DELETED; const channelType = channel.type; @@ -97,8 +92,6 @@ const CreateComment = forwardRef(({ diff --git a/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx b/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx index babcfd4d6b..7a5cddc258 100644 --- a/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx +++ b/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx @@ -355,7 +355,6 @@ class ThreadViewerVirtualized extends PureComponent { void; - currentChannel: Channel; + channelId: string; currentUserId: string; - currentChannelTeammateUsername?: string; } const translate = {x: -6, y: -6}; export const SendMessageTour = ({ prefillMessage, - currentChannel, + channelId, currentUserId, - currentChannelTeammateUsername, }: Props) => { const chips = ( ); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 2db749141f..acecfd0400 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3341,7 +3341,6 @@ "create_group_memberships_modal.create": "Yes", "create_group_memberships_modal.desc": "You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time.", "create_group_memberships_modal.title": "Re-add {username} to teams and channels", - "create_post.comment": "Comment", "create_post.deactivated": "You are viewing an archived channel with a **deactivated user**. New messages cannot be posted.", "create_post.error_message": "Your message is too long. Character count: {length}/{limit}", "create_post.file_limit_sticky_banner.admin_message": "New uploads will automatically archive older files. To view them again, you can delete older files or upgrade to a paid plan.", diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.test.ts index 543dc8837b..a747150374 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.test.ts @@ -2243,206 +2243,6 @@ describe('getPostsInCurrentChannel', () => { }); }); -describe('getCurrentUsersLatestPost', () => { - const user1 = TestHelper.fakeUserWithId(); - const profiles: Record = {}; - profiles[user1.id] = user1; - it('no posts', () => { - const noPosts = {}; - const state = { - entities: { - users: { - currentUserId: user1.id, - profiles, - }, - posts: { - posts: noPosts, - postsInChannel: [], - }, - preferences: { - myPreferences: {}, - }, - channels: { - currentChannelId: 'abcd', - }, - }, - } as unknown as GlobalState; - const actual = Selectors.getCurrentUsersLatestPost(state, ''); - - expect(actual).toEqual(null); - }); - - it('return first post which user can edit', () => { - const postsAny = { - a: {id: 'a', channel_id: 'a', create_at: 1, highlight: false, user_id: 'a'}, - b: {id: 'b', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', state: Posts.POST_DELETED}, - c: {id: 'c', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: 'system_join_channel'}, - d: {id: 'd', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: Posts.POST_TYPES.EPHEMERAL}, - e: {id: 'e', channel_id: 'abcd', create_at: 4, highlight: false, user_id: 'c'}, - f: {id: 'f', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id}, - }; - const state = { - entities: { - users: { - currentUserId: user1.id, - profiles, - }, - posts: { - posts: postsAny, - postsInChannel: { - abcd: [ - {order: ['b', 'c', 'd', 'e', 'f'], recent: true}, - ], - }, - postsInThread: {}, - }, - preferences: { - myPreferences: {}, - }, - channels: { - currentChannelId: 'abcd', - }, - }, - } as unknown as GlobalState; - const actual = Selectors.getCurrentUsersLatestPost(state, ''); - - expect(actual).toMatchObject(postsAny.f); - }); - - it('return first post which user can edit ignore pending and failed', () => { - const postsAny = { - a: {id: 'a', channel_id: 'a', create_at: 1, highlight: false, user_id: 'a'}, - b: {id: 'b', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id, pending_post_id: 'b'}, - c: {id: 'c', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id, failed: true}, - d: {id: 'd', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: Posts.POST_TYPES.EPHEMERAL}, - e: {id: 'e', channel_id: 'abcd', create_at: 4, highlight: false, user_id: 'c'}, - f: {id: 'f', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id}, - }; - const state = { - entities: { - users: { - currentUserId: user1.id, - profiles, - }, - posts: { - posts: postsAny, - postsInChannel: { - abcd: [ - {order: ['b', 'c', 'd', 'e', 'f'], recent: true}, - ], - }, - postsInThread: {}, - }, - preferences: { - myPreferences: {}, - }, - channels: { - currentChannelId: 'abcd', - }, - }, - } as unknown as GlobalState; - const actual = Selectors.getCurrentUsersLatestPost(state, ''); - - expect(actual).toMatchObject(postsAny.f); - }); - - it('return first post which has rootId match', () => { - const postsAny = { - a: {id: 'a', channel_id: 'a', create_at: 1, highlight: false, user_id: 'a'}, - b: {id: 'b', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', state: Posts.POST_DELETED}, - c: {id: 'c', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: 'system_join_channel'}, - d: {id: 'd', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: Posts.POST_TYPES.EPHEMERAL}, - e: {id: 'e', channel_id: 'abcd', create_at: 4, highlight: false, user_id: 'c'}, - f: {id: 'f', root_id: 'e', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id}, - }; - const state = { - entities: { - users: { - currentUserId: user1.id, - profiles, - }, - posts: { - posts: postsAny, - postsInChannel: { - abcd: [ - {order: ['b', 'c', 'd', 'e', 'f'], recent: true}, - ], - }, - postsInThread: {}, - }, - preferences: { - myPreferences: {}, - }, - channels: { - currentChannelId: 'abcd', - }, - }, - } as unknown as GlobalState; - const actual = Selectors.getCurrentUsersLatestPost(state, 'e'); - - expect(actual).toMatchObject(postsAny.f); - }); - - it('should not return posts outside of the recent block', () => { - const postsAny = { - a: {id: 'a', channel_id: 'a', create_at: 1, user_id: 'a'}, - }; - const state = { - entities: { - users: { - currentUserId: user1.id, - profiles, - }, - posts: { - posts: postsAny, - postsInChannel: { - abcd: [ - {order: ['a'], recent: false}, - ], - }, - }, - preferences: { - myPreferences: {}, - }, - channels: { - currentChannelId: 'abcd', - }, - }, - } as unknown as GlobalState; - const actual = Selectors.getCurrentUsersLatestPost(state, 'e'); - - expect(actual).toEqual(null); - }); - - it('determine the sending posts', () => { - const state = { - entities: { - users: { - currentUserId: user1.id, - profiles, - }, - posts: { - posts: {}, - postsInChannel: {}, - pendingPostIds: ['1', '2', '3'], - }, - preferences: { - myPreferences: {}, - }, - channels: { - currentChannelId: 'abcd', - }, - }, - } as unknown as GlobalState; - - expect(Selectors.isPostIdSending(state, '1')).toEqual(true); - expect(Selectors.isPostIdSending(state, '2')).toEqual(true); - expect(Selectors.isPostIdSending(state, '3')).toEqual(true); - expect(Selectors.isPostIdSending(state, '4')).toEqual(false); - expect(Selectors.isPostIdSending(state, '')).toEqual(false); - }); -}); - describe('makeGetProfilesForThread', () => { it('should return profiles for threads in the right order and exclude current user', () => { const getProfilesForThread = Selectors.makeGetProfilesForThread(); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.ts index 94be7a741d..b39ededda1 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/posts.ts @@ -22,13 +22,13 @@ import type { import {General, Posts, Preferences} from 'mattermost-redux/constants'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; import {getChannel} from 'mattermost-redux/selectors/entities/channels'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; +import {getCurrentChannelId, getCurrentUser} from 'mattermost-redux/selectors/entities/common'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getUsers, getCurrentUserId, getUserStatuses} from 'mattermost-redux/selectors/entities/users'; import {createIdsSelector} from 'mattermost-redux/utils/helpers'; -import {shouldShowJoinLeaveMessages} from 'mattermost-redux/utils/post_list'; +import {isCombinedUserActivityPost, shouldShowJoinLeaveMessages} from 'mattermost-redux/utils/post_list'; import { isPostEphemeral, isSystemMessage, @@ -269,9 +269,71 @@ function formatPostInChannel(post: Post, previousPost: Post | undefined | null, }; } +export function getLatestInteractablePostId(state: GlobalState, channelId: string, rootId = '') { + const postsIds = rootId ? getPostsInThread(state)[rootId] : getPostIdsInChannel(state, channelId); + if (!postsIds) { + return ''; + } + + const allPosts = getAllPosts(state); + + for (const postId of postsIds) { + if (isCombinedUserActivityPost(postId)) { + continue; + } + + const post = allPosts[postId]; + if (!post) { + continue; + } + + if (post.delete_at) { + continue; + } + + if (isPostEphemeral(post)) { + continue; + } + + if (isSystemMessage(post)) { + continue; + } + + return postId; + } + + if (rootId && allPosts[rootId] && !allPosts[rootId].delete_at) { + return rootId; + } + + return ''; +} + +export function getLatestPostToEdit(state: GlobalState, channelId: string, rootId = '') { + const postsIds = rootId ? getPostsInThread(state)[rootId] : getPostIdsInChannel(state, channelId); + if (!postsIds) { + return ''; + } + + const allPosts = getAllPosts(state); + const currentUserId = getCurrentUserId(state); + + for (const postId of postsIds) { + const post = allPosts[postId]; + if (!post || post.user_id !== currentUserId || (post.props?.from_webhook) || post.state === Posts.POST_DELETED || isSystemMessage(post) || isPostEphemeral(post) || isPostPendingOrFailed(post)) { + continue; + } + + return post.id; + } + + return ''; +} + +export const getLatestReplyablePostId: (state: GlobalState) => Post['id'] = (state) => getLatestInteractablePostId(state, getCurrentChannelId(state)); + // makeGetPostsInChannel creates a selector that returns up to the given number of posts loaded at the bottom of the // given channel. It does not include older posts such as those loaded by viewing a thread or a permalink. - export function makeGetPostsInChannel(): (state: GlobalState, channelId: Channel['id'], numPosts: number) => PostWithFormatData[] | undefined | null { return createSelector( 'makeGetPostsInChannel', @@ -526,50 +588,6 @@ export const getMostRecentPostIdInChannel: (state: GlobalState, channelId: Chann }, ); -export const getLatestReplyablePostId: (state: GlobalState) => Post['id'] = createSelector( - 'getLatestReplyablePostId', - getPostsInCurrentChannel, - (posts) => { - if (!posts) { - return ''; - } - - const latestReplyablePost = posts.find((post) => post.state !== Posts.POST_DELETED && !isSystemMessage(post) && !isPostEphemeral(post)); - if (!latestReplyablePost) { - return ''; - } - - return latestReplyablePost.id; - }, -); - -export const getCurrentUsersLatestPost: (state: GlobalState, postId: Post['id']) => PostWithFormatData | undefined | null = createSelector( - 'getCurrentUsersLatestPost', - getPostsInCurrentChannel, - getCurrentUser, - (state: GlobalState, rootId: string) => rootId, - (posts, currentUser, rootId) => { - if (!posts) { - return null; - } - - const lastPost = posts.find((post) => { - // don't edit webhook posts, deleted posts, or system messages - if (post.user_id !== currentUser.id || (post.props && post.props.from_webhook) || post.state === Posts.POST_DELETED || isSystemMessage(post) || isPostEphemeral(post) || isPostPendingOrFailed(post)) { - return false; - } - - if (rootId) { - return post.root_id === rootId || post.id === rootId; - } - - return true; - }); - - return lastPost; - }, -); - export function getRecentPostsChunkInChannel(state: GlobalState, channelId: Channel['id']): PostOrderBlock | null | undefined { const postsForChannel = state.entities.posts.postsInChannel[channelId]; diff --git a/webapp/channels/src/selectors/rhs.ts b/webapp/channels/src/selectors/rhs.ts index 9c8a49e4ea..ab3d54ac53 100644 --- a/webapp/channels/src/selectors/rhs.ts +++ b/webapp/channels/src/selectors/rhs.ts @@ -8,7 +8,7 @@ import {createSelector} from 'mattermost-redux/selectors/create_selector'; import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; -import {makeGetGlobalItem, makeGetGlobalItemWithDefault} from 'selectors/storage'; +import {getGlobalItem, makeGetGlobalItem, makeGetGlobalItemWithDefault} from 'selectors/storage'; import type {SidebarSize} from 'components/resizable_sidebar/constants'; @@ -138,6 +138,37 @@ export function getIsSearchGettingMore(state: GlobalState): boolean { return state.entities.search.isSearchGettingMore; } +export function makeGetDraft() { + let defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''}; + return (state: GlobalState, channelId: string, rootId = ''): PostDraft => { + if (defaultDraft.channelId !== channelId || defaultDraft.rootId !== rootId) { + defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId, rootId}; + } + const prefix = rootId ? StoragePrefixes.COMMENT_DRAFT : StoragePrefixes.DRAFT; + const suffix = rootId || channelId; + const draft = getGlobalItem(state, `${prefix}${suffix}`, defaultDraft); + + let toReturn = defaultDraft; + if ( + typeof draft.message !== 'undefined' && + typeof draft.uploadsInProgress !== 'undefined' && + typeof draft.fileInfos !== 'undefined' + ) { + toReturn = draft; + } + + if (draft.rootId !== rootId || draft.channelId !== channelId) { + toReturn = { + ...draft, + rootId, + channelId, + }; + } + + return toReturn; + }; +} + export function makeGetChannelDraft() { const defaultDraft = Object.freeze({message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''}); const getDraft = makeGetGlobalItemWithDefault(defaultDraft); diff --git a/webapp/channels/src/selectors/storage.ts b/webapp/channels/src/selectors/storage.ts index 00392ea7ed..09a62d3e9e 100644 --- a/webapp/channels/src/selectors/storage.ts +++ b/webapp/channels/src/selectors/storage.ts @@ -6,21 +6,21 @@ import type {GlobalState} from 'types/store'; export const getGlobalItem = (state: GlobalState, name: string, defaultValue: T) => { const storage = state && state.storage && state.storage.storage; - return getItemFromStorage(storage, name, defaultValue); + return getItemFromStorage(storage, name, defaultValue); }; export const makeGetGlobalItem = (name: string, defaultValue: T) => { return (state: GlobalState) => { - return getGlobalItem(state, name, defaultValue); + return getGlobalItem(state, name, defaultValue); }; }; -export const getItemFromStorage = (storage: Record, name: string, defaultValue: T) => { +export const getItemFromStorage = (storage: Record, name: string, defaultValue: T): T => { return storage[name]?.value ?? defaultValue; }; export const makeGetGlobalItemWithDefault = (defaultValue: T) => { return (state: GlobalState, name: string) => { - return getGlobalItem(state, name, defaultValue); + return getGlobalItem(state, name, defaultValue); }; }; diff --git a/webapp/channels/src/utils/post_utils.ts b/webapp/channels/src/utils/post_utils.ts index cdddba9b00..b669d34159 100644 --- a/webapp/channels/src/utils/post_utils.ts +++ b/webapp/channels/src/utils/post_utils.ts @@ -303,7 +303,7 @@ export function postMessageOnKeyPress( now = 0, lastChannelSwitchAt = 0, caretPosition = 0, -): {allowSending: boolean; ignoreKeyPress?: boolean} { +): {allowSending: boolean; ignoreKeyPress?: boolean; withClosedCodeBlock?: boolean; message?: string} { if (!event) { return {allowSending: false}; } @@ -341,6 +341,10 @@ export function postMessageOnKeyPress( return {allowSending: false}; } +export function isServerError(err: unknown): err is ServerError { + return Boolean(err && typeof err === 'object' && 'server_error_id' in err); +} + export function isErrorInvalidSlashCommand(error: ServerError | null): boolean { if (error && error.server_error_id) { return error.server_error_id === 'api.command.execute_command.not_found.app_error'; diff --git a/webapp/channels/src/utils/utils.tsx b/webapp/channels/src/utils/utils.tsx index 6bba4364c2..27dd6fcb44 100644 --- a/webapp/channels/src/utils/utils.tsx +++ b/webapp/channels/src/utils/utils.tsx @@ -803,7 +803,7 @@ export function setSelectionRange(input: HTMLInputElement | HTMLTextAreaElement, input.setSelectionRange(selectionStart, selectionEnd); } -export function setCaretPosition(input: HTMLInputElement, pos: number) { +export function setCaretPosition(input: HTMLInputElement | HTMLTextAreaElement, pos: number) { if (!input) { return; }