[MM-56713] Show consistent markdown formatting buttons & keyboard shortcuts when editing messages (#29398)
* footer * changes * edit * some changes * form submit * ci fixes * rev fix 1 * ci fix * Fixed some styles * Added delete on empty post option * fix E2E tests * fix more tests * Fixed UI isses when editing post in RHS * Fixed formatting bar behjaviour * Reset draft to original post when cancelling or escaping * DFisplayed @mention warning * fixed existing test * Added test for @mention during editing * Displayed long message warning during edit post * Removed a console log: * Handled message with image links when using image proxy * Fixed a11y styling for button * Fixed emoji picker keyboard shortcut * Checnged edit box ID * Added draft test * Fixed edit text box id * e2e fix * e2e fix * handled deleting empty fposts * Fixed e2e test --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: Harshil Sharma <harshil.sharma@mattermost.com> Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
041c874961
Коммит
6f73204448
@@ -68,8 +68,8 @@ describe('Keyboard Shortcuts', () => {
|
||||
cy.uiPostDropdownMenuShortcut(postId, 'Edit', 'E');
|
||||
|
||||
// # add test to the message
|
||||
cy.get('body').type(postEditMessage);
|
||||
cy.get('body').type('{enter}');
|
||||
cy.get('#edit_textbox').type(postEditMessage);
|
||||
cy.get('#edit_textbox').type('{enter}');
|
||||
|
||||
// * Verify edited message
|
||||
cy.uiWaitUntilMessagePostedIncludes(postMessage + postEditMessage);
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('Keyboard Shortcuts', () => {
|
||||
cy.clickPostDotMenu(postId);
|
||||
cy.findByText('Reply').click();
|
||||
const replyMessage = 'Well, hello there.';
|
||||
cy.uiGetReplyTextBox().type(replyMessage);
|
||||
cy.uiGetReplyTextBox().type(replyMessage, {delay: 100});
|
||||
cy.uiGetReplyTextBox().type('{enter}');
|
||||
cy.uiWaitUntilMessagePostedIncludes(replyMessage);
|
||||
|
||||
|
||||
@@ -116,7 +116,8 @@ describe('Messaging', () => {
|
||||
cy.get('#edit_textbox').should('be.visible');
|
||||
|
||||
// * Update the post message and type ENTER
|
||||
cy.get('#edit_textbox').invoke('val', '').type(message2).type('{enter}').wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('#edit_textbox').clear().type(message2);
|
||||
cy.get('#edit_textbox').type('{enter}').wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
// * Edit Post Input is still visible after typing ENTER
|
||||
cy.get('#edit_textbox').should('be.visible');
|
||||
@@ -764,9 +765,6 @@ describe('Messaging', () => {
|
||||
// * Edit Post Input should appear
|
||||
cy.get('#edit_textbox').should('be.visible');
|
||||
|
||||
// # Check that a scrollbar exists
|
||||
cy.get('.post--editing__wrapper.scroll').should('be.visible');
|
||||
|
||||
// # Update the message
|
||||
cy.get('#edit_textbox', {timeout: TIMEOUTS.FIVE_SEC}).type(' test').wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('Direct Message', () => {
|
||||
|
||||
// * Edit post Input should appear, and edit the post
|
||||
cy.get('#edit_textbox').should('be.visible');
|
||||
cy.get('#edit_textbox').should('have.text', originalMessage).type(' World{enter}');
|
||||
cy.get('#edit_textbox').should('have.text', originalMessage).type(' World{enter}', {delay: 100});
|
||||
cy.get('#edit_textbox').should('not.exist');
|
||||
|
||||
// * Verify that last post does contain "Edited"
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('Edit Message', () => {
|
||||
cy.get('#suggestionList').should('not.exist');
|
||||
|
||||
// # In the modal click the emoji picker icon
|
||||
cy.get('#editPostEmoji').click();
|
||||
cy.get('div.post-edit__container button#emojiPickerButton').click();
|
||||
|
||||
// * Assert emoji picker is visible
|
||||
cy.get('#emojiPicker').should('be.visible');
|
||||
@@ -93,7 +93,7 @@ describe('Edit Message', () => {
|
||||
cy.get(`#edit_post_${postId}`).click();
|
||||
|
||||
// # Edit the post
|
||||
cy.get('#edit_textbox').type('Some text {enter}');
|
||||
cy.get('#edit_textbox').type('Some text {enter}', {delay: 100});
|
||||
|
||||
// # Mouseover the post again
|
||||
cy.get(`#post_${postId}`).trigger('mouseover');
|
||||
@@ -138,7 +138,7 @@ describe('Edit Message', () => {
|
||||
|
||||
// * Edit Post Input should appear, and edit the post
|
||||
cy.get('#edit_textbox').should('be.visible');
|
||||
cy.get('#edit_textbox').should('have.text', secondMessage).type(' Another new message{enter}');
|
||||
cy.get('#edit_textbox').should('have.text', secondMessage).type(' Another new message{enter}', {delay: 100});
|
||||
cy.get('#edit_textbox').should('not.exist');
|
||||
|
||||
// * Check the second post and verify that it contains new edited message.
|
||||
@@ -162,7 +162,7 @@ describe('Edit Message', () => {
|
||||
cy.get('#edit_textbox').should('be.visible');
|
||||
|
||||
// * Press the escape key to cancel
|
||||
cy.get('#edit_textbox').should('have.text', message).type(' Another new message{esc}');
|
||||
cy.get('#edit_textbox').should('have.text', message).type(' Another new message{esc}', {delay: 100});
|
||||
cy.get('#edit_textbox').should('not.exist');
|
||||
|
||||
// * Check that the message wasn't edited
|
||||
@@ -195,14 +195,14 @@ describe('Edit Message', () => {
|
||||
cy.get('#edit_textbox').type(' @user');
|
||||
|
||||
// # Press the enter key
|
||||
cy.get('#edit_textbox').wait(TIMEOUTS.HALF_SEC).focus().type('{enter}');
|
||||
cy.get('#edit_textbox').wait(TIMEOUTS.HALF_SEC).focus().type('{enter}', {delay: 100});
|
||||
|
||||
// * Check if the textbox contains expected text
|
||||
cy.get('.post-body__info').should('be.visible');
|
||||
cy.get('.post-body__info').contains('span', "Editing this message with an '@mention' will not notify the recipient.");
|
||||
|
||||
// # Press the escape key
|
||||
cy.get('#edit_textbox').wait(TIMEOUTS.HALF_SEC).focus().type('{enter}');
|
||||
cy.get('#edit_textbox').wait(TIMEOUTS.HALF_SEC).focus().type('{enter}', {delay: 100});
|
||||
|
||||
// # Open the RHS
|
||||
cy.getLastPostId().then((postId) => {
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('Actions.Posts', () => {
|
||||
user_id: 'current_user_id',
|
||||
message: 'test msg',
|
||||
channel_id: 'current_channel_id',
|
||||
type: 'normal,',
|
||||
type: 'normal',
|
||||
};
|
||||
const initialState = {
|
||||
entities: {
|
||||
@@ -200,6 +200,9 @@ describe('Actions.Posts', () => {
|
||||
filesSearchExtFilter: [],
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
storage: {},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
|
||||
test('handleNewPost', async () => {
|
||||
@@ -276,8 +279,8 @@ describe('Actions.Posts', () => {
|
||||
expect(dataSet).toEqual(true);
|
||||
|
||||
// matches the action to set editingPost
|
||||
expect(testStore.getActions()).toEqual(
|
||||
[{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}],
|
||||
expect(testStore.getActions()[0].payload[0]).toEqual(
|
||||
{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST},
|
||||
);
|
||||
|
||||
// clear actions
|
||||
@@ -285,11 +288,11 @@ describe('Actions.Posts', () => {
|
||||
|
||||
// dispatch action to unset the editingPost
|
||||
const {data: dataUnset} = testStore.dispatch(Actions.unsetEditingPost());
|
||||
expect(dataUnset).toEqual({show: false});
|
||||
expect(dataUnset).toEqual(true);
|
||||
|
||||
// matches the action to unset editingPost
|
||||
expect(testStore.getActions()).toEqual(
|
||||
[{data: {show: false}, type: ActionTypes.TOGGLE_EDITING_POST}],
|
||||
expect(testStore.getActions()[0].payload[0]).toEqual(
|
||||
{data: {show: false}, type: ActionTypes.TOGGLE_EDITING_POST},
|
||||
);
|
||||
|
||||
// editingPost value is empty object, as it should
|
||||
@@ -302,8 +305,15 @@ describe('Actions.Posts', () => {
|
||||
const {data} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test'));
|
||||
expect(data).toEqual(true);
|
||||
|
||||
expect(testStore.getActions()).toEqual(
|
||||
[{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}],
|
||||
let actions = testStore.getActions();
|
||||
expect(actions.length).toEqual(1);
|
||||
expect(actions[0].payload.length).toEqual(2);
|
||||
|
||||
expect(actions[0].payload[0]).toEqual(
|
||||
{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST},
|
||||
);
|
||||
expect(actions[0].payload[1]).toEqual(
|
||||
{args: ['edit_draft_latest_post_id', {id: 'latest_post_id', user_id: 'current_user_id', message: 'test msg', channel_id: 'current_channel_id', type: 'normal'}], type: 'MOCK_SET_GLOBAL_ITEM'},
|
||||
);
|
||||
|
||||
const general = {
|
||||
@@ -321,8 +331,9 @@ describe('Actions.Posts', () => {
|
||||
|
||||
const {data: withLicenseData} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test'));
|
||||
expect(withLicenseData).toEqual(true);
|
||||
expect(testStore.getActions()).toEqual(
|
||||
[{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST}],
|
||||
|
||||
expect(testStore.getActions()[0].payload[0]).toEqual(
|
||||
{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST},
|
||||
);
|
||||
|
||||
// should not allow edit for pending post
|
||||
@@ -335,6 +346,36 @@ describe('Actions.Posts', () => {
|
||||
const {data: withPendingPostData} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test'));
|
||||
expect(withPendingPostData).toEqual(false);
|
||||
expect(testStore.getActions()).toEqual([]);
|
||||
|
||||
// should not save draft when it already exists
|
||||
const stateWithDraft = {
|
||||
...initialState,
|
||||
storage: {
|
||||
...initialState.storage,
|
||||
storage: {
|
||||
...initialState.storage.storage,
|
||||
edit_draft_latest_post_id: {
|
||||
timestamp: new Date(),
|
||||
value: {id: 'latest_post_id', user_id: 'current_user_id', message: 'test msg', channel_id: 'current_channel_id', type: 'normal'},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
|
||||
stateWithDraft.entities.posts.posts[latestPost.id] = latestPost as Post;
|
||||
|
||||
testStore = mockStore(stateWithDraft);
|
||||
|
||||
const {data: dataExisting} = await testStore.dispatch(Actions.setEditingPost('latest_post_id', 'test'));
|
||||
expect(dataExisting).toEqual(true);
|
||||
|
||||
actions = testStore.getActions();
|
||||
expect(actions.length).toEqual(1);
|
||||
expect(actions[0].payload.length).toEqual(1);
|
||||
|
||||
expect(actions[0].payload[0]).toEqual(
|
||||
{data: {isRHS: false, postId: 'latest_post_id', refocusId: 'test', show: true}, type: ActionTypes.TOGGLE_EDITING_POST},
|
||||
);
|
||||
});
|
||||
|
||||
test('searchForTerm', async () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {AnyAction} from 'redux';
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import type {FileInfo} from '@mattermost/types/files';
|
||||
import type {GroupChannel} from '@mattermost/types/groups';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
@@ -20,6 +23,7 @@ import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selec
|
||||
import {canEditPost, comparePosts} from 'mattermost-redux/utils/post_utils';
|
||||
|
||||
import {addRecentEmoji, addRecentEmojis} from 'actions/emoji_actions';
|
||||
import {setGlobalItem} from 'actions/storage';
|
||||
import * as StorageActions from 'actions/storage';
|
||||
import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions';
|
||||
import {removeDraft} from 'actions/views/drafts';
|
||||
@@ -44,12 +48,14 @@ import {matchEmoticons} from 'utils/emoticons';
|
||||
import {makeGetIsReactionAlreadyAddedToPost, makeGetUniqueEmojiNameReactionsForPost} from 'utils/post_utils';
|
||||
|
||||
import type {
|
||||
GlobalState,
|
||||
DispatchFunc,
|
||||
ActionFunc,
|
||||
ActionFuncAsync,
|
||||
ThunkActionFunc,
|
||||
GlobalState,
|
||||
} from 'types/store';
|
||||
import type {PostDraft} from 'types/store/draft';
|
||||
import type {StorageItem} from 'types/store/storage';
|
||||
|
||||
import type {NewPostMessageProps} from './new_post';
|
||||
import {completePostReceive} from './new_post';
|
||||
@@ -316,7 +322,7 @@ export function unpinPost(postId: string): ActionFuncAsync<boolean> {
|
||||
};
|
||||
}
|
||||
|
||||
export function setEditingPost(postId = '', refocusId = '', isRHS = false): ActionFunc<boolean> {
|
||||
export function setEditingPost(postId = '', refocusId = '', isRHS = false): ActionFunc<boolean, GlobalState> {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const post = PostSelectors.getPost(state, postId);
|
||||
@@ -331,27 +337,58 @@ export function setEditingPost(postId = '', refocusId = '', isRHS = false): Acti
|
||||
const channel = getChannel(state, post.channel_id);
|
||||
const teamId = channel?.team_id || '';
|
||||
|
||||
const canEditNow = canEditPost(state, config, license, teamId, post.channel_id, userId, post);
|
||||
const canEdit = canEditPost(state, config, license, teamId, post.channel_id, userId, post);
|
||||
|
||||
// Only show the modal if we can edit the post now, but allow it to be hidden at any time
|
||||
|
||||
if (canEditNow) {
|
||||
dispatch({
|
||||
type: ActionTypes.TOGGLE_EDITING_POST,
|
||||
data: {postId, refocusId, isRHS, show: true},
|
||||
});
|
||||
if (!canEdit) {
|
||||
return {data: false};
|
||||
}
|
||||
|
||||
return {data: canEditNow};
|
||||
const storageKey = `${StoragePrefixes.EDIT_DRAFT}${post.id}`;
|
||||
|
||||
const actions: AnyAction[] = [{
|
||||
type: ActionTypes.TOGGLE_EDITING_POST,
|
||||
data: {postId, refocusId, isRHS, show: true},
|
||||
}];
|
||||
|
||||
// We need to see if post's draft is already in store, if it is, we don't need to set it again
|
||||
const editDraftInStore = getGlobalItem(state, storageKey, null) as StorageItem<PostDraft>['value'] | null;
|
||||
|
||||
if (
|
||||
!editDraftInStore ||
|
||||
(editDraftInStore &&
|
||||
editDraftInStore?.message?.length === 0 &&
|
||||
editDraftInStore?.fileInfos?.length === 0 &&
|
||||
editDraftInStore?.uploadsInProgress?.length === 0
|
||||
)
|
||||
) {
|
||||
actions.push(setGlobalItem(storageKey, post));
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions));
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function unsetEditingPost() {
|
||||
return {
|
||||
type: ActionTypes.TOGGLE_EDITING_POST,
|
||||
data: {
|
||||
show: false,
|
||||
},
|
||||
export function unsetEditingPost(): ActionFunc<boolean, GlobalState> {
|
||||
return (dispatch, getState) => {
|
||||
const editingPostId = getState().views.posts.editingPost.postId;
|
||||
|
||||
const actions: AnyAction[] = [{
|
||||
type: ActionTypes.TOGGLE_EDITING_POST,
|
||||
data: {
|
||||
show: false,
|
||||
},
|
||||
}];
|
||||
|
||||
if (editingPostId) {
|
||||
const storageKey = `${StoragePrefixes.EDIT_DRAFT}${editingPostId}`;
|
||||
actions.push(StorageActions.removeGlobalItem(storageKey));
|
||||
}
|
||||
|
||||
dispatch(batchActions(actions));
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import EmojiMap from 'utils/emoji_map';
|
||||
import {containsAtChannel, groupsMentionedInText} from 'utils/post_utils';
|
||||
import * as Utils from 'utils/utils';
|
||||
|
||||
import type {ActionFunc, ActionFuncAsync} from 'types/store';
|
||||
import type {ActionFunc, ActionFuncAsync, GlobalState} from 'types/store';
|
||||
import type {PostDraft} from 'types/store/draft';
|
||||
|
||||
export function submitPost(
|
||||
@@ -200,7 +200,7 @@ export function onSubmit(
|
||||
};
|
||||
}
|
||||
|
||||
export function editLatestPost(channelId: string, rootId = ''): ActionFunc<boolean> {
|
||||
export function editLatestPost(channelId: string, rootId = ''): ActionFunc<boolean, GlobalState> {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
|
||||
|
||||
@@ -385,4 +385,31 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
|
||||
expect(mockedRemoveDraft).not.toHaveBeenCalled();
|
||||
expect(mockedUpdateDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show @mention warning when a mention exists in the message', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
postId: 'post_id_1',
|
||||
isInEditMode: true,
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<AdvancedTextEditor
|
||||
{...props}
|
||||
/>,
|
||||
mergeObjects(initialState, {
|
||||
storage: {
|
||||
storage: {
|
||||
[StoragePrefixes.COMMENT_DRAFT + 'post_id_1']: {
|
||||
value: TestHelper.getPostDraftMock({
|
||||
message: 'mentioning @user',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('editPostAtMentionWarning')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, {lazy, useCallback, useEffect, useMemo, useRef, useState} from 're
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {InformationOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
|
||||
|
||||
@@ -13,6 +14,7 @@ import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {Permissions} from 'mattermost-redux/constants';
|
||||
import {getChannel, makeGetChannel, getDirectChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getPost} from 'mattermost-redux/selectors/entities/posts';
|
||||
import {get, getBool, getInt} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
|
||||
import {getCurrentUserId, isCurrentUserGuestUser, getStatusForUserId, makeGetDisplayName} from 'mattermost-redux/selectors/entities/users';
|
||||
@@ -21,7 +23,9 @@ import * as GlobalActions from 'actions/global_actions';
|
||||
import {actionOnGlobalItemsWithPrefix} from 'actions/storage';
|
||||
import type {SubmitPostReturnType} from 'actions/views/create_comment';
|
||||
import {removeDraft, updateDraft} from 'actions/views/drafts';
|
||||
import {getSelectedPostFocussedAt, makeGetDraft} from 'selectors/rhs';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {makeGetDraft} from 'selectors/drafts';
|
||||
import {getSelectedPostFocussedAt} from 'selectors/rhs';
|
||||
import {connectionErrorCount} from 'selectors/views/system';
|
||||
import LocalStorageStore from 'stores/local_storage_store';
|
||||
|
||||
@@ -29,8 +33,7 @@ import PostBoxIndicator from 'components/advanced_text_editor/post_box_indicator
|
||||
import {makeAsyncComponent} from 'components/async_load';
|
||||
import AutoHeightSwitcher from 'components/common/auto_height_switcher';
|
||||
import useDidUpdate from 'components/common/hooks/useDidUpdate';
|
||||
import MessageSubmitError from 'components/message_submit_error';
|
||||
import MsgTyping from 'components/msg_typing';
|
||||
import DeletePostModal from 'components/delete_post_modal';
|
||||
import RhsSuggestionList from 'components/suggestion/rhs_suggestion_list';
|
||||
import SuggestionList from 'components/suggestion/suggestion_list';
|
||||
import Textbox from 'components/textbox';
|
||||
@@ -39,17 +42,28 @@ import type TextboxClass from 'components/textbox/textbox';
|
||||
import {OnboardingTourSteps, OnboardingTourStepsForGuestUsers, TutorialTourName} from 'components/tours/constant';
|
||||
import {SendMessageTour} from 'components/tours/onboarding_tour';
|
||||
|
||||
import Constants, {Locations, StoragePrefixes, Preferences, AdvancedTextEditor as AdvancedTextEditorConst, UserStatuses} from 'utils/constants';
|
||||
import Constants, {
|
||||
Locations,
|
||||
StoragePrefixes,
|
||||
Preferences,
|
||||
AdvancedTextEditor as AdvancedTextEditorConst,
|
||||
UserStatuses,
|
||||
ModalIdentifiers,
|
||||
} from 'utils/constants';
|
||||
import {canUploadFiles as canUploadFilesAccordingToConfig} from 'utils/file_utils';
|
||||
import {applyMarkdown as applyMarkdownUtil} from 'utils/markdown/apply_markdown';
|
||||
import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
|
||||
import {applyMarkdown as applyMarkdownUtil} from 'utils/markdown/apply_markdown';
|
||||
import {isErrorInvalidSlashCommand} from 'utils/post_utils';
|
||||
import {allAtMentions} from 'utils/text_formatting';
|
||||
import * as Utils from 'utils/utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
import type {PostDraft} from 'types/store/draft';
|
||||
import {isPostDraftEmpty} from 'types/store/draft';
|
||||
|
||||
import DoNotDisturbWarning from './do_not_disturb_warning';
|
||||
import EditPostFooter from './edit_post_footer';
|
||||
import Footer from './footer';
|
||||
import FormattingBar from './formatting_bar';
|
||||
import {FormattingBarSpacer, Separator} from './formatting_bar/formatting_bar';
|
||||
import SendButton from './send_button';
|
||||
@@ -69,10 +83,6 @@ import './advanced_text_editor.scss';
|
||||
|
||||
const FileLimitStickyBanner = makeAsyncComponent('FileLimitStickyBanner', lazy(() => import('components/file_limit_sticky_banner')));
|
||||
|
||||
function isDraftEmpty(draft: PostDraft) {
|
||||
return draft.message === '' && draft.fileInfos.length === 0 && draft.uploadsInProgress.length === 0;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
@@ -83,6 +93,13 @@ type Props = {
|
||||
postId: string;
|
||||
isThreadView?: boolean;
|
||||
placeholder?: string;
|
||||
isInEditMode?: boolean;
|
||||
|
||||
/**
|
||||
* Key to store the draft in the storage
|
||||
* If not provided, draft key will be computed based on the post
|
||||
*/
|
||||
storageKey?: string;
|
||||
|
||||
/**
|
||||
* Used by plugins to act after the post is made
|
||||
@@ -96,7 +113,9 @@ const AdvancedTextEditor = ({
|
||||
postId,
|
||||
isThreadView = false,
|
||||
placeholder,
|
||||
isInEditMode = false,
|
||||
afterSubmit,
|
||||
storageKey,
|
||||
}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
@@ -108,17 +127,32 @@ const AdvancedTextEditor = ({
|
||||
|
||||
const isRHS = Boolean(postId && !isThreadView);
|
||||
|
||||
const getFormattingBarPreferenceName = () => {
|
||||
let name: string;
|
||||
if (isRHS) {
|
||||
name = isInEditMode ? AdvancedTextEditorConst.EDIT : AdvancedTextEditorConst.COMMENT;
|
||||
} else {
|
||||
name = AdvancedTextEditorConst.POST;
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
const post = useSelector((state: GlobalState) => getPost(state, postId));
|
||||
const currentUserId = useSelector(getCurrentUserId);
|
||||
const channel = useSelector((state: GlobalState) => getChannelSelector(state, channelId));
|
||||
const channelDisplayName = channel?.display_name || '';
|
||||
const channelType = channel?.type || '';
|
||||
const isChannelShared = channel?.shared;
|
||||
const draftFromStore = useSelector((state: GlobalState) => getDraftSelector(state, channelId, postId));
|
||||
const draftFromStore = useSelector((state: GlobalState) => getDraftSelector(state, channelId, postId, storageKey));
|
||||
const badConnection = useSelector((state: GlobalState) => connectionErrorCount(state) > 1);
|
||||
const maxPostSize = useSelector((state: GlobalState) => parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT);
|
||||
const canUploadFiles = useSelector((state: GlobalState) => canUploadFilesAccordingToConfig(getConfig(state)));
|
||||
const fullWidthTextBox = useSelector((state: GlobalState) => get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN);
|
||||
const isFormattingBarHidden = useSelector((state: GlobalState) => getBool(state, Preferences.ADVANCED_TEXT_EDITOR, isRHS ? AdvancedTextEditorConst.COMMENT : AdvancedTextEditorConst.POST));
|
||||
const isFormattingBarHidden = useSelector((state: GlobalState) => {
|
||||
const preferenceName = getFormattingBarPreferenceName();
|
||||
return getBool(state, Preferences.ADVANCED_TEXT_EDITOR, preferenceName);
|
||||
});
|
||||
const teammateId = useSelector((state: GlobalState) => getDirectChannel(state, channelId)?.teammate_id || '');
|
||||
const teammateDisplayName = useSelector((state: GlobalState) => (teammateId ? getDisplayName(state, teammateId) : ''));
|
||||
const showDndWarning = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false));
|
||||
@@ -166,6 +200,7 @@ const AdvancedTextEditor = ({
|
||||
const [isMessageLong, setIsMessageLong] = useState(false);
|
||||
const [renderScrollbar, setRenderScrollbar] = useState(false);
|
||||
const [keepEditorInFocus, setKeepEditorInFocus] = useState(false);
|
||||
const [showMentionHelper, setShowMentionHelper] = useState<boolean>(false);
|
||||
|
||||
const readOnlyChannel = !canPost;
|
||||
const hasDraftMessage = Boolean(draft.message);
|
||||
@@ -182,6 +217,15 @@ const AdvancedTextEditor = ({
|
||||
GlobalActions.emitLocalUserTypingEvent(channelId, postId);
|
||||
}, [channelId, postId]);
|
||||
|
||||
const handleShowMentionHelper = useCallback((message: string) => {
|
||||
if (!isInEditMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMentions = allAtMentions(message).length > 0;
|
||||
setShowMentionHelper(isMentions);
|
||||
}, [isInEditMode]);
|
||||
|
||||
const handleDraftChange = useCallback((draftToChange: PostDraft, options: {instant?: boolean; show?: boolean} = {instant: false, show: false}) => {
|
||||
if (saveDraftFrame.current) {
|
||||
clearTimeout(saveDraftFrame.current);
|
||||
@@ -190,12 +234,15 @@ const AdvancedTextEditor = ({
|
||||
setDraft(draftToChange);
|
||||
|
||||
const saveDraft = () => {
|
||||
let key = `${StoragePrefixes.DRAFT}${draftToChange.channelId}`;
|
||||
let prefix = StoragePrefixes.DRAFT;
|
||||
let suffix = draftToChange.channelId;
|
||||
if (draftToChange.rootId) {
|
||||
key = `${StoragePrefixes.COMMENT_DRAFT}${draftToChange.rootId}`;
|
||||
prefix = StoragePrefixes.COMMENT_DRAFT;
|
||||
suffix = draftToChange.rootId;
|
||||
}
|
||||
const key = storageKey || `${prefix}${suffix}`;
|
||||
|
||||
if (isDraftEmpty(draftToChange)) {
|
||||
if (isPostDraftEmpty(draftToChange)) {
|
||||
dispatch(removeDraft(key, draftToChange.channelId, draftToChange.rootId));
|
||||
return;
|
||||
}
|
||||
@@ -241,22 +288,36 @@ const AdvancedTextEditor = ({
|
||||
dispatch(savePreferences(currentUserId, [{
|
||||
category: Preferences.ADVANCED_TEXT_EDITOR,
|
||||
user_id: currentUserId,
|
||||
name: isRHS ? AdvancedTextEditorConst.COMMENT : AdvancedTextEditorConst.POST,
|
||||
|
||||
// name: isRHS ? AdvancedTextEditorConst.COMMENT : AdvancedTextEditorConst.POST,
|
||||
name: getFormattingBarPreferenceName(),
|
||||
value: String(!isFormattingBarHidden),
|
||||
}]));
|
||||
}, [currentUserId, isRHS, isFormattingBarHidden, dispatch]);
|
||||
}, [dispatch, currentUserId, getFormattingBarPreferenceName, isFormattingBarHidden]);
|
||||
|
||||
useOrientationHandler(textboxRef, postId);
|
||||
const pluginItems = usePluginItems(draft, textboxRef, handleDraftChange);
|
||||
const focusTextbox = useTextboxFocus(textboxRef, channelId, isRHS, canPost);
|
||||
const [attachmentPreview, fileUploadJSX] = useUploadFiles(draft, postId, channelId, isThreadView, storedDrafts, isDisabled, textboxRef, handleDraftChange, focusTextbox, setServerError);
|
||||
const [attachmentPreview, fileUploadJSX] = useUploadFiles(
|
||||
draft,
|
||||
postId,
|
||||
channelId,
|
||||
isThreadView,
|
||||
storedDrafts,
|
||||
isDisabled,
|
||||
textboxRef,
|
||||
handleDraftChange,
|
||||
focusTextbox,
|
||||
setServerError,
|
||||
isInEditMode,
|
||||
);
|
||||
const {
|
||||
emojiPicker,
|
||||
enableEmojiPicker,
|
||||
toggleEmojiPicker,
|
||||
} = useEmojiPicker(isDisabled, draft, caretPosition, setCaretPosition, handleDraftChange, showPreview, focusTextbox);
|
||||
const {
|
||||
labels,
|
||||
labels: priorityLabels,
|
||||
additionalControl: priorityAdditionalControl,
|
||||
isValidPersistentNotifications,
|
||||
onSubmitCheck: prioritySubmitCheck,
|
||||
@@ -275,7 +336,38 @@ const AdvancedTextEditor = ({
|
||||
prioritySubmitCheck,
|
||||
undefined,
|
||||
afterSubmit,
|
||||
undefined,
|
||||
isInEditMode,
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
// This resets the draft to the post's original content
|
||||
handleDraftChange({
|
||||
...draft,
|
||||
message: post?.message || '',
|
||||
});
|
||||
}, [handleDraftChange, draft, post]);
|
||||
|
||||
const handleSubmitWrapper = useCallback(() => {
|
||||
const isEmptyPost = isPostDraftEmpty(draft);
|
||||
|
||||
if (isInEditMode && isEmptyPost) {
|
||||
const deletePostModalData = {
|
||||
modalId: ModalIdentifiers.DELETE_POST,
|
||||
dialogType: DeletePostModal,
|
||||
dialogProps: {
|
||||
post: draft,
|
||||
isRHS,
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(openModal(deletePostModalData));
|
||||
return;
|
||||
}
|
||||
|
||||
handleSubmit();
|
||||
}, [dispatch, draft, handleSubmit, isInEditMode, isRHS]);
|
||||
|
||||
const [handleKeyDown, postMsgKeyPress] = useKeyHandler(
|
||||
draft,
|
||||
channelId,
|
||||
@@ -288,14 +380,19 @@ const AdvancedTextEditor = ({
|
||||
focusTextbox,
|
||||
applyMarkdown,
|
||||
handleDraftChange,
|
||||
handleSubmit,
|
||||
handleSubmitWrapper,
|
||||
emitTypingEvent,
|
||||
handleShowPreview,
|
||||
toggleAdvanceTextEditor,
|
||||
toggleEmojiPicker,
|
||||
isInEditMode,
|
||||
handleCancel,
|
||||
);
|
||||
|
||||
const noArgumentHandleSubmit = useCallback(() => handleSubmit(), [handleSubmit]);
|
||||
const handleSubmitWithEvent = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}, [handleSubmit]);
|
||||
|
||||
const handlePostError = useCallback((err: React.ReactNode) => {
|
||||
setPostError(err);
|
||||
@@ -465,8 +562,13 @@ const AdvancedTextEditor = ({
|
||||
};
|
||||
}, [channelId, postId]);
|
||||
|
||||
useEffect(() => {
|
||||
// this checks for the mention helper for initial load of component.
|
||||
handleShowMentionHelper(draft.message);
|
||||
}, [draft.message, handleShowMentionHelper]);
|
||||
|
||||
const disableSendButton = Boolean(isDisabled || (!draft.message.trim().length && !draft.fileInfos.length)) || !isValidPersistentNotifications;
|
||||
const sendButton = readOnlyChannel ? null : (
|
||||
const sendButton = readOnlyChannel || isInEditMode ? null : (
|
||||
<SendButton
|
||||
disabled={disableSendButton}
|
||||
handleSubmit={handleSubmitPostAndScheduledMessage}
|
||||
@@ -510,7 +612,7 @@ const AdvancedTextEditor = ({
|
||||
createMessage = formatMessage({id: 'create_comment.addComment', defaultMessage: 'Reply to this thread...'});
|
||||
}
|
||||
|
||||
const messageValue = isDisabled ? '' : draft.message;
|
||||
const messageValue = isDisabled ? '' : draft.message_source || draft.message;
|
||||
|
||||
let textboxId = 'textbox';
|
||||
|
||||
@@ -526,6 +628,10 @@ const AdvancedTextEditor = ({
|
||||
break;
|
||||
}
|
||||
|
||||
if (isInEditMode) {
|
||||
textboxId = 'edit_textbox';
|
||||
}
|
||||
|
||||
const wasNotifiedOfLogIn = LocalStorageStore.getWasNotifiedOfLogIn();
|
||||
|
||||
let loginSuccessfulLabel;
|
||||
@@ -551,12 +657,10 @@ const AdvancedTextEditor = ({
|
||||
|
||||
const ariaLabel = loginSuccessfulLabel ? `${loginSuccessfulLabel} ${ariaLabelMessageInput}` : ariaLabelMessageInput;
|
||||
|
||||
const additionalControls = useMemo(() =>
|
||||
[
|
||||
priorityAdditionalControl,
|
||||
...(pluginItems || []),
|
||||
].filter(Boolean),
|
||||
[pluginItems, priorityAdditionalControl]);
|
||||
const additionalControls = useMemo(() => [
|
||||
!isInEditMode && priorityAdditionalControl,
|
||||
...(pluginItems || []),
|
||||
].filter(Boolean), [pluginItems, priorityAdditionalControl, isInEditMode]);
|
||||
|
||||
const formattingBar = (
|
||||
<AutoHeightSwitcher
|
||||
@@ -577,23 +681,26 @@ const AdvancedTextEditor = ({
|
||||
);
|
||||
|
||||
const showFormattingSpacer = isMessageLong || showPreview || attachmentPreview || isRHS || isThreadView;
|
||||
|
||||
return (
|
||||
<form
|
||||
id={postId ? undefined : 'create_post'}
|
||||
data-testid={postId ? undefined : 'create-post'}
|
||||
className={(!postId && !fullWidthTextBox) ? 'center' : undefined}
|
||||
onSubmit={noArgumentHandleSubmit}
|
||||
onSubmit={handleSubmitWithEvent}
|
||||
>
|
||||
{canPost && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0) && (
|
||||
<FileLimitStickyBanner/>
|
||||
)}
|
||||
{showDndWarning && <DoNotDisturbWarning displayName={teammateDisplayName}/>}
|
||||
<PostBoxIndicator
|
||||
channelId={channelId}
|
||||
teammateDisplayName={teammateDisplayName}
|
||||
location={location}
|
||||
postId={postId}
|
||||
/>
|
||||
{!isInEditMode && (
|
||||
<PostBoxIndicator
|
||||
channelId={channelId}
|
||||
teammateDisplayName={teammateDisplayName}
|
||||
location={location}
|
||||
postId={postId}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames('AdvancedTextEditor', {
|
||||
'AdvancedTextEditor__attachment-disabled': !canUploadFiles,
|
||||
@@ -625,9 +732,9 @@ const AdvancedTextEditor = ({
|
||||
tabIndex={-1}
|
||||
className='AdvancedTextEditor__cell a11y__region'
|
||||
>
|
||||
{labels}
|
||||
{!isInEditMode && priorityLabels}
|
||||
<Textbox
|
||||
hasLabels={Boolean(labels)}
|
||||
hasLabels={isInEditMode ? false : Boolean(priorityLabels)}
|
||||
suggestionList={location === Locations.RHS_COMMENT ? RhsSuggestionList : SuggestionList}
|
||||
onChange={handleChange}
|
||||
onKeyPress={postMsgKeyPress}
|
||||
@@ -652,6 +759,7 @@ const AdvancedTextEditor = ({
|
||||
useChannelMentions={useChannelMentions}
|
||||
rootId={postId}
|
||||
onWidthChange={handleWidthChange}
|
||||
isInEditMode={isInEditMode}
|
||||
/>
|
||||
{attachmentPreview}
|
||||
{!isDisabled && (showFormattingBar || showPreview) && (
|
||||
@@ -693,28 +801,40 @@ const AdvancedTextEditor = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id='postCreateFooter'
|
||||
role='form'
|
||||
className='AdvancedTextEditor__footer'
|
||||
>
|
||||
{postError && (
|
||||
<div className={classNames('post-error', {errorClass})}>
|
||||
{postError}
|
||||
</div>
|
||||
)}
|
||||
{serverError && (
|
||||
<MessageSubmitError
|
||||
error={serverError}
|
||||
submittedMessage={serverError.submittedMessage}
|
||||
handleSubmit={noArgumentHandleSubmit}
|
||||
/>
|
||||
)}
|
||||
<MsgTyping
|
||||
channelId={channelId}
|
||||
postId={postId}
|
||||
{ showMentionHelper ? (
|
||||
<div
|
||||
className='post-body__info'
|
||||
data-testid='editPostAtMentionWarning'
|
||||
>
|
||||
<span className='post-body__info__icon'>
|
||||
<InformationOutlineIcon
|
||||
size={14}
|
||||
color='currentColor'
|
||||
/>
|
||||
</span>
|
||||
<span>{
|
||||
formatMessage({
|
||||
id: 'edit_post.no_notification_trigger_on_mention',
|
||||
defaultMessage: "Editing this message with an '@mention' will not notify the recipient.",
|
||||
})
|
||||
}</span>
|
||||
</div>) : null
|
||||
}
|
||||
{isInEditMode && (
|
||||
<EditPostFooter
|
||||
onSave={handleSubmitWrapper}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Footer
|
||||
postError={postError}
|
||||
errorClass={errorClass}
|
||||
serverError={serverError}
|
||||
channelId={channelId}
|
||||
postId={postId}
|
||||
noArgumentHandleSubmit={handleSubmitWrapper}
|
||||
isInEditMode={isInEditMode}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {unsetEditingPost} from 'actions/post_actions';
|
||||
import {isSendOnCtrlEnter} from 'selectors/preferences';
|
||||
|
||||
import {isMac} from 'utils/user_agent';
|
||||
|
||||
type Props = {
|
||||
onSave: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function EditPostFooter(props: Props) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const sendOnCtrlEnter = useSelector(isSendOnCtrlEnter);
|
||||
const ctrlSendKey = isMac() ? '⌘+' : 'CTRL+';
|
||||
|
||||
function handleCancel() {
|
||||
props.onCancel?.();
|
||||
dispatch(unsetEditingPost());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='post-body__footer'>
|
||||
<button
|
||||
onClick={props.onSave}
|
||||
className='save'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='edit_post.action_buttons.save'
|
||||
defaultMessage='Save'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className='cancel'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='edit_post.action_buttons.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
</button>
|
||||
<FormattedMessage
|
||||
id='edit_post.helper_text'
|
||||
defaultMessage='<strong>{key}ENTER</strong> to Save, <strong>ESC</strong> to Cancel'
|
||||
values={{
|
||||
key: sendOnCtrlEnter ? ctrlSendKey : '',
|
||||
strong: (x: string) => <strong>{x}</strong>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
webapp/channels/src/components/advanced_text_editor/footer.tsx
Обычный файл
61
webapp/channels/src/components/advanced_text_editor/footer.tsx
Обычный файл
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import type {ReactNode} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
|
||||
import MessageSubmitError from 'components/message_submit_error';
|
||||
import MsgTyping from 'components/msg_typing';
|
||||
|
||||
interface Props {
|
||||
postError?: ReactNode;
|
||||
errorClass: string | null;
|
||||
serverError: ServerError & {submittedMessage?: string} | null;
|
||||
channelId: Channel['id'];
|
||||
postId: Post['id'];
|
||||
noArgumentHandleSubmit: () => void;
|
||||
isInEditMode: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({
|
||||
postError,
|
||||
errorClass,
|
||||
serverError,
|
||||
channelId,
|
||||
postId,
|
||||
noArgumentHandleSubmit,
|
||||
isInEditMode,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
id='postCreateFooter'
|
||||
role='form'
|
||||
className='AdvancedTextEditor__footer'
|
||||
>
|
||||
{postError && (
|
||||
<div className={classNames('post-error', {errorClass})}>
|
||||
{postError}
|
||||
</div>
|
||||
)}
|
||||
{serverError && (
|
||||
<MessageSubmitError
|
||||
error={serverError}
|
||||
submittedMessage={serverError.submittedMessage}
|
||||
handleSubmit={noArgumentHandleSubmit}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
!isInEditMode &&
|
||||
<MsgTyping
|
||||
channelId={channelId}
|
||||
postId={postId}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export const FormattingBarSpacer = styled.div`
|
||||
height: 48px;
|
||||
transition: height 0.25s ease;
|
||||
align-items: end;
|
||||
background: var(--center-channel-bg);
|
||||
`;
|
||||
|
||||
const FormattingBarContainer = styled.div`
|
||||
@@ -214,7 +215,10 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => {
|
||||
const showSeparators = wideMode === 'wide';
|
||||
|
||||
return (
|
||||
<FormattingBarContainer ref={formattingBarRef}>
|
||||
<FormattingBarContainer
|
||||
ref={formattingBarRef}
|
||||
data-testid='formattingBarContainer'
|
||||
>
|
||||
{controls.map((mode) => {
|
||||
return (
|
||||
<React.Fragment key={mode}>
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {SchedulingInfo} from '@mattermost/types/schedule_post';
|
||||
|
||||
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {emitShortcutReactToLastPostFrom} from 'actions/post_actions';
|
||||
import {emitShortcutReactToLastPostFrom, unsetEditingPost} from 'actions/post_actions';
|
||||
import {editLatestPost} from 'actions/views/create_comment';
|
||||
import {replyToLatestPostInChannel} from 'actions/views/rhs';
|
||||
import {getIsRhsExpanded} from 'selectors/rhs';
|
||||
@@ -47,6 +47,8 @@ const useKeyHandler = (
|
||||
toggleShowPreview: () => void,
|
||||
toggleAdvanceTextEditor: () => void,
|
||||
toggleEmojiPicker: () => void,
|
||||
isInEditMode?: boolean,
|
||||
onCancel?: () => void,
|
||||
): [
|
||||
(e: React.KeyboardEvent<TextboxElement>) => void,
|
||||
(e: React.KeyboardEvent<TextboxElement>) => void,
|
||||
@@ -171,7 +173,11 @@ const useKeyHandler = (
|
||||
}
|
||||
|
||||
if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) {
|
||||
onCancel?.();
|
||||
textboxRef.current?.blur();
|
||||
if (isInEditMode) {
|
||||
dispatch(unsetEditingPost());
|
||||
}
|
||||
}
|
||||
|
||||
const upKeyOnly = !ctrlOrMetaKeyPressed && !e.altKey && !e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP);
|
||||
@@ -312,9 +318,15 @@ const useKeyHandler = (
|
||||
|
||||
const lastMessageReactionKeyCombo = ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH);
|
||||
if (lastMessageReactionKeyCombo) {
|
||||
// we need to stop propagating and prevent default even if a
|
||||
// post is being edited so the document level event handler doesn't trigger
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
dispatch(emitShortcutReactToLastPostFrom(postId ? Locations.RHS_ROOT : Locations.CENTER));
|
||||
|
||||
if (!isInEditMode) {
|
||||
// don't show the reaction dialog if a post is being edited
|
||||
dispatch(emitShortcutReactToLastPostFrom(postId ? Locations.RHS_ROOT : Locations.CENTER));
|
||||
}
|
||||
}
|
||||
|
||||
if (!postId) {
|
||||
|
||||
@@ -16,11 +16,12 @@ 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 type {CreatePostOptions} from 'actions/post_actions';
|
||||
import {unsetEditingPost, type CreatePostOptions} from 'actions/post_actions';
|
||||
import {scrollPostListToBottom} from 'actions/views/channel';
|
||||
import type {OnSubmitOptions, SubmitPostReturnType} from 'actions/views/create_comment';
|
||||
import {onSubmit} from 'actions/views/create_comment';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {editPost} from 'actions/views/posts';
|
||||
|
||||
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
|
||||
import EditChannelPurposeModal from 'components/edit_channel_purpose_modal';
|
||||
@@ -33,6 +34,7 @@ import {isErrorInvalidSlashCommand, isServerError, specialMentionsInText} from '
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
import type {PostDraft} from 'types/store/draft';
|
||||
import {isPostDraftEmpty} from 'types/store/draft';
|
||||
|
||||
import useGroups from './use_groups';
|
||||
|
||||
@@ -65,6 +67,7 @@ const useSubmit = (
|
||||
afterOptimisticSubmit?: () => void,
|
||||
afterSubmit?: (response: SubmitPostReturnType) => void,
|
||||
skipCommands?: boolean,
|
||||
isInEditMode?: boolean,
|
||||
): [
|
||||
(submittingDraft?: PostDraft, schedulingInfo?: SchedulingInfo, options?: CreatePostOptions) => void,
|
||||
string | null,
|
||||
@@ -135,7 +138,7 @@ const useSubmit = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (submittingDraft.message.trim().length === 0 && submittingDraft.fileInfos.length === 0) {
|
||||
if (isPostDraftEmpty(draft)) {
|
||||
isDraftSubmitting.current = false;
|
||||
return;
|
||||
}
|
||||
@@ -168,9 +171,14 @@ const useSubmit = (
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await dispatch(onSubmit(submittingDraft, options, schedulingInfo));
|
||||
if (res.error) {
|
||||
throw res.error;
|
||||
let response;
|
||||
if (isInEditMode) {
|
||||
response = await dispatch(editPost(submittingDraft));
|
||||
} else {
|
||||
response = await dispatch(onSubmit(submittingDraft, options, schedulingInfo));
|
||||
}
|
||||
if (response?.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
setServerError(null);
|
||||
@@ -203,8 +211,14 @@ const useSubmit = (
|
||||
dispatch(scrollPostListToBottom());
|
||||
}
|
||||
|
||||
if (isInEditMode) {
|
||||
dispatch(unsetEditingPost());
|
||||
}
|
||||
|
||||
isDraftSubmitting.current = false;
|
||||
}, [draft,
|
||||
}, [
|
||||
dispatch,
|
||||
draft,
|
||||
postError,
|
||||
isRootDeleted,
|
||||
serverError,
|
||||
@@ -216,9 +230,9 @@ const useSubmit = (
|
||||
afterOptimisticSubmit,
|
||||
postId,
|
||||
showPostDeletedModal,
|
||||
dispatch,
|
||||
handleDraftChange,
|
||||
channelId,
|
||||
isInEditMode,
|
||||
]);
|
||||
|
||||
const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number, onConfirm: () => void) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ const useUploadFiles = (
|
||||
handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void,
|
||||
focusTextbox: (forceFocust?: boolean) => void,
|
||||
setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void,
|
||||
isInEditMode: boolean,
|
||||
): [React.ReactNode, React.ReactNode] => {
|
||||
const locale = useSelector(getCurrentLocale);
|
||||
|
||||
@@ -157,7 +158,7 @@ const useUploadFiles = (
|
||||
postType = isThreadView ? 'thread' : 'comment';
|
||||
}
|
||||
|
||||
const fileUploadJSX = isDisabled ? null : (
|
||||
const fileUploadJSX = isDisabled || isInEditMode ? null : (
|
||||
<FileUpload
|
||||
ref={fileUploadRef}
|
||||
fileCount={getFileCount(draft)}
|
||||
|
||||
@@ -98,7 +98,7 @@ exports[`components/channel_view Should match snapshot if channel is deactivated
|
||||
channelId="channelId"
|
||||
/>
|
||||
<div
|
||||
className="post-create__container"
|
||||
className="post-create__container AdvancedTextEditor__ctr"
|
||||
id="post-create"
|
||||
>
|
||||
<div
|
||||
@@ -161,7 +161,7 @@ exports[`components/channel_view Should match snapshot with base props 1`] = `
|
||||
channelId="channelId"
|
||||
/>
|
||||
<div
|
||||
className="post-create__container AdvancedTextEditor__ctr"
|
||||
className="post-create__container"
|
||||
data-testid="post-create"
|
||||
id="post-create"
|
||||
>
|
||||
|
||||
@@ -110,7 +110,7 @@ export default class ChannelView extends React.PureComponent<Props, State> {
|
||||
if (this.props.deactivatedChannel) {
|
||||
createPost = (
|
||||
<div
|
||||
className='post-create__container'
|
||||
className='post-create__container AdvancedTextEditor__ctr'
|
||||
id='post-create'
|
||||
>
|
||||
<div
|
||||
@@ -171,7 +171,7 @@ export default class ChannelView extends React.PureComponent<Props, State> {
|
||||
<div
|
||||
id='post-create'
|
||||
data-testid='post-create'
|
||||
className='post-create__container AdvancedTextEditor__ctr'
|
||||
className='post-create__container'
|
||||
>
|
||||
<AdvancedCreatePost/>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {useScrollOnRender} from 'components/common/hooks/use_scroll_on_render';
|
||||
import ScheduledPostActions from 'components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions';
|
||||
import PlaceholderScheduledPostsTitle
|
||||
from 'components/drafts/placeholder_scheduled_post_title/placeholder_scheduled_posts_title';
|
||||
import EditPost from 'components/edit_post';
|
||||
import EditScheduledPost from 'components/edit_scheduled_post';
|
||||
|
||||
import Constants, {StoragePrefixes} from 'utils/constants';
|
||||
import {copyToClipboard} from 'utils/utils';
|
||||
@@ -378,19 +378,15 @@ function DraftRow({
|
||||
remote={isRemote || false}
|
||||
error={postError || serverError?.message}
|
||||
/>
|
||||
|
||||
{
|
||||
isEditing &&
|
||||
<EditPost
|
||||
{isEditing && (
|
||||
<EditScheduledPost
|
||||
scheduledPost={item as ScheduledPost}
|
||||
onCancel={handleCancelEdit}
|
||||
afterSave={handleCancelEdit}
|
||||
onDeleteScheduledPost={handleSchedulePostOnDelete}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!isEditing &&
|
||||
)}
|
||||
{!isEditing && (
|
||||
<PanelBody
|
||||
channelId={channel?.id}
|
||||
displayName={displayName}
|
||||
@@ -402,7 +398,7 @@ function DraftRow({
|
||||
userId={user.id}
|
||||
username={user.username}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
44
webapp/channels/src/components/edit_post/edit_post.scss
Обычный файл
44
webapp/channels/src/components/edit_post/edit_post.scss
Обычный файл
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.post--editing__wrapper {
|
||||
position: relative;
|
||||
|
||||
textarea, #edit_textbox_placeholder {
|
||||
padding: 13px 16px 12px 16px;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.edit-post-footer {
|
||||
&.has-error {
|
||||
color: rgb(var(--dnd-indicator-rgb));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-edit__container {
|
||||
padding-top: 8px;
|
||||
background: transparent;
|
||||
|
||||
.AdvancedTextEditor {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-testid='formattingBarContainer'] {
|
||||
background-color: var(--center-channel-bg);;
|
||||
}
|
||||
|
||||
.custom-textarea {
|
||||
padding: 13px 0 12px 16px;
|
||||
}
|
||||
|
||||
.AdvancedTextEditor__body {
|
||||
.AutoHeight {
|
||||
background-color: var(--center-channel-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.AdvancedTextEditor__footer {
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,671 +1,42 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {EmoticonPlusOutlineIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
||||
import {scheduledPostToPost} from '@mattermost/types/schedule_post';
|
||||
import {getEditingPostDetailsAndPost} from 'selectors/posts';
|
||||
|
||||
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import type {ActionResult} from 'mattermost-redux/types/actions';
|
||||
import {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
|
||||
import AdvancedTextEditor from 'components/advanced_text_editor/advanced_text_editor';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {getConnectionId} from 'selectors/general';
|
||||
import {Locations, StoragePrefixes} from 'utils/constants';
|
||||
|
||||
import DeletePostModal from 'components/delete_post_modal';
|
||||
import DeleteScheduledPostModal
|
||||
from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal';
|
||||
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
|
||||
import Textbox from 'components/textbox';
|
||||
import type {TextboxClass, TextboxElement} from 'components/textbox';
|
||||
|
||||
import {AppEvents, Constants, ModalIdentifiers, StoragePrefixes} from 'utils/constants';
|
||||
import * as Keyboard from 'utils/keyboard';
|
||||
import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
|
||||
import {applyMarkdown} from 'utils/markdown/apply_markdown';
|
||||
import {
|
||||
formatGithubCodePaste,
|
||||
formatMarkdownMessage,
|
||||
getHtmlTable,
|
||||
hasHtmlLink,
|
||||
isGitHubCodeBlock,
|
||||
} from 'utils/paste';
|
||||
import {postMessageOnKeyPress, splitMessageBasedOnCaretPosition} from 'utils/post_utils';
|
||||
import {allAtMentions} from 'utils/text_formatting';
|
||||
import * as Utils from 'utils/utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
import type {PostDraft} from 'types/store/draft';
|
||||
|
||||
import EditPostFooter from './edit_post_footer';
|
||||
|
||||
import './style.scss';
|
||||
|
||||
export type Actions = {
|
||||
addMessageIntoHistory: (message: string) => void;
|
||||
editPost: (input: Partial<Post>) => Promise<Post>;
|
||||
setDraft: (name: string, value: PostDraft | null) => void;
|
||||
unsetEditingPost: () => void;
|
||||
scrollPostListToBottom: () => void;
|
||||
runMessageWillBeUpdatedHooks: (newPost: Partial<Post>, oldPost: Post) => Promise<ActionResult>;
|
||||
updateScheduledPost: (scheduledPost: ScheduledPost, connectionId: string) => Promise<ActionResult>;
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
canEditPost?: boolean;
|
||||
canDeletePost?: boolean;
|
||||
readOnlyChannel?: boolean;
|
||||
teamId: string;
|
||||
channelId: string;
|
||||
codeBlockOnCtrlEnter: boolean;
|
||||
ctrlSend: boolean;
|
||||
draft: PostDraft;
|
||||
config: {
|
||||
EnableEmojiPicker?: string;
|
||||
EnableGifPicker?: string;
|
||||
};
|
||||
maxPostSize: number;
|
||||
useChannelMentions: boolean;
|
||||
editingPost: {
|
||||
post: Post | null;
|
||||
postId?: string;
|
||||
refocusId?: string;
|
||||
title?: string;
|
||||
isRHS?: boolean;
|
||||
};
|
||||
isRHSOpened: boolean;
|
||||
isEditHistoryShowing: boolean;
|
||||
actions: Actions;
|
||||
scheduledPost?: ScheduledPost;
|
||||
afterSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
onDeleteScheduledPost?: () => Promise<{error?: string}>;
|
||||
};
|
||||
|
||||
export type State = {
|
||||
editText: string;
|
||||
selectionRange: {start: number; end: number};
|
||||
postError: React.ReactNode;
|
||||
errorClass: string | null;
|
||||
showEmojiPicker: boolean;
|
||||
renderScrollbar: boolean;
|
||||
scrollbarWidth: number;
|
||||
prevShowState: boolean;
|
||||
};
|
||||
|
||||
const {KeyCodes} = Constants;
|
||||
|
||||
const TOP_OFFSET = 0;
|
||||
const RIGHT_OFFSET = 10;
|
||||
|
||||
const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, scheduledPost, afterSave, onCancel, onDeleteScheduledPost, ...rest}: Props): JSX.Element | null => {
|
||||
const connectionId = useSelector(getConnectionId);
|
||||
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [editText, setEditText] = useState<string>(
|
||||
draft.message || editingPost?.post?.message_source || editingPost?.post?.message || scheduledPost?.message || '',
|
||||
);
|
||||
const [selectionRange, setSelectionRange] = useState<State['selectionRange']>({start: editText.length, end: editText.length});
|
||||
const caretPosition = useRef<number>(editText.length);
|
||||
const [postError, setPostError] = useState<React.ReactNode | null>(null);
|
||||
const [errorClass, setErrorClass] = useState<string>('');
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [renderScrollbar, setRenderScrollbar] = useState<boolean>(false);
|
||||
const [showMentionHelper, setShowMentionHelper] = useState<boolean>(false);
|
||||
|
||||
const textboxRef = useRef<TextboxClass>(null);
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// using a ref here makes sure that the unmounting callback (saveDraft) is fired with the correct value.
|
||||
// If we would just use the editText value from the state it would be a stale since it is encapsuled in the
|
||||
// function closure on initial render
|
||||
const draftRef = useRef<PostDraft>(draft);
|
||||
const saveDraftFrame = useRef<number|null>();
|
||||
|
||||
const id = scheduledPost ? scheduledPost.id : editingPost.postId;
|
||||
const draftStorageId = `${StoragePrefixes.EDIT_DRAFT}${id}`;
|
||||
import './edit_post.scss';
|
||||
|
||||
export default function EditPost() {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const saveDraft = useCallback(() => {
|
||||
// to be run on unmount and only when there is an active saveDraftFrame timer
|
||||
if (saveDraftFrame.current && editingPost.postId) {
|
||||
actions.setDraft(draftStorageId, draftRef.current);
|
||||
clearTimeout(saveDraftFrame.current);
|
||||
saveDraftFrame.current = null;
|
||||
}
|
||||
}, [actions, draftStorageId, editingPost.postId]);
|
||||
const editingPostDetailsAndPost = useSelector(getEditingPostDetailsAndPost);
|
||||
|
||||
useEffect(() => saveDraft, [saveDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveDraftFrame.current) {
|
||||
clearTimeout(saveDraftFrame.current);
|
||||
}
|
||||
|
||||
saveDraftFrame.current = window.setTimeout(() => {
|
||||
actions.setDraft(draftStorageId, draftRef.current);
|
||||
}, Constants.SAVE_DRAFT_TIMEOUT);
|
||||
|
||||
if (!scheduledPost) {
|
||||
const isMentions = allAtMentions(editText).length > 0;
|
||||
setShowMentionHelper(isMentions);
|
||||
}
|
||||
}, [actions, draftStorageId, editText, scheduledPost]);
|
||||
|
||||
useEffect(() => {
|
||||
const focusTextBox = () => textboxRef?.current?.focus();
|
||||
|
||||
document.addEventListener(AppEvents.FOCUS_EDIT_TEXTBOX, focusTextBox);
|
||||
return () => document.removeEventListener(AppEvents.FOCUS_EDIT_TEXTBOX, focusTextBox);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionRange.start === selectionRange.end) {
|
||||
Utils.setCaretPosition(textboxRef.current?.getInputBox(), selectionRange.start);
|
||||
} else {
|
||||
Utils.setSelectionRange(textboxRef.current?.getInputBox(), selectionRange.start, selectionRange.end);
|
||||
}
|
||||
}, [selectionRange]);
|
||||
|
||||
// just a helper so it's not always needed to update with setting both properties to the same value
|
||||
const setSelectionRangeByCaretPosition = (position: number) => setSelectionRange({start: position, end: position});
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<TextboxElement, Element>) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
caretPosition.current = target.selectionEnd;
|
||||
};
|
||||
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => {
|
||||
const {clipboardData, target} = e;
|
||||
if (
|
||||
!clipboardData ||
|
||||
!clipboardData.items ||
|
||||
!canEditPost ||
|
||||
(target as HTMLTextAreaElement).id !== 'edit_textbox'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLinks = hasHtmlLink(clipboardData);
|
||||
const table = getHtmlTable(clipboardData);
|
||||
if (!table && !hasLinks) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let message = editText;
|
||||
let newCaretPosition = selectionRange.start;
|
||||
|
||||
if (table && isGitHubCodeBlock(table.className)) {
|
||||
const {formattedMessage, formattedCodeBlock} = formatGithubCodePaste({selectionStart: (target as any).selectionStart, selectionEnd: (target as any).selectionEnd, message, clipboardData});
|
||||
message = formattedMessage;
|
||||
newCaretPosition = selectionRange.start + formattedCodeBlock.length;
|
||||
} else {
|
||||
message = formatMarkdownMessage(clipboardData, editText.trim(), newCaretPosition).formattedMessage;
|
||||
newCaretPosition = message.length - (editText.length - newCaretPosition);
|
||||
}
|
||||
|
||||
setEditText(message);
|
||||
setSelectionRangeByCaretPosition(newCaretPosition);
|
||||
}, [canEditPost, selectionRange, editText]);
|
||||
|
||||
const isSaveDisabled = () => {
|
||||
const {post} = editingPost;
|
||||
const hasAttachments = post && post.file_ids && post.file_ids.length > 0;
|
||||
|
||||
if (hasAttachments) {
|
||||
return !canEditPost;
|
||||
}
|
||||
|
||||
if (editText.trim() !== '') {
|
||||
return !canEditPost;
|
||||
}
|
||||
|
||||
return !rest.canDeletePost;
|
||||
};
|
||||
|
||||
const applyHotkeyMarkdown = (params: ApplyMarkdownOptions) => {
|
||||
if (params.selectionStart === null || params.selectionEnd === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = applyMarkdown(params);
|
||||
|
||||
setEditText(res.message);
|
||||
setSelectionRange({start: res.selectionStart, end: res.selectionEnd});
|
||||
};
|
||||
|
||||
const handleRefocusAndExit = (refocusId: string|null) => {
|
||||
if (refocusId) {
|
||||
const element = document.getElementById(refocusId);
|
||||
element?.focus();
|
||||
}
|
||||
|
||||
actions.unsetEditingPost();
|
||||
};
|
||||
|
||||
const handleAutomatedRefocusAndExit = () => {
|
||||
draftRef.current = {
|
||||
...draftRef.current,
|
||||
message: '',
|
||||
};
|
||||
handleRefocusAndExit(editingPost.refocusId || null);
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (scheduledPost) {
|
||||
await handleEditScheduledPost();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingPost.post || isSaveDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedPost = {
|
||||
message: editText,
|
||||
id: editingPost.postId,
|
||||
channel_id: editingPost.post.channel_id,
|
||||
};
|
||||
|
||||
const hookResult = await actions.runMessageWillBeUpdatedHooks(updatedPost, editingPost.post);
|
||||
if (hookResult.error && hookResult.error.message) {
|
||||
setPostError(<>{hookResult.error.message}</>);
|
||||
return;
|
||||
}
|
||||
|
||||
updatedPost = hookResult.data;
|
||||
|
||||
if (postError) {
|
||||
setErrorClass('animation--highlight');
|
||||
setTimeout(() => setErrorClass(''), Constants.ANIMATION_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updatedPost.message === (editingPost.post?.message_source || editingPost.post?.message)) {
|
||||
handleAutomatedRefocusAndExit();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAttachment = Boolean(
|
||||
editingPost.post?.file_ids && editingPost.post?.file_ids.length > 0,
|
||||
);
|
||||
if (updatedPost.message.trim().length === 0 && !hasAttachment) {
|
||||
handleRefocusAndExit(null);
|
||||
|
||||
const deletePostModalData = {
|
||||
modalId: ModalIdentifiers.DELETE_POST,
|
||||
dialogType: DeletePostModal,
|
||||
dialogProps: {
|
||||
post: editingPost.post,
|
||||
isRHS: editingPost.isRHS,
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(openModal(deletePostModalData));
|
||||
return;
|
||||
}
|
||||
|
||||
await actions.editPost(updatedPost as Post);
|
||||
|
||||
handleAutomatedRefocusAndExit();
|
||||
afterSave?.();
|
||||
};
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onCancel?.();
|
||||
handleAutomatedRefocusAndExit();
|
||||
}, [onCancel, handleAutomatedRefocusAndExit]);
|
||||
|
||||
const handleEditScheduledPost = useCallback(async () => {
|
||||
if (!scheduledPost || isSaveDisabled() || !channel || !onDeleteScheduledPost) {
|
||||
return;
|
||||
}
|
||||
|
||||
const post = scheduledPostToPost(scheduledPost);
|
||||
|
||||
let updatedPost = {
|
||||
message: editText,
|
||||
id: scheduledPost.id,
|
||||
channel_id: scheduledPost?.channel_id,
|
||||
};
|
||||
|
||||
const hookResult = await actions.runMessageWillBeUpdatedHooks(updatedPost, post);
|
||||
if (hookResult.error && hookResult.error.message) {
|
||||
setPostError(<>{hookResult.error.message}</>);
|
||||
return;
|
||||
}
|
||||
|
||||
updatedPost = hookResult.data;
|
||||
|
||||
if (postError) {
|
||||
setErrorClass('animation--highlight');
|
||||
setTimeout(() => setErrorClass(''), Constants.ANIMATION_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updatedPost.message === post.message) {
|
||||
handleAutomatedRefocusAndExit();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAttachment = Boolean(
|
||||
scheduledPost.file_ids && scheduledPost.file_ids.length > 0,
|
||||
);
|
||||
if (updatedPost.message.trim().length === 0 && !hasAttachment) {
|
||||
handleRefocusAndExit(null);
|
||||
|
||||
const deleteScheduledPostModalData = {
|
||||
modalId: ModalIdentifiers.DELETE_DRAFT,
|
||||
dialogType: DeleteScheduledPostModal,
|
||||
dialogProps: {
|
||||
channelDisplayName: channel.display_name,
|
||||
onConfirm: onDeleteScheduledPost,
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(openModal(deleteScheduledPostModalData));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedScheduledPost = {
|
||||
...scheduledPost,
|
||||
message: updatedPost.message,
|
||||
};
|
||||
|
||||
const response = await actions.updateScheduledPost(updatedScheduledPost, connectionId);
|
||||
if (response.error) {
|
||||
setPostError(response.error.message);
|
||||
} else {
|
||||
handleAutomatedRefocusAndExit();
|
||||
afterSave?.();
|
||||
}
|
||||
}, [
|
||||
actions,
|
||||
connectionId,
|
||||
editText,
|
||||
handleAutomatedRefocusAndExit,
|
||||
handleRefocusAndExit,
|
||||
isSaveDisabled,
|
||||
postError,
|
||||
scheduledPost,
|
||||
afterSave,
|
||||
channel,
|
||||
onDeleteScheduledPost,
|
||||
]);
|
||||
|
||||
const handleEditKeyPress = (e: React.KeyboardEvent) => {
|
||||
const {ctrlSend, codeBlockOnCtrlEnter} = rest;
|
||||
const inputBox = textboxRef.current?.getInputBox();
|
||||
|
||||
const {allowSending, ignoreKeyPress} = postMessageOnKeyPress(
|
||||
e,
|
||||
editText,
|
||||
ctrlSend,
|
||||
codeBlockOnCtrlEnter,
|
||||
Date.now(),
|
||||
0,
|
||||
inputBox.selectionStart,
|
||||
);
|
||||
|
||||
if (ignoreKeyPress) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowSending && textboxRef.current) {
|
||||
e.preventDefault();
|
||||
textboxRef.current.blur();
|
||||
handleEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<TextboxElement>) => {
|
||||
const {ctrlSend, codeBlockOnCtrlEnter} = rest;
|
||||
|
||||
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
|
||||
const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
|
||||
const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey;
|
||||
const ctrlEnterKeyCombo =
|
||||
(ctrlSend || codeBlockOnCtrlEnter) &&
|
||||
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
|
||||
ctrlOrMetaKeyPressed;
|
||||
const markdownLinkKey = Keyboard.isKeyPressed(e, KeyCodes.K);
|
||||
const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey;
|
||||
const lastMessageReactionKeyCombo = ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH);
|
||||
|
||||
// listen for line break key combo and insert new line character
|
||||
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
|
||||
e.stopPropagation(); // perhaps this should happen in all of these cases? or perhaps Modal should not be listening?
|
||||
setEditText(Utils.insertLineBreakFromKeyEvent(e.nativeEvent));
|
||||
} else if (ctrlEnterKeyCombo) {
|
||||
handleEdit();
|
||||
} else if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE) && !showEmojiPicker) {
|
||||
onCancel?.();
|
||||
handleAutomatedRefocusAndExit();
|
||||
} else if (ctrlAltCombo && markdownLinkKey) {
|
||||
applyHotkeyMarkdown({
|
||||
markdownMode: 'link',
|
||||
selectionStart: e.currentTarget.selectionStart,
|
||||
selectionEnd: e.currentTarget.selectionEnd,
|
||||
message: e.currentTarget.value,
|
||||
});
|
||||
} else if (ctrlKeyCombo && Keyboard.isKeyPressed(e, KeyCodes.B)) {
|
||||
applyHotkeyMarkdown({
|
||||
markdownMode: 'bold',
|
||||
selectionStart: e.currentTarget.selectionStart,
|
||||
selectionEnd: e.currentTarget.selectionEnd,
|
||||
message: e.currentTarget.value,
|
||||
});
|
||||
} else if (ctrlKeyCombo && Keyboard.isKeyPressed(e, KeyCodes.I)) {
|
||||
applyHotkeyMarkdown({
|
||||
markdownMode: 'italic',
|
||||
selectionStart: e.currentTarget.selectionStart,
|
||||
selectionEnd: e.currentTarget.selectionEnd,
|
||||
message: e.currentTarget.value,
|
||||
});
|
||||
} else if (lastMessageReactionKeyCombo) {
|
||||
// Stop document from handling the hotkey and opening the reaction
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<TextboxElement>) => {
|
||||
const message = e.target.value;
|
||||
|
||||
draftRef.current = {
|
||||
...draftRef.current,
|
||||
message,
|
||||
};
|
||||
|
||||
setEditText(message);
|
||||
};
|
||||
|
||||
const handleHeightChange = (height: number, maxHeight: number) => setRenderScrollbar(height > maxHeight);
|
||||
|
||||
const handlePostError = (_postError: React.ReactNode) => {
|
||||
if (_postError !== postError) {
|
||||
setPostError(_postError);
|
||||
}
|
||||
};
|
||||
|
||||
const hideEmojiPicker = () => {
|
||||
setShowEmojiPicker(false);
|
||||
textboxRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleEmojiClick = (emoji?: Emoji) => {
|
||||
if (!emoji) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojiAlias = getEmojiName(emoji);
|
||||
if (!emojiAlias) {
|
||||
//Oops.. There went something wrong
|
||||
return;
|
||||
}
|
||||
|
||||
let newMessage = `:${emojiAlias}: `;
|
||||
let newCaretPosition = newMessage.length;
|
||||
|
||||
if (editText.length > 0) {
|
||||
const {firstPiece, lastPiece} = splitMessageBasedOnCaretPosition(
|
||||
caretPosition.current,
|
||||
editText,
|
||||
);
|
||||
|
||||
// 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}`;
|
||||
newCaretPosition = firstPiece === '' ? `:${emojiAlias}: `.length : `${firstPiece} :${emojiAlias}: `.length;
|
||||
}
|
||||
|
||||
draftRef.current = {
|
||||
...draftRef.current,
|
||||
message: newMessage,
|
||||
};
|
||||
|
||||
setEditText(newMessage);
|
||||
setSelectionRangeByCaretPosition(newCaretPosition);
|
||||
setShowEmojiPicker(false);
|
||||
textboxRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleGifClick = (gif: string) => {
|
||||
let newMessage = gif;
|
||||
|
||||
if (editText.length > 0) {
|
||||
newMessage = (/\s+$/).test(editText) ? `${editText}${gif}` : `${editText} ${gif}`;
|
||||
}
|
||||
|
||||
draftRef.current = {
|
||||
...draftRef.current,
|
||||
message: newMessage,
|
||||
};
|
||||
|
||||
setEditText(newMessage);
|
||||
setShowEmojiPicker(false);
|
||||
textboxRef.current?.focus();
|
||||
};
|
||||
|
||||
const toggleEmojiPicker = (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||
e?.stopPropagation();
|
||||
setShowEmojiPicker(!showEmojiPicker);
|
||||
if (showEmojiPicker) {
|
||||
textboxRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const getEmojiTargetRef = useCallback(() => emojiButtonRef.current, [emojiButtonRef]);
|
||||
|
||||
let emojiPicker = null;
|
||||
|
||||
if (config.EnableEmojiPicker === 'true') {
|
||||
emojiPicker = (
|
||||
<>
|
||||
<EmojiPickerOverlay
|
||||
show={showEmojiPicker}
|
||||
target={getEmojiTargetRef}
|
||||
onHide={hideEmojiPicker}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
onGifClick={handleGifClick}
|
||||
enableGifPicker={config.EnableGifPicker === 'true'}
|
||||
topOffset={TOP_OFFSET}
|
||||
rightOffset={RIGHT_OFFSET}
|
||||
/>
|
||||
<button
|
||||
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
|
||||
id='editPostEmoji'
|
||||
ref={emojiButtonRef}
|
||||
className='style--none post-action'
|
||||
onClick={toggleEmojiPicker}
|
||||
>
|
||||
<EmoticonPlusOutlineIcon
|
||||
size={18}
|
||||
color='currentColor'
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
if (!editingPostDetailsAndPost.show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rootId = '';
|
||||
if (editingPost.post) {
|
||||
rootId = editingPost.post.root_id || editingPost.post.id;
|
||||
}
|
||||
const channelId = editingPostDetailsAndPost.post.channel_id;
|
||||
const location = editingPostDetailsAndPost.isRHS ? Locations.RHS_COMMENT : Locations.CENTER;
|
||||
const rootId = editingPostDetailsAndPost.post.root_id || editingPostDetailsAndPost.post.id || '';
|
||||
const storageKey = `${StoragePrefixes.EDIT_DRAFT}${editingPostDetailsAndPost.post.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('post--editing__wrapper', {
|
||||
scroll: renderScrollbar,
|
||||
})}
|
||||
ref={wrapperRef}
|
||||
>
|
||||
<Textbox
|
||||
tabIndex={0}
|
||||
rootId={rootId}
|
||||
onChange={handleChange}
|
||||
onKeyPress={handleEditKeyPress}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onHeightChange={handleHeightChange}
|
||||
handlePostError={handlePostError}
|
||||
onPaste={handlePaste}
|
||||
value={editText}
|
||||
<div className='post-edit__container'>
|
||||
<AdvancedTextEditor
|
||||
location={location}
|
||||
channelId={channelId}
|
||||
emojiEnabled={config.EnableEmojiPicker === 'true'}
|
||||
createMessage={formatMessage({id: 'edit_post.editPost', defaultMessage: 'Edit the post...'})}
|
||||
supportsCommands={false}
|
||||
suggestionListPosition='bottom'
|
||||
id='edit_textbox'
|
||||
ref={textboxRef}
|
||||
characterLimit={rest.maxPostSize}
|
||||
useChannelMentions={rest.useChannelMentions}
|
||||
postId={rootId}
|
||||
isInEditMode={true}
|
||||
storageKey={storageKey}
|
||||
placeholder={formatMessage({id: 'edit_post.editPost', defaultMessage: 'Edit the post...'})}
|
||||
/>
|
||||
<div className='post-body__actions'>
|
||||
{emojiPicker}
|
||||
</div>
|
||||
{ showMentionHelper ? (
|
||||
<div className='post-body__info'>
|
||||
<span className='post-body__info__icon'>
|
||||
<InformationOutlineIcon
|
||||
size={14}
|
||||
color='currentColor'
|
||||
/>
|
||||
</span>
|
||||
<span>{
|
||||
formatMessage({
|
||||
id: 'edit_post.no_notification_trigger_on_mention',
|
||||
defaultMessage: "Editing this message with an '@mention' will not notify the recipient.",
|
||||
})
|
||||
}</span>
|
||||
</div>) : null
|
||||
}
|
||||
<EditPostFooter
|
||||
onSave={handleEdit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
{postError && (
|
||||
<div className={classNames('edit-post-footer', {'has-error': postError})}>
|
||||
<label className={classNames('post-error', errorClass)}>{postError}</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPost;
|
||||
}
|
||||
|
||||
@@ -1,101 +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 {ScheduledPost} from '@mattermost/types/schedule_post';
|
||||
|
||||
import {addMessageIntoHistory} from 'mattermost-redux/actions/posts';
|
||||
import {updateScheduledPost} from 'mattermost-redux/actions/scheduled_posts';
|
||||
import {Preferences, Permissions} from 'mattermost-redux/constants';
|
||||
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {runMessageWillBeUpdatedHooks} from 'actions/hooks';
|
||||
import {unsetEditingPost} from 'actions/post_actions';
|
||||
import {setGlobalItem} from 'actions/storage';
|
||||
import {scrollPostListToBottom} from 'actions/views/channel';
|
||||
import {editPost} from 'actions/views/posts';
|
||||
import {getEditingPost} from 'selectors/posts';
|
||||
import {getIsRhsOpen, getPostDraft, getRhsState} from 'selectors/rhs';
|
||||
|
||||
import Constants, {RHSStates, StoragePrefixes} from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import EditPost from './edit_post';
|
||||
|
||||
type Props = {
|
||||
scheduledPost?: ScheduledPost;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState, props: Props) {
|
||||
const config = getConfig(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
let editingPost;
|
||||
let channelId: string;
|
||||
let draft;
|
||||
let isAuthor;
|
||||
|
||||
if (props.scheduledPost) {
|
||||
editingPost = {post: null};
|
||||
channelId = props.scheduledPost.channel_id;
|
||||
draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, props.scheduledPost.id);
|
||||
isAuthor = true;
|
||||
} else {
|
||||
editingPost = getEditingPost(state);
|
||||
channelId = editingPost.post.channel_id;
|
||||
draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, editingPost.postId);
|
||||
isAuthor = editingPost?.post?.user_id === currentUserId;
|
||||
}
|
||||
|
||||
const teamId = getCurrentTeamId(state);
|
||||
const deletePermission = isAuthor ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS;
|
||||
const editPermission = isAuthor ? Permissions.EDIT_POST : Permissions.EDIT_OTHERS_POSTS;
|
||||
|
||||
const channel = getChannel(state, channelId);
|
||||
const useChannelMentions = haveIChannelPermission(state, teamId, channelId, Permissions.USE_CHANNEL_MENTIONS);
|
||||
|
||||
const canEdit = haveIChannelPermission(state, teamId, channelId, editPermission);
|
||||
|
||||
return {
|
||||
canEditPost: canEdit,
|
||||
canDeletePost: haveIChannelPermission(state, teamId, channelId, deletePermission),
|
||||
codeBlockOnCtrlEnter: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true),
|
||||
ctrlSend: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
|
||||
draft,
|
||||
config,
|
||||
editingPost,
|
||||
teamId,
|
||||
channelId,
|
||||
maxPostSize: parseInt(config.MaxPostSize || '0', 10) || Constants.DEFAULT_CHARACTER_LIMIT,
|
||||
readOnlyChannel: !isCurrentUserSystemAdmin(state) && channel?.name === Constants.DEFAULT_CHANNEL,
|
||||
useChannelMentions,
|
||||
isRHSOpened: getIsRhsOpen(state),
|
||||
isEditHistoryShowing: getRhsState(state) === RHSStates.EDIT_HISTORY,
|
||||
scheduledPost: props.scheduledPost,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
scrollPostListToBottom,
|
||||
addMessageIntoHistory,
|
||||
editPost,
|
||||
setDraft: setGlobalItem,
|
||||
unsetEditingPost,
|
||||
runMessageWillBeUpdatedHooks,
|
||||
updateScheduledPost,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditPost);
|
||||
export default EditPost;
|
||||
|
||||
671
webapp/channels/src/components/edit_scheduled_post/edit_post.tsx
Обычный файл
671
webapp/channels/src/components/edit_scheduled_post/edit_post.tsx
Обычный файл
@@ -0,0 +1,671 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {EmoticonPlusOutlineIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {ScheduledPost} from '@mattermost/types/schedule_post';
|
||||
import {scheduledPostToPost} from '@mattermost/types/schedule_post';
|
||||
|
||||
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import type {ActionResult} from 'mattermost-redux/types/actions';
|
||||
import {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {getConnectionId} from 'selectors/general';
|
||||
|
||||
import DeletePostModal from 'components/delete_post_modal';
|
||||
import DeleteScheduledPostModal
|
||||
from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal';
|
||||
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
|
||||
import Textbox from 'components/textbox';
|
||||
import type {TextboxClass, TextboxElement} from 'components/textbox';
|
||||
|
||||
import {AppEvents, Constants, ModalIdentifiers, StoragePrefixes} from 'utils/constants';
|
||||
import * as Keyboard from 'utils/keyboard';
|
||||
import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
|
||||
import {applyMarkdown} from 'utils/markdown/apply_markdown';
|
||||
import {
|
||||
formatGithubCodePaste,
|
||||
formatMarkdownMessage,
|
||||
getHtmlTable,
|
||||
hasHtmlLink,
|
||||
isGitHubCodeBlock,
|
||||
} from 'utils/paste';
|
||||
import {postMessageOnKeyPress, splitMessageBasedOnCaretPosition} from 'utils/post_utils';
|
||||
import {allAtMentions} from 'utils/text_formatting';
|
||||
import * as Utils from 'utils/utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
import type {PostDraft} from 'types/store/draft';
|
||||
|
||||
import EditPostFooter from './edit_post_footer';
|
||||
|
||||
import './style.scss';
|
||||
|
||||
export type Actions = {
|
||||
addMessageIntoHistory: (message: string) => void;
|
||||
editPost: (input: Partial<Post>) => Promise<Post>;
|
||||
setDraft: (name: string, value: PostDraft | null) => void;
|
||||
unsetEditingPost: () => void;
|
||||
scrollPostListToBottom: () => void;
|
||||
runMessageWillBeUpdatedHooks: (newPost: Partial<Post>, oldPost: Post) => Promise<ActionResult>;
|
||||
updateScheduledPost: (scheduledPost: ScheduledPost, connectionId: string) => Promise<ActionResult>;
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
canEditPost?: boolean;
|
||||
canDeletePost?: boolean;
|
||||
readOnlyChannel?: boolean;
|
||||
teamId: string;
|
||||
channelId: string;
|
||||
codeBlockOnCtrlEnter: boolean;
|
||||
ctrlSend: boolean;
|
||||
draft: PostDraft;
|
||||
config: {
|
||||
EnableEmojiPicker?: string;
|
||||
EnableGifPicker?: string;
|
||||
};
|
||||
maxPostSize: number;
|
||||
useChannelMentions: boolean;
|
||||
editingPost: {
|
||||
post: Post | null;
|
||||
postId?: string;
|
||||
refocusId?: string;
|
||||
title?: string;
|
||||
isRHS?: boolean;
|
||||
};
|
||||
isRHSOpened: boolean;
|
||||
isEditHistoryShowing: boolean;
|
||||
actions: Actions;
|
||||
scheduledPost?: ScheduledPost;
|
||||
afterSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
onDeleteScheduledPost?: () => Promise<{error?: string}>;
|
||||
};
|
||||
|
||||
export type State = {
|
||||
editText: string;
|
||||
selectionRange: {start: number; end: number};
|
||||
postError: React.ReactNode;
|
||||
errorClass: string | null;
|
||||
showEmojiPicker: boolean;
|
||||
renderScrollbar: boolean;
|
||||
scrollbarWidth: number;
|
||||
prevShowState: boolean;
|
||||
};
|
||||
|
||||
const {KeyCodes} = Constants;
|
||||
|
||||
const TOP_OFFSET = 0;
|
||||
const RIGHT_OFFSET = 10;
|
||||
|
||||
const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, scheduledPost, afterSave, onCancel, onDeleteScheduledPost, ...rest}: Props): JSX.Element | null => {
|
||||
const connectionId = useSelector(getConnectionId);
|
||||
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [editText, setEditText] = useState<string>(
|
||||
draft.message || editingPost?.post?.message_source || editingPost?.post?.message || scheduledPost?.message || '',
|
||||
);
|
||||
const [selectionRange, setSelectionRange] = useState<State['selectionRange']>({start: editText.length, end: editText.length});
|
||||
const caretPosition = useRef<number>(editText.length);
|
||||
const [postError, setPostError] = useState<React.ReactNode | null>(null);
|
||||
const [errorClass, setErrorClass] = useState<string>('');
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [renderScrollbar, setRenderScrollbar] = useState<boolean>(false);
|
||||
const [showMentionHelper, setShowMentionHelper] = useState<boolean>(false);
|
||||
|
||||
const textboxRef = useRef<TextboxClass>(null);
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// using a ref here makes sure that the unmounting callback (saveDraft) is fired with the correct value.
|
||||
// If we would just use the editText value from the state it would be a stale since it is encapsuled in the
|
||||
// function closure on initial render
|
||||
const draftRef = useRef<PostDraft>(draft);
|
||||
const saveDraftFrame = useRef<number|null>();
|
||||
|
||||
const id = scheduledPost ? scheduledPost.id : editingPost.postId;
|
||||
const draftStorageId = `${StoragePrefixes.EDIT_DRAFT}${id}`;
|
||||
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const saveDraft = useCallback(() => {
|
||||
// to be run on unmount and only when there is an active saveDraftFrame timer
|
||||
if (saveDraftFrame.current && editingPost.postId) {
|
||||
actions.setDraft(draftStorageId, draftRef.current);
|
||||
clearTimeout(saveDraftFrame.current);
|
||||
saveDraftFrame.current = null;
|
||||
}
|
||||
}, [actions, draftStorageId, editingPost.postId]);
|
||||
|
||||
useEffect(() => saveDraft, [saveDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveDraftFrame.current) {
|
||||
clearTimeout(saveDraftFrame.current);
|
||||
}
|
||||
|
||||
saveDraftFrame.current = window.setTimeout(() => {
|
||||
actions.setDraft(draftStorageId, draftRef.current);
|
||||
}, Constants.SAVE_DRAFT_TIMEOUT);
|
||||
|
||||
if (!scheduledPost) {
|
||||
const isMentions = allAtMentions(editText).length > 0;
|
||||
setShowMentionHelper(isMentions);
|
||||
}
|
||||
}, [actions, draftStorageId, editText, scheduledPost]);
|
||||
|
||||
useEffect(() => {
|
||||
const focusTextBox = () => textboxRef?.current?.focus();
|
||||
|
||||
document.addEventListener(AppEvents.FOCUS_EDIT_TEXTBOX, focusTextBox);
|
||||
return () => document.removeEventListener(AppEvents.FOCUS_EDIT_TEXTBOX, focusTextBox);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionRange.start === selectionRange.end) {
|
||||
Utils.setCaretPosition(textboxRef.current?.getInputBox(), selectionRange.start);
|
||||
} else {
|
||||
Utils.setSelectionRange(textboxRef.current?.getInputBox(), selectionRange.start, selectionRange.end);
|
||||
}
|
||||
}, [selectionRange]);
|
||||
|
||||
// just a helper so it's not always needed to update with setting both properties to the same value
|
||||
const setSelectionRangeByCaretPosition = (position: number) => setSelectionRange({start: position, end: position});
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<TextboxElement, Element>) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
caretPosition.current = target.selectionEnd;
|
||||
};
|
||||
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => {
|
||||
const {clipboardData, target} = e;
|
||||
if (
|
||||
!clipboardData ||
|
||||
!clipboardData.items ||
|
||||
!canEditPost ||
|
||||
(target as HTMLTextAreaElement).id !== 'edit_textbox'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLinks = hasHtmlLink(clipboardData);
|
||||
const table = getHtmlTable(clipboardData);
|
||||
if (!table && !hasLinks) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let message = editText;
|
||||
let newCaretPosition = selectionRange.start;
|
||||
|
||||
if (table && isGitHubCodeBlock(table.className)) {
|
||||
const {formattedMessage, formattedCodeBlock} = formatGithubCodePaste({selectionStart: (target as any).selectionStart, selectionEnd: (target as any).selectionEnd, message, clipboardData});
|
||||
message = formattedMessage;
|
||||
newCaretPosition = selectionRange.start + formattedCodeBlock.length;
|
||||
} else {
|
||||
message = formatMarkdownMessage(clipboardData, editText.trim(), newCaretPosition).formattedMessage;
|
||||
newCaretPosition = message.length - (editText.length - newCaretPosition);
|
||||
}
|
||||
|
||||
setEditText(message);
|
||||
setSelectionRangeByCaretPosition(newCaretPosition);
|
||||
}, [canEditPost, selectionRange, editText]);
|
||||
|
||||
const isSaveDisabled = () => {
|
||||
const {post} = editingPost;
|
||||
const hasAttachments = post && post.file_ids && post.file_ids.length > 0;
|
||||
|
||||
if (hasAttachments) {
|
||||
return !canEditPost;
|
||||
}
|
||||
|
||||
if (editText.trim() !== '') {
|
||||
return !canEditPost;
|
||||
}
|
||||
|
||||
return !rest.canDeletePost;
|
||||
};
|
||||
|
||||
const applyHotkeyMarkdown = (params: ApplyMarkdownOptions) => {
|
||||
if (params.selectionStart === null || params.selectionEnd === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = applyMarkdown(params);
|
||||
|
||||
setEditText(res.message);
|
||||
setSelectionRange({start: res.selectionStart, end: res.selectionEnd});
|
||||
};
|
||||
|
||||
const handleRefocusAndExit = (refocusId: string|null) => {
|
||||
if (refocusId) {
|
||||
const element = document.getElementById(refocusId);
|
||||
element?.focus();
|
||||
}
|
||||
|
||||
actions.unsetEditingPost();
|
||||
};
|
||||
|
||||
const handleAutomatedRefocusAndExit = () => {
|
||||
draftRef.current = {
|
||||
...draftRef.current,
|
||||
message: '',
|
||||
};
|
||||
handleRefocusAndExit(editingPost.refocusId || null);
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (scheduledPost) {
|
||||
await handleEditScheduledPost();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingPost.post || isSaveDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedPost = {
|
||||
message: editText,
|
||||
id: editingPost.postId,
|
||||
channel_id: editingPost.post.channel_id,
|
||||
};
|
||||
|
||||
const hookResult = await actions.runMessageWillBeUpdatedHooks(updatedPost, editingPost.post);
|
||||
if (hookResult.error && hookResult.error.message) {
|
||||
setPostError(<>{hookResult.error.message}</>);
|
||||
return;
|
||||
}
|
||||
|
||||
updatedPost = hookResult.data;
|
||||
|
||||
if (postError) {
|
||||
setErrorClass('animation--highlight');
|
||||
setTimeout(() => setErrorClass(''), Constants.ANIMATION_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updatedPost.message === (editingPost.post?.message_source || editingPost.post?.message)) {
|
||||
handleAutomatedRefocusAndExit();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAttachment = Boolean(
|
||||
editingPost.post?.file_ids && editingPost.post?.file_ids.length > 0,
|
||||
);
|
||||
if (updatedPost.message.trim().length === 0 && !hasAttachment) {
|
||||
handleRefocusAndExit(null);
|
||||
|
||||
const deletePostModalData = {
|
||||
modalId: ModalIdentifiers.DELETE_POST,
|
||||
dialogType: DeletePostModal,
|
||||
dialogProps: {
|
||||
post: editingPost.post,
|
||||
isRHS: editingPost.isRHS,
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(openModal(deletePostModalData));
|
||||
return;
|
||||
}
|
||||
|
||||
await actions.editPost(updatedPost as Post);
|
||||
|
||||
handleAutomatedRefocusAndExit();
|
||||
afterSave?.();
|
||||
};
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onCancel?.();
|
||||
handleAutomatedRefocusAndExit();
|
||||
}, [onCancel, handleAutomatedRefocusAndExit]);
|
||||
|
||||
const handleEditScheduledPost = useCallback(async () => {
|
||||
if (!scheduledPost || isSaveDisabled() || !channel || !onDeleteScheduledPost) {
|
||||
return;
|
||||
}
|
||||
|
||||
const post = scheduledPostToPost(scheduledPost);
|
||||
|
||||
let updatedPost = {
|
||||
message: editText,
|
||||
id: scheduledPost.id,
|
||||
channel_id: scheduledPost?.channel_id,
|
||||
};
|
||||
|
||||
const hookResult = await actions.runMessageWillBeUpdatedHooks(updatedPost, post);
|
||||
if (hookResult.error && hookResult.error.message) {
|
||||
setPostError(<>{hookResult.error.message}</>);
|
||||
return;
|
||||
}
|
||||
|
||||
updatedPost = hookResult.data;
|
||||
|
||||
if (postError) {
|
||||
setErrorClass('animation--highlight');
|
||||
setTimeout(() => setErrorClass(''), Constants.ANIMATION_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updatedPost.message === post.message) {
|
||||
handleAutomatedRefocusAndExit();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAttachment = Boolean(
|
||||
scheduledPost.file_ids && scheduledPost.file_ids.length > 0,
|
||||
);
|
||||
if (updatedPost.message.trim().length === 0 && !hasAttachment) {
|
||||
handleRefocusAndExit(null);
|
||||
|
||||
const deleteScheduledPostModalData = {
|
||||
modalId: ModalIdentifiers.DELETE_DRAFT,
|
||||
dialogType: DeleteScheduledPostModal,
|
||||
dialogProps: {
|
||||
channelDisplayName: channel.display_name,
|
||||
onConfirm: onDeleteScheduledPost,
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(openModal(deleteScheduledPostModalData));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedScheduledPost = {
|
||||
...scheduledPost,
|
||||
message: updatedPost.message,
|
||||
};
|
||||
|
||||
const response = await actions.updateScheduledPost(updatedScheduledPost, connectionId);
|
||||
if (response.error) {
|
||||
setPostError(response.error.message);
|
||||
} else {
|
||||
handleAutomatedRefocusAndExit();
|
||||
afterSave?.();
|
||||
}
|
||||
}, [
|
||||
actions,
|
||||
connectionId,
|
||||
editText,
|
||||
handleAutomatedRefocusAndExit,
|
||||
handleRefocusAndExit,
|
||||
isSaveDisabled,
|
||||
postError,
|
||||
scheduledPost,
|
||||
afterSave,
|
||||
channel,
|
||||
onDeleteScheduledPost,
|
||||
]);
|
||||
|
||||
const handleEditKeyPress = (e: React.KeyboardEvent) => {
|
||||
const {ctrlSend, codeBlockOnCtrlEnter} = rest;
|
||||
const inputBox = textboxRef.current?.getInputBox();
|
||||
|
||||
const {allowSending, ignoreKeyPress} = postMessageOnKeyPress(
|
||||
e,
|
||||
editText,
|
||||
ctrlSend,
|
||||
codeBlockOnCtrlEnter,
|
||||
Date.now(),
|
||||
0,
|
||||
inputBox.selectionStart,
|
||||
);
|
||||
|
||||
if (ignoreKeyPress) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowSending && textboxRef.current) {
|
||||
e.preventDefault();
|
||||
textboxRef.current.blur();
|
||||
handleEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<TextboxElement>) => {
|
||||
const {ctrlSend, codeBlockOnCtrlEnter} = rest;
|
||||
|
||||
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
|
||||
const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
|
||||
const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey;
|
||||
const ctrlEnterKeyCombo =
|
||||
(ctrlSend || codeBlockOnCtrlEnter) &&
|
||||
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
|
||||
ctrlOrMetaKeyPressed;
|
||||
const markdownLinkKey = Keyboard.isKeyPressed(e, KeyCodes.K);
|
||||
const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey;
|
||||
const lastMessageReactionKeyCombo = ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH);
|
||||
|
||||
// listen for line break key combo and insert new line character
|
||||
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
|
||||
e.stopPropagation(); // perhaps this should happen in all of these cases? or perhaps Modal should not be listening?
|
||||
setEditText(Utils.insertLineBreakFromKeyEvent(e.nativeEvent));
|
||||
} else if (ctrlEnterKeyCombo) {
|
||||
handleEdit();
|
||||
} else if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE) && !showEmojiPicker) {
|
||||
onCancel?.();
|
||||
handleAutomatedRefocusAndExit();
|
||||
} else if (ctrlAltCombo && markdownLinkKey) {
|
||||
applyHotkeyMarkdown({
|
||||
markdownMode: 'link',
|
||||
selectionStart: e.currentTarget.selectionStart,
|
||||
selectionEnd: e.currentTarget.selectionEnd,
|
||||
message: e.currentTarget.value,
|
||||
});
|
||||
} else if (ctrlKeyCombo && Keyboard.isKeyPressed(e, KeyCodes.B)) {
|
||||
applyHotkeyMarkdown({
|
||||
markdownMode: 'bold',
|
||||
selectionStart: e.currentTarget.selectionStart,
|
||||
selectionEnd: e.currentTarget.selectionEnd,
|
||||
message: e.currentTarget.value,
|
||||
});
|
||||
} else if (ctrlKeyCombo && Keyboard.isKeyPressed(e, KeyCodes.I)) {
|
||||
applyHotkeyMarkdown({
|
||||
markdownMode: 'italic',
|
||||
selectionStart: e.currentTarget.selectionStart,
|
||||
selectionEnd: e.currentTarget.selectionEnd,
|
||||
message: e.currentTarget.value,
|
||||
});
|
||||
} else if (lastMessageReactionKeyCombo) {
|
||||
// Stop document from handling the hotkey and opening the reaction
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<TextboxElement>) => {
|
||||
const message = e.target.value;
|
||||
|
||||
draftRef.current = {
|
||||
...draftRef.current,
|
||||
message,
|
||||
};
|
||||
|
||||
setEditText(message);
|
||||
};
|
||||
|
||||
const handleHeightChange = (height: number, maxHeight: number) => setRenderScrollbar(height > maxHeight);
|
||||
|
||||
const handlePostError = (_postError: React.ReactNode) => {
|
||||
if (_postError !== postError) {
|
||||
setPostError(_postError);
|
||||
}
|
||||
};
|
||||
|
||||
const hideEmojiPicker = () => {
|
||||
setShowEmojiPicker(false);
|
||||
textboxRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleEmojiClick = (emoji?: Emoji) => {
|
||||
if (!emoji) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojiAlias = getEmojiName(emoji);
|
||||
if (!emojiAlias) {
|
||||
//Oops.. There went something wrong
|
||||
return;
|
||||
}
|
||||
|
||||
let newMessage = `:${emojiAlias}: `;
|
||||
let newCaretPosition = newMessage.length;
|
||||
|
||||
if (editText.length > 0) {
|
||||
const {firstPiece, lastPiece} = splitMessageBasedOnCaretPosition(
|
||||
caretPosition.current,
|
||||
editText,
|
||||
);
|
||||
|
||||
// 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}`;
|
||||
newCaretPosition = firstPiece === '' ? `:${emojiAlias}: `.length : `${firstPiece} :${emojiAlias}: `.length;
|
||||
}
|
||||
|
||||
draftRef.current = {
|
||||
...draftRef.current,
|
||||
message: newMessage,
|
||||
};
|
||||
|
||||
setEditText(newMessage);
|
||||
setSelectionRangeByCaretPosition(newCaretPosition);
|
||||
setShowEmojiPicker(false);
|
||||
textboxRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleGifClick = (gif: string) => {
|
||||
let newMessage = gif;
|
||||
|
||||
if (editText.length > 0) {
|
||||
newMessage = (/\s+$/).test(editText) ? `${editText}${gif}` : `${editText} ${gif}`;
|
||||
}
|
||||
|
||||
draftRef.current = {
|
||||
...draftRef.current,
|
||||
message: newMessage,
|
||||
};
|
||||
|
||||
setEditText(newMessage);
|
||||
setShowEmojiPicker(false);
|
||||
textboxRef.current?.focus();
|
||||
};
|
||||
|
||||
const toggleEmojiPicker = (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||
e?.stopPropagation();
|
||||
setShowEmojiPicker(!showEmojiPicker);
|
||||
if (showEmojiPicker) {
|
||||
textboxRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const getEmojiTargetRef = useCallback(() => emojiButtonRef.current, [emojiButtonRef]);
|
||||
|
||||
let emojiPicker = null;
|
||||
|
||||
if (config.EnableEmojiPicker === 'true') {
|
||||
emojiPicker = (
|
||||
<>
|
||||
<EmojiPickerOverlay
|
||||
show={showEmojiPicker}
|
||||
target={getEmojiTargetRef}
|
||||
onHide={hideEmojiPicker}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
onGifClick={handleGifClick}
|
||||
enableGifPicker={config.EnableGifPicker === 'true'}
|
||||
topOffset={TOP_OFFSET}
|
||||
rightOffset={RIGHT_OFFSET}
|
||||
/>
|
||||
<button
|
||||
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
|
||||
id='editPostEmoji'
|
||||
ref={emojiButtonRef}
|
||||
className='style--none post-action'
|
||||
onClick={toggleEmojiPicker}
|
||||
>
|
||||
<EmoticonPlusOutlineIcon
|
||||
size={18}
|
||||
color='currentColor'
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let rootId = '';
|
||||
if (editingPost.post) {
|
||||
rootId = editingPost.post.root_id || editingPost.post.id;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('post--editing__wrapper', {
|
||||
scroll: renderScrollbar,
|
||||
})}
|
||||
ref={wrapperRef}
|
||||
>
|
||||
<Textbox
|
||||
tabIndex={0}
|
||||
rootId={rootId}
|
||||
onChange={handleChange}
|
||||
onKeyPress={handleEditKeyPress}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onHeightChange={handleHeightChange}
|
||||
handlePostError={handlePostError}
|
||||
onPaste={handlePaste}
|
||||
value={editText}
|
||||
channelId={channelId}
|
||||
emojiEnabled={config.EnableEmojiPicker === 'true'}
|
||||
createMessage={formatMessage({id: 'edit_post.editPost', defaultMessage: 'Edit the post...'})}
|
||||
supportsCommands={false}
|
||||
suggestionListPosition='bottom'
|
||||
id='edit_textbox'
|
||||
ref={textboxRef}
|
||||
characterLimit={rest.maxPostSize}
|
||||
useChannelMentions={rest.useChannelMentions}
|
||||
/>
|
||||
<div className='post-body__actions'>
|
||||
{emojiPicker}
|
||||
</div>
|
||||
{ showMentionHelper ? (
|
||||
<div className='post-body__info'>
|
||||
<span className='post-body__info__icon'>
|
||||
<InformationOutlineIcon
|
||||
size={14}
|
||||
color='currentColor'
|
||||
/>
|
||||
</span>
|
||||
<span>{
|
||||
formatMessage({
|
||||
id: 'edit_post.no_notification_trigger_on_mention',
|
||||
defaultMessage: "Editing this message with an '@mention' will not notify the recipient.",
|
||||
})
|
||||
}</span>
|
||||
</div>) : null
|
||||
}
|
||||
<EditPostFooter
|
||||
onSave={handleEdit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
{postError && (
|
||||
<div className={classNames('edit-post-footer', {'has-error': postError})}>
|
||||
<label className={classNames('post-error', errorClass)}>{postError}</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPost;
|
||||
101
webapp/channels/src/components/edit_scheduled_post/index.ts
Обычный файл
101
webapp/channels/src/components/edit_scheduled_post/index.ts
Обычный файл
@@ -0,0 +1,101 @@
|
||||
// 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 {ScheduledPost} from '@mattermost/types/schedule_post';
|
||||
|
||||
import {addMessageIntoHistory} from 'mattermost-redux/actions/posts';
|
||||
import {updateScheduledPost} from 'mattermost-redux/actions/scheduled_posts';
|
||||
import {Preferences, Permissions} from 'mattermost-redux/constants';
|
||||
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {runMessageWillBeUpdatedHooks} from 'actions/hooks';
|
||||
import {unsetEditingPost} from 'actions/post_actions';
|
||||
import {setGlobalItem} from 'actions/storage';
|
||||
import {scrollPostListToBottom} from 'actions/views/channel';
|
||||
import {editPost} from 'actions/views/posts';
|
||||
import {getEditingPostDetailsAndPost} from 'selectors/posts';
|
||||
import {getIsRhsOpen, getPostDraft, getRhsState} from 'selectors/rhs';
|
||||
|
||||
import Constants, {RHSStates, StoragePrefixes} from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import EditPost from './edit_post';
|
||||
|
||||
type Props = {
|
||||
scheduledPost?: ScheduledPost;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState, props: Props) {
|
||||
const config = getConfig(state);
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
let editingPost;
|
||||
let channelId: string;
|
||||
let draft;
|
||||
let isAuthor;
|
||||
|
||||
if (props.scheduledPost) {
|
||||
editingPost = {post: null};
|
||||
channelId = props.scheduledPost.channel_id;
|
||||
draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, props.scheduledPost.id);
|
||||
isAuthor = true;
|
||||
} else {
|
||||
editingPost = getEditingPostDetailsAndPost(state);
|
||||
channelId = editingPost.post.channel_id;
|
||||
draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, editingPost.postId);
|
||||
isAuthor = editingPost?.post?.user_id === currentUserId;
|
||||
}
|
||||
|
||||
const teamId = getCurrentTeamId(state);
|
||||
const deletePermission = isAuthor ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS;
|
||||
const editPermission = isAuthor ? Permissions.EDIT_POST : Permissions.EDIT_OTHERS_POSTS;
|
||||
|
||||
const channel = getChannel(state, channelId);
|
||||
const useChannelMentions = haveIChannelPermission(state, teamId, channelId, Permissions.USE_CHANNEL_MENTIONS);
|
||||
|
||||
const canEdit = haveIChannelPermission(state, teamId, channelId, editPermission);
|
||||
|
||||
return {
|
||||
canEditPost: canEdit,
|
||||
canDeletePost: haveIChannelPermission(state, teamId, channelId, deletePermission),
|
||||
codeBlockOnCtrlEnter: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true),
|
||||
ctrlSend: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
|
||||
draft,
|
||||
config,
|
||||
editingPost,
|
||||
teamId,
|
||||
channelId,
|
||||
maxPostSize: parseInt(config.MaxPostSize || '0', 10) || Constants.DEFAULT_CHARACTER_LIMIT,
|
||||
readOnlyChannel: !isCurrentUserSystemAdmin(state) && channel?.name === Constants.DEFAULT_CHANNEL,
|
||||
useChannelMentions,
|
||||
isRHSOpened: getIsRhsOpen(state),
|
||||
isEditHistoryShowing: getRhsState(state) === RHSStates.EDIT_HISTORY,
|
||||
scheduledPost: props.scheduledPost,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
scrollPostListToBottom,
|
||||
addMessageIntoHistory,
|
||||
editPost,
|
||||
setDraft: setGlobalItem,
|
||||
unsetEditingPost,
|
||||
runMessageWillBeUpdatedHooks,
|
||||
updateScheduledPost,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(EditPost);
|
||||
@@ -472,7 +472,7 @@ const PostComponent = (props: Props): JSX.Element => {
|
||||
/>
|
||||
);
|
||||
|
||||
const showSlot = props.isPostBeingEdited ? AutoHeightSlots.SLOT2 : AutoHeightSlots.SLOT1;
|
||||
const slotBasedOnEditOrMessageView = props.isPostBeingEdited ? AutoHeightSlots.SLOT2 : AutoHeightSlots.SLOT1;
|
||||
const threadFooter = props.location !== Locations.RHS_ROOT && props.isCollapsedThreadsEnabled && !post.root_id && (props.hasReplies || post.is_following) ? (
|
||||
<ThreadFooter
|
||||
threadId={post.id}
|
||||
@@ -639,7 +639,7 @@ const PostComponent = (props: Props): JSX.Element => {
|
||||
>
|
||||
{post.failed && <FailedPostOptions post={post}/>}
|
||||
<AutoHeightSwitcher
|
||||
showSlot={showSlot}
|
||||
showSlot={slotBasedOnEditOrMessageView}
|
||||
shouldScrollIntoView={props.isPostBeingEdited}
|
||||
slot1={message}
|
||||
slot2={<EditPost/>}
|
||||
|
||||
@@ -72,6 +72,7 @@ export type Props = {
|
||||
openWhenEmpty?: boolean;
|
||||
priorityProfiles?: UserProfile[];
|
||||
hasLabels?: boolean;
|
||||
isInEditMode?: boolean;
|
||||
};
|
||||
|
||||
const VISIBLE = {visibility: 'visible'};
|
||||
|
||||
@@ -13,11 +13,7 @@ import {StoragePrefixes, StorageTypes} from 'utils/constants';
|
||||
import {getDraftInfoFromKey} from 'utils/storage_utils';
|
||||
|
||||
import type {MMAction} from 'types/store';
|
||||
|
||||
type StorageEntry = {
|
||||
timestamp: Date;
|
||||
data: any;
|
||||
}
|
||||
import type {StorageItem} from 'types/store/storage';
|
||||
|
||||
function storage(state: Record<string, any> = {}, action: MMAction) {
|
||||
switch (action.type) {
|
||||
@@ -30,7 +26,7 @@ function storage(state: Record<string, any> = {}, action: MMAction) {
|
||||
const nextState = {...state};
|
||||
|
||||
for (const [key, value] of Object.entries(action.payload)) {
|
||||
const nextValue = {...value as StorageEntry};
|
||||
const nextValue = {...value as StorageItem};
|
||||
if (nextValue.timestamp && typeof nextValue.timestamp === 'string') {
|
||||
nextValue.timestamp = new Date(nextValue.timestamp);
|
||||
}
|
||||
|
||||
@@ -8,22 +8,29 @@ import {UserTypes} from 'mattermost-redux/action_types';
|
||||
import {ActionTypes} from 'utils/constants';
|
||||
|
||||
import type {MMAction} from 'types/store';
|
||||
import type {ViewsState} from 'types/store/views';
|
||||
|
||||
const defaultState = {
|
||||
post: {},
|
||||
const editingPostDefaultState: ViewsState['posts']['editingPost'] = {
|
||||
show: false,
|
||||
postId: '',
|
||||
refocusId: '',
|
||||
isRHS: false,
|
||||
};
|
||||
|
||||
function editingPost(state = defaultState, action: MMAction) {
|
||||
function editingPost(state: ViewsState['posts']['editingPost'] = editingPostDefaultState, action: MMAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.TOGGLE_EDITING_POST:
|
||||
return {
|
||||
...state,
|
||||
...action.data,
|
||||
};
|
||||
case ActionTypes.TOGGLE_EDITING_POST: {
|
||||
if (action.data.show) {
|
||||
return {
|
||||
...state,
|
||||
...action.data,
|
||||
};
|
||||
}
|
||||
|
||||
return editingPostDefaultState;
|
||||
}
|
||||
case UserTypes.LOGOUT_SUCCESS:
|
||||
return defaultState;
|
||||
return editingPostDefaultState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
@mixin button-focus {
|
||||
&:focus {
|
||||
border-width: 0;
|
||||
box-shadow: inset 0 0 0 2px var(--sidebar-text-active-border);
|
||||
box-shadow: 0 0 0 2px var(--sidebar-text-active-border);
|
||||
}
|
||||
|
||||
&:focus:not(:focus-visible) {
|
||||
@@ -208,7 +208,7 @@
|
||||
|
||||
&:focus-visible {
|
||||
border-width: 0;
|
||||
box-shadow: inset 0 0 0 2px var(--sidebar-text-active-border);
|
||||
box-shadow: 0 0 0 2px var(--sidebar-text-active-border);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
|
||||
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
|
||||
import {StoragePrefixes} from 'utils/constants';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import {makeGetDrafts, makeGetDraftsByPrefix, makeGetDraftsCount} from './drafts';
|
||||
import {makeGetDrafts, makeGetDraftsByPrefix, makeGetDraftsCount, makeGetDraft} from './drafts';
|
||||
|
||||
const currentUserId = 'currentUserId';
|
||||
const currentChannelId = 'channelId';
|
||||
@@ -156,3 +159,57 @@ describe('makeGetDraftsCount', () => {
|
||||
expect(draftCount).toEqual([...expectedChannelDrafts, ...expectedCommentDrafts].length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeGetDraft', () => {
|
||||
const getDraft = makeGetDraft();
|
||||
|
||||
let initialStore: GlobalState;
|
||||
|
||||
const channelId1 = TestHelper.getChannelMock({id: 'channelId1'}).id;
|
||||
const channelId2 = TestHelper.getChannelMock({id: 'channelId2'}).id;
|
||||
const draft1 = TestHelper.getPostDraftMock({message: 'draft 1 with channelId 1', channelId: channelId1});
|
||||
const draft2 = TestHelper.getPostDraftMock({message: 'draft 2 with channelId 2', channelId: channelId2});
|
||||
|
||||
beforeEach(() => {
|
||||
initialStore = {storage: {storage: {
|
||||
[StoragePrefixes.DRAFT + channelId1]: {
|
||||
timestamp: Date.now(),
|
||||
value: draft1,
|
||||
},
|
||||
[StoragePrefixes.DRAFT + channelId2]: {
|
||||
timestamp: Date.now(),
|
||||
value: draft2,
|
||||
},
|
||||
}}} as unknown as GlobalState;
|
||||
});
|
||||
|
||||
test('should return a draft with the correct fields', () => {
|
||||
const draft = getDraft(initialStore, channelId1);
|
||||
expect(draft).toEqual(draft1);
|
||||
});
|
||||
|
||||
test('should return draft with correct fields even if some fields are missing from drafts in storage', () => {
|
||||
const store = cloneDeep(initialStore);
|
||||
delete store.storage.storage[StoragePrefixes.DRAFT + channelId1].value.message;
|
||||
delete store.storage.storage[StoragePrefixes.DRAFT + channelId1].value.fileInfos;
|
||||
delete store.storage.storage[StoragePrefixes.DRAFT + channelId1].value.uploadsInProgress;
|
||||
|
||||
const draft = getDraft(store, channelId1);
|
||||
expect(draft.message).toBeDefined();
|
||||
expect(draft.fileInfos).toBeDefined();
|
||||
expect(draft.uploadsInProgress).toBeDefined();
|
||||
});
|
||||
|
||||
test('should return a draft with the correct fields even if the draft\'s channelId or rootId mismatches with the passed one', () => {
|
||||
const store = cloneDeep(initialStore);
|
||||
|
||||
// Change the channelId and rootId of the draft in storage of the draft
|
||||
store.storage.storage[StoragePrefixes.DRAFT + channelId2].value.channelId = 'channelId1New';
|
||||
store.storage.storage[StoragePrefixes.DRAFT + channelId2].value.rootId = 'rootId1';
|
||||
|
||||
const draft = getDraft(store, channelId2);
|
||||
|
||||
// Verify that the draft has the correct fields by which it is returned
|
||||
expect(draft).toEqual(draft2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {createSelector} from 'mattermost-redux/selectors/create_selector';
|
||||
import {getMyActiveChannelIds} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {get, onboardingTourTipsEnabled} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {getGlobalItem} from 'selectors/storage';
|
||||
import {getIsMobileView} from 'selectors/views/browser';
|
||||
|
||||
import {StoragePrefixes} from 'utils/constants';
|
||||
@@ -102,3 +103,44 @@ export function makeGetDraftsCount(): DraftCountSelector {
|
||||
filter((draft) => myChannels.indexOf(draft.value.channelId) !== -1).length,
|
||||
);
|
||||
}
|
||||
|
||||
export function makeGetDraft() {
|
||||
const DEFAULT_DRAFT = Object.freeze({
|
||||
message: '',
|
||||
fileInfos: [],
|
||||
uploadsInProgress: [],
|
||||
createAt: 0,
|
||||
updateAt: 0,
|
||||
channelId: '',
|
||||
rootId: '',
|
||||
});
|
||||
|
||||
return (state: GlobalState, channelId: string, rootId = '', storageKey = ''): PostDraft => {
|
||||
let prefixStorageKey = StoragePrefixes.DRAFT;
|
||||
let suffixStorageKey = channelId;
|
||||
if (rootId) {
|
||||
prefixStorageKey = StoragePrefixes.COMMENT_DRAFT;
|
||||
suffixStorageKey = rootId;
|
||||
}
|
||||
const key = storageKey || `${prefixStorageKey}${suffixStorageKey}`;
|
||||
|
||||
const retrievedDraft = getGlobalItem<PostDraft>(state, key, DEFAULT_DRAFT);
|
||||
|
||||
// Check if the draft has the required values in its properties
|
||||
const isDraftWithRequiredValues = typeof retrievedDraft.message !== 'undefined' && typeof retrievedDraft.uploadsInProgress !== 'undefined' && typeof retrievedDraft.fileInfos !== 'undefined';
|
||||
|
||||
// Check if draft's channelId or rootId mismatches with the passed one
|
||||
const isDraftMismatched = retrievedDraft.channelId !== channelId || retrievedDraft.rootId !== rootId;
|
||||
|
||||
if (isDraftWithRequiredValues && !isDraftMismatched) {
|
||||
return retrievedDraft;
|
||||
}
|
||||
|
||||
return {
|
||||
...DEFAULT_DRAFT,
|
||||
...retrievedDraft,
|
||||
channelId,
|
||||
rootId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,12 +19,13 @@ import {getGlobalItem} from 'selectors/storage';
|
||||
import {StoragePrefixes} from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
import type {EditingPostDetails} from 'types/store/views';
|
||||
|
||||
export function getIsPostBeingEdited(state: GlobalState, postId: string) {
|
||||
return state.views.posts.editingPost.postId === postId && state.views.posts.editingPost.show;
|
||||
}
|
||||
export function getIsPostBeingEditedInRHS(state: GlobalState, postId: string) {
|
||||
const editingPost = getEditingPost(state);
|
||||
const editingPost = getEditingPostDetailsAndPost(state);
|
||||
|
||||
return editingPost.isRHS && editingPost.postId === postId && state.views.posts.editingPost.show;
|
||||
}
|
||||
@@ -33,15 +34,17 @@ export function getPostEditHistory(state: GlobalState): Post[] {
|
||||
return state.entities.posts.postEditHistory;
|
||||
}
|
||||
|
||||
export const getEditingPost = createSelector(
|
||||
'getEditingPost',
|
||||
export const getEditingPostDetailsAndPost = createSelector(
|
||||
'getEditingPostDetailsAndPost',
|
||||
(state: GlobalState) => state.views.posts.editingPost,
|
||||
(state: GlobalState) => getPost(state, state.views.posts.editingPost.postId),
|
||||
(editingPost, post) => {
|
||||
return {
|
||||
const editingPostDetailsAndPost: EditingPostDetails & {post: Post} = {
|
||||
...editingPost,
|
||||
post,
|
||||
};
|
||||
|
||||
return editingPostDetailsAndPost;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {getGlobalItem, makeGetGlobalItem, makeGetGlobalItemWithDefault} from 'selectors/storage';
|
||||
import {makeGetGlobalItem, makeGetGlobalItemWithDefault} from 'selectors/storage';
|
||||
|
||||
import type {SidebarSize} from 'components/resizable_sidebar/constants';
|
||||
|
||||
@@ -157,53 +157,6 @@ 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: '',
|
||||
|
||||
@@ -12,7 +12,9 @@ export type DraftInfo = {
|
||||
|
||||
export type PostDraft = {
|
||||
message: string;
|
||||
message_source?: string;
|
||||
fileInfos: FileInfo[];
|
||||
file_ids?: string[];
|
||||
uploadsInProgress: string[];
|
||||
props?: any;
|
||||
caretPosition?: number;
|
||||
@@ -30,6 +32,14 @@ export type PostDraft = {
|
||||
};
|
||||
};
|
||||
|
||||
export function isPostDraftEmpty(draft: PostDraft): boolean {
|
||||
const hasMessage = draft.message.trim() !== '';
|
||||
const hasAttachment = draft.fileInfos.length > 0 || draft.file_ids?.length;
|
||||
const hasUploadingFiles = draft.uploadsInProgress.length > 0;
|
||||
|
||||
return !hasMessage && !hasAttachment && !hasUploadingFiles;
|
||||
}
|
||||
|
||||
export function scheduledPostToPostDraft(scheduledPost: ScheduledPost): PostDraft {
|
||||
return {
|
||||
message: scheduledPost.message,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {MMReduxAction} from 'mattermost-redux/action_types';
|
||||
import type * as MMReduxTypes from 'mattermost-redux/types/actions';
|
||||
|
||||
import type {PluginsState} from './plugins';
|
||||
import type {StorageState} from './storage';
|
||||
import type {ViewsState} from './views';
|
||||
|
||||
export type DraggingState = {
|
||||
@@ -18,10 +19,7 @@ export type DraggingState = {
|
||||
|
||||
export type GlobalState = BaseGlobalState & {
|
||||
plugins: PluginsState;
|
||||
storage: {
|
||||
storage: Record<string, any>;
|
||||
initialized: boolean;
|
||||
};
|
||||
storage: StorageState;
|
||||
views: ViewsState;
|
||||
};
|
||||
|
||||
|
||||
14
webapp/channels/src/types/store/storage.ts
Обычный файл
14
webapp/channels/src/types/store/storage.ts
Обычный файл
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export type StorageItem<T = any> = {
|
||||
timestamp: Date;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export type StorageInitialized = boolean;
|
||||
|
||||
export type StorageState = {
|
||||
initialized: StorageInitialized;
|
||||
storage: Record<string, StorageItem>;
|
||||
}
|
||||
@@ -37,6 +37,13 @@ export type AdminConsoleUserManagementTableProperties = {
|
||||
dateRange?: ReportDuration;
|
||||
};
|
||||
|
||||
export type EditingPostDetails = {
|
||||
postId: string;
|
||||
refocusId: string;
|
||||
isRHS: boolean;
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
export type ViewsState = {
|
||||
admin: {
|
||||
navigationBlock: {
|
||||
@@ -92,11 +99,7 @@ export type ViewsState = {
|
||||
rhsSuppressed: boolean;
|
||||
|
||||
posts: {
|
||||
editingPost: {
|
||||
postId: string;
|
||||
show: boolean;
|
||||
isRHS: boolean;
|
||||
};
|
||||
editingPost: EditingPostDetails;
|
||||
menuActions: {
|
||||
[postId: string]: {
|
||||
[actionId: string]: {
|
||||
|
||||
Ссылка в новой задаче
Block a user