diff --git a/app/actions/views/channel.test.js b/app/actions/views/channel.test.js
index 71d527182..997f84641 100644
--- a/app/actions/views/channel.test.js
+++ b/app/actions/views/channel.test.js
@@ -174,7 +174,7 @@ describe('Actions.Views.Channel', () => {
channelSelectors.getCurrentChannelId = jest.fn(() => currentChannelId);
channelSelectors.getMyChannelMember = jest.fn(() => ({data: {member: {}}}));
- const appChannelSelectors = require('app/selectors/channel');
+ const appChannelSelectors = require('@selectors/channel');
const getChannelReachableOriginal = appChannelSelectors.getChannelReachable;
appChannelSelectors.getChannelReachable = jest.fn(() => true);
diff --git a/app/actions/views/command.ts b/app/actions/views/command.ts
index cae61af86..69bb0f3d9 100644
--- a/app/actions/views/command.ts
+++ b/app/actions/views/command.ts
@@ -40,7 +40,7 @@ export function executeCommand(message: string, channelId: string, rootId: strin
const appsAreEnabled = appsEnabled(state);
if (appsAreEnabled) {
- const parser = new AppCommandParser({dispatch, getState}, intl, args.channel_id, args.root_id);
+ const parser = new AppCommandParser({dispatch, getState}, intl, args.channel_id, args.team_id, args.root_id);
if (parser.isAppCommand(msg)) {
const {call, errorMessage} = await parser.composeCallFromCommand(msg);
const createErrorMessage = (errMessage: string) => {
diff --git a/app/components/autocomplete/autocomplete.js b/app/components/autocomplete/autocomplete.js
index fd2fe9ad7..86d8d677b 100644
--- a/app/components/autocomplete/autocomplete.js
+++ b/app/components/autocomplete/autocomplete.js
@@ -20,6 +20,7 @@ import ChannelMention from './channel_mention';
import DateSuggestion from './date_suggestion';
import EmojiSuggestion from './emoji_suggestion';
import SlashSuggestion from './slash_suggestion';
+import AppSlashSuggestion from './slash_suggestion/app_slash_suggestion';
export default class Autocomplete extends PureComponent {
static propTypes = {
@@ -39,6 +40,7 @@ export default class Autocomplete extends PureComponent {
offsetY: PropTypes.number,
onKeyboardOffsetChanged: PropTypes.func,
style: ViewPropTypes.style,
+ appsEnabled: PropTypes.bool,
};
static defaultProps = {
@@ -77,6 +79,7 @@ export default class Autocomplete extends PureComponent {
cursorPosition: props.cursorPosition,
emojiCount: 0,
commandCount: 0,
+ appCommandCount: 0,
dateCount: 0,
keyboardOffset: 0,
value: props.value,
@@ -137,6 +140,10 @@ export default class Autocomplete extends PureComponent {
this.setState({emojiCount});
};
+ handleAppCommandCountChange = (appCommandCount) => {
+ this.setState({appCommandCount});
+ }
+
handleCommandCountChange = (commandCount) => {
this.setState({commandCount});
};
@@ -178,8 +185,8 @@ export default class Autocomplete extends PureComponent {
}
render() {
- const {atMentionCount, channelMentionCount, emojiCount, commandCount, dateCount, cursorPosition, value} = this.state;
- const {theme, isSearch, offsetY} = this.props;
+ const {atMentionCount, channelMentionCount, emojiCount, commandCount, appCommandCount, dateCount, cursorPosition, value} = this.state;
+ const {theme, isSearch, offsetY, appsEnabled} = this.props;
const style = getStyleFromTheme(theme);
const maxListHeight = this.maxListHeight();
const wrapperStyles = [];
@@ -197,11 +204,12 @@ export default class Autocomplete extends PureComponent {
}
// Hide when there are no active autocompletes
- if (atMentionCount + channelMentionCount + emojiCount + commandCount + dateCount === 0) {
+ if (atMentionCount + channelMentionCount + emojiCount + commandCount + appCommandCount + dateCount === 0) {
wrapperStyles.push(style.hidden);
containerStyles.push(style.hidden);
}
+ const appsTakeOver = this.state.appCommandCount > 0;
return (
-
-
-
-
- {(this.props.isSearch && this.props.enableDateSuggestion) &&
-
- }
+ {appsEnabled && (
+
+ )}
+ {(!appsTakeOver || !appsEnabled) && (<>
+
+
+
+
+ {(this.props.isSearch && this.props.enableDateSuggestion) &&
+
+ }
+ >)}
);
diff --git a/app/components/autocomplete/channel_mention_item/channel_mention_item.js b/app/components/autocomplete/channel_mention_item/channel_mention_item.tsx
similarity index 86%
rename from app/components/autocomplete/channel_mention_item/channel_mention_item.js
rename to app/components/autocomplete/channel_mention_item/channel_mention_item.tsx
index d995f3877..7911a899a 100644
--- a/app/components/autocomplete/channel_mention_item/channel_mention_item.js
+++ b/app/components/autocomplete/channel_mention_item/channel_mention_item.tsx
@@ -1,7 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import PropTypes from 'prop-types';
import React from 'react';
import {Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
@@ -10,9 +9,10 @@ import CompassIcon from '@components/compass_icon';
import {BotTag, GuestTag} from '@components/tag';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {General} from '@mm-redux/constants';
+import {Theme} from '@mm-redux/types/preferences';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
-const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
+const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
icon: {
fontSize: 18,
@@ -38,7 +38,20 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
};
});
-const ChannelMentionItem = (props) => {
+type Props = {
+ channelId: string;
+ displayName?: string;
+ name?: string;
+ type?: string;
+ isBot: boolean;
+ isGuest: boolean;
+ onPress: (name?: string) => void;
+ theme: Theme;
+ shared: boolean;
+ testID?: string;
+};
+
+const ChannelMentionItem = (props: Props) => {
const insets = useSafeAreaInsets();
const {
channelId,
@@ -55,7 +68,7 @@ const ChannelMentionItem = (props) => {
const completeMention = () => {
if (type === General.DM_CHANNEL || type === General.GM_CHANNEL) {
- onPress('@' + displayName.replace(/ /g, ''));
+ onPress('@' + displayName?.replace(/ /g, ''));
} else {
onPress(name);
}
@@ -120,15 +133,4 @@ const ChannelMentionItem = (props) => {
return component;
};
-ChannelMentionItem.propTypes = {
- channelId: PropTypes.string.isRequired,
- displayName: PropTypes.string,
- name: PropTypes.string,
- type: PropTypes.string,
- isBot: PropTypes.bool.isRequired,
- isGuest: PropTypes.bool.isRequired,
- onPress: PropTypes.func.isRequired,
- theme: PropTypes.object.isRequired,
-};
-
export default ChannelMentionItem;
diff --git a/app/components/autocomplete/channel_mention_item/index.js b/app/components/autocomplete/channel_mention_item/index.ts
similarity index 84%
rename from app/components/autocomplete/channel_mention_item/index.js
rename to app/components/autocomplete/channel_mention_item/index.ts
index 5ae940528..348fb189e 100644
--- a/app/components/autocomplete/channel_mention_item/index.js
+++ b/app/components/autocomplete/channel_mention_item/index.ts
@@ -7,19 +7,24 @@ import {General} from '@mm-redux/constants';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getUser} from '@mm-redux/selectors/entities/users';
+import {GlobalState} from '@mm-redux/types/store';
import {getChannelNameForSearchAutocomplete} from '@selectors/channel';
import {isGuest as isGuestUser} from '@utils/users';
import ChannelMentionItem from './channel_mention_item';
-function mapStateToProps(state, ownProps) {
+type OwnProps = {
+ channelId: string;
+}
+
+function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const channel = getChannel(state, ownProps.channelId);
let displayName = getChannelNameForSearchAutocomplete(state, ownProps.channelId);
let isBot = false;
let isGuest = false;
if (channel?.type === General.DM_CHANNEL) {
- const teammate = getUser(state, channel.teammate_id);
+ const teammate = getUser(state, channel.teammate_id!);
if (teammate) {
displayName = teammate.username;
isBot = teammate.is_bot || false;
diff --git a/app/components/autocomplete/index.js b/app/components/autocomplete/index.js
index e40242bc7..38c1db45b 100644
--- a/app/components/autocomplete/index.js
+++ b/app/components/autocomplete/index.js
@@ -5,6 +5,7 @@ import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getDimensions} from '@selectors/device';
+import {appsEnabled} from '@utils/apps';
import Autocomplete from './autocomplete';
@@ -13,6 +14,7 @@ function mapStateToProps(state) {
return {
deviceHeight,
theme: getTheme(state),
+ appsEnabled: appsEnabled(state),
};
}
diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts
index b20834db3..943adfa30 100644
--- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts
+++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts
@@ -50,7 +50,7 @@ describe('AppCommandParser', () => {
let parser: AppCommandParser;
beforeEach(async () => {
const store = await makeStore(testBindings);
- parser = new AppCommandParser(store as any, intl, 'current_channel_id', 'root_id');
+ parser = new AppCommandParser(store as any, intl, 'current_channel_id', 'team_id', 'root_id');
});
type Variant = {
diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts
index 8a6ca9a88..409043c03 100644
--- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts
+++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts
@@ -7,11 +7,12 @@ import {
AppCallRequest,
AppBinding,
AppField,
- AppSelectOption,
- AppCallResponse,
+ DoAppCallResult,
+ AppLookupResponse,
AppContext,
AppForm,
AppCallValues,
+ AppSelectOption,
AutocompleteSuggestion,
AutocompleteStaticSelect,
Channel,
@@ -22,51 +23,57 @@ import {
AppCallResponseTypes,
AppCallTypes,
AppFieldTypes,
- getAppsBindings,
+ makeAppBindingsSelector,
selectChannel,
+ getChannel,
getCurrentTeamId,
doAppCall,
getStore,
EXECUTE_CURRENT_COMMAND_ITEM_ID,
+ COMMAND_SUGGESTION_ERROR,
getExecuteSuggestion,
displayError,
- keyMirror,
createCallRequest,
selectUserByUsername,
getUserByUsername,
+ selectUser,
+ getUser,
getChannelByNameAndTeamName,
getCurrentTeam,
selectChannelByName,
errorMessage as parserErrorMessage,
- selectUser,
- getUser,
- getChannel,
+ autocompleteUsersInChannel,
+ autocompleteChannels,
+ ExtendedAutocompleteSuggestion,
+ getChannelSuggestions,
+ getUserSuggestions,
+ inTextMentionSuggestions,
} from './app_command_parser_dependencies';
-export type Store = {
+export interface Store {
dispatch: DispatchFunc;
getState: () => GlobalState;
}
-export const ParseState = keyMirror({
- Start: null,
- Command: null,
- EndCommand: null,
- CommandSeparator: null,
- StartParameter: null,
- ParameterSeparator: null,
- Flag1: null,
- Flag: null,
- FlagValueSeparator: null,
- StartValue: null,
- NonspaceValue: null,
- QuotedValue: null,
- TickValue: null,
- EndValue: null,
- EndQuotedValue: null,
- EndTickedValue: null,
- Error: null,
-});
+export enum ParseState {
+ Start = 'Start',
+ Command = 'Command',
+ EndCommand = 'EndCommand',
+ CommandSeparator = 'CommandSeparator',
+ StartParameter = 'StartParameter',
+ ParameterSeparator = 'ParameterSeparator',
+ Flag1 = 'Flag1',
+ Flag = 'Flag',
+ FlagValueSeparator = 'FlagValueSeparator',
+ StartValue = 'StartValue',
+ NonspaceValue = 'NonspaceValue',
+ QuotedValue = 'QuotedValue',
+ TickValue = 'TickValue',
+ EndValue = 'EndValue',
+ EndQuotedValue = 'EndQuotedValue',
+ EndTickedValue = 'EndTickedValue',
+ Error = 'Error',
+}
interface FormsCache {
getForm: (location: string, binding: AppBinding) => Promise<{form?: AppForm; error?: string} | undefined>;
@@ -76,8 +83,10 @@ interface Intl {
formatMessage(config: {id: string; defaultMessage: string}, values?: {[name: string]: any}): string;
}
+const getCommandBindings = makeAppBindingsSelector(AppBindingLocations.COMMAND);
+
export class ParsedCommand {
- state: string = ParseState.Start;
+ state = ParseState.Start;
command: string;
i = 0;
incomplete = '';
@@ -98,14 +107,14 @@ export class ParsedCommand {
this.intl = intl;
}
- asError = (message: string): ParsedCommand => {
+ private asError = (message: string): ParsedCommand => {
this.state = ParseState.Error;
this.error = message;
return this;
};
// matchBinding finds the closest matching command binding.
- matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise => {
+ public matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise => {
if (commandBindings.length === 0) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.no_bindings',
@@ -229,7 +238,7 @@ export class ParsedCommand {
}
// parseForm parses the rest of the command using the previously matched form.
- parseForm = (autocompleteMode = false): ParsedCommand => {
+ public parseForm = (autocompleteMode = false): ParsedCommand => {
if (this.state === ParseState.Error || !this.form) {
return this;
}
@@ -545,15 +554,17 @@ export class ParsedCommand {
export class AppCommandParser {
private store: Store;
private channelID: string;
+ private teamID: string;
private rootPostID?: string;
private intl: Intl;
forms: {[location: string]: AppForm} = {};
- constructor(store: Store|null, intl: Intl, channelID: string, rootPostID = '') {
+ constructor(store: Store|null, intl: Intl, channelID: string, teamID = '', rootPostID = '') {
this.store = store || getStore() as Store;
this.channelID = channelID;
this.rootPostID = rootPostID;
+ this.teamID = teamID;
this.intl = intl;
}
@@ -681,9 +692,9 @@ export class AppCommandParser {
}
// getSuggestions returns suggestions for subcommands and/or form arguments
- public getSuggestions = async (pretext: string): Promise => {
+ public getSuggestions = async (pretext: string): Promise => {
let parsed = new ParsedCommand(pretext, this, this.intl);
- let suggestions: AutocompleteSuggestion[] = [];
+ let suggestions: ExtendedAutocompleteSuggestion[] = [];
const commandBindings = this.getCommandBindings();
if (!commandBindings) {
@@ -740,7 +751,7 @@ export class AppCommandParser {
id: 'apps.suggestion.no_suggestion',
defaultMessage: 'No matching suggestions.',
}),
- IconData: 'error',
+ IconData: COMMAND_SUGGESTION_ERROR,
Description: '',
}];
}
@@ -753,13 +764,13 @@ export class AppCommandParser {
id: 'apps.suggestion.errors.parser_error',
defaultMessage: 'Parsing error',
}),
- IconData: 'error',
+ IconData: COMMAND_SUGGESTION_ERROR,
Description: parsed.error,
}];
}
// composeCallFromParsed creates the form submission call
- composeCallFromParsed = async (parsed: ParsedCommand): Promise<{call: AppCallRequest | null; errorMessage?: string}> => {
+ private composeCallFromParsed = async (parsed: ParsedCommand): Promise<{call: AppCallRequest | null; errorMessage?: string}> => {
if (!parsed.binding) {
return {call: null,
errorMessage: this.intl.formatMessage({
@@ -788,7 +799,7 @@ export class AppCommandParser {
return {call: createCallRequest(call, context, {}, values, parsed.command)};
}
- expandOptions = async (parsed: ParsedCommand, values: AppCallValues): Promise<{errorMessage?: string}> => {
+ private expandOptions = async (parsed: ParsedCommand, values: AppCallValues): Promise<{errorMessage?: string}> => {
if (!parsed.form?.fields) {
return {};
}
@@ -878,7 +889,7 @@ export class AppCommandParser {
}
// decorateSuggestionComplete applies the necessary modifications for a suggestion to be processed
- decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => {
+ private decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => {
if (choice.Complete && choice.Complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) {
return choice as AutocompleteSuggestion;
}
@@ -900,27 +911,30 @@ export class AppCommandParser {
// getCommandBindings returns the commands in the redux store.
// They are grouped by app id since each app has one base command
- getCommandBindings = (): AppBinding[] => {
- const bindings = getAppsBindings(this.store.getState(), AppBindingLocations.COMMAND);
+ private getCommandBindings = (): AppBinding[] => {
+ const bindings = getCommandBindings(this.store.getState());
return bindings;
}
// getChannel gets the channel in which the user is typing the command
- getChannel = (): Channel | null => {
+ private getChannel = (): Channel | null => {
const state = this.store.getState();
return selectChannel(state, this.channelID);
}
- setChannelContext = (channelID: string, rootPostID?: string) => {
- if (this.channelID !== channelID || this.rootPostID !== rootPostID) {
+ public setChannelContext = (channelID: string, teamID = '', rootPostID?: string) => {
+ if (this.channelID !== channelID || this.rootPostID !== rootPostID || this.teamID !== teamID) {
this.forms = {};
}
this.channelID = channelID;
this.rootPostID = rootPostID;
+ this.teamID = teamID;
}
- // isAppCommand determines if subcommand/form suggestions need to be returned
- isAppCommand = (pretext: string): boolean => {
+ // isAppCommand determines if subcommand/form suggestions need to be returned.
+ // When this returns true, the caller knows that the parser should handle all suggestions for the current command string.
+ // When it returns false, the caller should call getSuggestionsBase() to check if there are any base commands that match the command string.
+ public isAppCommand = (pretext: string): boolean => {
const command = pretext.toLowerCase();
for (const binding of this.getCommandBindings()) {
let base = binding.label;
@@ -940,7 +954,7 @@ export class AppCommandParser {
}
// getAppContext collects post/channel/team info for performing calls
- getAppContext = (appID: string): AppContext => {
+ private getAppContext = (appID: string): AppContext => {
const context: AppContext = {
app_id: appID,
location: AppBindingLocations.COMMAND,
@@ -953,13 +967,13 @@ export class AppCommandParser {
}
context.channel_id = channel.id;
- context.team_id = channel.team_id || getCurrentTeamId(this.store.getState());
+ context.team_id = this.teamID || channel.team_id || getCurrentTeamId(this.store.getState());
return context;
}
// fetchForm unconditionaly retrieves the form for the given binding (subcommand)
- fetchForm = async (binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
+ private fetchForm = async (binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
if (!binding.call) {
return undefined;
}
@@ -969,16 +983,16 @@ export class AppCommandParser {
this.getAppContext(binding.app_id),
);
- const res = await this.store.dispatch(doAppCall(payload, AppCallTypes.FORM, this.intl));
+ const res = await this.store.dispatch(doAppCall(payload, AppCallTypes.FORM, this.intl)) as DoAppCallResult;
if (res.error) {
- const errorResponse = res.error as AppCallResponse;
+ const errorResponse = res.error;
return {error: errorResponse.error || this.intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
})};
}
- const callResponse = res.data as AppCallResponse;
+ const callResponse = res.data!;
switch (callResponse.type) {
case AppCallResponseTypes.FORM:
break;
@@ -1002,7 +1016,7 @@ export class AppCommandParser {
return {form: callResponse.form};
}
- getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
+ public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
const rootID = this.rootPostID || '';
const key = `${this.channelID}-${rootID}-${location}`;
const form = this.forms[key];
@@ -1019,17 +1033,16 @@ export class AppCommandParser {
}
// displayError shows an error that was caught by the parser
- displayError = (err: any): void => {
+ private displayError = (err: any): void => {
let errStr = err as string;
if (err.message) {
errStr = err.message;
}
-
displayError(this.intl, errStr);
}
// getSuggestionsForSubCommands returns suggestions for a subcommand's name
- getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
+ private getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
if (!parsed.binding?.bindings?.length) {
return [];
}
@@ -1052,7 +1065,7 @@ export class AppCommandParser {
}
// getParameterSuggestions computes suggestions for positional argument values, flag names, and flag argument values
- getParameterSuggestions = async (parsed: ParsedCommand): Promise => {
+ private getParameterSuggestions = async (parsed: ParsedCommand): Promise => {
switch (parsed.state) {
case ParseState.StartParameter: {
// see if there's a matching positional field
@@ -1082,7 +1095,7 @@ export class AppCommandParser {
}
// getMissingFields collects the required fields that were not supplied in a submission
- getMissingFields = (parsed: ParsedCommand): AppField[] => {
+ private getMissingFields = (parsed: ParsedCommand): AppField[] => {
const form = parsed.form;
if (!form) {
return [];
@@ -1102,7 +1115,7 @@ export class AppCommandParser {
}
// getFlagNameSuggestions returns suggestions for flag names
- getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
+ private getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
if (!parsed.form || !parsed.form.fields || !parsed.form.fields.length) {
return [];
}
@@ -1134,7 +1147,7 @@ export class AppCommandParser {
}
// getSuggestionsForField gets suggestions for a positional or flag field value
- getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => {
+ private getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => {
if (!parsed || !parsed.field) {
return [];
}
@@ -1142,9 +1155,9 @@ export class AppCommandParser {
switch (f.type) {
case AppFieldTypes.USER:
- return this.getUserSuggestions(parsed);
+ return this.getUserFieldSuggestions(parsed);
case AppFieldTypes.CHANNEL:
- return this.getChannelSuggestions(parsed);
+ return this.getChannelFieldSuggestions(parsed);
case AppFieldTypes.BOOL:
return this.getBooleanSuggestions(parsed);
case AppFieldTypes.DYNAMIC_SELECT:
@@ -1153,14 +1166,21 @@ export class AppCommandParser {
return this.getStaticSelectSuggestions(parsed, delimiter);
}
+ const mentionSuggestions = await inTextMentionSuggestions(parsed.incomplete, this.store, this.channelID, this.teamID, delimiter);
+ if (mentionSuggestions) {
+ return mentionSuggestions;
+ }
+
+ // Handle text values
let complete = parsed.incomplete;
if (complete && delimiter) {
complete = delimiter + complete + delimiter;
}
+ const fieldName = parsed.field.modal_label || parsed.field.label || parsed.field.name;
return [{
Complete: complete,
- Suggestion: `${parsed.field.label || parsed.field.name}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`,
+ Suggestion: `${fieldName}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`,
Description: f.description || '',
Hint: '',
IconData: parsed.binding?.icon || '',
@@ -1168,7 +1188,7 @@ export class AppCommandParser {
}
// getStaticSelectSuggestions returns suggestions specified in the field's options property
- getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => {
+ private getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => {
const f = parsed.field as AutocompleteStaticSelect;
const opts = f.options?.filter((opt) => opt.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()));
if (!opts?.length) {
@@ -1180,7 +1200,7 @@ export class AppCommandParser {
defaultMessage: 'No matching options.',
}),
Description: '',
- IconData: 'error',
+ IconData: COMMAND_SUGGESTION_ERROR,
}];
}
return opts.map((opt) => {
@@ -1201,7 +1221,7 @@ export class AppCommandParser {
}
// getDynamicSelectSuggestions fetches and returns suggestions from the server
- getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => {
+ private getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => {
const f = parsed.field;
if (!f) {
// Should never happen
@@ -1223,17 +1243,17 @@ export class AppCommandParser {
call.selected_field = f.name;
call.query = parsed.incomplete;
- type ResponseType = {items: AppSelectOption[]};
- const res = await this.store.dispatch(doAppCall(call, AppCallTypes.LOOKUP, this.intl));
+ const res = await this.store.dispatch(doAppCall(call, AppCallTypes.LOOKUP, this.intl)) as DoAppCallResult;
+
if (res.error) {
- const errorResponse = res.error as AppCallResponse;
+ const errorResponse = res.error;
return this.makeDynamicSelectSuggestionError(errorResponse.error || this.intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
}));
}
- const callResponse = res.data as AppCallResponse;
+ const callResponse = res.data!;
switch (callResponse.type) {
case AppCallResponseTypes.OK:
break;
@@ -1288,7 +1308,7 @@ export class AppCommandParser {
});
}
- makeDynamicSelectSuggestionError = (message: string): AutocompleteSuggestion[] => {
+ private makeDynamicSelectSuggestionError = (message: string): AutocompleteSuggestion[] => {
const errMsg = this.intl.formatMessage({
id: 'apps.error',
defaultMessage: 'Error: {error}',
@@ -1302,43 +1322,31 @@ export class AppCommandParser {
defaultMessage: 'Dynamic select error',
}),
Hint: '',
- IconData: 'error',
+ IconData: COMMAND_SUGGESTION_ERROR,
Description: errMsg,
}];
}
- // getUserSuggestions returns a suggestion with `@` if the user has not started typing
- getUserSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
- if (parsed.incomplete.trim().length === 0) {
- return [{
- Complete: '',
- Suggestion: '',
- Description: parsed.field?.description || '',
- Hint: parsed.field?.hint || '@username',
- IconData: parsed.binding?.icon || '',
- }];
+ private getUserFieldSuggestions = async (parsed: ParsedCommand): Promise => {
+ let input = parsed.incomplete.trim();
+ if (input[0] === '@') {
+ input = input.substring(1);
}
-
- return [];
+ const {data} = await this.store.dispatch(autocompleteUsersInChannel(input, this.channelID));
+ return getUserSuggestions(data);
}
- // getChannelSuggestions returns a suggestion with `~` if the user has not started typing
- getChannelSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
- if (parsed.incomplete.trim().length === 0) {
- return [{
- Complete: '',
- Suggestion: '',
- Description: parsed.field?.description || '',
- Hint: parsed.field?.hint || '~channelname',
- IconData: parsed.binding?.icon || '',
- }];
+ private getChannelFieldSuggestions = async (parsed: ParsedCommand): Promise => {
+ let input = parsed.incomplete.trim();
+ if (input[0] === '~') {
+ input = input.substring(1);
}
-
- return [];
+ const {data} = await this.store.dispatch(autocompleteChannels(this.teamID, input));
+ return getChannelSuggestions(data);
}
// getBooleanSuggestions returns true/false suggestions
- getBooleanSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
+ private getBooleanSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
const suggestions: AutocompleteSuggestion[] = [];
if ('true'.startsWith(parsed.incomplete)) {
diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts
index 1fc45f3aa..e8938b2b7 100644
--- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts
+++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts
@@ -4,7 +4,12 @@
import {intlShape} from 'react-intl';
import {Alert} from 'react-native';
-import keyMirror from '@mm-redux/utils/key_mirror';
+import {getUserByUsername, getUser, autocompleteUsers} from '@mm-redux/actions/users';
+import {getCurrentTeamId, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
+import {
+ ActionFunc,
+ DispatchFunc,
+} from '@mm-redux/types/actions';
import Store from '@store/store';
import type {ParsedCommand} from './app_command_parser';
@@ -24,8 +29,14 @@ export type {
AutocompleteStaticSelect,
AutocompleteUserSelect,
AutocompleteChannelSelect,
+ AppLookupResponse,
+ UserAutocomplete,
} from '@mm-redux/types/apps';
+export type {
+ DoAppCallResult,
+} from 'types/actions/apps';
+
export type {AutocompleteSuggestion};
export type {
@@ -38,33 +49,62 @@ export type {
export type {
DispatchFunc,
-} from '@mm-redux/types/actions';
+};
+
+export type {
+ UserProfile,
+} from '@mm-redux/types/users';
export {
AppBindingLocations,
AppCallTypes,
AppFieldTypes,
AppCallResponseTypes,
+ COMMAND_SUGGESTION_ERROR,
+ COMMAND_SUGGESTION_CHANNEL,
+ COMMAND_SUGGESTION_USER,
} from '@mm-redux/constants/apps';
-export {getAppsBindings} from '@mm-redux/selectors/entities/apps';
+export {makeAppBindingsSelector} from '@mm-redux/selectors/entities/apps';
+
export {getPost} from '@mm-redux/selectors/entities/posts';
export {getChannel as selectChannel, getCurrentChannel, getChannelByName as selectChannelByName} from '@mm-redux/selectors/entities/channels';
-export {getCurrentTeamId, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
+
+export {
+ getCurrentTeamId,
+ getCurrentTeam,
+};
+
export {getUserByUsername as selectUserByUsername, getUser as selectUser} from '@mm-redux/selectors/entities/users';
-export {getUserByUsername, getUser} from '@mm-redux/actions/users';
-export {getChannelByNameAndTeamName, getChannel} from '@mm-redux/actions/channels';
+export {
+ getUserByUsername,
+ getUser,
+ autocompleteUsers,
+};
+
+export {getChannelByNameAndTeamName, getChannel, autocompleteChannels} from '@mm-redux/actions/channels';
export {doAppCall} from '@actions/apps';
export {createCallRequest} from '@utils/apps';
export const getStore = () => Store.redux;
-export {keyMirror};
+export const autocompleteUsersInChannel = (prefix: string, channelID: string): ActionFunc => {
+ return async (dispatch, getState) => {
+ const state = getState();
+ const currentTeamID = getCurrentTeamId(state);
+ return dispatch(autocompleteUsers(prefix, currentTeamID, channelID));
+ };
+};
export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command';
+export type ExtendedAutocompleteSuggestion = AutocompleteSuggestion & {
+ type?: string;
+ item?: string;
+}
+
export const getExecuteSuggestion = (_: ParsedCommand): AutocompleteSuggestion | null => { // eslint-disable-line @typescript-eslint/no-unused-vars
return null;
};
@@ -85,3 +125,9 @@ export const errorMessage = (intl: typeof intlShape, error: string, _command: st
error,
});
};
+
+export {
+ getChannelSuggestions,
+ getUserSuggestions,
+ inTextMentionSuggestions,
+} from '@utils/mentions';
diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts
index d372565f2..6b977fb59 100644
--- a/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts
+++ b/app/components/autocomplete/slash_suggestion/app_command_parser/tests/app_command_parser_test_data.ts
@@ -89,7 +89,10 @@ export const reduxTestState = {
general: {
license: {IsLicensed: 'false'},
serverVersion: '5.25.0',
- config: {PostEditTimeLimit: -1},
+ config: {
+ PostEditTimeLimit: -1,
+ FeatureFlagAppsEnabled: 'true',
+ },
},
},
};
diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/__snapshots__/app_slash_suggestion.test.tsx.snap b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/__snapshots__/app_slash_suggestion.test.tsx.snap
new file mode 100644
index 000000000..5e754da27
--- /dev/null
+++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/__snapshots__/app_slash_suggestion.test.tsx.snap
@@ -0,0 +1,45 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components/autocomplete/app_slash_suggestion should match snapshot 1`] = `
+
+`;
diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.test.tsx b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.test.tsx
new file mode 100644
index 000000000..fe1ebffdf
--- /dev/null
+++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.test.tsx
@@ -0,0 +1,147 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {shallow} from 'enzyme';
+import React from 'react';
+import {intl} from 'test/intl-test-helper';
+
+import Preferences from '@mm-redux/constants/preferences';
+import {AutocompleteSuggestion} from '@mm-redux/types/integrations';
+import Store from '@store/store';
+
+import {
+ reduxTestState,
+ testBindings,
+} from '../app_command_parser/tests/app_command_parser_test_data';
+import {
+ thunk,
+ configureStore,
+ Client4,
+ AppBinding,
+} from '../app_command_parser/tests/app_command_parser_test_dependencies';
+
+const mockStore = configureStore([thunk]);
+
+const makeStore = async (bindings: AppBinding[]) => {
+ const initialState = {
+ ...reduxTestState,
+ entities: {
+ ...reduxTestState.entities,
+ apps: {bindings},
+ },
+ } as any;
+ const testStore = await mockStore(initialState);
+
+ return testStore;
+};
+
+import SlashSuggestion, {Props} from './app_slash_suggestion';
+
+describe('components/autocomplete/app_slash_suggestion', () => {
+ const baseProps: Props = {
+ currentTeamId: '',
+ isSearch: false,
+ maxListHeight: 50,
+ theme: Preferences.THEMES.default,
+ onChangeText: jest.fn(),
+ onResultCountChange: jest.fn(),
+ value: '',
+ nestedScrollEnabled: false,
+ rootId: '',
+ channelId: 'thechannel',
+ appsEnabled: true,
+ };
+
+ const f = Client4.getServerVersion;
+
+ beforeAll(async () => {
+ Client4.getServerVersion = jest.fn().mockReturnValue('5.30.0');
+
+ const store = await makeStore(testBindings);
+ Store.redux = store;
+ });
+
+ afterAll(() => {
+ Client4.getServerVersion = f;
+ });
+
+ test('should match snapshot', () => {
+ const props: Props = {
+ ...baseProps,
+ };
+
+ const wrapper = shallow(
+ ,
+ {context: {intl}},
+ );
+
+ const dataSource: AutocompleteSuggestion[] = [
+ {
+ Complete: 'thetrigger',
+ Description: 'The Description',
+ Hint: 'The Hint',
+ IconData: 'iconurl.com',
+ Suggestion: '/thetrigger',
+ },
+ ];
+ wrapper.setState({active: true, dataSource, lastCommandRequest: 1234});
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+
+ test('should not show commands from app base commands', async () => {
+ const props: Props = {
+ ...baseProps,
+ };
+
+ const wrapper = shallow(
+ ,
+ {context: {intl}},
+ );
+ wrapper.setProps({value: '/ji'});
+
+ expect(wrapper.state('dataSource')).toEqual([]);
+ });
+
+ test('should show commands from app sub commands', async (done) => {
+ const props: Props = {
+ ...baseProps,
+ };
+
+ const wrapper = shallow(
+ ,
+ {context: {intl}},
+ );
+ wrapper.setProps({value: '/jira i'});
+
+ const expected: AutocompleteSuggestion[] = [
+ {
+ Complete: 'jira issue',
+ Description: 'Interact with Jira issues',
+ Hint: 'Issue hint',
+ IconData: 'Issue icon',
+ Suggestion: 'issue',
+ },
+ ];
+
+ setTimeout(() => {
+ expect(wrapper.state('dataSource')).toEqual(expected);
+ done();
+ });
+ });
+
+ test('should avoid using app commands when apps are disabled', async () => {
+ const props: Props = {
+ ...baseProps,
+ appsEnabled: false,
+ };
+
+ const wrapper = shallow(
+ ,
+ {context: {intl}},
+ );
+ wrapper.setProps({value: '/jira i'});
+
+ expect(wrapper.state('dataSource')).toEqual([]);
+ });
+});
diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx
new file mode 100644
index 000000000..76d11cca4
--- /dev/null
+++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx
@@ -0,0 +1,238 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {PureComponent} from 'react';
+import {intlShape} from 'react-intl';
+import {
+ FlatList,
+ Platform,
+} from 'react-native';
+
+import {Client4} from '@client/rest';
+import AtMentionItem from '@components/autocomplete/at_mention_item';
+import ChannelMentionItem from '@components/autocomplete/channel_mention_item';
+import {analytics} from '@init/analytics';
+import {AutocompleteSuggestion} from '@mm-redux/types/integrations';
+import {Theme} from '@mm-redux/types/preferences';
+import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
+import {makeStyleSheetFromTheme} from '@utils/theme';
+
+import {AppCommandParser} from '../app_command_parser/app_command_parser';
+import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER, ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser_dependencies';
+import SlashSuggestionItem from '../slash_suggestion_item';
+
+export type Props = {
+ currentTeamId: string;
+ isSearch?: boolean;
+ maxListHeight?: number;
+ theme: Theme;
+ onChangeText: (text: string) => void;
+ onResultCountChange: (count: number) => void;
+ value: string;
+ nestedScrollEnabled?: boolean;
+ rootId?: string;
+ channelId: string;
+ appsEnabled: boolean;
+};
+
+type State = {
+ active: boolean;
+ dataSource: AutocompleteSuggestion[];
+}
+
+export default class AppSlashSuggestion extends PureComponent {
+ static defaultProps = {
+ defaultChannel: {},
+ value: '',
+ };
+
+ static contextTypes = {
+ intl: intlShape.isRequired,
+ };
+
+ appCommandParser: AppCommandParser;
+
+ state = {
+ active: false,
+ dataSource: [],
+ };
+
+ constructor(props: Props, context: any) {
+ super(props);
+ this.appCommandParser = new AppCommandParser(null, context.intl, props.channelId, props.currentTeamId, props.rootId);
+ }
+
+ setActive(active: boolean) {
+ this.setState({active});
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (!this.props.appsEnabled) {
+ this.setActive(false);
+ this.props.onResultCountChange(0);
+ return;
+ }
+
+ if (
+ (this.props.value === prevProps.value) ||
+ this.props.isSearch || this.props.value.startsWith('//') || !this.props.channelId
+ ) {
+ this.props.onResultCountChange(0);
+ return;
+ }
+
+ const {
+ value: nextValue,
+ } = this.props;
+
+ if (nextValue[0] !== '/') {
+ this.setActive(false);
+ this.props.onResultCountChange(0);
+ return;
+ }
+
+ if (nextValue.indexOf(' ') === -1) { // delegate base command to command parser
+ this.setActive(false);
+ this.props.onResultCountChange(0);
+ return;
+ }
+
+ if (!this.isAppCommand(nextValue, this.props.channelId, this.props.currentTeamId, this.props.rootId)) {
+ this.setActive(false);
+ this.props.onResultCountChange(0);
+ return;
+ }
+
+ this.fetchAndShowAppCommandSuggestions(nextValue, this.props.channelId, this.props.currentTeamId, this.props.rootId);
+ }
+
+ isAppCommand = (pretext: string, channelID: string, teamID = '', rootID?: string) => {
+ this.appCommandParser.setChannelContext(channelID, teamID, rootID);
+ return this.appCommandParser.isAppCommand(pretext);
+ }
+
+ fetchAndShowAppCommandSuggestions = async (pretext: string, channelID: string, teamID = '', rootID?: string) => {
+ this.appCommandParser.setChannelContext(channelID, teamID, rootID);
+ const suggestions = await this.appCommandParser.getSuggestions(pretext);
+ this.updateSuggestions(suggestions);
+ }
+
+ updateSuggestions = (matches: ExtendedAutocompleteSuggestion[]) => {
+ this.setState({
+ active: Boolean(matches.length),
+ dataSource: matches,
+ });
+ this.props.onResultCountChange(matches.length);
+ }
+
+ completeSuggestion = (command: string) => {
+ const {onChangeText} = this.props;
+ analytics.trackCommand('complete_suggestion', `/${command} `);
+
+ // We are going to set a double / on iOS to prevent the auto correct from taking over and replacing it
+ // with the wrong value, this is a hack but I could not found another way to solve it
+ let completedDraft = `/${command} `;
+ if (Platform.OS === 'ios') {
+ completedDraft = `//${command} `;
+ }
+
+ onChangeText(completedDraft);
+
+ if (Platform.OS === 'ios') {
+ // This is the second part of the hack were we replace the double / with just one
+ // after the auto correct vanished
+ setTimeout(() => {
+ onChangeText(completedDraft.replace(`//${command} `, `/${command} `));
+ });
+ }
+
+ if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
+ this.setState({
+ active: false,
+ });
+ }
+ };
+
+ completeUserSuggestion = (base: string): (username: string) => void => {
+ return () => {
+ this.completeSuggestion(base);
+ };
+ }
+
+ completeChannelMention = (base: string): (channelName: string) => void => {
+ return () => {
+ this.completeSuggestion(base);
+ };
+ }
+
+ keyExtractor = (item: ExtendedAutocompleteSuggestion): string => item.Suggestion + item.type + item.item;
+
+ renderItem = ({item}: {item: ExtendedAutocompleteSuggestion}) => {
+ switch (item.type) {
+ case COMMAND_SUGGESTION_USER:
+ return (
+
+ );
+ case COMMAND_SUGGESTION_CHANNEL:
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+ }
+
+ render() {
+ const {maxListHeight, theme, nestedScrollEnabled} = this.props;
+
+ 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(theme);
+
+ return (
+
+ );
+ }
+}
+
+const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
+ return {
+ listView: {
+ flex: 1,
+ backgroundColor: theme.centerChannelBg,
+ paddingTop: 8,
+ borderRadius: 4,
+ },
+ };
+});
diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts
new file mode 100644
index 000000000..414c7b1dd
--- /dev/null
+++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts
@@ -0,0 +1,21 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {connect} from 'react-redux';
+
+import {getTheme} from '@mm-redux/selectors/entities/preferences';
+import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
+import {GlobalState} from '@mm-redux/types/store';
+import {appsEnabled} from '@utils/apps';
+
+import AppSlashSuggestion from './app_slash_suggestion';
+
+function mapStateToProps(state: GlobalState) {
+ return {
+ currentTeamId: getCurrentTeamId(state),
+ theme: getTheme(state),
+ appsEnabled: appsEnabled(state),
+ };
+}
+
+export default connect(mapStateToProps)(AppSlashSuggestion);
diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.test.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.test.tsx
index 5bfd74eda..6fe2f13b0 100644
--- a/app/components/autocomplete/slash_suggestion/slash_suggestion.test.tsx
+++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.test.tsx
@@ -212,33 +212,6 @@ describe('components/autocomplete/slash_suggestion', () => {
]);
});
- test('should show commands from app sub commands', async (done) => {
- const props: Props = {
- ...baseProps,
- };
-
- const wrapper = shallow(
- ,
- {context: {intl}},
- );
- wrapper.setProps({value: '/jira i', suggestions: []});
-
- const expected: AutocompleteSuggestion[] = [
- {
- Complete: 'jira issue',
- Description: 'Interact with Jira issues',
- Hint: 'Issue hint',
- IconData: 'Issue icon',
- Suggestion: 'issue',
- },
- ];
-
- setTimeout(() => {
- expect(wrapper.state('dataSource')).toEqual(expected);
- done();
- });
- });
-
test('should avoid using app commands when apps are disabled', async () => {
const props: Props = {
...baseProps,
diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx
index ef08458ff..1b2174cb0 100644
--- a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx
+++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx
@@ -66,7 +66,7 @@ export default class SlashSuggestion extends PureComponent {
constructor(props: Props, context: any) {
super(props);
- this.appCommandParser = new AppCommandParser(null, context.intl, props.channelId, props.rootId);
+ this.appCommandParser = new AppCommandParser(null, context.intl, props.channelId, props.currentTeamId, props.rootId);
}
setActive(active: boolean) {
@@ -109,12 +109,9 @@ export default class SlashSuggestion extends PureComponent {
this.setLastCommandRequest(Date.now());
}
- this.showBaseCommands(nextValue, nextCommands, prevProps.channelId, prevProps.rootId);
+ this.showBaseCommands(nextValue, nextCommands, this.props.channelId, this.props.currentTeamId, this.props.rootId);
} else if (isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
- // If this is an app command, then hand it off to the app command parser.
- if (this.props.appsEnabled && this.isAppCommand(nextValue, prevProps.channelId, prevProps.rootId)) {
- this.fetchAndShowAppCommandSuggestions(nextValue, prevProps.channelId, prevProps.rootId);
- } else if (nextSuggestions === prevProps.suggestions) {
+ if (nextSuggestions === prevProps.suggestions) {
const args = {
channel_id: prevProps.channelId,
team_id: prevProps.currentTeamId,
@@ -135,11 +132,11 @@ export default class SlashSuggestion extends PureComponent {
}
}
- showBaseCommands = (text: string, commands: Command[], channelID: string, rootID?: string) => {
+ showBaseCommands = (text: string, commands: Command[], channelID: string, teamID = '', rootID?: string) => {
let matches: AutocompleteSuggestion[] = [];
if (this.props.appsEnabled) {
- const appCommands = this.getAppBaseCommandSuggestions(text, channelID, rootID);
+ const appCommands = this.getAppBaseCommandSuggestions(text, channelID, teamID, rootID);
matches = matches.concat(appCommands);
}
@@ -155,19 +152,8 @@ export default class SlashSuggestion extends PureComponent {
this.updateSuggestions(matches);
}
- isAppCommand = (pretext: string, channelID: string, rootID?: string) => {
- this.appCommandParser.setChannelContext(channelID, rootID);
- return this.appCommandParser.isAppCommand(pretext);
- }
-
- fetchAndShowAppCommandSuggestions = async (pretext: string, channelID: string, rootID?: string) => {
- this.appCommandParser.setChannelContext(channelID, rootID);
- const suggestions = await this.appCommandParser.getSuggestions(pretext);
- this.updateSuggestions(suggestions);
- }
-
- getAppBaseCommandSuggestions = (pretext: string, channelID: string, rootID?: string): AutocompleteSuggestion[] => {
- this.appCommandParser.setChannelContext(channelID, rootID);
+ getAppBaseCommandSuggestions = (pretext: string, channelID: string, teamID = '', rootID?: string): AutocompleteSuggestion[] => {
+ this.appCommandParser.setChannelContext(channelID, teamID, rootID);
const suggestions = this.appCommandParser.getSuggestionsBase(pretext);
return suggestions;
}
@@ -225,12 +211,6 @@ export default class SlashSuggestion extends PureComponent {
onChangeText(completedDraft.replace(`//${command} `, `/${command} `));
});
}
-
- if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
- this.setState({
- active: false,
- });
- }
};
keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.Suggestion;
diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx
index 854da0a3b..8348fc025 100644
--- a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx
+++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx
@@ -9,6 +9,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {SvgXml} from 'react-native-svg';
import TouchableWithFeedback from '@components/touchable_with_feedback';
+import {COMMAND_SUGGESTION_ERROR} from '@mm-redux/constants/apps';
import {Theme} from '@mm-redux/types/preferences';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -105,7 +106,7 @@ const SlashSuggestionItem = (props: Props) => {
source={slashIcon}
/>
);
- if (props.icon === 'error') {
+ if (props.icon === COMMAND_SUGGESTION_ERROR) {
image = (
{
+ return createSelector(
+ (state: GlobalState) => state.entities.apps.bindings,
+ (state: GlobalState) => appsEnabled(state),
+ (bindings: AppBinding[], areAppsEnabled: boolean) => {
+ if (!areAppsEnabled || !bindings) {
+ return [];
+ }
+
+ const headerBindings = bindings.filter((b) => b.location === location);
+ return headerBindings.reduce((accum: AppBinding[], current: AppBinding) => accum.concat(current.bindings || []), []);
+ },
+ );
+};
diff --git a/app/mm-redux/types/apps.ts b/app/mm-redux/types/apps.ts
index adf48e23c..b29bb65e7 100644
--- a/app/mm-redux/types/apps.ts
+++ b/app/mm-redux/types/apps.ts
@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {UserProfile} from './users';
+
export type AppManifest = {
app_id: string;
display_name: string;
@@ -177,6 +179,14 @@ export type AppField = {
max_length?: number;
};
+export type UserAutocomplete = {
+ users: UserProfile[];
+
+ // out_of_channel contains users that aren't in the given channel. It's only populated when autocompleting users in
+ // a given channel ID.
+ out_of_channel?: UserProfile[];
+};
+
export type AutocompleteSuggestion = {
suggestion: string;
complete?: string;
diff --git a/app/mm-redux/types/channels.ts b/app/mm-redux/types/channels.ts
index 5655ff9f5..392a75cce 100644
--- a/app/mm-redux/types/channels.ts
+++ b/app/mm-redux/types/channels.ts
@@ -37,6 +37,7 @@ export type Channel = {
status?: string;
fake?: boolean;
group_constrained: boolean;
+ shared?: boolean;
};
export type ChannelWithTeamData = Channel & {
team_display_name: string;
diff --git a/app/selectors/autocomplete.js b/app/selectors/autocomplete.ts
similarity index 90%
rename from app/selectors/autocomplete.js
rename to app/selectors/autocomplete.ts
index 30c602415..3b228bd76 100644
--- a/app/selectors/autocomplete.js
+++ b/app/selectors/autocomplete.ts
@@ -11,15 +11,16 @@ import {
getCurrentUser, getProfilesInCurrentChannel,
getProfilesNotInCurrentChannel, getProfilesInCurrentTeam,
} from '@mm-redux/selectors/entities/users';
+import {GlobalState} from '@mm-redux/types/store';
import {sortChannelsByDisplayName} from '@mm-redux/utils/channel_utils';
import {sortByUsername} from '@mm-redux/utils/user_utils';
import {getCurrentLocale} from '@selectors/i18n';
export const getMatchTermForAtMention = (() => {
- let lastMatchTerm = null;
- let lastValue;
- let lastIsSearch;
- return (value, isSearch) => {
+ let lastMatchTerm: string | null = null;
+ let lastValue: string;
+ let lastIsSearch: boolean;
+ return (value: string, isSearch: boolean) => {
if (value !== lastValue || isSearch !== lastIsSearch) {
const regex = isSearch ? Autocomplete.AT_MENTION_SEARCH_REGEX : Autocomplete.AT_MENTION_REGEX;
let term = value;
@@ -41,10 +42,10 @@ export const getMatchTermForAtMention = (() => {
})();
export const getMatchTermForChannelMention = (() => {
- let lastMatchTerm = null;
- let lastValue;
- let lastIsSearch;
- return (value, isSearch) => {
+ let lastMatchTerm: string | null = null;
+ let lastValue: string;
+ let lastIsSearch: boolean;
+ return (value: string, isSearch: boolean) => {
if (value !== lastValue || isSearch !== lastIsSearch) {
const regex = isSearch ? Autocomplete.CHANNEL_MENTION_SEARCH_REGEX : Autocomplete.CHANNEL_MENTION_REGEX;
const match = value.match(regex);
@@ -53,7 +54,7 @@ export const getMatchTermForChannelMention = (() => {
if (match) {
if (isSearch) {
lastMatchTerm = match[1].toLowerCase();
- } else if (match.index > 0 && value[match.index - 1] === '~') {
+ } else if (match.index && match.index > 0 && value[match.index - 1] === '~') {
lastMatchTerm = null;
} else {
lastMatchTerm = match[2].toLowerCase();
@@ -68,7 +69,7 @@ export const getMatchTermForChannelMention = (() => {
export const filterMembersInChannel = createSelector(
getProfilesInCurrentChannel,
- (state, matchTerm) => matchTerm,
+ (state: GlobalState, matchTerm: string) => matchTerm,
(profilesInChannel, matchTerm) => {
if (matchTerm === null) {
return null;
@@ -95,7 +96,7 @@ export const filterMembersInChannel = createSelector(
export const filterMembersNotInChannel = createSelector(
getProfilesNotInCurrentChannel,
- (state, matchTerm) => matchTerm,
+ (state: GlobalState, matchTerm: string) => matchTerm,
(profilesNotInChannel, matchTerm) => {
if (matchTerm === null) {
return null;
@@ -127,7 +128,7 @@ export const filterMembersNotInChannel = createSelector(
export const filterMembersInCurrentTeam = createSelector(
getProfilesInCurrentTeam,
getCurrentUser,
- (state, matchTerm) => matchTerm,
+ (state: GlobalState, matchTerm: string) => matchTerm,
(profilesInTeam, currentUser, matchTerm) => {
if (matchTerm === null) {
return null;
@@ -151,7 +152,7 @@ export const filterMembersInCurrentTeam = createSelector(
export const filterMyChannels = createSelector(
getMyChannels,
- (state, opts) => opts,
+ (state: GlobalState, opts: any) => opts,
(myChannels, matchTerm) => {
if (matchTerm === null) {
return null;
@@ -175,7 +176,7 @@ export const filterMyChannels = createSelector(
export const filterOtherChannels = createSelector(
getOtherChannels,
- (state, matchTerm) => matchTerm,
+ (state: GlobalState, matchTerm: string) => matchTerm,
(otherChannels, matchTerm) => {
if (matchTerm === null) {
return null;
@@ -198,7 +199,7 @@ export const filterPublicChannels = createSelector(
getMyChannels,
getOtherChannels,
getCurrentLocale,
- (state, matchTerm) => matchTerm,
+ (state: GlobalState, matchTerm: string) => matchTerm,
getConfig,
(myChannels, otherChannels, locale, matchTerm, config) => {
if (matchTerm === null) {
@@ -230,7 +231,7 @@ export const filterPublicChannels = createSelector(
export const filterPrivateChannels = createSelector(
getMyChannels,
- (state, matchTerm) => matchTerm,
+ (state: GlobalState, matchTerm: string) => matchTerm,
getConfig,
(myChannels, matchTerm, config) => {
if (matchTerm === null) {
@@ -261,7 +262,7 @@ export const filterPrivateChannels = createSelector(
export const filterDirectAndGroupMessages = createSelector(
getMyChannels,
(state) => state.entities.channels.channels,
- (state, matchTerm) => matchTerm,
+ (state: GlobalState, matchTerm: string) => matchTerm,
(myChannels, originalChannels, matchTerm) => {
if (matchTerm === null) {
return null;
@@ -293,9 +294,9 @@ export const filterDirectAndGroupMessages = createSelector(
);
export const makeGetMatchTermForDateMention = () => {
- let lastMatchTerm = null;
- let lastValue;
- return (value) => {
+ let lastMatchTerm: string | null = null;
+ let lastValue: string;
+ return (value: string) => {
if (value !== lastValue) {
const regex = Autocomplete.DATE_MENTION_SEARCH_REGEX;
const match = value.match(regex);
diff --git a/app/selectors/channel.js b/app/selectors/channel.ts
similarity index 81%
rename from app/selectors/channel.js
rename to app/selectors/channel.ts
index 5ceb6a8d4..49a311a7f 100644
--- a/app/selectors/channel.js
+++ b/app/selectors/channel.ts
@@ -8,10 +8,12 @@ import {getChannelByName, getChannelsInCurrentTeam, getMyChannelMemberships} fro
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getTeamByName} from '@mm-redux/selectors/entities/teams';
import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users';
+import {Channel} from '@mm-redux/types/channels';
+import {GlobalState} from '@mm-redux/types/store';
import {isArchivedChannel} from '@mm-redux/utils/channel_utils';
const getOtherUserIdForDm = createSelector(
- (state, channel) => channel,
+ (state: GlobalState, channel: Channel) => channel,
getCurrentUserId,
(channel, currentUserId) => {
if (!channel) {
@@ -23,7 +25,7 @@ const getOtherUserIdForDm = createSelector(
);
export const getChannelMembersForDm = createSelector(
- (state, channel) => getUser(state, getOtherUserIdForDm(state, channel)),
+ (state: GlobalState, channel: Channel) => getUser(state, getOtherUserIdForDm(state, channel)),
(otherUser) => {
if (!otherUser) {
return [];
@@ -34,7 +36,7 @@ export const getChannelMembersForDm = createSelector(
);
export const getChannelNameForSearchAutocomplete = createSelector(
- (state, channelId) => state.entities.channels.channels[channelId],
+ (state: GlobalState, channelId: string) => state.entities.channels.channels[channelId],
(channel) => {
if (channel && channel.display_name) {
return channel.display_name;
@@ -43,8 +45,8 @@ export const getChannelNameForSearchAutocomplete = createSelector(
},
);
-const getTeam = (state, channelName, teamName) => getTeamByName(state, teamName);
-const getChannel = (state, channelName) => getChannelByName(state, channelName);
+const getTeam = (state: GlobalState, channelName: string, teamName: string) => getTeamByName(state, teamName);
+const getChannel = (state: GlobalState, channelName: string) => getChannelByName(state, channelName);
export const getChannelReachable = createSelector(
getTeam,
diff --git a/app/utils/mentions/index.ts b/app/utils/mentions/index.ts
new file mode 100644
index 000000000..249da4677
--- /dev/null
+++ b/app/utils/mentions/index.ts
@@ -0,0 +1,122 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {
+ autocompleteChannels,
+ AutocompleteSuggestion,
+ autocompleteUsersInChannel,
+ Channel,
+ COMMAND_SUGGESTION_CHANNEL,
+ COMMAND_SUGGESTION_USER,
+ DispatchFunc,
+ GlobalState,
+ UserAutocomplete,
+ UserProfile,
+} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies';
+
+interface Store {
+ dispatch: DispatchFunc;
+ getState: () => GlobalState;
+}
+
+export async function inTextMentionSuggestions(pretext: string, store: Store, channelID: string, teamID: string, delimiter = ''): Promise {
+ const separatedWords = pretext.split(' ');
+ const incompleteLessLastWord = separatedWords.slice(0, -1).join(' ');
+ const lastWord = separatedWords[separatedWords.length - 1];
+ if (lastWord.startsWith('@')) {
+ const {data} = await store.dispatch(autocompleteUsersInChannel(lastWord.substring(1), channelID));
+ const users = await getUserSuggestions(data);
+ users.forEach((u) => {
+ let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + u.Complete : u.Complete;
+ if (delimiter) {
+ complete = delimiter + complete;
+ }
+ u.Complete = complete;
+ });
+ return users;
+ }
+
+ if (lastWord.startsWith('~') && !lastWord.startsWith('~~')) {
+ const {data} = await store.dispatch(autocompleteChannels(teamID, lastWord.substring(1)));
+ const channels = await getChannelSuggestions(data);
+ channels.forEach((c) => {
+ let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + c.Complete : c.Complete;
+ if (delimiter) {
+ complete = delimiter + complete;
+ }
+ c.Complete = complete;
+ });
+ return channels;
+ }
+
+ return null;
+}
+
+export async function getUserSuggestions(usersAutocomplete?: UserAutocomplete): Promise {
+ const notFoundSuggestions = [{
+ Complete: '',
+ Suggestion: '',
+ Description: 'No user found',
+ Hint: '',
+ IconData: '',
+ }];
+ if (!usersAutocomplete) {
+ return notFoundSuggestions;
+ }
+
+ if (!usersAutocomplete.users.length && !usersAutocomplete.out_of_channel?.length) {
+ return notFoundSuggestions;
+ }
+
+ const items: AutocompleteSuggestion[] = [];
+ usersAutocomplete.users.forEach((u) => {
+ items.push(getUserSuggestion(u));
+ });
+ usersAutocomplete.out_of_channel?.forEach((u) => {
+ items.push(getUserSuggestion(u));
+ });
+
+ return items;
+}
+
+export async function getChannelSuggestions(channels?: Channel[]): Promise {
+ const notFoundSuggestion = [{
+ Complete: '',
+ Suggestion: '',
+ Description: 'No channel found',
+ Hint: '',
+ IconData: '',
+ }];
+ if (!channels) {
+ return notFoundSuggestion;
+ }
+ if (!channels.length) {
+ return notFoundSuggestion;
+ }
+
+ const items = channels.map((c) => {
+ return {
+ Complete: '~' + c.name,
+ Suggestion: '',
+ Description: '',
+ Hint: '',
+ IconData: '',
+ type: COMMAND_SUGGESTION_CHANNEL,
+ item: c.id,
+ };
+ });
+
+ return items;
+}
+
+function getUserSuggestion(u: UserProfile) {
+ return {
+ Complete: '@' + u.username,
+ Suggestion: '',
+ Description: '',
+ Hint: '',
+ IconData: '',
+ type: COMMAND_SUGGESTION_USER,
+ item: u.id,
+ };
+}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index e9191806c..cc467edb9 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -26,7 +26,6 @@
"apps.error.lookup.error_preparing_request": "Error preparing lookup request: {errorMessage}",
"apps.error.parser": "Parsing error: {error}",
"apps.error.parser.empty_value": "empty values are not allowed",
- "apps.error.parser.missing_binding": "Missing command bindings.",
"apps.error.parser.missing_call": "Missing binding call.",
"apps.error.parser.missing_field_value": "Field value is missing.",
"apps.error.parser.missing_quote": "Matching double quote expected before end of input.",