Unify advanced create post and advanced create comment (#26419)

* Unify advanced create post and advanced create comment

* Re-add focus on mount prop and fix minor selector issue with get draft

* Address feedback

* Some merge fixes and some comments addressed

* Remove tests

* Fix tests

* Address feedback

* Fix formatting bar spacer and minor refactoring

* Fix remove upload from clean draft issue

* Fix types

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Daniel Espino García 2024-07-15 12:14:14 +02:00 committed by GitHub
parent ff3ed78124
commit a272fb29a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2225 additions and 8189 deletions

View File

@ -22,6 +22,7 @@ import {removeDraft, setGlobalDraftSource} from 'actions/views/drafts';
import mockStore from 'tests/test_store';
import {StoragePrefixes} from 'utils/constants';
import {TestHelper} from 'utils/test_helper';
/* eslint-disable global-require */
@ -121,13 +122,21 @@ describe('rhs view actions', () => {
messages: ['test message'],
},
},
channels: {
channels: {
[channelId]: TestHelper.getChannelMock({id: channelId}),
},
roles: {
[channelId]: new Set(['channel_roles']),
},
},
preferences: {
myPreferences: {},
},
users: {
currentUserId,
profiles: {
[currentUserId]: {id: currentUserId},
[currentUserId]: TestHelper.getUserMock({id: currentUserId}),
},
},
teams: {
@ -136,6 +145,13 @@ describe('rhs view actions', () => {
emojis: {
customEmoji: {},
},
roles: {
roles: {
channel_roles: {
permissions: '',
},
},
},
general: {
config: {
EnableCustomEmoji: 'true',

View File

@ -6,12 +6,20 @@ import type {Post} from '@mattermost/types/posts';
import {
addMessageIntoHistory,
} from 'mattermost-redux/actions/posts';
import {Permissions} from 'mattermost-redux/constants';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups';
import {
getLatestInteractablePostId,
getLatestPostToEdit,
getPost,
makeGetPostIdsForThread,
} from 'mattermost-redux/selectors/entities/posts';
import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {ActionFunc, ActionFuncAsync} from 'mattermost-redux/types/actions';
@ -25,6 +33,7 @@ import {updateDraft, removeDraft} from 'actions/views/drafts';
import {Constants, StoragePrefixes} from 'utils/constants';
import EmojiMap from 'utils/emoji_map';
import {containsAtChannel, groupsMentionedInText} from 'utils/post_utils';
import * as Utils from 'utils/utils';
import type {GlobalState} from 'types/store';
@ -63,10 +72,30 @@ export function submitPost(channelId: string, rootId: string, draft: PostDraft):
pending_post_id: `${userId}:${time}`,
user_id: userId,
create_at: time,
metadata: {},
metadata: {...draft.metadata},
props: {...draft.props},
} as unknown as Post;
const channel = getChannel(state, channelId);
if (!channel) {
return {error: new Error('cannot find channel')};
}
const useChannelMentions = haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_CHANNEL_MENTIONS);
if (!useChannelMentions && containsAtChannel(post.message, {checkAllMentions: true})) {
post.props.mentionHighlightDisabled = true;
}
const license = getLicense(state);
const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
const useLDAPGroupMentions = isLDAPEnabled && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS);
const useCustomGroupMentions = isCustomGroupsEnabled(state) && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS);
const groupsWithAllowReference = useLDAPGroupMentions || useCustomGroupMentions ? getAssociatedGroupsForReferenceByMention(state, channel.team_id, channel.id) : null;
if (!useLDAPGroupMentions && !useCustomGroupMentions && groupsMentionedInText(post.message, groupsWithAllowReference)) {
post.props.disable_group_highlight = true;
}
const hookResult = await dispatch(runMessageWillBePostedHooks(post));
if (hookResult.error) {
return {error: hookResult.error};
@ -146,6 +175,32 @@ export function makeOnSubmit(channelId: string, rootId: string, latestPostId: st
};
}
export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean}): ActionFuncAsync<boolean, GlobalState> {
return async (dispatch, getState) => {
const {message, channelId, rootId} = draft;
const state = getState();
dispatch(addMessageIntoHistory(message));
const isReaction = Utils.REACTION_PATTERN.exec(message);
const emojis = getCustomEmojisByName(state);
const emojiMap = new EmojiMap(emojis);
if (isReaction && emojiMap.has(isReaction[2])) {
const latestPostId = getLatestInteractablePostId(state, channelId, rootId);
if (latestPostId) {
dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2]));
}
} else if (message.indexOf('/') === 0 && !options.ignoreSlash) {
await dispatch(submitCommand(channelId, rootId, draft));
} else {
await dispatch(submitPost(channelId, rootId, draft));
}
return {data: true};
};
}
function makeGetCurrentUsersLatestReply() {
const getPostIdsInThread = makeGetPostIdsForThread();
return createSelector(
@ -211,3 +266,22 @@ export function makeOnEditLatestPost(rootId: string): () => ActionFunc<boolean>
));
};
}
export function editLatestPost(channelId: string, rootId = ''): ActionFunc<boolean> {
return (dispatch, getState) => {
const state = getState();
const lastPostId = getLatestPostToEdit(state, channelId, rootId);
if (!lastPostId) {
return {data: false};
}
return dispatch(PostActions.setEditingPost(
lastPostId,
rootId ? 'reply_textbox' : 'post_textbox',
'', // title is no longer used
Boolean(rootId),
));
};
}

View File

