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