Merge pull request #6061 from larkox/CommandAutocomplete

[Gekidou] Add command autocomplete
This commit is contained in:
Elias Nahum
2022-03-24 15:34:11 -03:00
committed by GitHub
22 changed files with 2406 additions and 25 deletions

View File

@@ -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<Res=unknown>(serverUrl: string, call: AppCallReq
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
return {error: makeCallErrorResponse((error as ClientError).message)};
}
try {

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import {PER_PAGE_DEFAULT} from './constants';
export interface ClientIntegrationsMix {
getCommandsList: (teamId: string) => Promise<Command[]>;
getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, commandArgs?: CommandArgs) => Promise<Command[]>;
getCommandAutocompleteSuggestionsList: (userInput: string, teamId: string, channelId: string, rootId?: string) => Promise<AutocompleteSuggestion[]>;
getAutocompleteCommandsList: (teamId: string, page?: number, perPage?: number) => Promise<Command[]>;
executeCommand: (command: string, commandArgs?: CommandArgs) => Promise<CommandResponse>;
addCommand: (command: Command) => Promise<Command>;
@@ -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'},
);
};

View File

@@ -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 && (
<AppSlashSuggestion
maxListHeight={maxListHeight}
updateValue={updateValue}
onResultCountChange={setShowingAppCommand}
onShowingChange={setShowingAppCommand}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
channelId={channelId}
rootId={rootId}
/>
)} */}
)}
{(!appsTakeOver || !isAppsEnabled) && (<>
<AtMention
cursorPosition={cursorPosition}
@@ -192,14 +197,18 @@ const Autocomplete = ({
hasFilesAttached={hasFilesAttached}
/>
}
{/* <SlashSuggestion
{showCommands &&
<SlashSuggestion
maxListHeight={maxListHeight}
updateValue={updateValue}
onResultCountChange={setShowingCommand}
onShowingChange={setShowingCommand}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
channelId={channelId}
rootId={rootId}
/>
{(isSearch && enableDateSuggestion) &&
}
{/* {(isSearch && enableDateSuggestion) &&
<DateSuggestion
cursorPosition={cursorPosition}
updateValue={updateValue}

View File

@@ -12,6 +12,8 @@ import {General} from '@constants';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import type ChannelModel from '@typings/database/models/servers/channel';
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
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}
/>

View File

@@ -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$<UserModel | undefined>(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,

View File

@@ -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<AutocompleteSuggestion[] | null> {
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<AutocompleteSuggestion[]> {
const notFoundSuggestions = [{
Complete: '',
Suggestion: '',
Description: 'No user found',
Hint: '',
IconData: '',
}];
if (!usersAutocomplete) {
return notFoundSuggestions;
}
if (!usersAutocomplete.users.length && !usersAutocomplete.out_of_channel?.length) {
return notFoundSuggestions;
}
const items: AutocompleteSuggestion[] = [];
usersAutocomplete.users.forEach((u) => {
items.push(getUserSuggestion(u));
});
usersAutocomplete.out_of_channel?.forEach((u) => {
items.push(getUserSuggestion(u));
});
return items;
}
export async function getChannelSuggestions(channels?: Channel[]): Promise<AutocompleteSuggestion[]> {
const notFoundSuggestion = [{
Complete: '',
Suggestion: '',
Description: 'No channel found',
Hint: '',
IconData: '',
}];
if (!channels?.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,
};
}

View File

@@ -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<AppCommandParser>(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme));
const [dataSource, setDataSource] = useState<AutocompleteSuggestion[]>(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 (
<AtMentionItem
user={item.item as UserProfile | UserModel}
onPress={completeIgnoringSuggestion(item.Complete)}
testID={`autocomplete.at_mention.item.${item.item}`}
/>
);
case COMMAND_SUGGESTION_CHANNEL:
if (!item.item) {
return null;
}
return (
<ChannelMentionItem
channel={item.item as Channel | ChannelModel}
onPress={completeIgnoringSuggestion(item.Complete)}
testID={`autocomplete.channel_mention.item.${item.item}`}
/>
);
default:
return (
<SlashSuggestionItem
description={item.Description}
hint={item.Hint}
onPress={completeSuggestion}
suggestion={item.Suggestion}
complete={item.Complete}
icon={item.IconData}
/>
);
}
}, [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 (
<FlatList
testID='app_slash_suggestion.list'
keyboardShouldPersistTaps='always'
style={listStyle}
data={dataSource}
keyExtractor={keyExtractor}
removeClippedSubviews={true}
renderItem={renderItem}
nestedScrollEnabled={nestedScrollEnabled}
/>
);
};
export default AppSlashSuggestion;

View File

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

View File

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

View File

@@ -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<AppCommandParser>(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme));
const mounted = useRef(false);
const [noResultsTerm, setNoResultsTerm] = useState<string|null>(null);
const [dataSource, setDataSource] = useState<AutocompleteSuggestion[]>(emptySuggestionList);
const [commands, setCommands] = useState<Command[]>();
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}) => (
<SlashSuggestionItem
description={item.Description}
hint={item.Hint}
onPress={completeSuggestion}
suggestion={item.Suggestion}
complete={item.Complete}
icon={item.IconData}
/>
), [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 (
<FlatList
testID='slash_suggestion.list'
keyboardShouldPersistTaps='always'
style={listStyle}
data={dataSource}
keyExtractor={keyExtractor}
removeClippedSubviews={true}
renderItem={renderItem}
nestedScrollEnabled={nestedScrollEnabled}
/>
);
};
export default SlashSuggestion;

View File

@@ -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 = (
<Image
style={style.iconColor}
width={10}
height={16}
source={slashIcon}
/>
);
if (icon === COMMAND_SUGGESTION_ERROR) {
image = (
<CompassIcon
name='alert-circle-outline'
size={24}
/>
);
} else if (icon.startsWith('http')) {
image = (
<FastImage
source={iconAsSource}
style={style.uriIcon}
/>
);
} 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 = (
<SvgXml
xml={xml}
width={32}
height={32}
/>
);
} catch (error) {
// Do nothing
}
} else {
image = (
<Image
source={iconAsSource}
style={style.uriIcon}
/>
);
}
}
return (
<TouchableWithFeedback
onPress={completeSuggestion}
style={touchableStyle}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
type={'native'}
>
<View style={style.container}>
<View style={style.icon}>
{image}
</View>
<View style={style.suggestionContainer}>
<Text style={style.suggestionName}>{`${suggestionText}`}</Text>
{Boolean(description) &&
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.suggestionDescription}
>
{description}
</Text>
}
</View>
</View>
</TouchableWithFeedback>
);
};
export default SlashSuggestionItem;

View File

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

View File

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

View File

@@ -170,3 +170,5 @@ export const makeCallErrorResponse = (errMessage: string) => {
error: errMessage,
};
};
export const filterEmptyOptions = (option: AppSelectOption): boolean => Boolean(option.value && !option.value.match(/^[ \t]+$/));

View File

@@ -62,6 +62,9 @@ export function buildQueryString(parameters: Dictionary<any>): 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) {

24
package-lock.json generated
View File

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

View File

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

10
types/api/apps.d.ts vendored
View File

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

View File

@@ -19,6 +19,7 @@ type Command = {
'display_name': string;
'description': string;
'url': string;
'autocomplete_icon_data'?: string;
};
type CommandArgs = {