[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>
Этот коммит содержится в:
M-ZubairAhmed
2024-12-18 13:17:42 +05:30
коммит произвёл GitHub
родитель 041c874961
Коммит 6f73204448
39 изменённых файлов: 1500 добавлений и 954 удалений

Просмотреть файл

@@ -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>
);
}

Просмотреть файл

@@ -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>

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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]: {