diff --git a/app/actions/remote/apps.ts b/app/actions/remote/apps.ts index c991ad29af..9168243c07 100644 --- a/app/actions/remote/apps.ts +++ b/app/actions/remote/apps.ts @@ -4,6 +4,7 @@ import {IntlShape} from 'react-intl'; import {sendEphemeralPost} from '@actions/local/post'; +import ClientError from '@client/rest/error'; import CompassIcon from '@components/compass_icon'; import {Screens} from '@constants'; import {AppCallResponseTypes, AppCallTypes} from '@constants/apps'; @@ -20,7 +21,7 @@ export async function doAppCall(serverUrl: string, call: AppCallReq try { client = NetworkManager.getClient(serverUrl); } catch (error) { - return {error}; + return {error: makeCallErrorResponse((error as ClientError).message)}; } try { diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index cbfa4ec541..c17da7d8cb 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -914,3 +914,19 @@ export const searchChannels = async (serverUrl: string, term: string) => { return {error}; } }; + +export const fetchChannelById = async (serverUrl: string, id: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const channel = await client.getChannel(id); + return {channel}; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index d53011b689..f7dc1abfb7 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -200,3 +200,32 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc } return {data: true}; }; + +export const fetchCommands = async (serverUrl: string, teamId: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error: error as ClientErrorProps}; + } + try { + return {commands: await client.getCommandsList(teamId)}; + } catch (error) { + return {error: error as ClientErrorProps}; + } +}; + +export const fetchSuggestions = async (serverUrl: string, term: string, teamId: string, channelId: string, rootId?: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error: error as ClientErrorProps}; + } + + try { + return {suggestions: await client.getCommandAutocompleteSuggestionsList(term, teamId, channelId, rootId)}; + } catch (error) { + return {error: error as ClientErrorProps}; + } +}; diff --git a/app/client/rest/integrations.ts b/app/client/rest/integrations.ts index a3cce391b9..020f596d3c 100644 --- a/app/client/rest/integrations.ts +++ b/app/client/rest/integrations.ts @@ -7,7 +7,7 @@ import {PER_PAGE_DEFAULT} from './constants'; export interface ClientIntegrationsMix { getCommandsList: (teamId: string) => Promise; - getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, commandArgs?: CommandArgs) => Promise; + getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, channelId: string, rootId?: string) => Promise; getAutocompleteCommandsList: (teamId: string, page?: number, perPage?: number) => Promise; executeCommand: (command: string, commandArgs?: CommandArgs) => Promise; addCommand: (command: Command) => Promise; @@ -22,9 +22,9 @@ const ClientIntegrations = (superclass: any) => class extends superclass { ); }; - getCommandAutocompleteSuggestionsList = async (userInput: string, teamId: string, commandArgs: {}) => { + getCommandAutocompleteSuggestionsList = async (userInput: string, teamId: string, channelId: string, rootId?: string) => { return this.doFetch( - `${this.getTeamRoute(teamId)}/commands/autocomplete_suggestions${buildQueryString({...commandArgs, user_input: userInput})}`, + `${this.getTeamRoute(teamId)}/commands/autocomplete_suggestions${buildQueryString({user_input: userInput, team_id: teamId, channel_id: channelId, root_id: rootId})}`, {method: 'get'}, ); }; diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx index 5b82aeb9b6..440a6f2028 100644 --- a/app/components/autocomplete/autocomplete.tsx +++ b/app/components/autocomplete/autocomplete.tsx @@ -12,6 +12,8 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import AtMention from './at_mention/'; import ChannelMention from './channel_mention/'; import EmojiSuggestion from './emoji_suggestion/'; +import SlashSuggestion from './slash_suggestion/'; +import AppSlashSuggestion from './slash_suggestion/app_slash_suggestion/'; const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { return { @@ -92,13 +94,14 @@ const Autocomplete = ({ const [showingAtMention, setShowingAtMention] = useState(false); const [showingChannelMention, setShowingChannelMention] = useState(false); const [showingEmoji, setShowingEmoji] = useState(false); + const [showingCommand, setShowingCommand] = useState(false); + const [showingAppCommand, setShowingAppCommand] = useState(false); - // const [showingCommand, setShowingCommand] = useState(false); - // const [showingAppCommand, setShowingAppCommand] = useState(false); // const [showingDate, setShowingDate] = useState(false); - const hasElements = showingChannelMention || showingEmoji || showingAtMention; // || showingCommand || showingAppCommand || showingDate; - const appsTakeOver = false; // showingAppCommand; + const hasElements = showingChannelMention || showingEmoji || showingAtMention || showingCommand || showingAppCommand; // || showingDate; + const appsTakeOver = showingAppCommand; + const showCommands = !(showingChannelMention || showingEmoji || showingAtMention); const maxListHeight = useMemo(() => { if (maxHeightOverride) { @@ -151,15 +154,17 @@ const Autocomplete = ({ testID='autocomplete' style={containerStyles} > - {/* {isAppsEnabled && ( + {isAppsEnabled && ( - )} */} + )} {(!appsTakeOver || !isAppsEnabled) && (<> } - {/* - {(isSearch && enableDateSuggestion) && + } + {/* {(isSearch && enableDateSuggestion) && { return { icon: { @@ -37,7 +39,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { }); type Props = { - channel: Channel; + channel: Channel | ChannelModel; displayName?: string; isBot: boolean; isGuest: boolean; @@ -74,6 +76,8 @@ const ChannelMentionItem = ({ let component; + const isArchived = ('delete_at' in channel ? channel.delete_at : channel.deleteAt) > 0; + if (channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL) { if (!displayName) { return null; @@ -112,7 +116,7 @@ const ChannelMentionItem = ({ shared={channel.shared} type={channel.type} isInfo={true} - isArchived={channel.delete_at > 0} + isArchived={isArchived} size={18} style={style.icon} /> diff --git a/app/components/autocomplete/channel_mention_item/index.ts b/app/components/autocomplete/channel_mention_item/index.ts index 6a8063b3a5..662cbb6928 100644 --- a/app/components/autocomplete/channel_mention_item/index.ts +++ b/app/components/autocomplete/channel_mention_item/index.ts @@ -12,21 +12,24 @@ import {observeUser} from '@queries/servers/user'; import ChannelMentionItem from './channel_mention_item'; import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; import type UserModel from '@typings/database/models/servers/user'; type OwnProps = { - channel: Channel; + channel: Channel | ChannelModel; } const enhanced = withObservables([], ({database, channel}: WithDatabaseArgs & OwnProps) => { let user = of$(undefined); - if (channel.type === General.DM_CHANNEL) { - user = observeUser(database, channel.teammate_id!); + const teammateId = 'teammate_id' in channel ? channel.teammate_id : ''; + const channelDisplayName = 'display_name' in channel ? channel.display_name : channel.displayName; + if (channel.type === General.DM_CHANNEL && teammateId) { + user = observeUser(database, teammateId!); } const isBot = user.pipe(switchMap((u) => of$(u ? u.isBot : false))); const isGuest = user.pipe(switchMap((u) => of$(u ? u.isGuest : false))); - const displayName = user.pipe(switchMap((u) => of$(u ? u.username : channel.display_name))); + const displayName = user.pipe(switchMap((u) => of$(u ? u.username : channelDisplayName))); return { isBot, 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 new file mode 100644 index 0000000000..4f69940b7a --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts @@ -0,0 +1,1420 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; +import {IntlShape} from 'react-intl'; + +import {doAppCall} from '@actions/remote/apps'; +import {fetchChannelById, fetchChannelByName, searchChannels} from '@actions/remote/channel'; +import {fetchUsersByIds, fetchUsersByUsernames, searchUsers} from '@actions/remote/user'; +import {AppCallResponseTypes, AppCallTypes, AppFieldTypes, COMMAND_SUGGESTION_ERROR} from '@constants/apps'; +import DatabaseManager from '@database/manager'; +import IntegrationsManager from '@init/integrations_manager'; +import {getChannelById, queryChannelsByNames} from '@queries/servers/channel'; +import {getCurrentTeamId} from '@queries/servers/system'; +import {getUserById, queryUsersByUsername} from '@queries/servers/user'; +import ChannelModel from '@typings/database/models/servers/channel'; +import UserModel from '@typings/database/models/servers/user'; +import {createCallRequest, filterEmptyOptions} from '@utils/apps'; + +import {getChannelSuggestions, getUserSuggestions, inTextMentionSuggestions} from './mentions'; +/* eslint-disable max-lines */ + +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', + Rest = 'Rest', +} + +interface FormsCache { + getForm: (location: string, binding: AppBinding) => Promise<{form?: AppForm; error?: string} | undefined>; +} + +interface Intl { + formatMessage(config: {id: string; defaultMessage: string}, values?: {[name: string]: any}): string; +} + +// Common dependencies with Webapp. Due to the big change of removing redux, we may have to rethink how to deal with this. +const getExecuteSuggestion = (parsed: ParsedCommand) => null; // eslint-disable-line @typescript-eslint/no-unused-vars +export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command'; +export const parserErrorMessage = (intl: IntlShape, error: string, _command: string, _position: number): string => { // eslint-disable-line @typescript-eslint/no-unused-vars + return intl.formatMessage({ + id: 'apps.error.parser', + defaultMessage: 'Parsing error: {error}', + }, { + error, + }); +}; + +export type ExtendedAutocompleteSuggestion = AutocompleteSuggestion & { + type?: string; + item?: UserProfile | UserModel | Channel | ChannelModel; +} + +export class ParsedCommand { + state = ParseState.Start; + command: string; + i = 0; + incomplete = ''; + incompleteStart = 0; + binding: AppBinding | undefined; + form: AppForm | undefined; + formsCache: FormsCache; + field: AppField | undefined; + position = 0; + values: {[name: string]: string} = {}; + location = ''; + error = ''; + intl: Intl; + + constructor(command: string, formsCache: FormsCache, intl: IntlShape) { + this.command = command; + this.formsCache = formsCache || []; + this.intl = intl; + } + + private asError = (message: string): ParsedCommand => { + this.state = ParseState.Error; + this.error = message; + return this; + }; + + private findBinding = (b: AppBinding) => b.label.toLowerCase() === this.incomplete.toLowerCase(); + + // matchBinding finds the closest matching command binding. + public matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise => { + if (commandBindings.length === 0) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_bindings', + defaultMessage: 'No command bindings.', + })); + } + let bindings = commandBindings; + + let done = false; + while (!done) { + let c = ''; + if (this.i < this.command.length) { + c = this.command[this.i]; + } + + switch (this.state) { + case ParseState.Start: { + if (c !== '/') { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_slash_start', + defaultMessage: 'Command must start with a `/`.', + })); + } + this.i++; + this.incomplete = ''; + this.incompleteStart = this.i; + this.state = ParseState.Command; + break; + } + + case ParseState.Command: { + switch (c) { + case '': { + if (autocompleteMode) { + // Finish in the Command state, 'incomplete' will have the query string + done = true; + } else { + this.state = ParseState.EndCommand; + } + break; + } + case ' ': + case '\t': { + this.state = ParseState.EndCommand; + break; + } + default: + this.incomplete += c; + this.i++; + break; + } + break; + } + + case ParseState.EndCommand: { + const binding = bindings.find(this.findBinding); + if (!binding) { + // gone as far as we could, this token doesn't match a sub-command. + // return the state from the last matching binding + done = true; + break; + } + this.binding = binding; + this.location += '/' + binding.label; + bindings = binding.bindings || []; + this.state = ParseState.CommandSeparator; + break; + } + + case ParseState.CommandSeparator: { + if (c === '') { + done = true; + } + + switch (c) { + case ' ': + case '\t': { + this.i++; + break; + } + default: { + this.incomplete = ''; + this.incompleteStart = this.i; + this.state = ParseState.Command; + break; + } + } + break; + } + + default: { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_state', + defaultMessage: 'Unreachable: Unexpected state in matchBinding: `{state}`.', + }, { + state: this.state, + })); + } + } + } + + if (!this.binding) { + if (autocompleteMode) { + return this; + } + + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_match', + defaultMessage: '`{command}`: No matching command found in this workspace.', + }, { + command: this.command, + })); + } + + if (!autocompleteMode && this.binding.bindings?.length) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.execute_non_leaf', + defaultMessage: 'You must select a subcommand.', + })); + } + + if (!this.binding.bindings?.length) { + this.form = this.binding?.form; + if (!this.form) { + const fetched = await this.formsCache.getForm(this.location, this.binding); + if (fetched?.error) { + return this.asError(fetched.error); + } + this.form = fetched?.form; + } + } + + return this; + }; + + // parseForm parses the rest of the command using the previously matched form. + public parseForm = (autocompleteMode = false): ParsedCommand => { + if (this.state === ParseState.Error || !this.form) { + return this; + } + + let fields: AppField[] = []; + if (this.form.fields) { + fields = this.form.fields; + } + + fields = fields.filter((f) => f.type !== AppFieldTypes.MARKDOWN && !f.readonly); + this.state = ParseState.StartParameter; + this.i = this.incompleteStart || 0; + let flagEqualsUsed = false; + let escaped = false; + + // eslint-disable-next-line no-constant-condition + while (true) { + let c = ''; + if (this.i < this.command.length) { + c = this.command[this.i]; + } + + switch (this.state) { + case ParseState.StartParameter: { + switch (c) { + case '': + return this; + case '-': { + // Named parameter (aka Flag). Flag1 consumes the optional second '-'. + this.state = ParseState.Flag1; + this.i++; + break; + } + case '—': { + // Em dash, introduced when two '-' are set in iOS. Will be considered as such. + this.state = ParseState.Flag; + this.i++; + this.incomplete = ''; + this.incompleteStart = this.i; + flagEqualsUsed = false; + break; + } + default: { + // Positional parameter. + this.position++; + // eslint-disable-next-line no-loop-func + let field = fields.find((f: AppField) => f.position === this.position); + if (!field) { + field = fields.find((f) => f.position === -1 && f.type === AppFieldTypes.TEXT); + if (!field || this.values[field.name]) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.no_argument_pos_x', + defaultMessage: 'Unable to identify argument.', + })); + } + this.incompleteStart = this.i; + this.incomplete = ''; + this.field = field; + this.state = ParseState.Rest; + break; + } + this.field = field; + this.state = ParseState.StartValue; + break; + } + } + break; + } + + case ParseState.Rest: { + if (!this.field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_value', + defaultMessage: 'Field value is missing.', + })); + } + + if (autocompleteMode && c === '') { + return this; + } + + if (c === '') { + this.values[this.field.name] = this.incomplete; + return this; + } + + this.i++; + this.incomplete += c; + break; + } + + case ParseState.ParameterSeparator: { + this.incompleteStart = this.i; + switch (c) { + case '': + this.state = ParseState.StartParameter; + return this; + case ' ': + case '\t': { + this.i++; + break; + } + default: + this.state = ParseState.StartParameter; + break; + } + break; + } + + case ParseState.Flag1: { + // consume the optional second '-' + if (c === '-') { + this.i++; + } + this.state = ParseState.Flag; + this.incomplete = ''; + this.incompleteStart = this.i; + flagEqualsUsed = false; + break; + } + + case ParseState.Flag: { + if (c === '' && autocompleteMode) { + return this; + } + + switch (c) { + case '': + case ' ': + case '\t': + case '=': { + const field = fields.find((f) => f.label?.toLowerCase() === this.incomplete.toLowerCase()); + if (!field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_flag', + defaultMessage: 'Command does not accept flag `{flagName}`.', + }, { + flagName: this.incomplete, + })); + } + this.state = ParseState.FlagValueSeparator; + this.field = field; + this.incomplete = ''; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.FlagValueSeparator: { + this.incompleteStart = this.i; + switch (c) { + case '': { + if (autocompleteMode) { + return this; + } + this.state = ParseState.StartValue; + break; + } + case ' ': + case '\t': { + this.i++; + break; + } + case '=': { + if (flagEqualsUsed) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.multiple_equal', + defaultMessage: 'Multiple `=` signs are not allowed.', + })); + } + flagEqualsUsed = true; + this.i++; + break; + } + default: { + this.state = ParseState.StartValue; + } + } + break; + } + + case ParseState.StartValue: { + this.incomplete = ''; + this.incompleteStart = this.i; + switch (c) { + case '"': { + this.state = ParseState.QuotedValue; + this.i++; + break; + } + case '`': { + this.state = ParseState.TickValue; + this.i++; + break; + } + case ' ': + case '\t': + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_whitespace', + defaultMessage: 'Unreachable: Unexpected whitespace.', + })); + default: { + this.state = ParseState.NonspaceValue; + break; + } + } + break; + } + + case ParseState.NonspaceValue: { + switch (c) { + case '': + case ' ': + case '\t': { + this.state = ParseState.EndValue; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.QuotedValue: { + switch (c) { + case '': { + if (!autocompleteMode) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_quote', + defaultMessage: 'Matching double quote expected before end of input.', + })); + } + return this; + } + case '"': { + if (this.incompleteStart === this.i - 1) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.empty_value', + defaultMessage: 'Empty values are not allowed.', + })); + } + this.i++; + this.state = ParseState.EndQuotedValue; + break; + } + case '\\': { + escaped = true; + this.i++; + break; + } + default: { + this.incomplete += c; + this.i++; + if (escaped) { + //TODO: handle \n, \t, other escaped chars + escaped = false; + } + break; + } + } + break; + } + + case ParseState.TickValue: { + switch (c) { + case '': { + if (!autocompleteMode) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_tick', + defaultMessage: 'Matching tick quote expected before end of input.', + })); + } + return this; + } + case '`': { + if (this.incompleteStart === this.i - 1) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.empty_value', + defaultMessage: 'Empty values are not allowed.', + })); + } + this.i++; + this.state = ParseState.EndTickedValue; + break; + } + default: { + this.incomplete += c; + this.i++; + break; + } + } + break; + } + + case ParseState.EndTickedValue: + case ParseState.EndQuotedValue: + case ParseState.EndValue: { + if (!this.field) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_value', + defaultMessage: 'Field value is missing.', + })); + } + + // special handling for optional BOOL values ('--boolflag true' + // vs '--boolflag next-positional' vs '--boolflag + // --next-flag...') + if (this.field.type === AppFieldTypes.BOOL && + ((autocompleteMode && !'true'.startsWith(this.incomplete) && !'false'.startsWith(this.incomplete)) || + (!autocompleteMode && this.incomplete !== 'true' && this.incomplete !== 'false'))) { + // reset back where the value started, and treat as a new parameter + this.i = this.incompleteStart; + this.values[this.field.name] = 'true'; + this.state = ParseState.StartParameter; + } else { + if (autocompleteMode && c === '') { + return this; + } + this.values[this.field.name] = this.incomplete; + this.incomplete = ''; + this.incompleteStart = this.i; + if (c === '') { + return this; + } + this.state = ParseState.ParameterSeparator; + } + break; + } + } + } + }; +} + +export class AppCommandParser { + private serverUrl: string; + private database: Database; + private channelID: string; + private teamID: string; + private rootPostID?: string; + private intl: IntlShape; + private theme: Theme; + + constructor(serverUrl: string, intl: IntlShape, channelID: string, teamID = '', rootPostID = '', theme: Theme) { + this.serverUrl = serverUrl; + this.database = DatabaseManager.serverDatabases[serverUrl]?.database; + this.channelID = channelID; + this.rootPostID = rootPostID; + this.teamID = teamID; + this.intl = intl; + this.theme = theme; + } + + // composeCallFromCommand creates the form submission call + public composeCallFromCommand = async (command: string): Promise<{call: AppCallRequest | null; errorMessage?: string}> => { + let parsed = new ParsedCommand(command, this, this.intl); + + const commandBindings = this.getCommandBindings(); + if (!commandBindings) { + return {call: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.parser.no_bindings', + defaultMessage: 'No command bindings.', + })}; + } + + parsed = await parsed.matchBinding(commandBindings, false); + parsed = parsed.parseForm(false); + if (parsed.state === ParseState.Error) { + return {call: null, errorMessage: parserErrorMessage(this.intl, parsed.error, parsed.command, parsed.i)}; + } + + await this.addDefaultAndReadOnlyValues(parsed); + + const missing = this.getMissingFields(parsed); + if (missing.length > 0) { + const missingStr = missing.map((f) => f.label).join(', '); + return {call: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.command.field_missing', + defaultMessage: 'Required fields missing: `{fieldName}`.', + }, { + fieldName: missingStr, + })}; + } + + return this.composeCallFromParsed(parsed); + }; + + private async addDefaultAndReadOnlyValues(parsed: ParsedCommand) { + if (!parsed.form?.fields) { + return; + } + + await Promise.all(parsed.form.fields.map(async (f) => { + if (!f.value) { + return; + } + + if (f.readonly || !(f.name in parsed.values)) { + switch (f.type) { + case AppFieldTypes.TEXT: + parsed.values[f.name] = f.value as string; + break; + case AppFieldTypes.BOOL: + parsed.values[f.name] = 'true'; + break; + case AppFieldTypes.USER: { + const userID = (f.value as AppSelectOption).value; + let user: UserModel | UserProfile | undefined = await getUserById(this.database, userID); + if (!user) { + const res = await fetchUsersByIds(this.serverUrl, [userID]); + if ('error' in res) { + // Silently fail on default value + break; + } + user = res.users[0] || res.existingUsers[0]; + } + parsed.values[f.name] = user.username; + break; + } + case AppFieldTypes.CHANNEL: { + const channelID = (f.value as AppSelectOption).label; + let channel: ChannelModel | Channel | undefined = await getChannelById(this.database, channelID); + if (!channel) { + const res = await fetchChannelById(this.serverUrl, channelID); + if ('error' in res) { + // Silently fail on default value + break; + } + channel = res.channel; + } + parsed.values[f.name] = channel.name; + break; + } + case AppFieldTypes.STATIC_SELECT: + case AppFieldTypes.DYNAMIC_SELECT: + parsed.values[f.name] = (f.value as AppSelectOption).value; + break; + case AppFieldTypes.MARKDOWN: + + // Do nothing + } + } + }) || []); + } + + // getSuggestionsBase is a synchronous function that returns results for base commands + public getSuggestionsBase = (pretext: string): AutocompleteSuggestion[] => { + const command = pretext.toLowerCase(); + const result: AutocompleteSuggestion[] = []; + + const bindings = this.getCommandBindings(); + + for (const binding of bindings) { + let base = binding.label; + if (!base) { + continue; + } + + if (base[0] !== '/') { + base = '/' + base; + } + + if (base.startsWith(command)) { + result.push({ + Complete: binding.label, + Suggestion: base, + Description: binding.description || '', + Hint: binding.hint || '', + IconData: binding.icon || '', + }); + } + } + + return result; + }; + + // getSuggestions returns suggestions for subcommands and/or form arguments + public getSuggestions = async (pretext: string): Promise => { + let parsed = new ParsedCommand(pretext, this, this.intl); + let suggestions: ExtendedAutocompleteSuggestion[] = []; + + const commandBindings = this.getCommandBindings(); + if (!commandBindings) { + return []; + } + + parsed = await parsed.matchBinding(commandBindings, true); + if (parsed.state === ParseState.Error) { + suggestions = this.getErrorSuggestion(parsed); + } + + if (parsed.state === ParseState.Command) { + suggestions = this.getCommandSuggestions(parsed); + } + + if (parsed.form || parsed.incomplete) { + parsed = parsed.parseForm(true); + if (parsed.state === ParseState.Error) { + suggestions = this.getErrorSuggestion(parsed); + } + const argSuggestions = await this.getParameterSuggestions(parsed); + suggestions = suggestions.concat(argSuggestions); + } + + // Add "Execute Current Command" suggestion + // TODO get full text from SuggestionBox + const executableStates: string[] = [ + ParseState.EndCommand, + ParseState.CommandSeparator, + ParseState.StartParameter, + ParseState.ParameterSeparator, + ParseState.EndValue, + ]; + const call = parsed.form?.call || parsed.binding?.call || parsed.binding?.form?.call; + const hasRequired = this.getMissingFields(parsed).length === 0; + const hasValue = (parsed.state !== ParseState.EndValue || (parsed.field && parsed.values[parsed.field.name] !== undefined)); + + if (executableStates.includes(parsed.state) && call && hasRequired && hasValue) { + const execute = getExecuteSuggestion(parsed); + if (execute) { + suggestions = [execute, ...suggestions]; + } + } else if (suggestions.length === 0 && (parsed.field?.type !== AppFieldTypes.USER && parsed.field?.type !== AppFieldTypes.CHANNEL)) { + suggestions = this.getNoMatchingSuggestion(); + } + return suggestions.map((suggestion) => this.decorateSuggestionComplete(parsed, suggestion)); + }; + + getNoMatchingSuggestion = (): AutocompleteSuggestion[] => { + return [{ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ + id: 'apps.suggestion.no_suggestion', + defaultMessage: 'No matching suggestions.', + }), + IconData: COMMAND_SUGGESTION_ERROR, + Description: '', + }]; + }; + + getErrorSuggestion = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + return [{ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ + id: 'apps.suggestion.errors.parser_error', + defaultMessage: 'Parsing error', + }), + IconData: COMMAND_SUGGESTION_ERROR, + Description: parsed.error, + }]; + }; + + // composeCallFromParsed creates the form submission call + private composeCallFromParsed = async (parsed: ParsedCommand): Promise<{call: AppCallRequest | null; errorMessage?: string}> => { + if (!parsed.binding) { + return {call: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.parser.missing_binding', + defaultMessage: 'Missing command bindings.', + })}; + } + + const call = parsed.form?.call || parsed.binding.call; + if (!call) { + return {call: null, + errorMessage: this.intl.formatMessage({ + id: 'apps.error.parser.missing_call', + defaultMessage: 'Missing binding call.', + })}; + } + + const values: AppCallValues = parsed.values; + const {errorMessage} = await this.expandOptions(parsed, values); + + if (errorMessage) { + return {call: null, errorMessage}; + } + + const context = await this.getAppContext(parsed.binding); + return {call: createCallRequest(call, context, {}, values, parsed.command)}; + }; + + private expandOptions = async (parsed: ParsedCommand, values: AppCallValues): Promise<{errorMessage?: string}> => { + if (!parsed.form?.fields) { + return {}; + } + + const errors: {[key: string]: string} = {}; + await Promise.all(parsed.form.fields.map(async (f) => { + if (!values[f.name]) { + return; + } + switch (f.type) { + case AppFieldTypes.DYNAMIC_SELECT: + values[f.name] = {label: '', value: values[f.name]}; + break; + case AppFieldTypes.STATIC_SELECT: { + const option = f.options?.find((o) => (o.value === values[f.name])); + if (!option) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.unknown_option', + defaultMessage: 'Unknown option for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: values[f.name], + }); + return; + } + values[f.name] = option; + break; + } + case AppFieldTypes.USER: { + let userName = values[f.name] as string; + if (userName[0] === '@') { + userName = userName.substr(1); + } + let user: UserModel | UserProfile | undefined = (await queryUsersByUsername(this.database, [userName]).fetch())[0]; + if (!user) { + const res = await fetchUsersByUsernames(this.serverUrl, [userName]); + if ('error' in res) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.unknown_user', + defaultMessage: 'Unknown user for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: values[f.name], + }); + return; + } + user = res.users[0]; + } + values[f.name] = {label: user.username, value: user.id}; + break; + } + case AppFieldTypes.CHANNEL: { + let channelName = values[f.name] as string; + if (channelName[0] === '~') { + channelName = channelName.substr(1); + } + let channel: ChannelModel | Channel | undefined = (await queryChannelsByNames(this.database, [channelName]).fetch())[0]; + if (!channel) { + const res = await fetchChannelByName(this.serverUrl, this.teamID, channelName); + if ('error' in res) { + errors[f.name] = this.intl.formatMessage({ + id: 'apps.error.command.unknown_channel', + defaultMessage: 'Unknown channel for field `{fieldName}`: `{option}`.', + }, { + fieldName: f.name, + option: values[f.name], + }); + return; + } + channel = res.channel; + } + const label = 'display_name' in channel ? channel.display_name : channel.displayName; + values[f.name] = {label, value: channel?.id}; + break; + } + } + })); + + if (Object.keys(errors).length === 0) { + return {}; + } + + let errorMessage = ''; + Object.keys(errors).forEach((v) => { + errorMessage = errorMessage + errors[v] + '\n'; + }); + return {errorMessage}; + }; + + // decorateSuggestionComplete applies the necessary modifications for a suggestion to be processed + private decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => { + if (choice.Complete && choice.Complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) { + return choice as AutocompleteSuggestion; + } + + let goBackSpace = 0; + if (choice.Complete === '') { + goBackSpace = 1; + } + let complete = parsed.command.substring(0, parsed.incompleteStart - goBackSpace); + complete += choice.Complete === undefined ? choice.Suggestion : choice.Complete; + complete = complete.substring(1); + + return { + ...choice, + Complete: complete, + }; + }; + + // getCommandBindings returns the commands in the redux store. + // They are grouped by app id since each app has one base command + private getCommandBindings = (): AppBinding[] => { + const manager = IntegrationsManager.getManager(this.serverUrl); + if (this.rootPostID) { + return manager.getRHSCommandBindings(); + } + return manager.getCommandBindings(); + }; + + // getChannel gets the channel in which the user is typing the command + private getChannel = async () => { + return getChannelById(this.database, this.channelID); + }; + + public setChannelContext = (channelID: string, teamID = '', rootPostID?: string) => { + this.channelID = channelID; + this.rootPostID = rootPostID; + this.teamID = teamID; + }; + + // 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; + if (!base) { + continue; + } + + if (base[0] !== '/') { + base = '/' + base; + } + + if (command.startsWith(base + ' ')) { + return true; + } + } + return false; + }; + + // getAppContext collects post/channel/team info for performing calls + private getAppContext = async (binding: AppBinding): Promise => { + const context: AppContext = { + app_id: binding.app_id, + location: binding.location, + root_id: this.rootPostID, + }; + + const channel = await this.getChannel(); + if (!channel) { + return context; + } + + context.channel_id = channel.id; + context.team_id = channel.teamId || await getCurrentTeamId(this.database); + + return context; + }; + + // fetchForm unconditionaly retrieves the form for the given binding (subcommand) + private fetchForm = async (binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { + if (!binding.call) { + return {error: this.intl.formatMessage({ + id: 'apps.error.parser.missing_call', + defaultMessage: 'Missing binding call.', + })}; + } + + const payload = createCallRequest( + binding.call, + await this.getAppContext(binding), + ); + + const res = await doAppCall(this.serverUrl, payload, AppCallTypes.FORM, this.intl, this.theme); + if (res.error) { + const errorResponse = res.error; + return {error: errorResponse.error || this.intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })}; + } + + const callResponse = res.data!; + switch (callResponse.type) { + case AppCallResponseTypes.FORM: + break; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.OK: + return {error: this.intl.formatMessage({ + id: 'apps.error.responses.unexpected_type', + defaultMessage: 'App response type was not expected. Response type: {type}', + }, { + type: callResponse.type, + })}; + default: + return {error: this.intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResponse.type, + })}; + } + + return {form: callResponse.form}; + }; + + public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { + const manager = IntegrationsManager.getManager(this.serverUrl); + const rootID = this.rootPostID || ''; + const key = `${this.channelID}-${rootID}-${location}`; + const form = this.rootPostID ? manager.getAppRHSCommandForm(key) : manager.getAppCommandForm(key); + if (form) { + return {form}; + } + + const fetched = await this.fetchForm(binding); + if (fetched?.form) { + if (this.rootPostID) { + manager.setAppRHSCommandForm(key, fetched.form); + } else { + manager.setAppCommandForm(key, fetched.form); + } + } + return fetched; + }; + + // getSuggestionsForSubCommands returns suggestions for a subcommand's name + private getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + if (!parsed.binding?.bindings?.length) { + return []; + } + const bindings = parsed.binding.bindings; + const result: AutocompleteSuggestion[] = []; + + bindings.forEach((b) => { + if (b.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())) { + result.push({ + Complete: b.label, + Suggestion: b.label, + Description: b.description || '', + Hint: b.hint || '', + IconData: b.icon || '', + }); + } + }); + + return result; + }; + + // getParameterSuggestions computes suggestions for positional argument values, flag names, and flag argument values + private getParameterSuggestions = async (parsed: ParsedCommand): Promise => { + switch (parsed.state) { + case ParseState.StartParameter: { + // see if there's a matching positional field + const positional = parsed.form?.fields?.find((f: AppField) => f.position === parsed.position + 1); + if (positional) { + parsed.field = positional; + return this.getValueSuggestions(parsed); + } + return this.getFlagNameSuggestions(parsed); + } + + case ParseState.Flag: + return this.getFlagNameSuggestions(parsed); + + case ParseState.EndValue: + case ParseState.FlagValueSeparator: + case ParseState.NonspaceValue: + return this.getValueSuggestions(parsed); + case ParseState.EndQuotedValue: + case ParseState.QuotedValue: + return this.getValueSuggestions(parsed, '"'); + case ParseState.EndTickedValue: + case ParseState.TickValue: + return this.getValueSuggestions(parsed, '`'); + case ParseState.Rest: { + const execute = getExecuteSuggestion(parsed); + const value = await this.getValueSuggestions(parsed); + if (execute) { + return [execute, ...value]; + } + return value; + } + } + return []; + }; + + // getMissingFields collects the required fields that were not supplied in a submission + private getMissingFields = (parsed: ParsedCommand): AppField[] => { + const form = parsed.form; + if (!form) { + return []; + } + + const missing: AppField[] = []; + + const values = parsed.values || []; + const fields = form.fields || []; + for (const field of fields) { + if (field.is_required && !values[field.name]) { + missing.push(field); + } + } + + return missing; + }; + + // getFlagNameSuggestions returns suggestions for flag names + private getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + if (!parsed.form || !parsed.form.fields || !parsed.form.fields.length) { + return []; + } + + // There have been 0 to 2 dashes in the command prior to this call, adjust. + const prevCharIndex = parsed.incompleteStart - 1; + let prefix = '--'; + for (let i = prevCharIndex; i > 0 && i >= parsed.incompleteStart - 2 && parsed.command[i] === '-'; i--) { + prefix = prefix.substring(1); + } + if (prevCharIndex > 0 && parsed.command[prevCharIndex] === '—') { + prefix = ''; + } + + const applicable = parsed.form.fields.filter((field) => field.label && field.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()) && !parsed.values[field.name]); + if (applicable) { + return applicable.map((f) => { + return { + Complete: prefix + (f.label || f.name), + Suggestion: '--' + (f.label || f.name), + Description: f.description || '', + Hint: f.hint || '', + IconData: parsed.binding?.icon || '', + }; + }); + } + + return []; + }; + + // getSuggestionsForField gets suggestions for a positional or flag field value + private getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { + if (!parsed || !parsed.field) { + return []; + } + const f = parsed.field; + + switch (f.type) { + case AppFieldTypes.USER: + return this.getUserFieldSuggestions(parsed); + case AppFieldTypes.CHANNEL: + return this.getChannelFieldSuggestions(parsed); + case AppFieldTypes.BOOL: + return this.getBooleanSuggestions(parsed); + case AppFieldTypes.DYNAMIC_SELECT: + return this.getDynamicSelectSuggestions(parsed, delimiter); + case AppFieldTypes.STATIC_SELECT: + return this.getStaticSelectSuggestions(parsed, delimiter); + } + + const mentionSuggestions = await inTextMentionSuggestions(this.serverUrl, parsed.incomplete, 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: `${fieldName}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`, + Description: f.description || '', + Hint: '', + IconData: parsed.binding?.icon || '', + }]; + }; + + // getStaticSelectSuggestions returns suggestions specified in the field's options property + 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) { + return [{ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ + id: 'apps.suggestion.no_static', + defaultMessage: 'No matching options.', + }), + Description: '', + IconData: COMMAND_SUGGESTION_ERROR, + }]; + } + return opts.map((opt) => { + let complete = opt.value; + if (delimiter) { + complete = delimiter + complete + delimiter; + } else if (isMultiword(opt.value)) { + complete = '`' + complete + '`'; + } + return { + Complete: complete, + Suggestion: opt.label, + Hint: f.hint || '', + Description: f.description || '', + IconData: opt.icon_data || parsed.binding?.icon || '', + }; + }); + }; + + // getDynamicSelectSuggestions fetches and returns suggestions from the server + private getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { + const f = parsed.field; + if (!f) { + // Should never happen + return this.makeDynamicSelectSuggestionError(this.intl.formatMessage({ + id: 'apps.error.parser.unexpected_error', + defaultMessage: 'Unexpected error.', + })); + } + + const {call, errorMessage} = await this.composeCallFromParsed(parsed); + if (!call) { + return this.makeDynamicSelectSuggestionError(this.intl.formatMessage({ + id: 'apps.error.lookup.error_preparing_request', + defaultMessage: 'Error preparing lookup request: {errorMessage}', + }, { + errorMessage, + })); + } + call.selected_field = f.name; + call.query = parsed.incomplete; + + const res = await doAppCall(this.serverUrl, call, AppCallTypes.LOOKUP, this.intl, this.theme); + + if (res.error) { + const errorResponse = res.error; + return this.makeDynamicSelectSuggestionError(errorResponse.error || this.intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })); + } + + const callResponse = res.data!; + switch (callResponse.type) { + case AppCallResponseTypes.OK: + break; + case AppCallResponseTypes.NAVIGATE: + case AppCallResponseTypes.FORM: + return this.makeDynamicSelectSuggestionError(this.intl.formatMessage({ + id: 'apps.error.responses.unexpected_type', + defaultMessage: 'App response type was not expected. Response type: {type}', + }, { + type: callResponse.type, + })); + default: + return this.makeDynamicSelectSuggestionError(this.intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResponse.type, + })); + } + + let items = callResponse?.data?.items; + items = items?.filter(filterEmptyOptions); + if (!items?.length) { + return [{ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ + id: 'apps.suggestion.no_static', + defaultMessage: 'No matching options.', + }), + IconData: '', + Description: this.intl.formatMessage({ + id: 'apps.suggestion.no_dynamic', + defaultMessage: 'No data was returned for dynamic suggestions', + }), + }]; + } + + return items.map((s): AutocompleteSuggestion => { + let complete = s.value; + if (delimiter) { + complete = delimiter + complete + delimiter; + } else if (isMultiword(s.value)) { + complete = '`' + complete + '`'; + } + return ({ + Complete: complete, + Description: s.label || s.value, + Suggestion: s.value, + Hint: '', + IconData: s.icon_data || parsed.binding?.icon || '', + }); + }); + }; + + private makeDynamicSelectSuggestionError = (message: string): AutocompleteSuggestion[] => { + const errMsg = this.intl.formatMessage({ + id: 'apps.error', + defaultMessage: 'Error: {error}', + }, { + error: message, + }); + return [{ + Complete: '', + Suggestion: '', + Hint: this.intl.formatMessage({ + id: 'apps.suggestion.dynamic.error', + defaultMessage: 'Dynamic select error', + }), + IconData: COMMAND_SUGGESTION_ERROR, + Description: errMsg, + }]; + }; + + private getUserFieldSuggestions = async (parsed: ParsedCommand): Promise => { + let input = parsed.incomplete.trim(); + if (input[0] === '@') { + input = input.substring(1); + } + const res = await searchUsers(this.serverUrl, input, this.channelID); + return getUserSuggestions(res.users); + }; + + private getChannelFieldSuggestions = async (parsed: ParsedCommand): Promise => { + let input = parsed.incomplete.trim(); + if (input[0] === '~') { + input = input.substring(1); + } + const res = await searchChannels(this.serverUrl, input); + return getChannelSuggestions(res.channels); + }; + + // getBooleanSuggestions returns true/false suggestions + private getBooleanSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + const suggestions: AutocompleteSuggestion[] = []; + + if ('true'.startsWith(parsed.incomplete)) { + suggestions.push({ + Complete: 'true', + Suggestion: 'true', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '', + IconData: parsed.binding?.icon || '', + }); + } + if ('false'.startsWith(parsed.incomplete)) { + suggestions.push({ + Complete: 'false', + Suggestion: 'false', + Description: parsed.field?.description || '', + Hint: parsed.field?.hint || '', + IconData: parsed.binding?.icon || '', + }); + } + return suggestions; + }; +} + +function isMultiword(value: string) { + if (value.indexOf(' ') !== -1) { + return true; + } + + if (value.indexOf('\t') !== -1) { + return true; + } + + return false; +} diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts new file mode 100644 index 0000000000..436779d418 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/mentions.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {searchChannels} from '@actions/remote/channel'; +import {searchUsers} from '@actions/remote/user'; +import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER} from '@constants/apps'; + +export async function inTextMentionSuggestions(serverUrl: string, pretext: string, 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 res = await searchUsers(serverUrl, lastWord.substring(1), channelID); + const users = await getUserSuggestions(res.users); + 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 res = await searchChannels(serverUrl, lastWord.substring(1)); + const channels = await getChannelSuggestions(res.channels); + 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?: {users: UserProfile[]; out_of_channel?: UserProfile[]}): 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?.length) { + return notFoundSuggestion; + } + + const items = channels.map((c) => { + return { + Complete: '~' + c.name, + Suggestion: '', + Description: '', + Hint: '', + IconData: '', + type: COMMAND_SUGGESTION_CHANNEL, + item: c, + }; + }); + + return items; +} + +function getUserSuggestion(u: UserProfile) { + return { + Complete: '@' + u.username, + Suggestion: '', + Description: '', + Hint: '', + IconData: '', + type: COMMAND_SUGGESTION_USER, + item: u, + }; +} 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 0000000000..be7a6c902c --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx @@ -0,0 +1,204 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {debounce} from 'lodash'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {FlatList, Platform} from 'react-native'; + +import AtMentionItem from '@components/autocomplete/at_mention_item'; +import ChannelMentionItem from '@components/autocomplete/channel_mention_item'; +import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER} from '@constants/apps'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import analytics from '@init/analytics'; +import ChannelModel from '@typings/database/models/servers/channel'; +import UserModel from '@typings/database/models/servers/user'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import {AppCommandParser, ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser'; +import SlashSuggestionItem from '../slash_suggestion_item'; + +export type Props = { + currentTeamId: string; + isSearch?: boolean; + maxListHeight?: number; + updateValue: (text: string) => void; + onShowingChange: (c: boolean) => void; + value: string; + nestedScrollEnabled?: boolean; + rootId?: string; + channelId: string; + isAppsEnabled: boolean; +}; + +const keyExtractor = (item: ExtendedAutocompleteSuggestion): string => item.Suggestion + item.type + item.item; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + listView: { + flex: 1, + backgroundColor: theme.centerChannelBg, + paddingTop: 8, + borderRadius: 4, + }, + }; +}); + +const emptySuggestonList: AutocompleteSuggestion[] = []; + +const AppSlashSuggestion = ({ + channelId, + currentTeamId, + rootId, + value = '', + isAppsEnabled, + maxListHeight, + nestedScrollEnabled, + updateValue, + onShowingChange, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const serverUrl = useServerUrl(); + const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); + const [dataSource, setDataSource] = useState(emptySuggestonList); + const active = isAppsEnabled && Boolean(dataSource.length); + const style = getStyleFromTheme(theme); + const mounted = useRef(false); + + const listStyle = useMemo(() => [style.listView, {maxHeight: maxListHeight}], [maxListHeight, style]); + + const fetchAndShowAppCommandSuggestions = useMemo(() => debounce(async (pretext: string, cId: string, tId = '', rId?: string) => { + appCommandParser.current.setChannelContext(cId, tId, rId); + const suggestions = await appCommandParser.current.getSuggestions(pretext); + if (!mounted.current) { + return; + } + updateSuggestions(suggestions); + }), []); + + const updateSuggestions = (matches: ExtendedAutocompleteSuggestion[]) => { + setDataSource(matches); + onShowingChange(Boolean(matches.length)); + }; + + const completeSuggestion = useCallback((command: string) => { + analytics.get(serverUrl)?.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} `; + } + + updateValue(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(() => { + updateValue(completedDraft.replace(`//${command} `, `/${command} `)); + }); + } + }, [serverUrl, updateValue]); + + const completeIgnoringSuggestion = useCallback((base: string): (toIgnore: string) => void => { + return () => { + completeSuggestion(base); + }; + }, [completeSuggestion]); + + const renderItem = useCallback(({item}: {item: ExtendedAutocompleteSuggestion}) => { + switch (item.type) { + case COMMAND_SUGGESTION_USER: + if (!item.item) { + return null; + } + return ( + + ); + case COMMAND_SUGGESTION_CHANNEL: + if (!item.item) { + return null; + } + return ( + + ); + default: + return ( + + ); + } + }, [completeSuggestion, completeIgnoringSuggestion]); + + const isAppCommand = (pretext: string, channelID: string, teamID = '', rootID?: string) => { + appCommandParser.current.setChannelContext(channelID, teamID, rootID); + return appCommandParser.current.isAppCommand(pretext); + }; + + useEffect(() => { + if (value[0] !== '/') { + fetchAndShowAppCommandSuggestions.cancel(); + updateSuggestions(emptySuggestonList); + return; + } + + if (value.indexOf(' ') === -1) { + // Let slash command suggestions handle base commands. + fetchAndShowAppCommandSuggestions.cancel(); + updateSuggestions(emptySuggestonList); + return; + } + + if (!isAppCommand(value, channelId, currentTeamId, rootId)) { + fetchAndShowAppCommandSuggestions.cancel(); + updateSuggestions(emptySuggestonList); + return; + } + fetchAndShowAppCommandSuggestions(value, channelId, currentTeamId, rootId); + }, [value]); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + if (!active) { + // If we are not in an active state return null so nothing is rendered + // other components are not blocked. + return null; + } + + return ( + + ); +}; +export default AppSlashSuggestion; 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 0000000000..d1911188b8 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; + +import {observeConfigBooleanValue, observeCurrentTeamId} from '@queries/servers/system'; + +import AppSlashSuggestion from './app_slash_suggestion'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ + currentTeamId: observeCurrentTeamId(database), + isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), +})); + +export default withDatabase(enhanced(AppSlashSuggestion)); diff --git a/app/components/autocomplete/slash_suggestion/index.ts b/app/components/autocomplete/slash_suggestion/index.ts new file mode 100644 index 0000000000..2a65f39795 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; + +import {observeConfigBooleanValue, observeCurrentTeamId} from '@queries/servers/system'; + +import SlashSuggestion from './slash_suggestion'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ + currentTeamId: observeCurrentTeamId(database), + isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), +})); + +export default withDatabase(enhanced(SlashSuggestion)); diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx new file mode 100644 index 0000000000..5b96de6a28 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -0,0 +1,247 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {debounce} from 'lodash'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import { + FlatList, + Platform, +} from 'react-native'; + +import {fetchSuggestions} from '@actions/remote/command'; +import IntegrationsManager from '@app/init/integrations_manager'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import analytics from '@init/analytics'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import {AppCommandParser} from './app_command_parser/app_command_parser'; +import SlashSuggestionItem from './slash_suggestion_item'; + +// TODO: Remove when all below commands have been implemented +const COMMANDS_TO_IMPLEMENT_LATER = ['collapse', 'expand', 'join', 'open', 'leave', 'logout', 'msg', 'grpmsg']; +const NON_MOBILE_COMMANDS = ['rename', 'invite_people', 'shortcuts', 'search', 'help', 'settings', 'remove']; + +const COMMANDS_TO_HIDE_ON_MOBILE = [...COMMANDS_TO_IMPLEMENT_LATER, ...NON_MOBILE_COMMANDS]; + +const commandFilter = (v: Command) => !COMMANDS_TO_HIDE_ON_MOBILE.includes(v.trigger); + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + listView: { + flex: 1, + backgroundColor: theme.centerChannelBg, + paddingTop: 8, + borderRadius: 4, + }, + }; +}); + +const filterCommands = (matchTerm: string, commands: Command[]): AutocompleteSuggestion[] => { + const data = commands.filter((command) => { + if (!command.auto_complete) { + return false; + } else if (!matchTerm) { + return true; + } + + return command.display_name.startsWith(matchTerm) || command.trigger.startsWith(matchTerm); + }); + return data.map((command) => { + return { + Complete: command.trigger, + Suggestion: '/' + command.trigger, + Hint: command.auto_complete_hint, + Description: command.auto_complete_desc, + IconData: command.icon_url || command.autocomplete_icon_data || '', + }; + }); +}; + +const keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.Suggestion; + +type Props = { + currentTeamId: string; + maxListHeight?: number; + updateValue: (text: string) => void; + onShowingChange: (c: boolean) => void; + value: string; + nestedScrollEnabled?: boolean; + rootId?: string; + channelId: string; + isAppsEnabled: boolean; +}; + +const emptyCommandList: Command[] = []; +const emptySuggestionList: AutocompleteSuggestion[] = []; + +const SlashSuggestion = ({ + channelId, + currentTeamId, + rootId, + onShowingChange, + isAppsEnabled, + maxListHeight, + nestedScrollEnabled, + updateValue, + value = '', +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + const serverUrl = useServerUrl(); + const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); + const mounted = useRef(false); + const [noResultsTerm, setNoResultsTerm] = useState(null); + + const [dataSource, setDataSource] = useState(emptySuggestionList); + const [commands, setCommands] = useState(); + + const active = Boolean(dataSource.length); + + const listStyle = useMemo(() => [style.listView, {maxHeight: maxListHeight}], [maxListHeight, style]); + + const updateSuggestions = useCallback((matches: AutocompleteSuggestion[]) => { + setDataSource(matches); + onShowingChange(Boolean(matches.length)); + }, [onShowingChange]); + + const runFetch = useMemo(() => debounce(async (sUrl: string, term: string, tId: string, cId: string, rId?: string) => { + try { + const res = await fetchSuggestions(sUrl, term, tId, cId, rId); + if (!mounted.current) { + return; + } + if (res.error) { + updateSuggestions(emptySuggestionList); + } else if (res.suggestions.length === 0) { + updateSuggestions(emptySuggestionList); + setNoResultsTerm(term); + } else { + updateSuggestions(res.suggestions); + } + } catch { + updateSuggestions(emptySuggestionList); + } + }, 200), [updateSuggestions]); + + const getAppBaseCommandSuggestions = (pretext: string): AutocompleteSuggestion[] => { + appCommandParser.current.setChannelContext(channelId, currentTeamId, rootId); + const suggestions = appCommandParser.current.getSuggestionsBase(pretext); + return suggestions; + }; + + const showBaseCommands = (text: string) => { + let matches: AutocompleteSuggestion[] = []; + + if (isAppsEnabled) { + const appCommands = getAppBaseCommandSuggestions(text); + matches = matches.concat(appCommands); + } + + matches = matches.concat(filterCommands(text.substring(1), commands!)); + + matches.sort((match1, match2) => { + if (match1.Suggestion === match2.Suggestion) { + return 0; + } + return match1.Suggestion > match2.Suggestion ? 1 : -1; + }); + + updateSuggestions(matches); + }; + + const completeSuggestion = useCallback((command: string) => { + analytics.get(serverUrl)?.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} `; + } + + updateValue(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(() => { + updateValue(completedDraft.replace(`//${command} `, `/${command} `)); + }); + } + }, [updateValue, serverUrl]); + + const renderItem = useCallback(({item}: {item: AutocompleteSuggestion}) => ( + + ), [completeSuggestion]); + + useEffect(() => { + if (value[0] !== '/') { + runFetch.cancel(); + setNoResultsTerm(null); + updateSuggestions(emptySuggestionList); + return; + } + + if (!commands) { + IntegrationsManager.getManager(serverUrl).fetchCommands(currentTeamId).then((res) => { + if (res.length) { + setCommands(res.filter(commandFilter)); + } else { + setCommands(emptyCommandList); + } + }); + return; + } + + if (value.indexOf(' ') === -1) { + runFetch.cancel(); + showBaseCommands(value); + setNoResultsTerm(null); + return; + } + + if (noResultsTerm && value.startsWith(noResultsTerm)) { + return; + } + + runFetch(serverUrl, value, currentTeamId, channelId, rootId); + }, [value, commands]); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + if (!active) { + // If we are not in an active state return null so nothing is rendered + // other components are not blocked. + return null; + } + + return ( + + ); +}; + +export default SlashSuggestion; diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx new file mode 100644 index 0000000000..50bfe0eb58 --- /dev/null +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion_item.tsx @@ -0,0 +1,182 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import base64 from 'base-64'; +import React, {useCallback, useMemo} from 'react'; +import {Image, Text, View} from 'react-native'; +import FastImage from 'react-native-fast-image'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {SvgXml} from 'react-native-svg'; + +import CompassIcon from '@app/components/compass_icon'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {COMMAND_SUGGESTION_ERROR} from '@constants/apps'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +const slashIcon = require('@assets/images/autocomplete/slash_command.png'); + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + icon: { + fontSize: 24, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + width: 35, + height: 35, + marginRight: 12, + borderRadius: 4, + justifyContent: 'center', + alignItems: 'center', + marginTop: 8, + }, + uriIcon: { + width: 16, + height: 16, + }, + iconColor: { + tintColor: theme.centerChannelColor, + }, + container: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: 8, + paddingHorizontal: 16, + overflow: 'hidden', + }, + suggestionContainer: { + flex: 1, + }, + suggestionDescription: { + fontSize: 12, + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + suggestionName: { + fontSize: 15, + color: theme.centerChannelColor, + marginBottom: 4, + }, + }; +}); + +type Props = { + complete: string; + description: string; + hint: string; + onPress: (complete: string) => void; + suggestion: string; + icon: string; +} + +const SlashSuggestionItem = ({ + complete = '', + description, + hint, + onPress, + suggestion, + icon, +}: Props) => { + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + + const iconAsSource = useMemo(() => { + return {uri: icon}; + }, [icon]); + + const touchableStyle = useMemo(() => { + return {marginLeft: insets.left, marginRight: insets.right}; + }, [insets]); + + const completeSuggestion = useCallback(() => { + onPress(complete); + }, [onPress, complete]); + + let suggestionText = suggestion; + if (suggestionText?.[0] === '/' && complete.split(' ').length === 1) { + suggestionText = suggestionText.substring(1); + } + + if (hint) { + if (suggestionText?.length) { + suggestionText += ` ${hint}`; + } else { + suggestionText = hint; + } + } + + let image = ( + + ); + if (icon === COMMAND_SUGGESTION_ERROR) { + image = ( + + ); + } else if (icon.startsWith('http')) { + image = ( + + ); + } else if (icon.startsWith('data:')) { + if (icon.startsWith('data:image/svg+xml')) { + let xml = ''; + try { + xml = base64.decode(icon.substring('data:image/svg+xml;base64,'.length)); + image = ( + + ); + } catch (error) { + // Do nothing + } + } else { + image = ( + + ); + } + } + + return ( + + + + {image} + + + {`${suggestionText}`} + {Boolean(description) && + + {description} + + } + + + + ); +}; + +export default SlashSuggestionItem; diff --git a/app/constants/apps.ts b/app/constants/apps.ts index 33546e1b67..91b79fa07e 100644 --- a/app/constants/apps.ts +++ b/app/constants/apps.ts @@ -44,6 +44,10 @@ export const AppFieldTypes: { [name: string]: AppFieldType } = { MARKDOWN: 'markdown', }; +export const COMMAND_SUGGESTION_ERROR = 'error'; +export const COMMAND_SUGGESTION_CHANNEL = 'channel'; +export const COMMAND_SUGGESTION_USER = 'user'; + export default { AppBindingLocations, AppBindingPresentations, @@ -51,4 +55,7 @@ export default { AppCallTypes, AppExpandLevels, AppFieldTypes, + COMMAND_SUGGESTION_ERROR, + COMMAND_SUGGESTION_CHANNEL, + COMMAND_SUGGESTION_USER, }; diff --git a/app/init/integrations_manager.ts b/app/init/integrations_manager.ts new file mode 100644 index 0000000000..86859775d9 --- /dev/null +++ b/app/init/integrations_manager.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fetchCommands} from '@actions/remote/command'; + +const TIME_TO_REFETCH_COMMANDS = 60000; // 1 minute +class ServerIntegrationsManager { + private serverUrl: string; + private commandsLastFetched: {[teamId: string]: number | undefined} = {}; + private commands: {[teamId: string]: Command[] | undefined} = {}; + + private triggerId = ''; + + private bindings: AppBinding[] = []; + private rhsBindings: AppBinding[] = []; + + private commandForms: {[key: string]: AppForm | undefined} = {}; + private rhsCommandForms: {[key: string]: AppForm | undefined} = {}; + + constructor(serverUrl: string) { + this.serverUrl = serverUrl; + } + + public async fetchCommands(teamId: string) { + const lastFetched = this.commandsLastFetched[teamId] || 0; + const lastCommands = this.commands[teamId]; + if (lastCommands && lastFetched + TIME_TO_REFETCH_COMMANDS > Date.now()) { + return lastCommands; + } + + try { + const res = await fetchCommands(this.serverUrl, teamId); + if (res.error) { + return []; + } + this.commands[teamId] = res.commands; + this.commandsLastFetched[teamId] = Date.now(); + return res.commands; + } catch { + return []; + } + } + + public getCommandBindings() { + // TODO filter bindings + return this.bindings; + } + + public getRHSCommandBindings() { + // TODO filter bindings + return this.rhsBindings; + } + + public getAppRHSCommandForm(key: string) { + return this.rhsCommandForms[key]; + } + public getAppCommandForm(key: string) { + return this.commandForms[key]; + } + public setAppRHSCommandForm(key: string, form: AppForm) { + this.rhsCommandForms[key] = form; + } + public setAppCommandForm(key: string, form: AppForm) { + this.commandForms[key] = form; + } + + public getTriggerId() { + return this.triggerId; + } + public setTriggerId(id: string) { + this.triggerId = id; + } +} + +class IntegrationsManager { + private serverManagers: {[serverUrl: string]: ServerIntegrationsManager | undefined} = {}; + public getManager(serverUrl: string): ServerIntegrationsManager { + if (!this.serverManagers[serverUrl]) { + this.serverManagers[serverUrl] = new ServerIntegrationsManager(serverUrl); + } + + return this.serverManagers[serverUrl]!; + } +} + +export default new IntegrationsManager(); diff --git a/app/utils/apps.ts b/app/utils/apps.ts index fc4de5196e..0aae5b319f 100644 --- a/app/utils/apps.ts +++ b/app/utils/apps.ts @@ -170,3 +170,5 @@ export const makeCallErrorResponse = (errMessage: string) => { error: errMessage, }; }; + +export const filterEmptyOptions = (option: AppSelectOption): boolean => Boolean(option.value && !option.value.match(/^[ \t]+$/)); diff --git a/app/utils/helpers.ts b/app/utils/helpers.ts index fab3d5e626..7bdd0d859f 100644 --- a/app/utils/helpers.ts +++ b/app/utils/helpers.ts @@ -62,6 +62,9 @@ export function buildQueryString(parameters: Dictionary): string { let query = '?'; for (let i = 0; i < keys.length; i++) { const key = keys[i]; + if (parameters[key] == null) { + continue; + } query += key + '=' + encodeURIComponent(parameters[key]); if (i < keys.length - 1) { diff --git a/package-lock.json b/package-lock.json index d6f4a29f4e..6a0df0afc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@rudderstack/rudder-sdk-react-native": "1.2.1", "@sentry/react-native": "3.2.13", "@stream-io/flat-list-mvcp": "0.10.1", + "base-64": "1.0.0", "commonmark": "github:mattermost/commonmark.js#90a62d97ed2dbd2d4711a5adda327128f5827983", "commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#4e52e1725c0ef5b1e2ecfe9883220ec36c2eb67d", "deep-equal": "2.0.5", @@ -109,6 +110,7 @@ "@babel/runtime": "7.17.2", "@react-native-community/eslint-config": "3.0.1", "@testing-library/react-native": "9.0.0", + "@types/base-64": "1.0.0", "@types/commonmark": "0.27.5", "@types/commonmark-react-renderer": "4.3.1", "@types/deep-equal": "1.0.1", @@ -5708,6 +5710,12 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-AvCJx/HrfYHmOQRFdVvgKMplXfzTUizmh0tz9GFTpDePWgCY4uoKll84zKlaRoeiYiCr7c9ZnqSTzkl0BUVD6g==", + "dev": true + }, "node_modules/@types/commonmark": { "version": "0.27.5", "resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.5.tgz", @@ -7609,6 +7617,11 @@ "node": ">=0.10.0" } }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, "node_modules/base/node_modules/define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", @@ -28525,6 +28538,12 @@ "@babel/types": "^7.3.0" } }, + "@types/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-AvCJx/HrfYHmOQRFdVvgKMplXfzTUizmh0tz9GFTpDePWgCY4uoKll84zKlaRoeiYiCr7c9ZnqSTzkl0BUVD6g==", + "dev": true + }, "@types/commonmark": { "version": "0.27.5", "resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.5.tgz", @@ -30051,6 +30070,11 @@ } } }, + "base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", diff --git a/package.json b/package.json index d9e1e28517..bf630838a1 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@rudderstack/rudder-sdk-react-native": "1.2.1", "@sentry/react-native": "3.2.13", "@stream-io/flat-list-mvcp": "0.10.1", + "base-64": "1.0.0", "commonmark": "github:mattermost/commonmark.js#90a62d97ed2dbd2d4711a5adda327128f5827983", "commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#4e52e1725c0ef5b1e2ecfe9883220ec36c2eb67d", "deep-equal": "2.0.5", @@ -106,6 +107,7 @@ "@babel/runtime": "7.17.2", "@react-native-community/eslint-config": "3.0.1", "@testing-library/react-native": "9.0.0", + "@types/base-64": "1.0.0", "@types/commonmark": "0.27.5", "@types/commonmark-react-renderer": "4.3.1", "@types/deep-equal": "1.0.1", diff --git a/types/api/apps.d.ts b/types/api/apps.d.ts index f46ff82cdf..78b7fe1fc8 100644 --- a/types/api/apps.d.ts +++ b/types/api/apps.d.ts @@ -178,11 +178,11 @@ type AppField = { }; type AutocompleteSuggestion = { - suggestion: string; - complete?: string; - description?: string; - hint?: string; - iconData?: string; + Suggestion: string; + Complete: string; + Description: string; + Hint: string; + IconData: string; }; type AutocompleteSuggestionWithComplete = AutocompleteSuggestion & { diff --git a/types/api/integrations.d.ts b/types/api/integrations.d.ts index 9817aac7af..c9ad79cf2c 100644 --- a/types/api/integrations.d.ts +++ b/types/api/integrations.d.ts @@ -19,6 +19,7 @@ type Command = { 'display_name': string; 'description': string; 'url': string; + 'autocomplete_icon_data'?: string; }; type CommandArgs = {