diff --git a/app/components/autocomplete/slash_suggestion/index.js b/app/components/autocomplete/slash_suggestion/index.js index a05e54526..958c4b89e 100644 --- a/app/components/autocomplete/slash_suggestion/index.js +++ b/app/components/autocomplete/slash_suggestion/index.js @@ -5,8 +5,8 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {createSelector} from 'reselect'; -import {getAutocompleteCommands} from '@mm-redux/actions/integrations'; -import {getAutocompleteCommandsList} from '@mm-redux/selectors/entities/integrations'; +import {getAutocompleteCommands, getCommandAutocompleteSuggestions} from '@mm-redux/actions/integrations'; +import {getAutocompleteCommandsList, getCommandAutocompleteSuggestionsList} from '@mm-redux/selectors/entities/integrations'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {isLandscape} from 'app/selectors/device'; @@ -32,6 +32,7 @@ function mapStateToProps(state) { currentTeamId: getCurrentTeamId(state), theme: getTheme(state), isLandscape: isLandscape(state), + suggestions: getCommandAutocompleteSuggestionsList(state), }; } @@ -39,8 +40,9 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ getAutocompleteCommands, + getCommandAutocompleteSuggestions, }, dispatch), }; } -export default connect(mapStateToProps, mapDispatchToProps)(SlashSuggestion); \ No newline at end of file +export default connect(mapStateToProps, mapDispatchToProps)(SlashSuggestion); diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.js b/app/components/autocomplete/slash_suggestion/slash_suggestion.js index c16b80bf6..39a104105 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion.js +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.js @@ -12,14 +12,16 @@ import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divide import {makeStyleSheetFromTheme} from 'app/utils/theme'; import SlashSuggestionItem from './slash_suggestion_item'; +import {Client4} from '@mm-redux/client'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; -const SLASH_REGEX = /(^\/)([a-zA-Z-]*)$/; const TIME_BEFORE_NEXT_COMMAND_REQUEST = 1000 * 60 * 5; export default class SlashSuggestion extends PureComponent { static propTypes = { actions: PropTypes.shape({ getAutocompleteCommands: PropTypes.func.isRequired, + getCommandAutocompleteSuggestions: PropTypes.func.isRequired, }).isRequired, currentTeamId: PropTypes.string.isRequired, commands: PropTypes.array, @@ -31,6 +33,9 @@ export default class SlashSuggestion extends PureComponent { value: PropTypes.string, isLandscape: PropTypes.bool.isRequired, nestedScrollEnabled: PropTypes.bool, + suggestions: PropTypes.array, + rootId: PropTypes.string, + channelId: PropTypes.string, }; static defaultProps = { @@ -40,13 +45,13 @@ export default class SlashSuggestion extends PureComponent { state = { active: false, - suggestionComplete: false, dataSource: [], lastCommandRequest: 0, }; componentWillReceiveProps(nextProps) { - if (nextProps.isSearch) { + if ((nextProps.value === this.props.value && nextProps.suggestions === this.props.suggestions) || + nextProps.isSearch || nextProps.value.startsWith('//') || !nextProps.channelId) { return; } @@ -55,49 +60,73 @@ export default class SlashSuggestion extends PureComponent { commands: nextCommands, currentTeamId: nextTeamId, value: nextValue, + suggestions: nextSuggestions, } = nextProps; - if (currentTeamId !== nextTeamId) { - this.setState({ - lastCommandRequest: 0, - }); - } - - const match = nextValue.match(SLASH_REGEX); - - if (!match || this.state.suggestionComplete) { + if (nextValue[0] !== '/') { 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 (nextValue.indexOf(' ') === -1) { // return suggestions for a top level cached commands + if (currentTeamId !== nextTeamId) { + this.setState({ + lastCommandRequest: 0, + }); + } - if ((!nextCommands.length || dataIsStale)) { - this.props.actions.getAutocompleteCommands(nextProps.currentTeamId); + const dataIsStale = Date.now() - this.state.lastCommandRequest > TIME_BEFORE_NEXT_COMMAND_REQUEST; + + if ((!nextCommands.length || dataIsStale)) { + this.props.actions.getAutocompleteCommands(nextProps.currentTeamId); + this.setState({ + lastCommandRequest: Date.now(), + }); + } + + const matches = this.filterSlashSuggestions(nextValue.substring(1), nextCommands); + this.updateSuggestions(matches); + } else if (isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) { + if (nextProps.suggestions === this.props.suggestions) { + const args = { + channel_id: this.props.channelId, + ...(this.props.rootId && {root_id: this.props.rootId, parent_id: this.props.rootId}), + }; + this.props.actions.getCommandAutocompleteSuggestions(nextProps.value, nextProps.currentTeamId, args); + } else { + const matches = []; + nextSuggestions.forEach((sug) => { + if (!this.contains(matches, '/' + sug.Complete)) { + matches.push({ + Complete: sug.Complete, + Suggestion: sug.Suggestion, + Hint: sug.Hint, + Description: sug.Description, + }); + } + }); + this.updateSuggestions(matches); + } + } else { this.setState({ - lastCommandRequest: Date.now(), + active: false, }); } + } - const matchTerm = match[2]; - - const data = this.filterSlashSuggestions(matchTerm, nextCommands); - + updateSuggestions = (matches) => { this.setState({ - active: data.length, - dataSource: data, + active: matches.length, + dataSource: matches, }); - - this.props.onResultCountChange(data.length); + this.props.onResultCountChange(matches.length); } filterSlashSuggestions = (matchTerm, commands) => { - return commands.filter((command) => { + const data = commands.filter((command) => { if (!command.auto_complete) { return false; } else if (!matchTerm) { @@ -106,6 +135,18 @@ export default class SlashSuggestion extends PureComponent { return command.display_name.startsWith(matchTerm) || command.trigger.startsWith(matchTerm); }); + return data.map((item) => { + return { + Complete: item.trigger, + Suggestion: '/' + item.trigger, + Hint: item.auto_complete_hint, + Description: item.auto_complete_desc, + }; + }); + } + + contains = (matches, complete) => { + return matches.findIndex((match) => match.complete === complete) !== -1; } completeSuggestion = (command) => { @@ -128,21 +169,23 @@ export default class SlashSuggestion extends PureComponent { }); } - this.setState({ - active: false, - suggestionComplete: true, - }); + if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) { + this.setState({ + active: false, + }); + } }; - keyExtractor = (item) => item.id || item.trigger; + keyExtractor = (item) => item.id || item.Suggestion; renderItem = ({item}) => ( ) diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.js b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.js index 922ef4570..00adc7ad8 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.js +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.js @@ -15,13 +15,14 @@ export default class SlashSuggestionItem extends PureComponent { hint: PropTypes.string, onPress: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, - trigger: PropTypes.string, + suggestion: PropTypes.string, + complete: PropTypes.string, isLandscape: PropTypes.bool.isRequired, }; completeSuggestion = () => { - const {onPress, trigger} = this.props; - onPress(trigger); + const {onPress, complete} = this.props; + onPress(complete); }; render() { @@ -29,7 +30,7 @@ export default class SlashSuggestionItem extends PureComponent { description, hint, theme, - trigger, + suggestion, isLandscape, } = this.props; @@ -41,7 +42,7 @@ export default class SlashSuggestionItem extends PureComponent { style={[style.row, padding(isLandscape)]} type={'opacity'} > - {`/${trigger} ${hint}`} + {`${suggestion} ${hint}`} {description} ); diff --git a/app/components/post_draft/post_draft.js b/app/components/post_draft/post_draft.js index 0798ab8ff..c0740c5b8 100644 --- a/app/components/post_draft/post_draft.js +++ b/app/components/post_draft/post_draft.js @@ -679,6 +679,8 @@ export default class PostDraft extends PureComponent { maxHeight={Math.min(this.state.top - AUTOCOMPLETE_MARGIN, AUTOCOMPLETE_MAX_HEIGHT)} onChangeText={this.handleInputQuickAction} valueEvent={valueEvent} + rootId={rootId} + channelId={channelId} /> } { + return this.doFetch( + `${this.getTeamRoute(teamId)}/commands/autocomplete_suggestions${buildQueryString({...commandArgs, user_input: userInput})}`, + {method: 'get'}, + ); + }; + // Groups linkGroupSyncable = async (groupID: string, syncableID: string, syncableType: string, patch: SyncablePatch) => { diff --git a/app/mm-redux/reducers/entities/integrations.ts b/app/mm-redux/reducers/entities/integrations.ts index 76828e8f5..22dd436d8 100644 --- a/app/mm-redux/reducers/entities/integrations.ts +++ b/app/mm-redux/reducers/entities/integrations.ts @@ -4,7 +4,7 @@ import {combineReducers} from 'redux'; import {IntegrationTypes, ChannelTypes} from '@mm-redux/action_types'; import {GenericAction} from '@mm-redux/types/actions'; -import {Command, IncomingWebhook, OutgoingWebhook, OAuthApp} from '@mm-redux/types/integrations'; +import {Command, IncomingWebhook, OutgoingWebhook, OAuthApp, AutocompleteSuggestion} from '@mm-redux/types/integrations'; import {Dictionary, IDMappedObjects} from '@mm-redux/types/utilities'; function incomingHooks(state: IDMappedObjects = {}, action: GenericAction) { @@ -159,6 +159,17 @@ function systemCommands(state: IDMappedObjects = {}, action: GenericAct } } +function commandAutocompleteSuggestions(state: Array = [], action: GenericAction) { + switch (action.type) { + case IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS: + return action.data; + case IntegrationTypes.RECEIVED_COMMAND_SUGGESTIONS_FAILURE: + return []; + default: + return state; + } +} + function oauthApps(state: IDMappedObjects = {}, action: GenericAction) { switch (action.type) { case IntegrationTypes.RECEIVED_OAUTH_APPS: { @@ -224,4 +235,7 @@ export default combineReducers({ // data for an interactive dialog to display dialog, + + // object represents slash command autocomplete suggestions + commandAutocompleteSuggestions, }); diff --git a/app/mm-redux/selectors/entities/integrations.ts b/app/mm-redux/selectors/entities/integrations.ts index ca1ca2519..6b64ac95e 100644 --- a/app/mm-redux/selectors/entities/integrations.ts +++ b/app/mm-redux/selectors/entities/integrations.ts @@ -26,6 +26,10 @@ export function getSystemCommands(state: types.store.GlobalState) { return state.entities.integrations.systemCommands; } +export function getCommandAutocompleteSuggestionsList(state: types.store.GlobalState) { + return state.entities.integrations.commandAutocompleteSuggestions; +} + /** * get outgoing hooks in current team */ diff --git a/app/mm-redux/types/integrations.ts b/app/mm-redux/types/integrations.ts index 92dd8755e..9e0f2f1e3 100644 --- a/app/mm-redux/types/integrations.ts +++ b/app/mm-redux/types/integrations.ts @@ -52,6 +52,15 @@ export type Command = { 'description': string; 'url': string; }; + +// AutocompleteSuggestion represents a single suggestion downloaded from the server. +export type AutocompleteSuggestion = { + Complete: string; + Suggestion: string; + Hint: string; + Description: string; + IconData: string; +}; export type OAuthApp = { 'id': string; 'creator_id': string; @@ -71,6 +80,7 @@ export type IntegrationsState = { oauthApps: IDMappedObjects; systemCommands: IDMappedObjects; commands: IDMappedObjects; + commandAutocompleteSuggestions: Array; }; export type DialogSubmission = { url: string; diff --git a/app/screens/channel/channel.ios.js b/app/screens/channel/channel.ios.js index 81d325206..d71ce39a5 100644 --- a/app/screens/channel/channel.ios.js +++ b/app/screens/channel/channel.ios.js @@ -71,6 +71,7 @@ export default class ChannelIOS extends ChannelBase { onChangeText={this.handleAutoComplete} cursorPositionEvent={CHANNEL_POST_TEXTBOX_CURSOR_CHANGE} valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE} + channelId={currentChannelId} /> {LocalConfig.EnableMobileClientUpgrade && } diff --git a/app/screens/thread/__snapshots__/thread.ios.test.js.snap b/app/screens/thread/__snapshots__/thread.ios.test.js.snap index 54bc86d92..12b2c1689 100644 --- a/app/screens/thread/__snapshots__/thread.ios.test.js.snap +++ b/app/screens/thread/__snapshots__/thread.ios.test.js.snap @@ -52,6 +52,7 @@ exports[`thread should match snapshot, has root post 1`] = ` nativeID="threadAccessoriesContainer" >