forked from Ivasoft/mattermost-mobile
Add command autocomplete
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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'},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
25
app/components/autocomplete/slash_suggestion/index.ts
Normal file
25
app/components/autocomplete/slash_suggestion/index.ts
Normal 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));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -175,3 +175,5 @@ export const makeCallErrorResponse = (errMessage: string) => {
|
||||
error: errMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export const filterEmptyOptions = (option: AppSelectOption): boolean => Boolean(option.value && !option.value.match(/^[ \t]+$/));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
2
types/api/apps.d.ts
vendored
@@ -179,7 +179,7 @@ type AppField = {
|
||||
|
||||
type AutocompleteSuggestion = {
|
||||
suggestion: string;
|
||||
complete?: string;
|
||||
complete: string;
|
||||
description?: string;
|
||||
hint?: string;
|
||||
iconData?: string;
|
||||
|
||||
1
types/api/integrations.d.ts
vendored
1
types/api/integrations.d.ts
vendored
@@ -19,6 +19,7 @@ type Command = {
|
||||
'display_name': string;
|
||||
'description': string;
|
||||
'url': string;
|
||||
'autocomplete_icon_data'?: string;
|
||||
};
|
||||
|
||||
type CommandArgs = {
|
||||
|
||||
Reference in New Issue
Block a user