@ -97,7 +97,7 @@ export function updateDraft(key: string, value: PostDraft|null, rootId = '', sav
let updatedValue: PostDraft|null = null;
if (value) {
const timestamp = new Date().getTime();
const data = getGlobalItem(state, key, {});
const data = getGlobalItem<Partial<PostDraft>>(state, key, {});
updatedValue = {
...value,
createAt: data.createAt || timestamp,

View File

@ -1,530 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/AdvancedCreateComment should match snapshot when cannot post 1`] = `
<form
onSubmit={[Function]}
>
<AdvanceTextEditor
additionalControls={Array []}
applyMarkdown={[Function]}
badConnection={false}
canPost={false}
canUploadFiles={true}
caretPosition={12}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={false}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
"channelId": "",
"createAt": 0,
"fileInfos": Array [
Object {
"archived": false,
"clientId": "client_id",
"create_at": 1,
"delete_at": 1,
"extension": "jpg",
"has_preview_image": true,
"height": 200,
"id": "file_info_id",
"mime_type": "mime_type",
"name": "name",
"size": 1,
"update_at": 1,
"user_id": "user_id",
"width": 350,
},
Object {
"archived": false,
"clientId": "client_id",
"create_at": 1,
"delete_at": 1,
"extension": "jpg",
"has_preview_image": true,
"height": 200,
"id": "file_info_id",
"mime_type": "mime_type",
"name": "name",
"size": 1,
"update_at": 1,
"user_id": "user_id",
"width": 350,
},
Object {
"archived": false,
"clientId": "client_id",
"create_at": 1,
"delete_at": 1,
"extension": "jpg",
"has_preview_image": true,
"height": 200,
"id": "file_info_id",
"mime_type": "mime_type",
"name": "name",
"size": 1,
"update_at": 1,
"user_id": "user_id",
"width": 350,
},
],
"message": "Test message",
"rootId": "",
"updateAt": 0,
"uploadsInProgress": Array [],
}
}
emitTypingEvent={[Function]}
enableEmojiPicker={true}
enableGifPicker={true}
errorClass={null}
fileUploadRef={
Object {
"current": null,
}
}
getFileUploadTarget={[Function]}
handleBlur={[Function]}
handleChange={[Function]}
handleEmojiClick={[Function]}
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
handleUploadError={[Function]}
handleUploadProgress={[Function]}
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message="Test message"
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
showEmojiPicker={false}
textboxRef={
Object {
"current": null,
}
}
toggleAdvanceTextEditor={[Function]}
toggleEmojiPicker={[Function]}
uploadsProgressPercent={Object {}}
useChannelMentions={true}
/>
</form>
`;
exports[`components/AdvancedCreateComment should match snapshot, comment with message 1`] = `
<form
onSubmit={[Function]}
>
<AdvanceTextEditor
additionalControls={Array []}
applyMarkdown={[Function]}
badConnection={false}
canPost={true}
canUploadFiles={true}
caretPosition={12}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={true}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
"channelId": "",
"createAt": 0,
"fileInfos": Array [],
"message": "Test message",
"rootId": "",
"updateAt": 0,
"uploadsInProgress": Array [],
}
}
emitTypingEvent={[Function]}
enableEmojiPicker={true}
enableGifPicker={true}
errorClass={null}
fileUploadRef={
Object {
"current": null,
}
}
getFileUploadTarget={[Function]}
handleBlur={[Function]}
handleChange={[Function]}
handleEmojiClick={[Function]}
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
handleUploadError={[Function]}
handleUploadProgress={[Function]}
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message="Test message"
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
showEmojiPicker={false}
textboxRef={
Object {
"current": null,
}
}
toggleAdvanceTextEditor={[Function]}
toggleEmojiPicker={[Function]}
uploadsProgressPercent={Object {}}
useChannelMentions={true}
/>
</form>
`;
exports[`components/AdvancedCreateComment should match snapshot, emoji picker disabled 1`] = `
<form
onSubmit={[Function]}
>
<FileLimitStickyBanner />
<AdvanceTextEditor
additionalControls={Array []}
applyMarkdown={[Function]}
badConnection={false}
canPost={true}
canUploadFiles={true}
caretPosition={12}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={false}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
"channelId": "",
"createAt": 0,
"fileInfos": Array [
Object {
"archived": false,
"clientId": "client_id",
"create_at": 1,
"delete_at": 1,
"extension": "jpg",
"has_preview_image": true,
"height": 200,
"id": "file_info_id",
"mime_type": "mime_type",
"name": "name",
"size": 1,
"update_at": 1,
"user_id": "user_id",
"width": 350,
},
Object {
"archived": false,
"clientId": "client_id",
"create_at": 1,
"delete_at": 1,
"extension": "jpg",
"has_preview_image": true,
"height": 200,
"id": "file_info_id",
"mime_type": "mime_type",
"name": "name",
"size": 1,
"update_at": 1,
"user_id": "user_id",
"width": 350,
},
Object {
"archived": false,
"clientId": "client_id",
"create_at": 1,
"delete_at": 1,
"extension": "jpg",
"has_preview_image": true,
"height": 200,
"id": "file_info_id",
"mime_type": "mime_type",
"name": "name",
"size": 1,
"update_at": 1,
"user_id": "user_id",
"width": 350,
},
],
"message": "Test message",
"rootId": "",
"updateAt": 0,
"uploadsInProgress": Array [],
}
}
emitTypingEvent={[Function]}
enableEmojiPicker={false}
enableGifPicker={true}
errorClass={null}
fileUploadRef={
Object {
"current": null,
}
}
getFileUploadTarget={[Function]}
handleBlur={[Function]}
handleChange={[Function]}
handleEmojiClick={[Function]}
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
handleUploadError={[Function]}
handleUploadProgress={[Function]}
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message="Test message"
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
showEmojiPicker={false}
textboxRef={
Object {
"current": null,
}
}
toggleAdvanceTextEditor={[Function]}
toggleEmojiPicker={[Function]}
uploadsProgressPercent={Object {}}
useChannelMentions={true}
/>
</form>
`;
exports[`components/AdvancedCreateComment should match snapshot, empty comment 1`] = `
<form
onSubmit={[Function]}
>
<AdvanceTextEditor
additionalControls={Array []}
applyMarkdown={[Function]}
badConnection={false}
canPost={true}
canUploadFiles={true}
caretPosition={0}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={true}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
"channelId": "",
"createAt": 0,
"fileInfos": Array [],
"message": "",
"rootId": "",
"updateAt": 0,
"uploadsInProgress": Array [],
}
}
emitTypingEvent={[Function]}
enableEmojiPicker={true}
enableGifPicker={true}
errorClass={null}
fileUploadRef={
Object {
"current": null,
}
}
getFileUploadTarget={[Function]}
handleBlur={[Function]}
handleChange={[Function]}
handleEmojiClick={[Function]}
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
handleUploadError={[Function]}
handleUploadProgress={[Function]}
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message=""
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
showEmojiPicker={false}
textboxRef={
Object {
"current": null,
}
}
toggleAdvanceTextEditor={[Function]}
toggleEmojiPicker={[Function]}
uploadsProgressPercent={Object {}}
useChannelMentions={true}
/>
</form>
`;
exports[`components/AdvancedCreateComment should match snapshot, non-empty message and uploadsInProgress + fileInfos 1`] = `
<form
onSubmit={[Function]}
>
<FileLimitStickyBanner />
<AdvanceTextEditor
additionalControls={Array []}
applyMarkdown={[Function]}
badConnection={false}
canPost={true}
canUploadFiles={true}
caretPosition={12}
channelId="g6139tbospd18cmxroesdk3kkc"
ctrlSend={false}
currentUserId="zaktnt8bpbgu8mb6ez9k64r7sa"
draft={
Object {
"channelId": "",
"createAt": 0,
"fileInfos": Array [
Object {
"archived": false,
"clientId": "client_id",
"create_at": 1,
"delete_at": 1,
"extension": "jpg",
"has_preview_image": true,
"height": 200,
"id": "file_info_id",
"mime_type": "mime_type",
"name": "name",
"size": 1,
"update_at": 1,
"user_id": "user_id",
"width": 350,
},
Object {
"archived": false,
"clientId": "client_id",
"create_at": 1,
"delete_at": 1,
"extension": "jpg",
"has_preview_image": true,
"height": 200,
"id": "file_info_id",
"mime_type": "mime_type",
"name": "name",
"size": 1,
"update_at": 1,
"user_id": "user_id",
"width": 350,
},
Object {
"archived": false,
"clientId": "client_id",
"create_at": 1,
"delete_at": 1,
"extension": "jpg",
"has_preview_image": true,
"height": 200,
"id": "file_info_id",
"mime_type": "mime_type",
"name": "name",
"size": 1,
"update_at": 1,
"user_id": "user_id",
"width": 350,
},
],
"message": "Test message",
"rootId": "",
"updateAt": 0,
"uploadsInProgress": Array [],
}
}
emitTypingEvent={[Function]}
enableEmojiPicker={true}
enableGifPicker={true}
errorClass={null}
fileUploadRef={
Object {
"current": null,
}
}
getFileUploadTarget={[Function]}
handleBlur={[Function]}
handleChange={[Function]}
handleEmojiClick={[Function]}
handleFileUploadChange={[Function]}
handleFileUploadComplete={[Function]}
handleGifClick={[Function]}
handleMouseUpKeyUp={[Function]}
handlePostError={[Function]}
handleSubmit={[Function]}
handleUploadError={[Function]}
handleUploadProgress={[Function]}
handleUploadStart={[Function]}
hideEmojiPicker={[Function]}
isFormattingBarHidden={false}
loadNextMessage={[Function]}
loadPrevMessage={[Function]}
location="RHS_COMMENT"
maxPostSize={4000}
message="Test message"
onEditLatestPost={[Function]}
onMessageChange={[Function]}
postId=""
postMsgKeyPress={[Function]}
removePreview={[Function]}
serverError={null}
setShowPreview={[Function]}
shouldShowPreview={false}
showEmojiPicker={false}
textboxRef={
Object {
"current": null,
}
}
toggleAdvanceTextEditor={[Function]}
toggleEmojiPicker={[Function]}
uploadsProgressPercent={Object {}}
useChannelMentions={true}
/>
</form>
`;

View File

@ -1,189 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {getChannelTimezones, getChannelMemberCountsByGroup} from 'mattermost-redux/actions/channels';
import {moveHistoryIndexBack, moveHistoryIndexForward, resetCreatePostRequest, resetHistoryIndex} from 'mattermost-redux/actions/posts';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {Permissions, Preferences, Posts} from 'mattermost-redux/constants';
import {getAllChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups';
import {makeGetMessageInHistoryItem} from 'mattermost-redux/selectors/entities/posts';
import {getBool, isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {emitShortcutReactToLastPostFrom} from 'actions/post_actions';
import {
clearCommentDraftUploads,
updateCommentDraft,
makeOnSubmit,
makeOnEditLatestPost,
} from 'actions/views/create_comment';
import {searchAssociatedGroupsForReference} from 'actions/views/group';
import {openModal} from 'actions/views/modals';
import {focusedRHS} from 'actions/views/rhs';
import {setShowPreviewOnCreateComment} from 'actions/views/textbox';
import {getCurrentLocale} from 'selectors/i18n';
import {getPostDraft, getIsRhsExpanded, getSelectedPostFocussedAt} from 'selectors/rhs';
import {getShouldFocusRHS} from 'selectors/views/rhs';
import {connectionErrorCount} from 'selectors/views/system';
import {showPreviewOnCreateComment} from 'selectors/views/textbox';
import {AdvancedTextEditor, Constants, StoragePrefixes} from 'utils/constants';
import {canUploadFiles} from 'utils/file_utils';
import type {PostDraft} from 'types/store/draft';
import type {GlobalState} from 'types/store/index.js';
import AdvancedCreateComment from './advanced_create_comment';
type OwnProps = {
rootId: string;
channelId: string;
latestPostId: string;
isPlugin?: boolean;
};
function makeMapStateToProps() {
const getMessageInHistoryItem = makeGetMessageInHistoryItem(Posts.MESSAGE_TYPES.COMMENT as 'comment');
return (state: GlobalState, ownProps: OwnProps) => {
const err = state.requests.posts.createPost.error || {};
const draft = getPostDraft(state, StoragePrefixes.COMMENT_DRAFT, ownProps.rootId);
const isRemoteDraft = state.views.drafts.remotes[`${StoragePrefixes.COMMENT_DRAFT}${ownProps.rootId}`] || false;
const channelMembersCount = getAllChannelStats(state)[ownProps.channelId] ? getAllChannelStats(state)[ownProps.channelId].member_count : 1;
const messageInHistory = getMessageInHistoryItem(state);
const channel = state.entities.channels.channels[ownProps.channelId] || {};
const config = getConfig(state);
const license = getLicense(state);
const currentUserId = getCurrentUserId(state);
const enableConfirmNotificationsToChannel = config.EnableConfirmNotificationsToChannel === 'true';
const enableEmojiPicker = config.EnableEmojiPicker === 'true';
const enableGifPicker = config.EnableGifPicker === 'true';
const badConnection = connectionErrorCount(state) > 1;
const canPost = haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CREATE_POST);
const useChannelMentions = haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_CHANNEL_MENTIONS);
const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
const useCustomGroupMentions = isCustomGroupsEnabled(state) && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS);
const useLDAPGroupMentions = isLDAPEnabled && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS);
const channelMemberCountsByGroup = selectChannelMemberCountsByGroup(state, ownProps.channelId);
const groupsWithAllowReference = useLDAPGroupMentions || useCustomGroupMentions ? getAssociatedGroupsForReferenceByMention(state, channel.team_id, channel.id) : null;
const isFormattingBarHidden = getBool(state, Constants.Preferences.ADVANCED_TEXT_EDITOR, AdvancedTextEditor.COMMENT);
const currentTeamId = getCurrentTeamId(state);
const postEditorActions = state.plugins.components.PostEditorAction;
const shouldFocusRHS = getShouldFocusRHS(state);
return {
currentTeamId,
draft,
isRemoteDraft,
messageInHistory,
channelMembersCount,
currentUserId,
isFormattingBarHidden,
codeBlockOnCtrlEnter: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true),
ctrlSend: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
createPostErrorId: err.server_error_id,
enableConfirmNotificationsToChannel,
enableEmojiPicker,
enableGifPicker,
locale: getCurrentLocale(state),
maxPostSize: parseInt(config.MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT,
rhsExpanded: getIsRhsExpanded(state),
badConnection,
selectedPostFocussedAt: getSelectedPostFocussedAt(state),
canPost,
useChannelMentions,
shouldShowPreview: showPreviewOnCreateComment(state),
groupsWithAllowReference,
useLDAPGroupMentions,
channelMemberCountsByGroup,
useCustomGroupMentions,
canUploadFiles: canUploadFiles(config),
postEditorActions,
shouldFocusRHS,
};
};
}
function makeOnUpdateCommentDraft(rootId: string, channelId: string) {
return (draft?: PostDraft, save = false) => updateCommentDraft(rootId, draft ? {...draft, channelId} : draft, save);
}
function makeUpdateCommentDraftWithRootId(channelId: string) {
return (rootId: string, draft?: PostDraft, save = false) => updateCommentDraft(rootId, draft ? {...draft, channelId} : draft, save);
}
function makeMapDispatchToProps() {
let onUpdateCommentDraft: ReturnType<typeof makeOnUpdateCommentDraft>;
let updateCommentDraftWithRootId: ReturnType<typeof makeUpdateCommentDraftWithRootId>;
let onSubmit: ReturnType<typeof makeOnSubmit>;
let onEditLatestPost: ReturnType<typeof makeOnEditLatestPost>;
function onResetHistoryIndex() {
return resetHistoryIndex(Posts.MESSAGE_TYPES.COMMENT);
}
let rootId: string;
let channelId: string;
let latestPostId: string;
return (dispatch: Dispatch, ownProps: OwnProps) => {
if (!ownProps.isPlugin) {
if (rootId !== ownProps.rootId) {
onUpdateCommentDraft = makeOnUpdateCommentDraft(ownProps.rootId, ownProps.channelId);
}
if (channelId !== ownProps.channelId) {
updateCommentDraftWithRootId = makeUpdateCommentDraftWithRootId(ownProps.channelId);
}
if (rootId !== ownProps.rootId) {
onEditLatestPost = makeOnEditLatestPost(ownProps.rootId);
}
if (rootId !== ownProps.rootId || channelId !== ownProps.channelId || latestPostId !== ownProps.latestPostId) {
onSubmit = makeOnSubmit(ownProps.channelId, ownProps.rootId, ownProps.latestPostId);
}
}
rootId = ownProps.rootId;
channelId = ownProps.channelId;
latestPostId = ownProps.latestPostId;
return bindActionCreators(
{
clearCommentDraftUploads,
onUpdateCommentDraft,
updateCommentDraftWithRootId,
onSubmit,
onResetHistoryIndex,
moveHistoryIndexBack,
moveHistoryIndexForward,
onEditLatestPost,
resetCreatePostRequest,
getChannelTimezones,
emitShortcutReactToLastPostFrom,
setShowPreview: setShowPreviewOnCreateComment,
getChannelMemberCountsByGroup,
openModal,
savePreferences,
searchAssociatedGroupsForReference,
focusedRHS,
},
dispatch,
);
};
}
export default connect(makeMapStateToProps, makeMapDispatchToProps, null, {forwardRef: true})(AdvancedCreateComment);
export default AdvancedCreateComment;

View File

@ -1,201 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import type {FileInfo} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
import {getChannelTimezones, getChannelMemberCountsByGroup} from 'mattermost-redux/actions/channels';
import {
addMessageIntoHistory,
moveHistoryIndexBack,
moveHistoryIndexForward,
removeReaction,
} from 'mattermost-redux/actions/posts';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {Permissions, Posts, Preferences as PreferencesRedux} from 'mattermost-redux/constants';
import {getCurrentChannelId, getCurrentChannel, getCurrentChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from 'mattermost-redux/selectors/entities/channels';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups';
import {
getCurrentUsersLatestPost,
getLatestReplyablePostId,
makeGetMessageInHistoryItem,
isPostPriorityEnabled,
} from 'mattermost-redux/selectors/entities/posts';
import {get, getInt, getBool, isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {haveICurrentChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId, getStatusForUserId, getUser, isCurrentUserGuestUser} from 'mattermost-redux/selectors/entities/users';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions.js';
import {executeCommand} from 'actions/command';
import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks';
import {addReaction, createPost, setEditingPost, emitShortcutReactToLastPostFrom, submitReaction} from 'actions/post_actions';
import {actionOnGlobalItemsWithPrefix} from 'actions/storage';
import {scrollPostListToBottom} from 'actions/views/channel';
import {removeDraft, updateDraft} from 'actions/views/drafts';
import {searchAssociatedGroupsForReference} from 'actions/views/group';
import {openModal} from 'actions/views/modals';
import {selectPostFromRightHandSideSearchByPostId} from 'actions/views/rhs';
import {setShowPreviewOnCreatePost} from 'actions/views/textbox';
import {getEmojiMap} from 'selectors/emojis';
import {getCurrentLocale} from 'selectors/i18n';
import {makeGetChannelDraft, getIsRhsExpanded, getIsRhsOpen} from 'selectors/rhs';
import {connectionErrorCount} from 'selectors/views/system';
import {showPreviewOnCreatePost} from 'selectors/views/textbox';
import {OnboardingTourSteps, TutorialTourName, OnboardingTourStepsForGuestUsers} from 'components/tours';
import {AdvancedTextEditor, Constants, Preferences, StoragePrefixes, UserStatuses} from 'utils/constants';
import {canUploadFiles} from 'utils/file_utils';
import type {PostDraft} from 'types/store/draft';
import type {GlobalState} from 'types/store/index.js';
import AdvancedCreatePost from './advanced_create_post';
function makeMapStateToProps() {
const getMessageInHistoryItem = makeGetMessageInHistoryItem(Posts.MESSAGE_TYPES.POST as any);
const getChannelDraft = makeGetChannelDraft();
return (state: GlobalState) => {
const config = getConfig(state);
const license = getLicense(state);
const currentChannel = getCurrentChannel(state);
const currentChannelTeammateUsername = currentChannel ? getUser(state, currentChannel.teammate_id || '')?.username : undefined;
const draft = getChannelDraft(state, currentChannel?.id || '');
const isRemoteDraft = (currentChannel && state.views.drafts.remotes[`${StoragePrefixes.DRAFT}${currentChannel.id}`]) || false;
const latestReplyablePostId = getLatestReplyablePostId(state);
const currentChannelMembersCount = getCurrentChannelStats(state)?.member_count ?? 1;
const enableEmojiPicker = config.EnableEmojiPicker === 'true';
const enableGifPicker = config.EnableGifPicker === 'true';
const enableConfirmNotificationsToChannel = config.EnableConfirmNotificationsToChannel === 'true';
const currentUserId = getCurrentUserId(state);
const userIsOutOfOffice = getStatusForUserId(state, currentUserId) === UserStatuses.OUT_OF_OFFICE;
const badConnection = connectionErrorCount(state) > 1;
const canPost = haveICurrentChannelPermission(state, Permissions.CREATE_POST);
const useChannelMentions = haveICurrentChannelPermission(state, Permissions.USE_CHANNEL_MENTIONS);
const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
const useCustomGroupMentions = isCustomGroupsEnabled(state) && haveICurrentChannelPermission(state, Permissions.USE_GROUP_MENTIONS);
const useLDAPGroupMentions = isLDAPEnabled && haveICurrentChannelPermission(state, Permissions.USE_GROUP_MENTIONS);
const channelMemberCountsByGroup = currentChannel ? selectChannelMemberCountsByGroup(state, currentChannel.id) : {};
const currentTeamId = getCurrentTeamId(state);
const groupsWithAllowReference = (currentChannel && (useLDAPGroupMentions || useCustomGroupMentions)) ?
getAssociatedGroupsForReferenceByMention(state, currentTeamId, currentChannel.id) :
null;
const enableTutorial = config.EnableTutorial === 'true';
const tutorialStep = getInt(state, TutorialTourName.ONBOARDING_TUTORIAL_STEP, currentUserId, 0);
// guest validation to see which point the messaging tour tip starts
const isGuestUser = isCurrentUserGuestUser(state);
const tourStep = isGuestUser ? OnboardingTourStepsForGuestUsers.SEND_MESSAGE : OnboardingTourSteps.SEND_MESSAGE;
const showSendTutorialTip = enableTutorial && tutorialStep === tourStep;
const isFormattingBarHidden = getBool(state, Preferences.ADVANCED_TEXT_EDITOR, AdvancedTextEditor.POST);
const postEditorActions = state.plugins.components.PostEditorAction;
return {
currentTeamId,
currentChannel,
currentChannelTeammateUsername,
currentChannelMembersCount,
currentUserId,
isFormattingBarHidden,
codeBlockOnCtrlEnter: getBool(state, PreferencesRedux.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true),
ctrlSend: getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'),
fullWidthTextBox: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT) === Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN,
showSendTutorialTip,
messageInHistoryItem: getMessageInHistoryItem(state),
draft,
isRemoteDraft,
latestReplyablePostId,
locale: getCurrentLocale(state),
currentUsersLatestPost: getCurrentUsersLatestPost(state, ''),
canUploadFiles: canUploadFiles(config),
enableEmojiPicker,
enableGifPicker,
enableConfirmNotificationsToChannel,
maxPostSize: parseInt(config.MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT,
userIsOutOfOffice,
rhsExpanded: getIsRhsExpanded(state),
rhsOpen: getIsRhsOpen(state),
emojiMap: getEmojiMap(state),
badConnection,
canPost,
useChannelMentions,
shouldShowPreview: showPreviewOnCreatePost(state),
groupsWithAllowReference,
useLDAPGroupMentions,
channelMemberCountsByGroup,
isLDAPEnabled,
useCustomGroupMentions,
isPostPriorityEnabled: isPostPriorityEnabled(state),
postEditorActions,
};
};
}
function onSubmitPost(post: Post, fileInfos: FileInfo[]) {
return (dispatch: Dispatch) => {
dispatch(createPost(post, fileInfos) as any);
};
}
function setDraft(key: string, value: PostDraft | null, draftChannelId: string, save = false): ActionFuncAsync<boolean, GlobalState> {
return (dispatch, getState) => {
const channelId = draftChannelId || getCurrentChannelId(getState());
let updatedValue = null;
if (value) {
updatedValue = {...value, channelId};
}
if (updatedValue) {
return dispatch(updateDraft(key, updatedValue, '', save));
}
return dispatch(removeDraft(key, channelId));
};
}
function clearDraftUploads() {
return actionOnGlobalItemsWithPrefix(StoragePrefixes.DRAFT, (_key: string, draft: PostDraft) => {
if (!draft || !draft.uploadsInProgress || draft.uploadsInProgress.length === 0) {
return draft;
}
return {...draft, uploadsInProgress: []};
});
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
addMessageIntoHistory,
onSubmitPost,
moveHistoryIndexBack,
moveHistoryIndexForward,
submitReaction,
addReaction,
removeReaction,
setDraft,
clearDraftUploads,
selectPostFromRightHandSideSearchByPostId,
setEditingPost,
emitShortcutReactToLastPostFrom,
openModal,
executeCommand,
getChannelTimezones,
runMessageWillBePostedHooks,
runSlashCommandWillBePostedHooks,
scrollPostListToBottom,
setShowPreview: setShowPreviewOnCreatePost,
getChannelMemberCountsByGroup,
savePreferences,
searchAssociatedGroupsForReference,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(AdvancedCreatePost);
export default AdvancedCreatePost;

View File

@ -3,19 +3,24 @@
import React, {useMemo, memo} from 'react';
import {defineMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import styled from 'styled-components';
import type {Channel} from '@mattermost/types/channels';
import {getChannel, getDirectTeammate} from 'mattermost-redux/selectors/entities/channels';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {trackEvent} from 'actions/telemetry_actions';
import Chip from 'components/common/chip/chip';
import Constants from 'utils/constants';
import type {GlobalState} from 'types/store';
type Props = {
prefillMessage: (msg: string, shouldFocus: boolean) => void;
currentChannel: Channel;
channelId: string;
currentUserId: string;
currentChannelTeammateUsername?: string;
}
const UsernameMention = styled.span`
@ -28,8 +33,11 @@ const ChipContainer = styled.div`
flex-wrap: wrap;
`;
const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateUsername, prefillMessage}: Props) => {
const PrewrittenChips = ({channelId, currentUserId, prefillMessage}: Props) => {
const {formatMessage} = useIntl();
const channelType = useSelector((state: GlobalState) => getChannel(state, channelId)?.type || Constants.OPEN_CHANNEL);
const channelTeammateId = useSelector((state: GlobalState) => getDirectTeammate(state, channelId)?.id || '');
const channelTeammateUsername = useSelector((state: GlobalState) => getUser(state, channelTeammateId)?.username || '');
const chips = useMemo(() => {
const customChip = {
@ -45,7 +53,11 @@ const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateU
leadingIcon: '',
};
if (currentChannel.type === 'O' || currentChannel.type === 'P' || currentChannel.type === 'G') {
if (
channelType === Constants.OPEN_CHANNEL ||
channelType === Constants.PRIVATE_CHANNEL ||
channelType === Constants.GM_CHANNEL
) {
return [
{
event: 'prefilled_message_selected_team_hi',
@ -87,7 +99,7 @@ const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateU
];
}
if (currentChannel.teammate_id === currentUserId) {
if (channelTeammateId === currentUserId) {
return [
{
event: 'prefilled_message_selected_self_note',
@ -144,12 +156,12 @@ const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateU
},
customChip,
];
}, [currentChannel, currentUserId]);
}, [channelType, channelTeammateId, currentUserId]);
return (
<ChipContainer>
{chips.map(({event, message, display, leadingIcon}) => {
const values = {username: currentChannelTeammateUsername};
const values = {username: channelTeammateUsername};
const messageToPrefill = message.id ? formatMessage(
message,
values,
@ -157,15 +169,14 @@ const PrewrittenChips = ({currentChannel, currentUserId, currentChannelTeammateU
const additionalMarkup = message.id === 'create_post.prewritten.tip.dm_hey' ? (
<UsernameMention>
{'@'}{currentChannelTeammateUsername}
{'@'}{channelTeammateUsername}
</UsernameMention>
) : null;
return (
<Chip
key={display.id}
id={display.id}
defaultMessage={display.defaultMessage}
display={display}
additionalMarkup={additionalMarkup}
values={values}
onClick={() => {

View File

@ -1,7 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react';
import React from 'react';
import type {Channel} from '@mattermost/types/channels';
@ -12,7 +11,7 @@ import type {FileUpload} from 'components/file_upload/file_upload';
import type Textbox from 'components/textbox/textbox';
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
import {renderWithContext, userEvent} from 'tests/react_testing_utils';
import {renderWithContext, userEvent, screen} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {PostDraft} from 'types/store/draft';
@ -146,71 +145,6 @@ const baseProps = {
describe('components/avanced_text_editor/advanced_text_editor', () => {
describe('keyDown behavior', () => {
it('Enter should call postMsgKeyPress', () => {
const postMsgKeyPress = jest.fn();
renderWithContext(
<AdavancedTextEditor
{...baseProps}
postMsgKeyPress={postMsgKeyPress}
message={'test'}
/>,
mergeObjects(initialState, {
entities: {
roles: {
roles: {
user_roles: {permissions: [Permissions.CREATE_POST]},
},
},
},
}),
);
userEvent.type(screen.getByTestId('post_textbox'), '{enter}');
expect(postMsgKeyPress).toHaveBeenCalledTimes(1);
});
it('Ctrl+up should call loadPrevMessage', () => {
const loadPrevMessage = jest.fn();
renderWithContext(
<AdavancedTextEditor
{...baseProps}
loadPrevMessage={loadPrevMessage}
/>,
mergeObjects(initialState, {
entities: {
roles: {
roles: {
user_roles: {permissions: [Permissions.CREATE_POST]},
},
},
},
}),
);
userEvent.type(screen.getByTestId('post_textbox'), '{ctrl}{arrowup}');
expect(loadPrevMessage).toHaveBeenCalledTimes(1);
});
it('up should call onEditLatestPost', () => {
const onEditLatestPost = jest.fn();
renderWithContext(
<AdavancedTextEditor
{...baseProps}
onEditLatestPost={onEditLatestPost}
/>,
mergeObjects(initialState, {
entities: {
roles: {
roles: {
user_roles: {permissions: [Permissions.CREATE_POST]},
},
},
},
}),
);
userEvent.type(screen.getByTestId('post_textbox'), '{arrowup}');
expect(onEditLatestPost).toHaveBeenCalledTimes(1);
});
it('ESC should blur the input', () => {
renderWithContext(
<AdavancedTextEditor
@ -230,59 +164,5 @@ describe('components/avanced_text_editor/advanced_text_editor', () => {
userEvent.type(textbox, 'something{esc}');
expect(textbox).not.toHaveFocus();
});
describe('markdown', () => {
const ttcc = [
{
input: '{ctrl}b',
markdownMode: 'bold',
},
{
input: '{ctrl}i',
markdownMode: 'italic',
},
{
input: '{ctrl}k',
markdownMode: 'link',
},
{
input: '{ctrl}{alt}k',
markdownMode: 'link',
},
];
for (const tc of ttcc) {
it(`component adds ${tc.markdownMode} markdown`, () => {
const applyMarkdown = jest.fn();
const message = 'Some markdown text';
const selectionStart = 5;
const selectionEnd = 10;
renderWithContext(
<AdavancedTextEditor
{...baseProps}
applyMarkdown={applyMarkdown}
message={'Some markdown text'}
/>,
mergeObjects(initialState, {
entities: {
roles: {
roles: {
user_roles: {permissions: [Permissions.CREATE_POST]},
},
},
},
}),
);
const textbox = screen.getByTestId('post_textbox');
userEvent.type(textbox, tc.input, {initialSelectionStart: selectionStart, initialSelectionEnd: selectionEnd});
expect(applyMarkdown).toHaveBeenCalledWith({
markdownMode: tc.markdownMode,
selectionStart,
selectionEnd,
message,
});
});
}
});
});
});

View File

@ -4,11 +4,13 @@
import {DateTime} from 'luxon';
import React, {useState, useEffect} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import styled from 'styled-components';
import type {UserProfile} from '@mattermost/types/users';
import type {GlobalState} from '@mattermost/types/store';
import {getTimezoneForUserProfile} from 'mattermost-redux/selectors/entities/timezone';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import Moon from 'components/common/svg_images_components/moon_svg';
import Timestamp from 'components/timestamp';
@ -43,15 +45,26 @@ const Icon = styled(Moon)`
`;
type Props = {
teammate: UserProfile;
teammateId: string;
displayName: string;
}
const RemoteUserHour = ({teammate, displayName}: Props) => {
const DEFAULT_TIMEZONE = {
useAutomaticTimezone: true,
automaticTimezone: '',
manualTimezone: '',
};
const RemoteUserHour = ({teammateId, displayName}: Props) => {
const [timestamp, setTimestamp] = useState(0);
const [showIt, setShowIt] = useState(false);
const teammateTimezone = getTimezoneForUserProfile(teammate);
const teammateTimezone = useSelector((state: GlobalState) => {
const teammate = teammateId ? getUser(state, teammateId) : undefined;
return teammate ? getTimezoneForUserProfile(teammate) : DEFAULT_TIMEZONE;
}, (a, b) => a.automaticTimezone === b.automaticTimezone &&
a.manualTimezone === b.manualTimezone &&
a.useAutomaticTimezone === b.useAutomaticTimezone);
useEffect(() => {
const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone);

View File

@ -0,0 +1,170 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {EmoticonHappyOutlineIcon} from '@mattermost/compass-icons/components';
import type {Emoji} from '@mattermost/types/emojis';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
import useDidUpdate from 'components/common/hooks/useDidUpdate';
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
import KeyboardShortcutSequence, {KEYBOARD_SHORTCUTS} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence';
import OverlayTrigger from 'components/overlay_trigger';
import Tooltip from 'components/tooltip';
import Constants from 'utils/constants';
import {splitMessageBasedOnCaretPosition} from 'utils/post_utils';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
import {IconContainer} from './formatting_bar/formatting_icon';
const useEmojiPicker = (
readOnlyChannel: boolean,
draft: PostDraft,
caretPosition: number,
setCaretPosition: (pos: number) => void,
handleDraftChange: (draft: PostDraft) => void,
shouldShowPreview: boolean,
focusTextbox: () => void,
) => {
const intl = useIntl();
const enableEmojiPicker = useSelector((state: GlobalState) => getConfig(state).EnableEmojiPicker === 'true');
const enableGifPicker = useSelector((state: GlobalState) => getConfig(state).EnableGifPicker === 'true');
const emojiPickerRef = useRef<HTMLButtonElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const toggleEmojiPicker = useCallback((e?: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
e?.stopPropagation();
setShowEmojiPicker((prev) => !prev);
}, []);
const hideEmojiPicker = useCallback(() => {
setShowEmojiPicker(false);
}, []);
const getEmojiPickerRef = useCallback(() => {
return emojiPickerRef.current;
}, []);
const handleEmojiClick = useCallback((emoji: Emoji) => {
const emojiAlias = getEmojiName(emoji);
if (!emojiAlias) {
//Oops.. There went something wrong
return;
}
let newMessage;
if (draft.message === '') {
newMessage = `:${emojiAlias}: `;
setCaretPosition(newMessage.length);
} else {
const {message} = draft;
const {firstPiece, lastPiece} = splitMessageBasedOnCaretPosition(caretPosition, message);
// check whether the first piece of the message is empty when cursor is placed at beginning of message and avoid adding an empty string at the beginning of the message
newMessage =
firstPiece === '' ? `:${emojiAlias}: ${lastPiece}` : `${firstPiece} :${emojiAlias}: ${lastPiece}`;
const newCaretPosition =
firstPiece === '' ? `:${emojiAlias}: `.length : `${firstPiece} :${emojiAlias}: `.length;
setCaretPosition(newCaretPosition);
}
handleDraftChange({
...draft,
message: newMessage,
});
setShowEmojiPicker(false);
}, [draft, caretPosition, handleDraftChange, setCaretPosition]);
const handleGifClick = useCallback((gif: string) => {
let newMessage: string;
if (draft.message === '') {
newMessage = gif;
} else if ((/\s+$/).test(draft.message)) {
// Check whether there is already a blank at the end of the current message
newMessage = `${draft.message}${gif} `;
} else {
newMessage = `${draft.message} ${gif} `;
}
handleDraftChange({
...draft,
message: newMessage,
});
setShowEmojiPicker(false);
}, [draft, handleDraftChange]);
// Focus textbox when the emoji picker closes
useDidUpdate(() => {
if (!showEmojiPicker) {
focusTextbox();
}
}, [showEmojiPicker]);
let emojiPicker = null;
if (enableEmojiPicker && !readOnlyChannel) {
const emojiPickerTooltip = (
<Tooltip id='upload-tooltip'>
<KeyboardShortcutSequence
shortcut={KEYBOARD_SHORTCUTS.msgShowEmojiPicker}
hoistDescription={true}
isInsideTooltip={true}
/>
</Tooltip>
);
emojiPicker = (
<>
<EmojiPickerOverlay
show={showEmojiPicker}
target={getEmojiPickerRef}
onHide={hideEmojiPicker}
onEmojiClick={handleEmojiClick}
onGifClick={handleGifClick}
enableGifPicker={enableGifPicker}
topOffset={-7}
/>
<OverlayTrigger
placement='top'
delayShow={Constants.OVERLAY_TIME_DELAY}
trigger={Constants.OVERLAY_DEFAULT_TRIGGER}
overlay={emojiPickerTooltip}
>
<IconContainer
id={'emojiPickerButton'}
ref={emojiPickerRef}
onClick={toggleEmojiPicker}
type='button'
aria-label={intl.formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
disabled={shouldShowPreview}
className={classNames({active: showEmojiPicker})}
>
<EmoticonHappyOutlineIcon
color={'currentColor'}
size={18}
/>
</IconContainer>
</OverlayTrigger>
</>
);
}
return {emojiPicker, enableEmojiPicker, toggleEmojiPicker};
};
export default useEmojiPicker;

View File

@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {GroupSource} from '@mattermost/types/groups';
import {getChannelMemberCountsByGroup} from 'mattermost-redux/actions/channels';
import {Permissions} from 'mattermost-redux/constants';
import {getChannel, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from 'mattermost-redux/selectors/entities/channels';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getAssociatedGroupsForReferenceByMention} from 'mattermost-redux/selectors/entities/groups';
import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {searchAssociatedGroupsForReference} from 'actions/views/group';
import Constants from 'utils/constants';
import {groupsMentionedInText, mentionsMinusSpecialMentionsInText} from 'utils/post_utils';
import type {GlobalState} from 'types/store';
const useGroups = (
channelId: string,
message: string,
) => {
const dispatch = useDispatch();
const teamId = useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
return channel?.team_id || getCurrentTeamId(state);
});
const canUseLDAPGroupMentions = useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
if (!channel) {
return false;
}
const license = getLicense(state);
const isLDAPEnabled = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true';
return isLDAPEnabled && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS);
});
const canUseCustomGroupMentions = useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
if (!channel) {
return false;
}
return isCustomGroupsEnabled(state) && haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_GROUP_MENTIONS);
});
const groupsWithAllowReference = useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
if (!channel) {
return null;
}
return canUseLDAPGroupMentions || canUseCustomGroupMentions ? getAssociatedGroupsForReferenceByMention(state, channel.team_id, channel.id) : null;
});
const channelMemberCountsByGroup = useSelector((state: GlobalState) => selectChannelMemberCountsByGroup(state, channelId));
const getGroupMentions = useCallback((message: string) => {
let memberNotifyCount = 0;
let channelTimezoneCount = 0;
let mentions: string[] = [];
if (canUseLDAPGroupMentions || canUseCustomGroupMentions) {
const mentionGroups = groupsMentionedInText(message, groupsWithAllowReference);
if (mentionGroups.length > 0) {
mentionGroups.
forEach((group) => {
if (group.source === GroupSource.Ldap && !canUseLDAPGroupMentions) {
return;
}
if (group.source === GroupSource.Custom && !canUseCustomGroupMentions) {
return;
}
const mappedValue = channelMemberCountsByGroup[group.id];
if (mappedValue && mappedValue.channel_member_count > Constants.NOTIFY_ALL_MEMBERS && mappedValue.channel_member_count > memberNotifyCount) {
memberNotifyCount = mappedValue.channel_member_count;
channelTimezoneCount = mappedValue.channel_member_timezones_count;
}
mentions.push(`@${group.name}`);
});
mentions = [...new Set(mentions)];
}
}
return {mentions, memberNotifyCount, channelTimezoneCount};
}, [channelMemberCountsByGroup, groupsWithAllowReference, canUseCustomGroupMentions, canUseLDAPGroupMentions]);
// Get channel member counts by group on channel switch
useEffect(() => {
if (canUseLDAPGroupMentions || canUseCustomGroupMentions) {
const mentions = mentionsMinusSpecialMentionsInText(message);
if (mentions.length === 1) {
dispatch(searchAssociatedGroupsForReference(mentions[0], teamId, channelId));
} else if (mentions.length > 1) {
dispatch(getChannelMemberCountsByGroup(channelId));
}
}
}, [channelId]);
return getGroupMentions;
};
export default useGroups;

View File

@ -0,0 +1,385 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type React from 'react';
import {useCallback, useEffect, useRef} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {getLatestReplyablePostId} from 'mattermost-redux/selectors/entities/posts';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {emitShortcutReactToLastPostFrom} from 'actions/post_actions';
import {editLatestPost} from 'actions/views/create_comment';
import {selectPostFromRightHandSideSearchByPostId} from 'actions/views/rhs';
import type {TextboxElement} from 'components/textbox';
import type TextboxClass from 'components/textbox/textbox';
import Constants, {Locations, Preferences} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {type ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
import {pasteHandler} from 'utils/paste';
import {isWithinCodeBlock, postMessageOnKeyPress} from 'utils/post_utils';
import * as UserAgent from 'utils/user_agent';
import * as Utils from 'utils/utils';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
const KeyCodes = Constants.KeyCodes;
const useKeyHandler = (
draft: PostDraft,
channelId: string,
postId: string,
caretPosition: number,
isValidPersistentNotifications: boolean,
location: string,
textboxRef: React.RefObject<TextboxClass>,
focusTextbox: (forceFocus?: boolean) => void,
applyMarkdown: (params: ApplyMarkdownOptions) => void,
handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void,
handleSubmit: (e: React.FormEvent, submittingDraft?: PostDraft) => void,
emitTypingEvent: () => void,
toggleShowPreview: () => void,
toggleAdvanceTextEditor: () => void,
toggleEmojiPicker: () => void,
): [
(e: React.KeyboardEvent<TextboxElement>) => void,
(e: React.KeyboardEvent<TextboxElement>) => void,
] => {
const dispatch = useDispatch();
const ctrlSend = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter'));
const codeBlockOnCtrlEnter = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', true));
const messageHistory = useSelector((state: GlobalState) => state.entities.posts.messagesHistory.messages);
const timeoutId = useRef<number>();
const messageHistoryIndex = useRef(messageHistory.length);
const lastChannelSwitchAt = useRef(0);
const isNonFormattedPaste = useRef(false);
const latestReplyablePostId = useSelector((state: GlobalState) => (postId ? '' : getLatestReplyablePostId(state)));
const replyToLastPost = useCallback((e: React.KeyboardEvent) => {
if (postId) {
return;
}
e.preventDefault();
const replyBox = document.getElementById('reply_textbox');
if (replyBox) {
replyBox.focus();
}
if (latestReplyablePostId) {
dispatch(selectPostFromRightHandSideSearchByPostId(latestReplyablePostId));
}
}, [latestReplyablePostId, dispatch, postId]);
const onEditLatestPost = useCallback((e: React.KeyboardEvent) => {
e.preventDefault();
const {data: canEditNow} = dispatch(editLatestPost(channelId, postId));
if (!canEditNow) {
focusTextbox(true);
}
}, [focusTextbox, channelId, postId, dispatch]);
const loadPrevMessage = useCallback((e: React.KeyboardEvent) => {
e.preventDefault();
if (messageHistoryIndex.current === 0) {
return;
}
messageHistoryIndex.current -= 1;
handleDraftChange({
...draft,
message: messageHistory[messageHistoryIndex.current] || '',
});
}, [draft, handleDraftChange, messageHistory]);
const loadNextMessage = useCallback((e: React.KeyboardEvent) => {
e.preventDefault();
if (messageHistoryIndex.current >= messageHistory.length) {
return;
}
messageHistoryIndex.current += 1;
handleDraftChange({
...draft,
message: messageHistory[messageHistoryIndex.current] || '',
});
}, [draft, handleDraftChange, messageHistory]);
const postMsgKeyPress = useCallback((e: React.KeyboardEvent<TextboxElement>) => {
const {allowSending, withClosedCodeBlock, ignoreKeyPress, message} = postMessageOnKeyPress(
e,
draft.message,
ctrlSend,
codeBlockOnCtrlEnter,
postId ? 0 : Date.now(),
postId ? 0 : lastChannelSwitchAt.current,
caretPosition,
);
if (ignoreKeyPress) {
e.preventDefault();
e.stopPropagation();
return;
}
if (allowSending && isValidPersistentNotifications) {
e.persist?.();
// textboxRef.current?.blur();
if (withClosedCodeBlock && message) {
handleSubmit(e, {...draft, message});
} else {
handleSubmit(e);
}
// setTimeout(() => {
// focusTextbox();
// });
}
emitTypingEvent();
}, [draft, ctrlSend, codeBlockOnCtrlEnter, caretPosition, postId, emitTypingEvent, handleSubmit, isValidPersistentNotifications]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<TextboxElement>) => {
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
const ctrlEnterKeyCombo = (ctrlSend || codeBlockOnCtrlEnter) &&
Keyboard.isKeyPressed(e, KeyCodes.ENTER) &&
ctrlOrMetaKeyPressed;
const ctrlKeyCombo = Keyboard.cmdOrCtrlPressed(e) && !e.altKey && !e.shiftKey;
const ctrlAltCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.altKey;
const shiftAltCombo = !Keyboard.cmdOrCtrlPressed(e) && e.shiftKey && e.altKey;
const ctrlShiftCombo = Keyboard.cmdOrCtrlPressed(e, true) && e.shiftKey;
// fix for FF not capturing the paste without formatting event when using ctrl|cmd + shift + v
if (e.key === KeyCodes.V[0] && ctrlOrMetaKeyPressed) {
if (e.shiftKey) {
isNonFormattedPaste.current = true;
timeoutId.current = window.setTimeout(() => {
isNonFormattedPaste.current = false;
}, 250);
}
}
// listen for line break key combo and insert new line character
if (Utils.isUnhandledLineBreakKeyCombo(e)) {
handleDraftChange({
...draft,
message: Utils.insertLineBreakFromKeyEvent(e.nativeEvent),
});
return;
}
if (ctrlEnterKeyCombo) {
postMsgKeyPress(e);
return;
}
if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE)) {
textboxRef.current?.blur();
}
const upKeyOnly = !ctrlOrMetaKeyPressed && !e.altKey && !e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP);
const messageIsEmpty = draft.message.length === 0;
const allowHistoryNavigation = draft.message.length === 0 || draft.message === messageHistory[messageHistoryIndex.current];
const caretIsWithinCodeBlock = caretPosition && isWithinCodeBlock(draft.message, caretPosition); // REVIEW
if (upKeyOnly && messageIsEmpty) {
e.preventDefault();
if (textboxRef.current) {
textboxRef.current.blur();
}
onEditLatestPost(e);
}
const {
selectionStart,
selectionEnd,
value,
} = e.target as TextboxElement;
if (ctrlKeyCombo && !caretIsWithinCodeBlock) {
if (allowHistoryNavigation && Keyboard.isKeyPressed(e, KeyCodes.UP)) {
e.stopPropagation();
e.preventDefault();
loadPrevMessage(e);
} else if (allowHistoryNavigation && Keyboard.isKeyPressed(e, KeyCodes.DOWN)) {
e.stopPropagation();
e.preventDefault();
loadNextMessage(e);
} else if (Keyboard.isKeyPressed(e, KeyCodes.B)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'bold',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.I)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'italic',
selectionStart,
selectionEnd,
message: value,
});
} else if (Utils.isTextSelectedInPostOrReply(e) && Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'link',
selectionStart,
selectionEnd,
message: value,
});
}
} else if (ctrlAltCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.K)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'link',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.C)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'code',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
toggleEmojiPicker();
} else if (Keyboard.isKeyPressed(e, KeyCodes.T)) {
e.stopPropagation();
e.preventDefault();
toggleAdvanceTextEditor();
} else if (Keyboard.isKeyPressed(e, KeyCodes.P) && draft.message.length && !UserAgent.isMac()) {
e.stopPropagation();
e.preventDefault();
toggleShowPreview();
}
} else if (shiftAltCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.X)) {
e.stopPropagation();
e.preventDefault();
applyMarkdown({
markdownMode: 'strike',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.SEVEN)) {
e.preventDefault();
applyMarkdown({
markdownMode: 'ol',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.EIGHT)) {
e.preventDefault();
applyMarkdown({
markdownMode: 'ul',
selectionStart,
selectionEnd,
message: value,
});
} else if (Keyboard.isKeyPressed(e, KeyCodes.NINE)) {
e.preventDefault();
applyMarkdown({
markdownMode: 'quote',
selectionStart,
selectionEnd,
message: value,
});
}
} else if (ctrlShiftCombo && !caretIsWithinCodeBlock) {
if (Keyboard.isKeyPressed(e, KeyCodes.P) && draft.message.length && UserAgent.isMac()) { // REVIEW
e.stopPropagation();
e.preventDefault();
toggleShowPreview();
} else if (Keyboard.isKeyPressed(e, KeyCodes.E)) {
e.stopPropagation();
e.preventDefault();
toggleEmojiPicker();
}
}
const lastMessageReactionKeyCombo = ctrlShiftCombo && Keyboard.isKeyPressed(e, KeyCodes.BACK_SLASH);
if (lastMessageReactionKeyCombo) {
e.stopPropagation();
e.preventDefault();
dispatch(emitShortcutReactToLastPostFrom(postId ? Locations.RHS_ROOT : Locations.CENTER));
}
if (!postId) {
const shiftUpKeyCombo = !ctrlOrMetaKeyPressed && !e.altKey && e.shiftKey && Keyboard.isKeyPressed(e, KeyCodes.UP);
if (shiftUpKeyCombo && messageIsEmpty) {
replyToLastPost?.(e);
}
}
}, [
applyMarkdown,
caretPosition,
codeBlockOnCtrlEnter,
ctrlSend,
dispatch,
draft,
handleDraftChange,
loadNextMessage,
loadPrevMessage,
messageHistory,
onEditLatestPost,
postId,
postMsgKeyPress,
replyToLastPost,
textboxRef,
toggleAdvanceTextEditor,
toggleEmojiPicker,
toggleShowPreview,
]);
// Register paste events
useEffect(() => {
function onPaste(event: ClipboardEvent) {
pasteHandler(event, location, draft.message, isNonFormattedPaste.current, caretPosition);
}
document.addEventListener('paste', onPaste);
return () => {
document.removeEventListener('paste', onPaste);
};
}, [location, draft.message, caretPosition]);
// Reset history index
useEffect(() => {
if (messageHistoryIndex.current === messageHistory.length) {
return;
}
if (draft.message !== messageHistory[messageHistoryIndex.current]) {
messageHistoryIndex.current = messageHistory.length;
}
}, [draft.message]);
// Update last channel switch at
useEffect(() => {
lastChannelSwitchAt.current = Date.now();
}, [channelId]);
return [handleKeyDown, postMsgKeyPress];
};
export default useKeyHandler;

View File

@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback, useEffect, useRef} from 'react';
import type TextboxClass from 'components/textbox/textbox';
import * as UserAgent from 'utils/user_agent';
const useOrientationHandler = (
textboxRef: React.RefObject<TextboxClass>,
postId: string,
) => {
const lastOrientation = useRef('');
const onOrientationChange = useCallback(() => {
if (!UserAgent.isIosWeb()) {
return;
}
const LANDSCAPE_ANGLE = 90;
let orientation = 'portrait';
if (window.orientation) {
orientation = Math.abs(window.orientation as number) === LANDSCAPE_ANGLE ? 'landscape' : 'portrait';
}
if (window.screen.orientation) {
orientation = window.screen.orientation.type.split('-')[0];
}
if (
lastOrientation.current &&
orientation !== lastOrientation.current &&
(document.activeElement || {}).id === 'post_textbox'
) {
textboxRef.current?.blur();
}
lastOrientation.current = orientation;
}, [textboxRef]);
useEffect(() => {
if (!postId && UserAgent.isIosWeb()) {
onOrientationChange();
if (window.screen.orientation && 'onchange' in window.screen.orientation) {
window.screen.orientation.addEventListener('change', onOrientationChange);
} else if ('onorientationchange' in window) {
window.addEventListener('orientationchange', onOrientationChange);
}
}
return () => {
if (!postId) {
if (window.screen.orientation && 'onchange' in window.screen.orientation) {
window.screen.orientation.removeEventListener('change', onOrientationChange);
} else if ('onorientationchange' in window) {
window.removeEventListener('orientationchange', onOrientationChange);
}
}
};
}, []);
};
export default useOrientationHandler;

