From be682240f4a1d09dbe198c167beee7c65cb14c08 Mon Sep 17 00:00:00 2001 From: Chris Duarte Date: Thu, 23 Nov 2017 09:25:03 -0800 Subject: [PATCH] Add slash command support (#1190) * Add slash command support * Review feedback * Review feedback 2 * Update to latest redux * Fix tests --- app/actions/views/command.js | 30 ++++ app/components/autocomplete/autocomplete.js | 52 ++++-- .../autocomplete/slash_suggestion/index.js | 45 +++++ .../slash_suggestion/slash_suggestion.js | 162 ++++++++++++++++++ .../slash_suggestion/slash_suggestion_item.js | 83 +++++++++ app/components/post_textbox/index.js | 2 + app/components/post_textbox/post_textbox.js | 46 +++-- assets/base/i18n/en.json | 1 + package.json | 2 +- yarn.lock | 22 ++- 10 files changed, 411 insertions(+), 34 deletions(-) create mode 100644 app/actions/views/command.js create mode 100644 app/components/autocomplete/slash_suggestion/index.js create mode 100644 app/components/autocomplete/slash_suggestion/slash_suggestion.js create mode 100644 app/components/autocomplete/slash_suggestion/slash_suggestion_item.js diff --git a/app/actions/views/command.js b/app/actions/views/command.js new file mode 100644 index 000000000..b55cac402 --- /dev/null +++ b/app/actions/views/command.js @@ -0,0 +1,30 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {executeCommand as executeCommandService} from 'mattermost-redux/actions/integrations'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +export function executeCommand(message, channelId) { + return async (dispatch, getState) => { + const state = getState(); + + const teamId = getCurrentTeamId(state); + + const args = { + channel_id: channelId, + team_id: teamId + }; + + let msg = message; + + let cmdLength = msg.indexOf(' '); + if (cmdLength < 0) { + cmdLength = msg.length; + } + + const cmd = msg.substring(0, cmdLength).toLowerCase(); + msg = cmd + msg.substring(cmdLength, msg.length); + + return await executeCommandService(msg, args)(dispatch, getState); + }; +} \ No newline at end of file diff --git a/app/components/autocomplete/autocomplete.js b/app/components/autocomplete/autocomplete.js index f551266e2..af64017dd 100644 --- a/app/components/autocomplete/autocomplete.js +++ b/app/components/autocomplete/autocomplete.js @@ -13,6 +13,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import AtMention from './at_mention'; import ChannelMention from './channel_mention'; import EmojiSuggestion from './emoji_suggestion'; +import SlashSuggestion from './slash_suggestion'; export default class Autocomplete extends PureComponent { static propTypes = { @@ -31,7 +32,8 @@ export default class Autocomplete extends PureComponent { cursorPosition: 0, atMentionCount: 0, channelMentionCount: 0, - emojiCount: 0 + emojiCount: 0, + commandCount: 0 }; handleSelectionChange = (event) => { @@ -52,6 +54,10 @@ export default class Autocomplete extends PureComponent { this.setState({emojiCount}); }; + handleCommandCountChange = (commandCount) => { + this.setState({commandCount}); + }; + render() { const style = getStyleFromTheme(this.props.theme); @@ -63,27 +69,34 @@ export default class Autocomplete extends PureComponent { } // We always need to render something, but we only draw the borders when we have results to show - if (this.state.atMentionCount + this.state.channelMentionCount + this.state.emojiCount > 0) { + const {atMentionCount, channelMentionCount, emojiCount, commandCount} = this.state; + if (atMentionCount + channelMentionCount + emojiCount + commandCount > 0) { containerStyle.push(style.borders); } return ( - - - - + + + + + + + ); } @@ -99,7 +112,8 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { }, borders: { borderWidth: 1, - borderColor: changeOpacity(theme.centerChannelColor, 0.2) + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderBottomWidth: 0 }, container: { bottom: 0, diff --git a/app/components/autocomplete/slash_suggestion/index.js b/app/components/autocomplete/slash_suggestion/index.js new file mode 100644 index 000000000..1da534017 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/index.js @@ -0,0 +1,45 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; +import {createSelector} from 'reselect'; + +import {getAutocompleteCommands} from 'mattermost-redux/actions/integrations'; +import {getAutocompleteCommandsList} from 'mattermost-redux/selectors/entities/integrations'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +import SlashSuggestion from './slash_suggestion'; + +// TODO: Remove when all below commands have been implemented +const COMMANDS_TO_IMPLEMENT_LATER = ['collapse', 'expand', 'join', 'open', 'leave', 'logout', 'msg', 'grpmsg']; +const NON_MOBILE_COMMANDS = ['rename', 'invite_people', 'shortcuts', 'search', 'help', 'settings', 'remove']; + +const COMMANDS_TO_HIDE_ON_MOBILE = [...COMMANDS_TO_IMPLEMENT_LATER, ...NON_MOBILE_COMMANDS]; + +const mobileCommandsSelector = createSelector( + getAutocompleteCommandsList, + (commands) => { + return commands.filter((command) => !COMMANDS_TO_HIDE_ON_MOBILE.includes(command.trigger)); + } +); + +function mapStateToProps(state) { + return { + commands: mobileCommandsSelector(state), + commandsRequest: state.requests.integrations.getAutocompleteCommands, + currentTeamId: getCurrentTeamId(state), + theme: getTheme(state) + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getAutocompleteCommands + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(SlashSuggestion); \ No newline at end of file diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.js b/app/components/autocomplete/slash_suggestion/slash_suggestion.js new file mode 100644 index 000000000..7a7373264 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.js @@ -0,0 +1,162 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import { + FlatList +} from 'react-native'; + +import {RequestStatus} from 'mattermost-redux/constants'; + +import {makeStyleSheetFromTheme} from 'app/utils/theme'; + +import SlashSuggestionItem from './slash_suggestion_item'; + +const SLASH_REGEX = /(^\/)([a-zA-Z-]*)$/; +const TIME_BEFORE_NEXT_COMMAND_REQUEST = 1000 * 60 * 5; + +export default class SlashSuggestion extends Component { + static propTypes = { + actions: PropTypes.shape({ + getAutocompleteCommands: PropTypes.func.isRequired + }).isRequired, + currentTeamId: PropTypes.string.isRequired, + commands: PropTypes.array, + commandsRequest: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, + onChangeText: PropTypes.func.isRequired, + onResultCountChange: PropTypes.func.isRequired, + value: PropTypes.string + }; + + static defaultProps = { + defaultChannel: {}, + value: '' + }; + + state = { + active: false, + suggestionComplete: false, + dataSource: [], + lastCommandRequest: 0 + }; + + componentWillReceiveProps(nextProps) { + const {currentTeamId} = this.props; + const { + commands: nextCommands, + commandsRequest: nextCommandsRequest, + currentTeamId: nextTeamId, + value: nextValue + } = nextProps; + + if (currentTeamId !== nextTeamId) { + this.setState({ + lastCommandRequest: 0 + }); + } + + const match = nextValue.match(SLASH_REGEX); + + if (!match || this.state.suggestionComplete) { + this.setState({ + active: false, + matchTerm: null, + suggestionComplete: false + }); + this.props.onResultCountChange(0); + return; + } + + const dataIsStale = Date.now() - this.state.lastCommandRequest > TIME_BEFORE_NEXT_COMMAND_REQUEST; + + if ((!nextCommands.length || dataIsStale) && nextCommandsRequest.status !== RequestStatus.STARTED) { + this.props.actions.getAutocompleteCommands(nextProps.currentTeamId); + this.setState({ + lastCommandRequest: Date.now() + }); + } + + const matchTerm = match[2]; + + const data = this.filterSlashSuggestions(matchTerm, nextCommands); + + this.setState({ + active: data.length, + dataSource: data + }); + + this.props.onResultCountChange(data.length); + } + + filterSlashSuggestions = (matchTerm, commands) => { + return commands.filter((command) => { + if (!command.auto_complete) { + return false; + } else if (!matchTerm) { + return true; + } + + return command.display_name.startsWith(matchTerm) || command.trigger.startsWith(matchTerm); + }); + } + + completeSuggestion = (command) => { + const {onChangeText} = this.props; + + const completedDraft = `/${command} `; + + onChangeText(completedDraft); + + this.setState({ + active: false, + suggestionComplete: true + }); + }; + + keyExtractor = (item) => item.id || item.trigger; + + renderItem = ({item}) => ( + + ) + + render() { + if (!this.state.active) { + // If we are not in an active state return null so nothing is rendered + // other components are not blocked. + return null; + } + + const style = getStyleFromTheme(this.props.theme); + + return ( + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + listView: { + flex: 1, + backgroundColor: theme.centerChannelBg + } + }; +}); diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.js b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.js new file mode 100644 index 000000000..d1bd52c52 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.js @@ -0,0 +1,83 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + Text, + TouchableOpacity +} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +export default class SlashSuggestionItem extends PureComponent { + static propTypes = { + displayName: PropTypes.string, + description: PropTypes.string, + hint: PropTypes.string, + onPress: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + trigger: PropTypes.string + }; + + completeSuggestion = () => { + const {onPress, trigger} = this.props; + onPress(trigger); + }; + + render() { + const { + displayName, + description, + hint, + theme, + trigger + } = this.props; + + const style = getStyleFromTheme(theme); + + return ( + + {`/${displayName || trigger} ${hint}`} + {description} + + ); + } +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + row: { + height: 55, + justifyContent: 'center', + paddingHorizontal: 8, + backgroundColor: theme.centerChannelBg, + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.2), + borderLeftWidth: 1, + borderLeftColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRightWidth: 1, + borderRightColor: changeOpacity(theme.centerChannelColor, 0.2) + }, + rowDisplayName: { + fontSize: 13, + color: theme.centerChannelColor + }, + rowName: { + color: theme.centerChannelColor, + opacity: 0.6 + }, + suggestionDescription: { + fontSize: 11, + color: changeOpacity(theme.centerChannelColor, 0.6) + }, + suggestionName: { + fontSize: 13, + color: theme.centerChannelColor, + marginBottom: 5 + } + }; +}); diff --git a/app/components/post_textbox/index.js b/app/components/post_textbox/index.js index 4e4c99555..de3571ba6 100644 --- a/app/components/post_textbox/index.js +++ b/app/components/post_textbox/index.js @@ -10,6 +10,7 @@ import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels' import {canUploadFilesOnMobile} from 'mattermost-redux/selectors/entities/general'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {executeCommand} from 'app/actions/views/command'; import {addReactionToLatestPost} from 'app/actions/views/emoji'; import {handlePostDraftChanged, handlePostDraftSelectionChanged} from 'app/actions/views/channel'; import {handleClearFiles, handleRemoveLastFile, handleUploadFiles} from 'app/actions/views/file_upload'; @@ -39,6 +40,7 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ addReactionToLatestPost, createPost, + executeCommand, handleClearFiles, handleCommentDraftChanged, handlePostDraftChanged, diff --git a/app/components/post_textbox/post_textbox.js b/app/components/post_textbox/post_textbox.js index 006cf350d..46f234bf2 100644 --- a/app/components/post_textbox/post_textbox.js +++ b/app/components/post_textbox/post_textbox.js @@ -25,6 +25,7 @@ class PostTextbox extends PureComponent { actions: PropTypes.shape({ addReactionToLatestPost: PropTypes.func.isRequired, createPost: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, handleCommentDraftChanged: PropTypes.func.isRequired, handlePostDraftChanged: PropTypes.func.isRequired, handleClearFiles: PropTypes.func.isRequired, @@ -302,21 +303,27 @@ class PostTextbox extends PureComponent { const {actions, currentUserId, channelId, files, rootId} = this.props; const {value} = this.state; - const postFiles = files.filter((f) => !f.failed); - const post = { - user_id: currentUserId, - channel_id: channelId, - root_id: rootId, - parent_id: rootId, - message: value - }; + if (value.indexOf('/') === 0) { + this.sendCommand(value); + } else { + const postFiles = files.filter((f) => !f.failed); + const post = { + user_id: currentUserId, + channel_id: channelId, + root_id: rootId, + parent_id: rootId, + message: value + }; + + actions.createPost(post, postFiles); + + if (postFiles.length) { + actions.handleClearFiles(channelId, rootId); + } + } - actions.createPost(post, postFiles); this.handleTextChange(''); this.changeDraft(''); - if (postFiles.length) { - actions.handleClearFiles(channelId, rootId); - } // Shrink the input textbox since the layout events lag slightly const nextState = { @@ -334,6 +341,21 @@ class PostTextbox extends PureComponent { this.setState(nextState, callback); }; + sendCommand = async (msg) => { + const {actions, channelId, intl} = this.props; + const {error} = await actions.executeCommand(msg, channelId); + + if (error) { + Alert.alert( + intl.formatMessage({ + id: 'mobile.commands.error_title', + defaultMessage: 'Error Executing Command' + }), + error.message + ); + } + } + sendReaction = (emoji) => { const {actions, rootId} = this.props; actions.addReactionToLatestPost(emoji, rootId); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index bdfb31228..be0b5398c 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1946,6 +1946,7 @@ "mobile.client_upgrade.no_upgrade_subtitle": "You already have the latest version.", "mobile.client_upgrade.no_upgrade_title": "Your App Is Up to Date", "mobile.client_upgrade.upgrade": "Update", + "mobile.commands.error_title": "Error Executing Command", "mobile.components.channels_list_view.yourChannels": "Your channels:", "mobile.components.error_list.dismiss_all": "Dismiss All", "mobile.components.select_server_view.continue": "Continue", diff --git a/package.json b/package.json index b83a5ad7b..40b5393c5 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "underscore": "1.8.3" }, "scripts": { - "test": "NODE_ENV=test nyc --reporter=text mocha --opts test/mocha.opts", + "test": "NODE_ENV=test nyc --reporter=text mocha --exit --opts test/mocha.opts", "postinstall": "make post-install" }, "rnpm": { diff --git a/yarn.lock b/yarn.lock index e208cf385..ab180ad24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3959,9 +3959,27 @@ makeerror@1.0.x: dependencies: tmpl "1.0.x" -mattermost-redux@mattermost/mattermost-redux#master: +"mattermost-redux@file:../mattermost-redux": version "1.0.1" - resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/7c5c77c8fcf2b18530c7a5a07f229ee29a8f7822" + dependencies: + deep-equal "1.0.1" + form-data "2.3.1" + harmony-reflect "1.5.1" + isomorphic-fetch "2.2.1" + mime-db "1.30.0" + redux "3.7.2" + redux-action-buffer "1.1.0" + redux-batched-actions "0.2.0" + redux-offline "git+https://github.com/enahum/redux-offline.git" + redux-persist "4.9.1" + redux-thunk "2.2.0" + reselect "3.0.1" + serialize-error "2.1.0" + shallow-equals "1.0.0" + +mattermost-redux@mattermost/mattermost-redux: + version "1.0.1" + resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/c293dff26e4db3cde91bf405677700c9fc6a58d8" dependencies: deep-equal "1.0.1" form-data "2.3.1"