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>
Этот коммит содержится в:
Daniel Espino García
2024-07-15 12:14:14 +02:00
коммит произвёл GitHub
родитель ff3ed78124
Коммит a272fb29a5
40 изменённых файлов: 2225 добавлений и 8189 удалений

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

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

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

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

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

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

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

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

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

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

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

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

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

@@ -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={() => {

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

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

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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