View File

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useSelector} from 'react-redux';
import type TextboxClass from 'components/textbox/textbox';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
const usePluginItems = (
draft: PostDraft,
textboxRef: React.RefObject<TextboxClass>,
handleDraftChange: (draft: PostDraft) => void,
) => {
const postEditorActions = useSelector((state: GlobalState) => state.plugins.components.PostEditorAction);
const getSelectedText = useCallback(() => {
const input = textboxRef.current?.getInputBox();
return {
start: input?.selectionStart,
end: input?.selectionEnd,
};
}, [textboxRef]);
const updateText = useCallback((message: string) => {
handleDraftChange({
...draft,
message,
});
// Missing setting the state eventually?
}, [handleDraftChange, draft]);
const items = useMemo(() => postEditorActions?.map((item) => {
if (!item.component) {
return null;
}
const Component = item.component as any;
return (
<Component
key={item.id}
draft={draft}
getSelectedText={getSelectedText}
updateText={updateText}
/>
);
}), [postEditorActions, draft, getSelectedText, updateText]);
return items;
};
export default usePluginItems;

View File

@ -0,0 +1,168 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import type {PostPriorityMetadata} from '@mattermost/types/posts';
import {PostPriority} from '@mattermost/types/posts';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {isPostPriorityEnabled as isPostPriorityEnabledSelector} from 'mattermost-redux/selectors/entities/posts';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {openModal} from 'actions/views/modals';
import PersistNotificationConfirmModal from 'components/persist_notification_confirm_modal';
import PostPriorityPickerOverlay from 'components/post_priority/post_priority_picker_overlay';
import Constants, {ModalIdentifiers} from 'utils/constants';
import {hasRequestedPersistentNotifications, mentionsMinusSpecialMentionsInText, specialMentionsInText} from 'utils/post_utils';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
import PriorityLabels from './priority_labels';
const usePriority = (
draft: PostDraft,
handleDraftChange: (draft: PostDraft, options: {instant?: boolean; show?: boolean}) => void,
focusTextbox: (keepFocus?: boolean) => void,
shouldShowPreview: boolean,
) => {
const dispatch = useDispatch();
const isPostPriorityEnabled = useSelector(isPostPriorityEnabledSelector);
const channelType = useSelector((state: GlobalState) => getChannel(state, draft.channelId)?.type || 'O');
const channelTeammateUsername = useSelector((state: GlobalState) => {
const channel = getChannel(state, draft.channelId);
return getUser(state, channel?.teammate_id || '')?.username || '';
});
const hasPrioritySet = isPostPriorityEnabled &&
draft.metadata?.priority &&
(
draft.metadata.priority.priority ||
draft.metadata.priority.requested_ack
);
const specialMentions = useMemo(() => {
return specialMentionsInText(draft.message);
}, [draft.message]);
const hasSpecialMentions = useMemo(() => {
return Object.values(specialMentions).includes(true);
}, [specialMentions]);
const isValidPersistentNotifications = useMemo(() => {
if (!hasPrioritySet) {
return true;
}
const {priority, persistent_notifications: persistentNotifications} = draft.metadata!.priority!;
if (priority !== PostPriority.URGENT || !persistentNotifications) {
return true;
}
if (channelType === Constants.DM_CHANNEL) {
return true;
}
if (hasSpecialMentions) {
return false;
}
const mentions = mentionsMinusSpecialMentionsInText(draft.message);
return mentions.length > 0;
}, [hasPrioritySet, draft, channelType, hasSpecialMentions]);
const handlePostPriorityApply = useCallback((settings?: PostPriorityMetadata) => {
const updatedDraft = {
...draft,
};
if (settings?.priority || settings?.requested_ack) {
updatedDraft.metadata = {
priority: {
...settings,
priority: settings!.priority || '',
requested_ack: settings!.requested_ack,
},
};
} else {
updatedDraft.metadata = {};
}
handleDraftChange(updatedDraft, {instant: true});
focusTextbox();
}, [focusTextbox, draft, handleDraftChange]);
const handlePostPriorityHide = useCallback(() => {
focusTextbox(true);
}, [focusTextbox]);
const handleRemovePriority = useCallback(() => {
handlePostPriorityApply();
}, [handlePostPriorityApply]);
const showPersistNotificationModal = useCallback((message: string, specialMentions: {[key: string]: boolean}, channelType: Channel['type'], onConfirm: () => void) => {
dispatch(openModal({
modalId: ModalIdentifiers.PERSIST_NOTIFICATION_CONFIRM_MODAL,
dialogType: PersistNotificationConfirmModal,
dialogProps: {
currentChannelTeammateUsername: channelTeammateUsername,
specialMentions,
channelType,
message,
onConfirm,
},
}));
}, [channelTeammateUsername, dispatch]);
const onSubmitCheck = useCallback((onConfirm: () => void) => {
if (
isPostPriorityEnabled &&
hasRequestedPersistentNotifications(draft?.metadata?.priority)
) {
showPersistNotificationModal(draft.message, specialMentions, channelType, onConfirm);
return true;
}
return false;
}, [isPostPriorityEnabled, showPersistNotificationModal, draft, channelType, specialMentions]);
const labels = useMemo(() => (
(hasPrioritySet && !draft.rootId) ? (
<PriorityLabels
canRemove={!shouldShowPreview}
hasError={!isValidPersistentNotifications}
specialMentions={specialMentions}
onRemove={handleRemovePriority}
persistentNotifications={draft!.metadata!.priority?.persistent_notifications}
priority={draft!.metadata!.priority?.priority}
requestedAck={draft!.metadata!.priority?.requested_ack}
/>
) : undefined
), [shouldShowPreview, draft, hasPrioritySet, isValidPersistentNotifications, specialMentions, handleRemovePriority]);
const additionalControl = useMemo(() =>
!draft.rootId && isPostPriorityEnabled && (
<PostPriorityPickerOverlay
key='post-priority-picker-key'
settings={draft.metadata?.priority}
onApply={handlePostPriorityApply}
onClose={handlePostPriorityHide}
disabled={shouldShowPreview}
/>
), [draft.rootId, isPostPriorityEnabled, draft.metadata?.priority, handlePostPriorityApply, handlePostPriorityHide, shouldShowPreview]);
return {
labels,
additionalControl,
isValidPersistentNotifications,
onSubmitCheck,
};
};
export default usePriority;

