diff --git a/webapp/channels/src/components/advanced_create_comment/__snapshots__/advanced_create_comment.test.jsx.snap b/webapp/channels/src/components/advanced_create_comment/__snapshots__/advanced_create_comment.test.jsx.snap index 1e7b3700f3..6f3b42ac39 100644 --- a/webapp/channels/src/components/advanced_create_comment/__snapshots__/advanced_create_comment.test.jsx.snap +++ b/webapp/channels/src/components/advanced_create_comment/__snapshots__/advanced_create_comment.test.jsx.snap @@ -5,6 +5,7 @@ exports[`components/AdvancedCreateComment should match snapshot when cannot post onSubmit={[Function]} > { useLDAPGroupMentions: true, useCustomGroupMentions: true, openModal: jest.fn(), + postEditorActions: [], }; const emptyDraft = { diff --git a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx index 1520555128..00c2736531 100644 --- a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx +++ b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx @@ -19,6 +19,7 @@ import {sortFileInfos} from 'mattermost-redux/utils/file_utils'; import * as GlobalActions from 'actions/global_actions'; import {PostDraft} from 'types/store/draft'; +import {PluginComponent} from 'types/store/plugins'; import {ModalData} from 'types/actions'; import Constants, {AdvancedTextEditor as AdvancedTextEditorConst, Locations, ModalIdentifiers, Preferences} from 'utils/constants'; @@ -193,6 +194,7 @@ type Props = { useCustomGroupMentions: boolean; isFormattingBarHidden: boolean; searchAssociatedGroupsForReference: (prefix: string, teamId: string, channelId: string | undefined) => Promise<{ data: any }>; + postEditorActions: PluginComponent[]; } type State = { @@ -1198,6 +1200,41 @@ class AdvancedCreateComment extends React.PureComponent { render() { const draft = this.state.draft!; + + const pluginItems = this.props.postEditorActions?. + map((item) => { + if (!item.component) { + return null; + } + + const Component = item.component as any; + return ( + { + const input = this.textboxRef.current?.getInputBox(); + + return { + start: input.selectionStart, + end: input.selectionEnd, + }; + }} + updateText={(message: string) => { + const draft = this.state.draft!; + const modifiedDraft = { + ...draft, + message, + }; + this.handleDraftChange(modifiedDraft); + this.setState({ + draft: modifiedDraft, + }); + }} + /> + ); + }); + return (
{ @@ -1251,6 +1288,7 @@ class AdvancedCreateComment extends React.PureComponent { getFileUploadTarget={this.getFileUploadTarget} fileUploadRef={this.fileUploadRef} isThreadView={this.props.isThreadView} + additionalControls={pluginItems.filter(Boolean)} /> ); diff --git a/webapp/channels/src/components/advanced_create_comment/index.ts b/webapp/channels/src/components/advanced_create_comment/index.ts index a71a322cb4..7400a056ec 100644 --- a/webapp/channels/src/components/advanced_create_comment/index.ts +++ b/webapp/channels/src/components/advanced_create_comment/index.ts @@ -87,6 +87,7 @@ function makeMapStateToProps() { 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; return { currentTeamId, @@ -116,6 +117,7 @@ function makeMapStateToProps() { channelMemberCountsByGroup, useCustomGroupMentions, canUploadFiles: canUploadFiles(config), + postEditorActions, }; }; } diff --git a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx index f8471921e7..0039f35f4b 100644 --- a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx +++ b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx @@ -18,6 +18,7 @@ import {CommandArgs} from '@mattermost/types/integrations'; import {Group, GroupSource} from '@mattermost/types/groups'; import {FileInfo} from '@mattermost/types/files'; import {Emoji} from '@mattermost/types/emojis'; +import {PluginComponent} from 'types/store/plugins'; import * as GlobalActions from 'actions/global_actions'; import Constants, { @@ -234,6 +235,7 @@ type Props = { channelMemberCountsByGroup: ChannelMemberCountsByGroup; useLDAPGroupMentions: boolean; useCustomGroupMentions: boolean; + postEditorActions: PluginComponent[]; } type State = { @@ -1398,7 +1400,7 @@ class AdvancedCreatePost extends React.PureComponent { this.setState({showEmojiPicker: false}); }; - setMessageAndCaretPostion = (newMessage: string, newCaretPosition: number) => { + setMessageAndCaretPosition = (newMessage: string, newCaretPosition: number) => { const textbox = this.textboxRef.current?.getInputBox(); this.setState({ @@ -1417,7 +1419,7 @@ class AdvancedCreatePost extends React.PureComponent { }; prefillMessage = (message: string, shouldFocus?: boolean) => { - this.setMessageAndCaretPostion(message, message.length); + this.setMessageAndCaretPosition(message, message.length); if (shouldFocus) { const inputBox = this.textboxRef.current?.getInputBox(); @@ -1439,7 +1441,7 @@ class AdvancedCreatePost extends React.PureComponent { if (this.state.message === '') { const newMessage = ':' + emojiAlias + ': '; - this.setMessageAndCaretPostion(newMessage, newMessage.length); + this.setMessageAndCaretPosition(newMessage, newMessage.length); } else { const {message} = this.state; const {firstPiece, lastPiece} = splitMessageBasedOnCaretPosition(this.state.caretPosition, message); @@ -1450,7 +1452,7 @@ class AdvancedCreatePost extends React.PureComponent { const newCaretPosition = firstPiece === '' ? `:${emojiAlias}: `.length : `${firstPiece} :${emojiAlias}: `.length; - this.setMessageAndCaretPostion(newMessage, newCaretPosition); + this.setMessageAndCaretPosition(newMessage, newCaretPosition); } this.handleEmojiClose(); @@ -1560,6 +1562,38 @@ class AdvancedCreatePost extends React.PureComponent { render() { const {draft, canPost} = this.props; + const pluginItems = this.props.postEditorActions?. + map((item) => { + if (!item.component) { + return null; + } + + const Component = item.component as any; + return ( + { + const input = this.textboxRef.current?.getInputBox(); + + return { + start: input.selectionStart, + end: input.selectionEnd, + }; + }} + updateText={(message: string) => { + this.setState({ + message, + }); + this.handleDraftChange({ + ...this.props.draft, + message, + }); + }} + /> + ); + }); + let centerClass = ''; if (!this.props.fullWidthTextBox) { centerClass = 'center'; @@ -1649,6 +1683,7 @@ class AdvancedCreatePost extends React.PureComponent { disabled={this.props.shouldShowPreview} /> ), + ...(pluginItems || []), ].filter(Boolean)} /> diff --git a/webapp/channels/src/components/advanced_create_post/index.ts b/webapp/channels/src/components/advanced_create_post/index.ts index c582f2316b..815f727312 100644 --- a/webapp/channels/src/components/advanced_create_post/index.ts +++ b/webapp/channels/src/components/advanced_create_post/index.ts @@ -104,6 +104,7 @@ function makeMapStateToProps() { 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, @@ -143,6 +144,7 @@ function makeMapStateToProps() { isLDAPEnabled, useCustomGroupMentions, isPostPriorityEnabled: isPostPriorityEnabled(state), + postEditorActions, }; }; } diff --git a/webapp/channels/src/components/dot_menu/index.ts b/webapp/channels/src/components/dot_menu/index.ts index 7b9e008080..ceb8dd62f6 100644 --- a/webapp/channels/src/components/dot_menu/index.ts +++ b/webapp/channels/src/components/dot_menu/index.ts @@ -114,7 +114,6 @@ function makeMapStateToProps() { postEditTimeLimit: config.PostEditTimeLimit, isLicensed: license.IsLicensed === 'true', teamId: getCurrentTeamId(state), - pluginMenuItems: state.plugins.components.PostDropdownMenu, canEdit: PostUtils.canEditPost(state, post, license, config, channel, userId), canDelete: PostUtils.canDeletePost(state, post, channel), teamUrl, diff --git a/webapp/channels/src/components/post/index.tsx b/webapp/channels/src/components/post/index.tsx index 340dbdd8b5..84cb5d3ed9 100644 --- a/webapp/channels/src/components/post/index.tsx +++ b/webapp/channels/src/components/post/index.tsx @@ -213,6 +213,7 @@ function makeMapStateToProps() { isCardOpen: selectedCard && selectedCard.id === post.id, shouldShowDotMenu: shouldShowDotMenu(state, post, channel), canDelete: canDeletePost(state, post, channel), + pluginActions: state.plugins.components.PostAction, }; }; } diff --git a/webapp/channels/src/components/post/post_component.test.tsx b/webapp/channels/src/components/post/post_component.test.tsx index 88dbad2b07..3789599e19 100644 --- a/webapp/channels/src/components/post/post_component.test.tsx +++ b/webapp/channels/src/components/post/post_component.test.tsx @@ -38,6 +38,7 @@ describe('PostComponent', () => { recentEmojis: [], replyCount: 0, team: currentTeam, + pluginActions: [], actions: { markPostAsUnread: jest.fn(), emitShortcutReactToLastPostFrom: jest.fn(), diff --git a/webapp/channels/src/components/post/post_component.tsx b/webapp/channels/src/components/post/post_component.tsx index fb16580ece..138b593b34 100644 --- a/webapp/channels/src/components/post/post_component.tsx +++ b/webapp/channels/src/components/post/post_component.tsx @@ -14,7 +14,7 @@ import Constants, {A11yCustomEventTypes, A11yFocusEventDetail, AppEvents, Locati import * as PostUtils from 'utils/post_utils'; -import {PostPluginComponent} from 'types/store/plugins'; +import {PostPluginComponent, PluginComponent} from 'types/store/plugins'; import FileAttachmentListContainer from 'components/file_attachment_list'; import DateSeparator from 'components/post_view/date_separator'; @@ -118,6 +118,7 @@ export type Props = { isPostPriorityEnabled: boolean; isCardOpen?: boolean; canDelete?: boolean; + pluginActions: PluginComponent[]; }; const PostComponent = (props: Props): JSX.Element => { diff --git a/webapp/channels/src/components/post/post_options.tsx b/webapp/channels/src/components/post/post_options.tsx index 83cafc3896..f08a1c2c0b 100644 --- a/webapp/channels/src/components/post/post_options.tsx +++ b/webapp/channels/src/components/post/post_options.tsx @@ -16,6 +16,7 @@ import PostFlagIcon from 'components/post_view/post_flag_icon'; import PostRecentReactions from 'components/post_view/post_recent_reactions'; import PostReaction from 'components/post_view/post_reaction'; import CommentIcon from 'components/common/comment_icon'; +import {PluginComponent} from 'types/store/plugins'; import {Emoji} from '@mattermost/types/emojis'; import {Post} from '@mattermost/types/posts'; @@ -49,6 +50,7 @@ type Props = { isPostHeaderVisible?: boolean | null; isPostBeingEdited?: boolean; canDelete?: boolean; + pluginActions: PluginComponent[]; actions: { emitShortcutReactToLastPostFrom: (emittedFrom: 'CENTER' | 'RHS_ROOT' | 'NO_WHERE') => void; }; @@ -175,6 +177,24 @@ const PostOptions = (props: Props): JSX.Element => { isMenuOpen={showActionsMenu} /> ); + + let pluginItems: ReactNode = null; + if ((!isEphemeral && !post.failed && !systemMessage) && hoverLocal) { + pluginItems = props.pluginActions?. + map((item) => { + if (item.component) { + const Component = item.component as any; + return ( + + ); + } + return null; + }) || []; + } + const dotMenu = ( { {showRecentReactions} {postReaction} {flagIcon} + {pluginItems} {actionsMenu} {commentIcon} {(collapsedThreadsEnabled || showRecentlyUsedReactions) && dotMenu} diff --git a/webapp/channels/src/plugins/registry.ts b/webapp/channels/src/plugins/registry.ts index a8a51c9433..1702723007 100644 --- a/webapp/channels/src/plugins/registry.ts +++ b/webapp/channels/src/plugins/registry.ts @@ -496,6 +496,18 @@ export default class PluginRegistry { return id; }); + // Register a component to the add to the post message menu shown on hover. + // Accepts a React component. Returns a unique identifier. + registerPostActionComponent = reArg(['component'], ({component}: DPluginComponentProp) => { + return dispatchPluginComponentAction('PostAction', this.id, component); + }); + + // Register a component to the add to the post text editor menu. + // Accepts a React component. Returns a unique identifier. + registerPostEditorActionComponent = reArg(['component'], ({component}: DPluginComponentProp) => { + return dispatchPluginComponentAction('PostEditorAction', this.id, component); + }); + // Register a post menu list item by providing some text and an action function. // Accepts the following: // - text - A string or React element to display in the menu diff --git a/webapp/channels/src/reducers/plugins/index.ts b/webapp/channels/src/reducers/plugins/index.ts index f0e0801a37..b072e74d2d 100644 --- a/webapp/channels/src/reducers/plugins/index.ts +++ b/webapp/channels/src/reducers/plugins/index.ts @@ -181,6 +181,8 @@ const initialComponents: PluginsState['components'] = { ChannelHeaderButton: [], MobileChannelHeaderButton: [], PostDropdownMenu: [], + PostAction: [], + PostEditorAction: [], Product: [], RightHandSidebarComponent: [], UserGuideDropdownItem: [], diff --git a/webapp/channels/src/types/store/plugins.ts b/webapp/channels/src/types/store/plugins.ts index cb49b7263a..9143dcd4c4 100644 --- a/webapp/channels/src/types/store/plugins.ts +++ b/webapp/channels/src/types/store/plugins.ts @@ -27,6 +27,8 @@ export type PluginsState = { Product: ProductComponent[]; CallButton: PluginComponent[]; PostDropdownMenu: PluginComponent[]; + PostAction: PluginComponent[]; + PostEditorAction: PluginComponent[]; FilePreview: PluginComponent[]; MainMenu: PluginComponent[]; LinkTooltip: PluginComponent[];