[MM-34415, MM-34808, MM-34572, MM-34385, MM-32445, MM-31525] Handle user and channel provider from the app parser (#5503)

* Handle user and channel provider from the app parser

* Do not process mapStateToProps on appsTakeOver

* Fix tsc

* Address feedback

* Fix broken reference

* Address feedback and fix tests

* Fix i18n

* Use instantiated teamID on parser context

* Address feedback

* Fix test

* Address feedback

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
Daniel Espino García 2021-08-17 10:45:49 +02:00 committed by GitHub
parent 0251a19aa9
commit b7fcda3956
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 902 additions and 253 deletions

View file

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

View file

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

View file

@ -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 (
<View
style={wrapperStyles}
@ -212,50 +220,62 @@ export default class Autocomplete extends PureComponent {
ref={this.containerRef}
style={containerStyles}
>
<AtMention
{...this.props}
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
onChangeText={this.onChangeText}
onResultCountChange={this.handleAtMentionCountChange}
value={value || ''}
nestedScrollEnabled={this.props.nestedScrollEnabled}
/>
<ChannelMention
{...this.props}
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
onChangeText={this.onChangeText}
onResultCountChange={this.handleChannelMentionCountChange}
value={value || ''}
nestedScrollEnabled={this.props.nestedScrollEnabled}
/>
<EmojiSuggestion
{...this.props}
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
onChangeText={this.onChangeText}
onResultCountChange={this.handleEmojiCountChange}
value={value || ''}
nestedScrollEnabled={this.props.nestedScrollEnabled}
/>
<SlashSuggestion
{...this.props}
maxListHeight={maxListHeight}
onChangeText={this.onChangeText}
onResultCountChange={this.handleCommandCountChange}
value={value || ''}
nestedScrollEnabled={this.props.nestedScrollEnabled}
/>
{(this.props.isSearch && this.props.enableDateSuggestion) &&
<DateSuggestion
{...this.props}
cursorPosition={cursorPosition}
onChangeText={this.onChangeText}
onResultCountChange={this.handleIsDateFilterChange}
value={value || ''}
/>
}
{appsEnabled && (
<AppSlashSuggestion
{...this.props}
maxListHeight={maxListHeight}
onChangeText={this.onChangeText}
onResultCountChange={this.handleAppCommandCountChange}
value={value || ''}
nestedScrollEnabled={this.props.nestedScrollEnabled}
/>
)}
{(!appsTakeOver || !appsEnabled) && (<>
<AtMention
{...this.props}
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
onChangeText={this.onChangeText}
onResultCountChange={this.handleAtMentionCountChange}
value={value || ''}
nestedScrollEnabled={this.props.nestedScrollEnabled}
/>
<ChannelMention
{...this.props}
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
onChangeText={this.onChangeText}
onResultCountChange={this.handleChannelMentionCountChange}
value={value || ''}
nestedScrollEnabled={this.props.nestedScrollEnabled}
/>
<EmojiSuggestion
{...this.props}
cursorPosition={cursorPosition}
maxListHeight={maxListHeight}
onChangeText={this.onChangeText}
onResultCountChange={this.handleEmojiCountChange}
value={value || ''}
nestedScrollEnabled={this.props.nestedScrollEnabled}
/>
<SlashSuggestion
{...this.props}
maxListHeight={maxListHeight}
onChangeText={this.onChangeText}
onResultCountChange={this.handleCommandCountChange}
value={value || ''}
nestedScrollEnabled={this.props.nestedScrollEnabled}
/>
{(this.props.isSearch && this.props.enableDateSuggestion) &&
<DateSuggestion
{...this.props}
cursorPosition={cursorPosition}
onChangeText={this.onChangeText}
onResultCountChange={this.handleIsDateFilterChange}
value={value || ''}
/>
}
</>)}
</View>
</View>
);

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ParsedCommand> => {
public matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise<ParsedCommand> => {
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<AutocompleteSuggestion[]> => {
public getSuggestions = async (pretext: string): Promise<ExtendedAutocompleteSuggestion[]> => {
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<AutocompleteSuggestion[]> => {
private getParameterSuggestions = async (parsed: ParsedCommand): Promise<ExtendedAutocompleteSuggestion[]> => {
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<AutocompleteSuggestion[]> => {
private getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise<ExtendedAutocompleteSuggestion[]> => {
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<AutocompleteSuggestion[]> => {
private getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise<AutocompleteSuggestion[]> => {
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<ResponseType>(call, AppCallTypes.LOOKUP, this.intl));
const res = await this.store.dispatch(doAppCall(call, AppCallTypes.LOOKUP, this.intl)) as DoAppCallResult<AppLookupResponse>;
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<ResponseType>;
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<AutocompleteSuggestion[]> => {
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<AutocompleteSuggestion[]> => {
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)) {

View file

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

View file

@ -89,7 +89,10 @@ export const reduxTestState = {
general: {
license: {IsLicensed: 'false'},
serverVersion: '5.25.0',
config: {PostEditTimeLimit: -1},
config: {
PostEditTimeLimit: -1,
FeatureFlagAppsEnabled: 'true',
},
},
},
};

View file

@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/autocomplete/app_slash_suggestion should match snapshot 1`] = `
<FlatList
data={
Array [
Object {
"Complete": "thetrigger",
"Description": "The Description",
"Hint": "The Hint",
"IconData": "iconurl.com",
"Suggestion": "/thetrigger",
},
]
}
disableVirtualization={false}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={10}
nestedScrollEnabled={false}
numColumns={1}
onEndReachedThreshold={2}
removeClippedSubviews={true}
renderItem={[Function]}
scrollEventThrottle={50}
style={
Array [
Object {
"backgroundColor": "#ffffff",
"borderRadius": 4,
"flex": 1,
"paddingTop": 8,
},
Object {
"maxHeight": 50,
},
]
}
testID="app_slash_suggestion.list"
updateCellsBatchingPeriod={50}
windowSize={21}
/>
`;

View file

@ -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(
<SlashSuggestion {...props}/>,
{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<SlashSuggestion>(
<SlashSuggestion {...props}/>,
{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<SlashSuggestion>(
<SlashSuggestion {...props}/>,
{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<SlashSuggestion>(
<SlashSuggestion {...props}/>,
{context: {intl}},
);
wrapper.setProps({value: '/jira i'});
expect(wrapper.state('dataSource')).toEqual([]);
});
});

View file

@ -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<Props, State> {
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 (
<AtMentionItem
userId={item.item || ''}
onPress={this.completeUserSuggestion(item.Complete)}
testID={`autocomplete.at_mention.item.${item.item}`}
/>
);
case COMMAND_SUGGESTION_CHANNEL:
return (
<ChannelMentionItem
channelId={item.item || ''}
onPress={this.completeChannelMention(item.Complete)}
testID={`autocomplete.channel_mention.item.${item.item}`}
/>
);
default:
return (
<SlashSuggestionItem
description={item.Description}
hint={item.Hint}
onPress={this.completeSuggestion}
theme={this.props.theme}
suggestion={item.Suggestion}
complete={item.Complete}
icon={item.IconData}
/>
);
}
}
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 (
<FlatList
testID='app_slash_suggestion.list'
keyboardShouldPersistTaps='always'
style={[style.listView, {maxHeight: maxListHeight}]}
data={this.state.dataSource}
keyExtractor={this.keyExtractor}
removeClippedSubviews={true}
renderItem={this.renderItem}
nestedScrollEnabled={nestedScrollEnabled}
/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
listView: {
flex: 1,
backgroundColor: theme.centerChannelBg,
paddingTop: 8,
borderRadius: 4,
},
};
});

View file

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

View file

@ -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<SlashSuggestion>(
<SlashSuggestion {...props}/>,
{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,

View file

@ -66,7 +66,7 @@ export default class SlashSuggestion extends PureComponent<Props, State> {
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<Props, State> {
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<Props, State> {
}
}
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<Props, State> {
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<Props, State> {
onChangeText(completedDraft.replace(`//${command} `, `/${command} `));
});
}
if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
this.setState({
active: false,
});
}
};
keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.Suggestion;

View file

@ -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 = (
<Image
style={style.iconColor}

View file

@ -45,3 +45,7 @@ export const AppFieldTypes: { [name: string]: AppFieldType } = {
CHANNEL: 'channel',
MARKDOWN: 'markdown',
};
export const COMMAND_SUGGESTION_ERROR = 'error';
export const COMMAND_SUGGESTION_CHANNEL = 'channel';
export const COMMAND_SUGGESTION_USER = 'user';

View file

@ -1,7 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createSelector} from 'reselect';
import {AppBinding} from '@mm-redux/types/apps';
import {GlobalState} from '@mm-redux/types/store';
import {appsEnabled} from '@utils/apps';
export function getAppsBindings(state: GlobalState, location?: string): AppBinding[] {
if (!state.entities.apps.bindings) {
@ -14,3 +18,18 @@ export function getAppsBindings(state: GlobalState, location?: string): AppBindi
}
return state.entities.apps.bindings;
}
export const makeAppBindingsSelector = (location: string) => {
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 || []), []);
},
);
};

View file

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

View file

@ -37,6 +37,7 @@ export type Channel = {
status?: string;
fake?: boolean;
group_constrained: boolean;
shared?: boolean;
};
export type ChannelWithTeamData = Channel & {
team_display_name: string;

View file

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

View file

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

122
app/utils/mentions/index.ts Normal file
View file

@ -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<AutocompleteSuggestion[] | null> {
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<AutocompleteSuggestion[]> {
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<AutocompleteSuggestion[]> {
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,
};
}

View file

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