View File

@ -0,0 +1,324 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type React from 'react';
import {useCallback, useRef, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {ServerError} from '@mattermost/types/errors';
import {getChannelTimezones} from 'mattermost-redux/actions/channels';
import {Permissions} from 'mattermost-redux/constants';
import {getChannel, getAllChannelStats} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import {scrollPostListToBottom} from 'actions/views/channel';
import {onSubmit} from 'actions/views/create_comment';
import {openModal} from 'actions/views/modals';
import EditChannelHeaderModal from 'components/edit_channel_header_modal';
import EditChannelPurposeModal from 'components/edit_channel_purpose_modal';
import NotifyConfirmModal from 'components/notify_confirm_modal';
import PostDeletedModal from 'components/post_deleted_modal';
import ResetStatusModal from 'components/reset_status_modal';
import Constants, {ModalIdentifiers, UserStatuses} from 'utils/constants';
import {isErrorInvalidSlashCommand, isServerError, specialMentionsInText} from 'utils/post_utils';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
import useGroups from './use_groups';
function getStatusFromSlashCommand(message: string) {
const tokens = message.split(' ');
const command = tokens[0] || '';
if (command[0] !== '/') {
return '';
}
const status = command.substring(1);
if (status === 'online' || status === 'away' || status === 'dnd' || status === 'offline') {
return status;
}
return '';
}
const useSubmit = (
draft: PostDraft,
postError: React.ReactNode,
channelId: string,
postId: string,
serverError: (ServerError & { submittedMessage?: string }) | null,
lastBlurAt: React.MutableRefObject<number>,
focusTextbox: (forceFocust?: boolean) => void,
setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void,
setPostError: (err: React.ReactNode) => void,
setShowPreview: (showPreview: boolean) => void,
handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void,
prioritySubmitCheck: (onConfirm: () => void) => boolean,
): [
(e: React.FormEvent, submittingDraft?: PostDraft) => void,
string | null,
] => {
const getGroupMentions = useGroups(channelId, draft.message);
const dispatch = useDispatch();
const isDraftSubmitting = useRef(false);
const [errorClass, setErrorClass] = useState<string | null>(null);
const isDirectOrGroup = useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
if (!channel) {
return false;
}
return channel.type === Constants.DM_CHANNEL || channel.type === Constants.GM_CHANNEL;
});
const channel = useSelector((state: GlobalState) => {
return getChannel(state, channelId);
});
const isRootDeleted = useSelector((state: GlobalState) => {
if (!postId) {
return false;
}
const post = getPost(state, postId);
if (!post || post.delete_at) {
return true;
}
return false;
});
const enableConfirmNotificationsToChannel = useSelector((state: GlobalState) => getConfig(state).EnableConfirmNotificationsToChannel === 'true');
const channelMembersCount = useSelector((state: GlobalState) => getAllChannelStats(state)[channelId]?.member_count ?? 1);
const userIsOutOfOffice = useSelector((state: GlobalState) => {
const currentUserId = getCurrentUserId(state);
return getStatusForUserId(state, currentUserId) === UserStatuses.OUT_OF_OFFICE;
});
const useChannelMentions = useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
if (!channel) {
return false;
}
return haveIChannelPermission(state, channel.team_id, channel.id, Permissions.USE_CHANNEL_MENTIONS);
});
const showPostDeletedModal = useCallback(() => {
dispatch(openModal({
modalId: ModalIdentifiers.POST_DELETED_MODAL,
dialogType: PostDeletedModal,
}));
}, [dispatch]);
const doSubmit = useCallback(async (e?: React.FormEvent, submittingDraft = draft) => {
e?.preventDefault();
if (submittingDraft.uploadsInProgress.length > 0) {
isDraftSubmitting.current = false;
return;
}
if (postError) {
setErrorClass('animation--highlight');
setTimeout(() => {
setErrorClass(null);
}, Constants.ANIMATION_TIMEOUT);
isDraftSubmitting.current = false;
return;
}
if (submittingDraft.message.trim().length === 0 && submittingDraft.fileInfos.length === 0) {
return;
}
if (isRootDeleted) {
showPostDeletedModal();
isDraftSubmitting.current = false;
return;
}
if (serverError && !isErrorInvalidSlashCommand(serverError)) {
return;
}
const fasterThanHumanWillClick = 150;
const forceFocus = Date.now() - lastBlurAt.current < fasterThanHumanWillClick;
focusTextbox(forceFocus);
setServerError(null);
const ignoreSlash = isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message;
const options = {ignoreSlash};
try {
await dispatch(onSubmit(submittingDraft, options));
setPostError(null);
setServerError(null);
handleDraftChange({
message: '',
fileInfos: [],
uploadsInProgress: [],
createAt: 0,
updateAt: 0,
channelId,
rootId: postId,
}, {instant: true});
} catch (err: unknown) {
if (isServerError(err)) {
if (isErrorInvalidSlashCommand(err)) {
handleDraftChange(submittingDraft, {instant: true});
}
setServerError({
...err,
submittedMessage: submittingDraft.message,
});
}
isDraftSubmitting.current = false;
return;
}
if (!postId) {
dispatch(scrollPostListToBottom());
}
isDraftSubmitting.current = false;
}, [handleDraftChange, dispatch, draft, focusTextbox, isRootDeleted, postError, serverError, showPostDeletedModal, channelId, postId, lastBlurAt, setPostError, setServerError]);
const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => {
dispatch(openModal({
modalId: ModalIdentifiers.NOTIFY_CONFIRM_MODAL,
dialogType: NotifyConfirmModal,
dialogProps: {
mentions,
channelTimezoneCount,
memberNotifyCount,
onConfirm: () => doSubmit(),
},
}));
}, [doSubmit, dispatch]);
const handleSubmit = useCallback(async (e: React.FormEvent, submittingDraft = draft) => {
if (!channel) {
return;
}
e.preventDefault();
setShowPreview(false);
isDraftSubmitting.current = true;
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
let memberNotifyCount = 0;
let channelTimezoneCount = 0;
let mentions: string[] = [];
const specialMentions = specialMentionsInText(submittingDraft.message);
const hasSpecialMentions = Object.values(specialMentions).includes(true);
if (enableConfirmNotificationsToChannel && !hasSpecialMentions) {
({memberNotifyCount, channelTimezoneCount, mentions} = getGroupMentions(submittingDraft.message));
}
if (notificationsToChannel && channelMembersCount > Constants.NOTIFY_ALL_MEMBERS && hasSpecialMentions) {
memberNotifyCount = channelMembersCount - 1;
for (const k in specialMentions) {
if (specialMentions[k]) {
mentions.push('@' + k);
}
}
const {data} = await dispatch(getChannelTimezones(channelId));
channelTimezoneCount = data ? data.length : 0;
}
if (prioritySubmitCheck(doSubmit)) {
isDraftSubmitting.current = false;
return;
}
if (memberNotifyCount > 0) {
showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount);
isDraftSubmitting.current = false;
return;
}
const status = getStatusFromSlashCommand(submittingDraft.message);
if (userIsOutOfOffice && status) {
const resetStatusModalData = {
modalId: ModalIdentifiers.RESET_STATUS,
dialogType: ResetStatusModal,
dialogProps: {newStatus: status},
};
dispatch(openModal(resetStatusModalData));
handleDraftChange({
...submittingDraft,
message: '',
});
isDraftSubmitting.current = false;
return;
}
if (submittingDraft.message.trimEnd() === '/header') {
const editChannelHeaderModalData = {
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal,
dialogProps: {channel},
};
dispatch(openModal(editChannelHeaderModalData));
handleDraftChange({
...submittingDraft,
message: '',
});
isDraftSubmitting.current = false;
return;
}
if (!isDirectOrGroup && submittingDraft.message.trimEnd() === '/purpose') {
const editChannelPurposeModalData = {
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
dialogType: EditChannelPurposeModal,
dialogProps: {channel},
};
dispatch(openModal(editChannelPurposeModalData));
handleDraftChange({
...submittingDraft,
message: '',
});
isDraftSubmitting.current = false;
return;
}
await doSubmit(e, submittingDraft);
}, [
doSubmit,
draft,
isDirectOrGroup,
channel,
channelId,
channelMembersCount,
dispatch,
enableConfirmNotificationsToChannel,
handleDraftChange,
showNotifyAllModal,
useChannelMentions,
userIsOutOfOffice,
getGroupMentions,
setShowPreview,
prioritySubmitCheck,
]);
return [handleSubmit, errorClass];
};
export default useSubmit;

