Add command autocomplete

This commit is contained in:
Daniel Espino García
2022-03-17 18:07:04 +01:00
parent 088aa193ab
commit b2408bd5d1
20 changed files with 2265 additions and 21 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

@@ -913,3 +913,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,23 @@ 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);
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);
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

@@ -16,8 +16,10 @@ import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {getUserCustomStatus, isGuest, isShared} from '@utils/user';
import type UserModel from '@typings/database/models/servers/user';
type AtMentionItemProps = {
user: UserProfile;
user: UserProfile | UserModel;
currentUserId: string;
onPress: (username: string) => void;
showFullName: boolean;
@@ -25,12 +27,13 @@ type AtMentionItemProps = {
isCustomStatusEnabled: boolean;
}
const getName = (user: UserProfile, showFullName: boolean, isCurrentUser: boolean) => {
const getName = (user: UserProfile | UserModel, showFullName: boolean, isCurrentUser: boolean) => {
let name = '';
const hasNickname = user.nickname.length > 0;
const firstName = 'first_name' in user ? user.first_name : user.firstName;
const lastName = 'last_name' in user ? user.last_name : user.lastName;
if (showFullName) {
name += `${user.first_name} ${user.last_name} `;
name += `${firstName} ${lastName} `;
}
if (hasNickname && !isCurrentUser) {
@@ -100,7 +103,7 @@ const AtMentionItem = ({
const isCurrentUser = currentUserId === user.id;
const name = getName(user, showFullName, isCurrentUser);
const customStatus = getUserCustomStatus(user);
const isBot = Boolean('is_bot' in user ? user.is_bot : user.isBot);
return (
<TouchableWithFeedback
testID={testID}
@@ -122,7 +125,7 @@ const AtMentionItem = ({
<View
style={[style.rowInfo, {maxWidth: shared ? '75%' : '80%'}]}
>
{Boolean(user.is_bot) && (<BotTag/>)}
{isBot && (<BotTag/>)}
{guest && (<GuestTag/>)}
{Boolean(name.length) && (
<Text
@@ -148,7 +151,7 @@ const AtMentionItem = ({
{` @${user.username}`}
</Text>
</View>
{isCustomStatusEnabled && !user.is_bot && customStatus && (
{isCustomStatusEnabled && !isBot && customStatus && (
<CustomStatusEmoji
customStatus={customStatus}
style={style.icon}

View File

@@ -5,13 +5,15 @@ import React, {useMemo} from 'react';
import {Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import ChannelIcon from '@app/components/channel_icon';
import ChannelIcon from '@components/channel_icon';
import {BotTag, GuestTag} from '@components/tag';
import TouchableWithFeedback from '@components/touchable_with_feedback';
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,22 +12,25 @@ import {MM_TABLES} from '@constants/database';
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 {SERVER: {USER}} = MM_TABLES;
const enhanced = withObservables([], ({database, channel}: WithDatabaseArgs & OwnProps) => {
let user = of$<UserModel | undefined>(undefined);
if (channel.type === General.DM_CHANNEL) {
user = database.get<UserModel>(USER).findAndObserve(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 = database.get<UserModel>(USER).findAndObserve(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,108 @@
// 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) {
return notFoundSuggestion;
}
if (!channels.length) {
return notFoundSuggestion;
}
const items = channels.map((c) => {
return {
complete: '~' + c.name,
suggestion: '',
description: '',
hint: '',
iconData: '',
type: COMMAND_SUGGESTION_CHANNEL,
item: c,
};
});
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,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, 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;
onChangeText: (text: string) => void;
onResultCountChange: (count: number) => void;
value: string;
nestedScrollEnabled?: boolean;
rootId?: string;
channelId: string;
appsEnabled: 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 AppSlashSuggestion = ({
channelId,
currentTeamId,
rootId,
value = '',
appsEnabled,
maxListHeight,
nestedScrollEnabled,
onChangeText,
onResultCountChange,
}: 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[]>([]);
const active = appsEnabled && Boolean(dataSource.length);
const style = getStyleFromTheme(theme);
const updateSuggestions = (matches: ExtendedAutocompleteSuggestion[]) => {
setDataSource(matches);
onResultCountChange(matches.length);
};
const completeSuggestion = (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} `;
}
onChangeText(completedDraft);
if (Platform.OS === 'ios') {
// This is the second part of the hack were we replace the double / with just one
// after the auto correct vanished
setTimeout(() => {
onChangeText(completedDraft.replace(`//${command} `, `/${command} `));
});
}
};
const completeUserSuggestion = (base: string): (username: string) => void => {
return () => {
completeSuggestion(base);
};
};
const completeChannelMention = (base: string): (channelName: string) => void => {
return () => {
completeSuggestion(base);
};
};
const renderItem = ({item}: {item: ExtendedAutocompleteSuggestion}) => {
switch (item.type) {
case COMMAND_SUGGESTION_USER:
if (!item.item) {
return null;
}
return (
<AtMentionItem
user={item.item as UserProfile | UserModel}
onPress={completeUserSuggestion(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={completeChannelMention(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}
/>
);
}
};
const isAppCommand = (pretext: string, channelID: string, teamID = '', rootID?: string) => {
appCommandParser.current.setChannelContext(channelID, teamID, rootID);
return appCommandParser.current.isAppCommand(pretext);
};
const fetchAndShowAppCommandSuggestions = async (pretext: string, channelID: string, teamID = '', rootID?: string) => {
appCommandParser.current.setChannelContext(channelID, teamID, rootID);
const suggestions = await appCommandParser.current.getSuggestions(pretext);
updateSuggestions(suggestions);
};
useEffect(() => {
if (value[0] !== '/') {
setDataSource([]);
onResultCountChange(0);
return;
}
if (value.indexOf(' ') === -1) {
setDataSource([]);
return;
}
if (!isAppCommand(value, channelId, currentTeamId, rootId)) {
setDataSource([]);
return;
}
fetchAndShowAppCommandSuggestions(value, channelId, currentTeamId, rootId);
}, [value]);
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={[style.listView, {maxHeight: maxListHeight}]}
data={dataSource}
keyExtractor={keyExtractor}
removeClippedSubviews={true}
renderItem={renderItem}
nestedScrollEnabled={nestedScrollEnabled}
/>
);
};
export default AppSlashSuggestion;

View File

@@ -0,0 +1,25 @@
// 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 {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import AppSlashSuggestion from './app_slash_suggestion';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentTeamId: database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
switchMap((v) => of$(v.value)),
),
isAppsEnabled: database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((cfg) => of$(cfg.value.FeatureFlagAppsEnabled === 'true')),
),
}));
export default withDatabase(enhanced(AppSlashSuggestion));

View File

@@ -0,0 +1,25 @@
// 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 {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import SlashSuggestion from './slash_suggestion';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentTeamId: database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
switchMap((v) => of$(v.value)),
),
isAppsEnabled: database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap((cfg) => of$(cfg.value.FeatureFlagAppsEnabled === 'true')),
),
}));
export default withDatabase(enhanced(SlashSuggestion));

View File

@@ -0,0 +1,224 @@
// 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 {fetchCommands, fetchSuggestions} from '@actions/remote/command';
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((item) => {
return {
complete: item.trigger,
suggestion: '/' + item.trigger,
hint: item.auto_complete_hint,
description: item.auto_complete_desc,
iconData: item.icon_url || item.autocomplete_icon_data || '',
};
});
};
const keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.suggestion || '';
type Props = {
currentTeamId: string;
commands: Command[];
maxListHeight?: number;
onChangeText: (text: string) => void;
onResultCountChange: (count: number) => void;
value: string;
nestedScrollEnabled?: boolean;
rootId?: string;
channelId: string;
appsEnabled: boolean;
};
const emptyCommandList: Command[] = [];
const emptySuggestionList: AutocompleteSuggestion[] = [];
const SlashSuggestion = ({
channelId,
currentTeamId,
rootId,
onResultCountChange,
appsEnabled,
maxListHeight,
nestedScrollEnabled,
onChangeText,
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 [dataSource, setDataSource] = useState<AutocompleteSuggestion[]>(emptySuggestionList);
const [commands, setCommands] = useState<Command[]>();
const active = Boolean(dataSource.length);
const updateSuggestions = useCallback((matches: AutocompleteSuggestion[]) => {
setDataSource(matches);
onResultCountChange(matches.length);
}, [onResultCountChange]);
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 (res.error) {
updateSuggestions(emptySuggestionList);
} else if (res.suggestions.length === 0) {
updateSuggestions(emptySuggestionList);
} 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 (appsEnabled) {
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} `;
}
onChangeText(completedDraft);
if (Platform.OS === 'ios') {
// This is the second part of the hack were we replace the double / with just one
// after the auto correct vanished
setTimeout(() => {
onChangeText(completedDraft.replace(`//${command} `, `/${command} `));
});
}
}, [onChangeText, 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] !== '/') {
updateSuggestions(emptySuggestionList);
return;
}
if (!commands) {
fetchCommands(serverUrl, currentTeamId).then((res) => {
if (res.error) {
setCommands(emptyCommandList);
} else {
setCommands(res.commands.filter(commandFilter));
}
});
return;
}
if (value.indexOf(' ') === -1) {
showBaseCommands(value);
return;
}
runFetch(serverUrl, value, currentTeamId, channelId, rootId);
}, [value, commands]);
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={[style.listView, {maxHeight: maxListHeight}]}
data={dataSource}
keyExtractor={keyExtractor}
removeClippedSubviews={true}
renderItem={renderItem}
nestedScrollEnabled={nestedScrollEnabled}
/>
);
};
export default SlashSuggestion;

View File

@@ -0,0 +1,170 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React 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 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 bangIcon = require('@assets/images/autocomplete/slash_command_error.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 completeSuggestion = () => {
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 = (
<Image
style={style.iconColor}
width={10}
height={16}
source={bangIcon}
/>
);
} else if (icon && icon.startsWith('http')) {
image = (
<FastImage
source={{uri: icon}}
style={style.uriIcon}
/>
);
} else if (icon && icon.startsWith('data:')) {
if (icon.startsWith('data:image/svg+xml')) {
const xml = ''; // base64.decode(icon.substring('data:image/svg+xml;base64,'.length));
image = (
<SvgXml
xml={xml}
width={32}
height={32}
/>
);
} else {
image = (
<Image
source={{uri: icon}}
style={style.uriIcon}
/>
);
}
}
return (
<TouchableWithFeedback
onPress={completeSuggestion}
style={{marginLeft: insets.left, marginRight: insets.right}}
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

@@ -175,3 +175,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) {

View File

@@ -192,8 +192,8 @@ export function confirmOutOfOfficeDisabled(intl: IntlShape, status: string, upda
);
}
export function isShared(user: UserProfile): boolean {
return Boolean(user.remote_id);
export function isShared(user: UserProfile | UserModel): boolean {
return 'remote_id' in user ? Boolean(user.remote_id) : false;
}
export function removeUserFromList(userId: string, originalList: UserProfile[]): UserProfile[] {

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

@@ -179,7 +179,7 @@ type AppField = {
type AutocompleteSuggestion = {
suggestion: string;
complete?: string;
complete: string;
description?: string;
hint?: string;
iconData?: string;

View File

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