View File

@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type React from 'react';
import {useCallback, useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {focusedRHS} from 'actions/views/rhs';
import {getIsRhsExpanded, getIsRhsOpen} from 'selectors/rhs';
import {getShouldFocusRHS} from 'selectors/views/rhs';
import useDidUpdate from 'components/common/hooks/useDidUpdate';
import type TextboxClass from 'components/textbox/textbox';
import {shouldFocusMainTextbox} from 'utils/post_utils';
import * as UserAgent from 'utils/user_agent';
const useTextboxFocus = (
textboxRef: React.RefObject<TextboxClass>,
channelId: string,
isRHS: boolean,
canPost: boolean,
) => {
const dispatch = useDispatch();
const rhsExpanded = useSelector(getIsRhsExpanded);
const rhsOpen = useSelector(getIsRhsOpen);
// We force the selector to always think it is the same value to avoid re-renders
// because we only use this value during mount.
const shouldFocusRHS = useSelector(getShouldFocusRHS, () => true);
const focusTextbox = useCallback((keepFocus = false) => {
const postTextboxDisabled = !canPost;
if (textboxRef.current && postTextboxDisabled) {
textboxRef.current.blur(); // Fixes Firefox bug which causes keyboard shortcuts to be ignored (MM-22482)
return;
}
if (textboxRef.current && (keepFocus || !UserAgent.isMobile())) {
textboxRef.current.focus();
}
}, [canPost, textboxRef]);
const focusTextboxIfNecessary = useCallback((e: KeyboardEvent) => {
// Do not focus if the rhs is expanded and this is not the RHS
if (!isRHS && rhsExpanded) {
return;
}
// Do not focus if the rhs is not expanded and this is the RHS
if (isRHS && !rhsExpanded) {
return;
}
// Do not focus the main textbox when the RHS is open as a hacky fix to avoid cursor jumping textbox sometimes
if (isRHS && rhsOpen && document.activeElement?.tagName === 'BODY') {
return;
}
// Bit of a hack to not steal focus from the channel switch modal if it's open
// This is a special case as the channel switch modal does not enforce focus like
// most modals do
if (document.getElementsByClassName('channel-switch-modal').length) {
return;
}
if (shouldFocusMainTextbox(e, document.activeElement)) {
focusTextbox();
}
}, [focusTextbox, rhsExpanded, rhsOpen, isRHS]);
// Register events for onkeydown
useEffect(() => {
document.addEventListener('keydown', focusTextboxIfNecessary);
return () => {
document.removeEventListener('keydown', focusTextboxIfNecessary);
};
}, [focusTextboxIfNecessary]);
// Focus on textbox on channel switch
useDidUpdate(() => {
focusTextbox();
}, [channelId]);
// Focus on mount
useEffect(() => {
if (isRHS && shouldFocusRHS) {
focusTextbox();
dispatch(focusedRHS());
}
}, []);
return focusTextbox;
};
export default useTextboxFocus;

View File

@ -0,0 +1,179 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef, useState} from 'react';
import {useSelector} from 'react-redux';
import type {ServerError} from '@mattermost/types/errors';
import type {FileInfo} from '@mattermost/types/files';
import {sortFileInfos} from 'mattermost-redux/utils/file_utils';
import {getCurrentLocale} from 'selectors/i18n';
import FilePreview from 'components/file_preview';
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
import FileUpload from 'components/file_upload';
import type {FileUpload as FileUploadClass} from 'components/file_upload/file_upload';
import type TextboxClass from 'components/textbox/textbox';
import type {PostDraft} from 'types/store/draft';
const getFileCount = (draft: PostDraft) => {
return draft.fileInfos.length + draft.uploadsInProgress.length;
};
const useUploadFiles = (
draft: PostDraft,
postId: string,
channelId: string,
isThreadView: boolean,
storedDrafts: React.MutableRefObject<Record<string, PostDraft | undefined>>,
readOnlyChannel: boolean,
textboxRef: React.RefObject<TextboxClass>,
handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void,
focusTextbox: (forceFocust?: boolean) => void,
setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void,
): [React.ReactNode, React.ReactNode] => {
const locale = useSelector(getCurrentLocale);
const [uploadsProgressPercent, setUploadsProgressPercent] = useState<{ [clientID: string]: FilePreviewInfo }>({});
const fileUploadRef = useRef<FileUploadClass>(null);
const handleFileUploadChange = useCallback(() => {
focusTextbox();
}, [focusTextbox]);
const getFileUploadTarget = useCallback(() => {
return textboxRef.current?.getInputBox();
}, [textboxRef]);
const handleUploadProgress = useCallback((filePreviewInfo: FilePreviewInfo) => {
setUploadsProgressPercent((prev) => ({
...prev,
[filePreviewInfo.clientId]: filePreviewInfo,
}));
}, []);
const handleFileUploadComplete = useCallback((fileInfos: FileInfo[], clientIds: string[], channelId: string, rootId?: string) => {
const key = rootId || channelId;
const draftToUpdate = storedDrafts.current[key];
if (!draftToUpdate) {
return;
}
const newFileInfos = sortFileInfos([...draftToUpdate.fileInfos || [], ...fileInfos], locale);
const clientIdsSet = new Set(clientIds);
const uploadsInProgress = (draftToUpdate.uploadsInProgress || []).filter((v) => !clientIdsSet.has(v));
const modifiedDraft = {
...draftToUpdate,
fileInfos: newFileInfos,
uploadsInProgress,
};
handleDraftChange(modifiedDraft, {instant: true});
}, [locale, handleDraftChange, storedDrafts]);
const handleUploadStart = useCallback((clientIds: string[]) => {
const uploadsInProgress = [...draft.uploadsInProgress, ...clientIds];
const updatedDraft = {
...draft,
uploadsInProgress,
};
handleDraftChange(updatedDraft, {instant: true});
focusTextbox();
}, [draft, handleDraftChange, focusTextbox]);
const handleUploadError = useCallback((uploadError: string | ServerError | null, clientId?: string, channelId = '', rootId = '') => {
if (clientId) {
const id = rootId || channelId;
const storedDraft = storedDrafts.current[id];
if (storedDraft) {
const modifiedDraft = {...storedDraft};
const index = modifiedDraft.uploadsInProgress.indexOf(clientId) ?? -1;
if (index !== -1) {
modifiedDraft.uploadsInProgress = [...modifiedDraft.uploadsInProgress];
modifiedDraft.uploadsInProgress.splice(index, 1);
handleDraftChange(modifiedDraft, {instant: true});
}
}
}
if (typeof uploadError === 'string') {
if (uploadError) {
setServerError(new Error(uploadError));
}
} else {
setServerError(uploadError);
}
}, [handleDraftChange, setServerError, storedDrafts]);
const removePreview = useCallback((clientId: string) => {
handleUploadError(null, clientId, draft.channelId, draft.rootId);
const modifiedDraft = {...draft};
let index = draft.fileInfos.findIndex((info) => info.id === clientId);
if (index === -1) {
index = draft.uploadsInProgress.indexOf(clientId);
if (index >= 0) {
modifiedDraft.uploadsInProgress = [...draft.uploadsInProgress];
modifiedDraft.uploadsInProgress.splice(index, 1);
fileUploadRef.current?.cancelUpload(clientId);
} else {
// No modification
return;
}
} else {
modifiedDraft.fileInfos = [...draft.fileInfos];
modifiedDraft.fileInfos.splice(index, 1);
}
handleDraftChange(modifiedDraft, {instant: true});
handleFileUploadChange();
}, [draft, fileUploadRef, handleDraftChange, handleUploadError, handleFileUploadChange]);
let attachmentPreview = null;
if (!readOnlyChannel && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0)) {
attachmentPreview = (
<FilePreview
fileInfos={draft.fileInfos}
onRemove={removePreview}
uploadsInProgress={draft.uploadsInProgress}
uploadsProgressPercent={uploadsProgressPercent}
/>
);
}
let postType = 'post';
if (postId) {
postType = isThreadView ? 'thread' : 'comment';
}
const fileUploadJSX = readOnlyChannel ? null : (
<FileUpload
ref={fileUploadRef}
fileCount={getFileCount(draft)}
getTarget={getFileUploadTarget}
onFileUploadChange={handleFileUploadChange}
onUploadStart={handleUploadStart}
onFileUpload={handleFileUploadComplete}
onUploadError={handleUploadError}
onUploadProgress={handleUploadProgress}
rootId={postId}
channelId={channelId}
postType={postType}
/>
);
return [attachmentPreview, fileUploadJSX];
};
export default useUploadFiles;

View File

@ -21,7 +21,7 @@ const LAST_ANALYTICS_TEAM = 'last_analytics_team';
function mapStateToProps(state: GlobalState) {
const teams = getTeamsList(state);
const teamId = makeGetGlobalItem(LAST_ANALYTICS_TEAM, null)(state);
const teamId = makeGetGlobalItem(LAST_ANALYTICS_TEAM, '')(state);
const initialTeam = state.entities.teams.teams[teamId] || (teams.length > 0 ? teams[0] : null);
return {

View File

@ -158,9 +158,7 @@ exports[`components/channel_view Should match snapshot with base props 1`] = `
data-testid="post-create"
id="post-create"
>
<Connect(AdvancedCreatePost)
getChannelView={[Function]}
/>
<Memo(AdvancedCreatePost) />
</div>
</div>
`;

View File

@ -80,10 +80,6 @@ export default class ChannelView extends React.PureComponent<Props, State> {
this.channelViewRef = React.createRef();
}
getChannelView = () => {
return this.channelViewRef.current;
};
onClickCloseChannel = () => {
this.props.goToLastViewedChannel();
};
@ -160,7 +156,7 @@ export default class ChannelView extends React.PureComponent<Props, State> {
data-testid='post-create'
className='post-create__container AdvancedTextEditor__ctr'
>
<AdvancedCreatePost getChannelView={this.getChannelView}/>
<AdvancedCreatePost/>
</div>
);
}

View File

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {FormattedMessage} from 'react-intl';
import styled from 'styled-components';
@ -11,8 +12,7 @@ import RenderEmoji from 'components/emoji/render_emoji';
type Props = {
onClick?: () => void;
id?: string;
defaultMessage?: string;
display?: MessageDescriptor;
values?: Record<string, any>;
className?: string;
@ -59,8 +59,7 @@ const Chip = ({
otherOption,
className,
leadingIcon,
id,
defaultMessage,
display,
values,
additionalMarkup,
}: Props) => {
@ -81,10 +80,9 @@ const Chip = ({
emojiStyle={emojiStyles}
/>
)}
{(id && defaultMessage && values) && (
{(display && values) && (
<FormattedMessage
id={id}
defaultMessage={defaultMessage}
{...display}
values={values}
/>
)}

View File

@ -9,7 +9,7 @@ import type {UserProfile, UserStatus} from '@mattermost/types/users';
import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams';
import PriorityLabels from 'components/advanced_create_post/priority_labels';
import PriorityLabels from 'components/advanced_text_editor/priority_labels';
import FilePreview from 'components/file_preview';
import Markdown from 'components/markdown';
import ProfilePicture from 'components/profile_picture';

View File

@ -5,10 +5,8 @@ import React, {memo, forwardRef, useMemo} from 'react';
import {useSelector} from 'react-redux';
import {ArchiveOutlineIcon} from '@mattermost/compass-icons/components';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import {Posts} from 'mattermost-redux/constants';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getPost, getLimitedViews} from 'mattermost-redux/selectors/entities/posts';
@ -23,7 +21,6 @@ import type {GlobalState} from 'types/store';
type Props = {
teammate?: UserProfile;
threadId: string;
latestPostId: Post['id'];
isThreadView?: boolean;
placeholder?: string;
};
@ -31,7 +28,6 @@ type Props = {
const CreateComment = forwardRef<HTMLDivElement, Props>(({
teammate,
threadId,
latestPostId,
isThreadView,
placeholder,
}: Props, ref) => {
@ -47,7 +43,6 @@ const CreateComment = forwardRef<HTMLDivElement, Props>(({
if (!channel || threadIsLimited) {
return null;
}
const rootDeleted = (rootPost as Post).state === Posts.POST_DELETED;
const isFakeDeletedPost = rootPost.type === Constants.PostTypes.FAKE_PARENT_DELETED;
const channelType = channel.type;
@ -97,8 +92,6 @@ const CreateComment = forwardRef<HTMLDivElement, Props>(({
<AdvancedCreateComment
placeholder={placeholder}
channelId={channel.id}
latestPostId={latestPostId}
rootDeleted={rootDeleted}
rootId={threadId}
isThreadView={isThreadView}
/>

View File

@ -355,7 +355,6 @@ class ThreadViewerVirtualized extends PureComponent<Props, State> {
<CreateComment
placeholder={this.props.inputPlaceholder}
isThreadView={this.props.isThreadView}
latestPostId={this.props.lastPost.id}
ref={this.postCreateContainerRef}
teammate={this.props.directTeammate}
threadId={this.props.selected.id}

View File

@ -5,7 +5,6 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useMeasurePunchouts} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
import PrewrittenChips from 'components/advanced_create_post/prewritten_chips';
@ -13,25 +12,22 @@ import OnboardingTourTip from './onboarding_tour_tip';
type Props = {
prefillMessage: (msg: string, shouldFocus: boolean) => void;
currentChannel: Channel;
channelId: string;
currentUserId: string;
currentChannelTeammateUsername?: string;
}
const translate = {x: -6, y: -6};
export const SendMessageTour = ({
prefillMessage,
currentChannel,
channelId,
currentUserId,
currentChannelTeammateUsername,
}: Props) => {
const chips = (
<PrewrittenChips
prefillMessage={prefillMessage}
currentChannel={currentChannel}
channelId={channelId}
currentUserId={currentUserId}
currentChannelTeammateUsername={currentChannelTeammateUsername}
/>
);

View File

@ -3341,7 +3341,6 @@
"create_group_memberships_modal.create": "Yes",
"create_group_memberships_modal.desc": "You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time.",
"create_group_memberships_modal.title": "Re-add {username} to teams and channels",
"create_post.comment": "Comment",
"create_post.deactivated": "You are viewing an archived channel with a **deactivated user**. New messages cannot be posted.",
"create_post.error_message": "Your message is too long. Character count: {length}/{limit}",
"create_post.file_limit_sticky_banner.admin_message": "New uploads will automatically archive older files. To view them again, you can delete older files or <a>upgrade to a paid plan.</a>",

View File

@ -2243,206 +2243,6 @@ describe('getPostsInCurrentChannel', () => {
});
});
describe('getCurrentUsersLatestPost', () => {
const user1 = TestHelper.fakeUserWithId();
const profiles: Record<string, UserProfile> = {};
profiles[user1.id] = user1;
it('no posts', () => {
const noPosts = {};
const state = {
entities: {
users: {
currentUserId: user1.id,
profiles,
},
posts: {
posts: noPosts,
postsInChannel: [],
},
preferences: {
myPreferences: {},
},
channels: {
currentChannelId: 'abcd',
},
},
} as unknown as GlobalState;
const actual = Selectors.getCurrentUsersLatestPost(state, '');
expect(actual).toEqual(null);
});
it('return first post which user can edit', () => {
const postsAny = {
a: {id: 'a', channel_id: 'a', create_at: 1, highlight: false, user_id: 'a'},
b: {id: 'b', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', state: Posts.POST_DELETED},
c: {id: 'c', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: 'system_join_channel'},
d: {id: 'd', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: Posts.POST_TYPES.EPHEMERAL},
e: {id: 'e', channel_id: 'abcd', create_at: 4, highlight: false, user_id: 'c'},
f: {id: 'f', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id},
};
const state = {
entities: {
users: {
currentUserId: user1.id,
profiles,
},
posts: {
posts: postsAny,
postsInChannel: {
abcd: [
{order: ['b', 'c', 'd', 'e', 'f'], recent: true},
],
},
postsInThread: {},
},
preferences: {
myPreferences: {},
},
channels: {
currentChannelId: 'abcd',
},
},
} as unknown as GlobalState;
const actual = Selectors.getCurrentUsersLatestPost(state, '');
expect(actual).toMatchObject(postsAny.f);
});
it('return first post which user can edit ignore pending and failed', () => {
const postsAny = {
a: {id: 'a', channel_id: 'a', create_at: 1, highlight: false, user_id: 'a'},
b: {id: 'b', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id, pending_post_id: 'b'},
c: {id: 'c', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id, failed: true},
d: {id: 'd', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: Posts.POST_TYPES.EPHEMERAL},
e: {id: 'e', channel_id: 'abcd', create_at: 4, highlight: false, user_id: 'c'},
f: {id: 'f', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id},
};
const state = {
entities: {
users: {
currentUserId: user1.id,
profiles,
},
posts: {
posts: postsAny,
postsInChannel: {
abcd: [
{order: ['b', 'c', 'd', 'e', 'f'], recent: true},
],
},
postsInThread: {},
},
preferences: {
myPreferences: {},
},
channels: {
currentChannelId: 'abcd',
},
},
} as unknown as GlobalState;
const actual = Selectors.getCurrentUsersLatestPost(state, '');
expect(actual).toMatchObject(postsAny.f);
});
it('return first post which has rootId match', () => {
const postsAny = {
a: {id: 'a', channel_id: 'a', create_at: 1, highlight: false, user_id: 'a'},
b: {id: 'b', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', state: Posts.POST_DELETED},
c: {id: 'c', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: 'system_join_channel'},
d: {id: 'd', root_id: 'a', channel_id: 'abcd', create_at: 3, highlight: false, user_id: 'b', type: Posts.POST_TYPES.EPHEMERAL},
e: {id: 'e', channel_id: 'abcd', create_at: 4, highlight: false, user_id: 'c'},
f: {id: 'f', root_id: 'e', channel_id: 'abcd', create_at: 4, highlight: false, user_id: user1.id},
};
const state = {
entities: {
users: {
currentUserId: user1.id,
profiles,
},
posts: {
posts: postsAny,
postsInChannel: {
abcd: [
{order: ['b', 'c', 'd', 'e', 'f'], recent: true},
],
},
postsInThread: {},
},
preferences: {
myPreferences: {},
},
channels: {
currentChannelId: 'abcd',
},
},
} as unknown as GlobalState;
const actual = Selectors.getCurrentUsersLatestPost(state, 'e');
expect(actual).toMatchObject(postsAny.f);
});
it('should not return posts outside of the recent block', () => {
const postsAny = {
a: {id: 'a', channel_id: 'a', create_at: 1, user_id: 'a'},
};
const state = {
entities: {
users: {
currentUserId: user1.id,
profiles,
},
posts: {
posts: postsAny,
postsInChannel: {
abcd: [
{order: ['a'], recent: false},
],
},
},
preferences: {
myPreferences: {},
},
channels: {
currentChannelId: 'abcd',
},
},
} as unknown as GlobalState;
const actual = Selectors.getCurrentUsersLatestPost(state, 'e');
expect(actual).toEqual(null);
});
it('determine the sending posts', () => {
const state = {
entities: {
users: {
currentUserId: user1.id,
profiles,
},
posts: {
posts: {},
postsInChannel: {},
pendingPostIds: ['1', '2', '3'],
},
preferences: {
myPreferences: {},
},
channels: {
currentChannelId: 'abcd',
},
},
} as unknown as GlobalState;
expect(Selectors.isPostIdSending(state, '1')).toEqual(true);
expect(Selectors.isPostIdSending(state, '2')).toEqual(true);
expect(Selectors.isPostIdSending(state, '3')).toEqual(true);
expect(Selectors.isPostIdSending(state, '4')).toEqual(false);
expect(Selectors.isPostIdSending(state, '')).toEqual(false);
});
});
describe('makeGetProfilesForThread', () => {
it('should return profiles for threads in the right order and exclude current user', () => {
const getProfilesForThread = Selectors.makeGetProfilesForThread();

View File

@ -22,13 +22,13 @@ import type {
import {General, Posts, Preferences} from 'mattermost-redux/constants';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {getCurrentChannelId, getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getUsers, getCurrentUserId, getUserStatuses} from 'mattermost-redux/selectors/entities/users';
import {createIdsSelector} from 'mattermost-redux/utils/helpers';
import {shouldShowJoinLeaveMessages} from 'mattermost-redux/utils/post_list';
import {isCombinedUserActivityPost, shouldShowJoinLeaveMessages} from 'mattermost-redux/utils/post_list';
import {
isPostEphemeral,
isSystemMessage,
@ -269,9 +269,71 @@ function formatPostInChannel(post: Post, previousPost: Post | undefined | null,
};
}
export function getLatestInteractablePostId(state: GlobalState, channelId: string, rootId = '') {
const postsIds = rootId ? getPostsInThread(state)[rootId] : getPostIdsInChannel(state, channelId);
if (!postsIds) {
return '';
}
const allPosts = getAllPosts(state);
for (const postId of postsIds) {
if (isCombinedUserActivityPost(postId)) {
continue;
}
const post = allPosts[postId];
if (!post) {
continue;
}
if (post.delete_at) {
continue;
}
if (isPostEphemeral(post)) {
continue;
}
if (isSystemMessage(post)) {
continue;
}
return postId;
}
if (rootId && allPosts[rootId] && !allPosts[rootId].delete_at) {
return rootId;
}
return '';
}
export function getLatestPostToEdit(state: GlobalState, channelId: string, rootId = '') {
const postsIds = rootId ? getPostsInThread(state)[rootId] : getPostIdsInChannel(state, channelId);
if (!postsIds) {
return '';
}
const allPosts = getAllPosts(state);
const currentUserId = getCurrentUserId(state);
for (const postId of postsIds) {
const post = allPosts[postId];
if (!post || post.user_id !== currentUserId || (post.props?.from_webhook) || post.state === Posts.POST_DELETED || isSystemMessage(post) || isPostEphemeral(post) || isPostPendingOrFailed(post)) {
continue;
}
return post.id;
}
return '';
}
export const getLatestReplyablePostId: (state: GlobalState) => Post['id'] = (state) => getLatestInteractablePostId(state, getCurrentChannelId(state));
// makeGetPostsInChannel creates a selector that returns up to the given number of posts loaded at the bottom of the
// given channel. It does not include older posts such as those loaded by viewing a thread or a permalink.
export function makeGetPostsInChannel(): (state: GlobalState, channelId: Channel['id'], numPosts: number) => PostWithFormatData[] | undefined | null {
return createSelector(
'makeGetPostsInChannel',
@ -526,50 +588,6 @@ export const getMostRecentPostIdInChannel: (state: GlobalState, channelId: Chann
},
);
export const getLatestReplyablePostId: (state: GlobalState) => Post['id'] = createSelector(
'getLatestReplyablePostId',
getPostsInCurrentChannel,
(posts) => {
if (!posts) {
return '';
}
const latestReplyablePost = posts.find((post) => post.state !== Posts.POST_DELETED && !isSystemMessage(post) && !isPostEphemeral(post));
if (!latestReplyablePost) {
return '';
}
return latestReplyablePost.id;
},
);
export const getCurrentUsersLatestPost: (state: GlobalState, postId: Post['id']) => PostWithFormatData | undefined | null = createSelector(
'getCurrentUsersLatestPost',
getPostsInCurrentChannel,
getCurrentUser,
(state: GlobalState, rootId: string) => rootId,
(posts, currentUser, rootId) => {
if (!posts) {
return null;
}
const lastPost = posts.find((post) => {
// don't edit webhook posts, deleted posts, or system messages
if (post.user_id !== currentUser.id || (post.props && post.props.from_webhook) || post.state === Posts.POST_DELETED || isSystemMessage(post) || isPostEphemeral(post) || isPostPendingOrFailed(post)) {
return false;
}
if (rootId) {
return post.root_id === rootId || post.id === rootId;
}
return true;
});
return lastPost;
},
);
export function getRecentPostsChunkInChannel(state: GlobalState, channelId: Channel['id']): PostOrderBlock | null | undefined {
const postsForChannel = state.entities.posts.postsInChannel[channelId];

View File

@ -8,7 +8,7 @@ import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {makeGetGlobalItem, makeGetGlobalItemWithDefault} from 'selectors/storage';
import {getGlobalItem, makeGetGlobalItem, makeGetGlobalItemWithDefault} from 'selectors/storage';
import type {SidebarSize} from 'components/resizable_sidebar/constants';
@ -138,6 +138,37 @@ export function getIsSearchGettingMore(state: GlobalState): boolean {
return state.entities.search.isSearchGettingMore;
}
export function makeGetDraft() {
let defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''};
return (state: GlobalState, channelId: string, rootId = ''): PostDraft => {
if (defaultDraft.channelId !== channelId || defaultDraft.rootId !== rootId) {
defaultDraft = {message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId, rootId};
}
const prefix = rootId ? StoragePrefixes.COMMENT_DRAFT : StoragePrefixes.DRAFT;
const suffix = rootId || channelId;
const draft = getGlobalItem(state, `${prefix}${suffix}`, defaultDraft);
let toReturn = defaultDraft;
if (
typeof draft.message !== 'undefined' &&
typeof draft.uploadsInProgress !== 'undefined' &&
typeof draft.fileInfos !== 'undefined'
) {
toReturn = draft;
}
if (draft.rootId !== rootId || draft.channelId !== channelId) {
toReturn = {
...draft,
rootId,
channelId,
};
}
return toReturn;
};
}
export function makeGetChannelDraft() {
const defaultDraft = Object.freeze({message: '', fileInfos: [], uploadsInProgress: [], createAt: 0, updateAt: 0, channelId: '', rootId: ''});
const getDraft = makeGetGlobalItemWithDefault(defaultDraft);

View File

@ -6,21 +6,21 @@ import type {GlobalState} from 'types/store';
export const getGlobalItem = <T = any>(state: GlobalState, name: string, defaultValue: T) => {
const storage = state && state.storage && state.storage.storage;
return getItemFromStorage(storage, name, defaultValue);
return getItemFromStorage<T>(storage, name, defaultValue);
};
export const makeGetGlobalItem = <T = any>(name: string, defaultValue: T) => {
return (state: GlobalState) => {
return getGlobalItem(state, name, defaultValue);
return getGlobalItem<T>(state, name, defaultValue);
};
};
export const getItemFromStorage = <T = any>(storage: Record<string, any>, name: string, defaultValue: T) => {
export const getItemFromStorage = <T = any>(storage: Record<string, any>, name: string, defaultValue: T): T => {
return storage[name]?.value ?? defaultValue;
};
export const makeGetGlobalItemWithDefault = <T = any>(defaultValue: T) => {
return (state: GlobalState, name: string) => {
return getGlobalItem(state, name, defaultValue);
return getGlobalItem<T>(state, name, defaultValue);
};
};

View File

@ -303,7 +303,7 @@ export function postMessageOnKeyPress(
now = 0,
lastChannelSwitchAt = 0,
caretPosition = 0,
): {allowSending: boolean; ignoreKeyPress?: boolean} {
): {allowSending: boolean; ignoreKeyPress?: boolean; withClosedCodeBlock?: boolean; message?: string} {
if (!event) {
return {allowSending: false};
}
@ -341,6 +341,10 @@ export function postMessageOnKeyPress(
return {allowSending: false};
}
export function isServerError(err: unknown): err is ServerError {
return Boolean(err && typeof err === 'object' && 'server_error_id' in err);
}
export function isErrorInvalidSlashCommand(error: ServerError | null): boolean {
if (error && error.server_error_id) {
return error.server_error_id === 'api.command.execute_command.not_found.app_error';

View File

@ -803,7 +803,7 @@ export function setSelectionRange(input: HTMLInputElement | HTMLTextAreaElement,
input.setSelectionRange(selectionStart, selectionEnd);
}
export function setCaretPosition(input: HTMLInputElement, pos: number) {
export function setCaretPosition(input: HTMLInputElement | HTMLTextAreaElement, pos: number) {
if (!input) {
return;
}