[NEW] Slash commands (#886)

* setup database

* added getSlashCommands to loginSucess

* added slash command first prototype

* added preview feture for commands that have preview enabled

* address requested changes

* added preview options for other types of files too

* address changes

* done requested changes

* undone un-nessary changes

* done suggested changes

* fixed lint

* done requested changes

* fixed lint

* fix e2e
This commit is contained in:
pranavpandey1998official 2019-06-11 00:06:56 +05:30 committed by Diego Mello
parent d68eb01b82
commit 82afb63327
7 changed files with 346 additions and 43 deletions

View File

@ -0,0 +1,47 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TouchableOpacity, ActivityIndicator } from 'react-native';
import FastImage from 'react-native-fast-image';
import styles from './styles';
import { CustomIcon } from '../../lib/Icons';
import { COLOR_PRIMARY } from '../../constants/colors';
export default class CommandPreview extends React.PureComponent {
static propTypes = {
onPress: PropTypes.func,
item: PropTypes.object
};
constructor(props) {
super(props);
this.state = { loading: true };
}
render() {
const { onPress, item } = this.props;
const { loading } = this.state;
return (
<TouchableOpacity
style={styles.commandPreview}
onPress={() => onPress(item)}
testID={`command-preview-item${ item.id }`}
>
{item.type === 'image'
? (
<FastImage
style={styles.commandPreviewImage}
source={{ uri: item.value }}
resizeMode={FastImage.resizeMode.cover}
onLoadStart={() => this.setState({ loading: true })}
onLoad={() => this.setState({ loading: false })}
>
{ loading ? <ActivityIndicator /> : null }
</FastImage>
)
: <CustomIcon name='file-generic' size={36} color={COLOR_PRIMARY} />
}
</TouchableOpacity>
);
}
}

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import {
View, TextInput, FlatList, Text, TouchableOpacity, Alert View, TextInput, FlatList, Text, TouchableOpacity, Alert, ScrollView
} from 'react-native'; } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { emojify } from 'react-emojione'; import { emojify } from 'react-emojione';
@ -32,9 +32,12 @@ import { COLOR_TEXT_DESCRIPTION } from '../../constants/colors';
import LeftButtons from './LeftButtons'; import LeftButtons from './LeftButtons';
import RightButtons from './RightButtons'; import RightButtons from './RightButtons';
import { isAndroid } from '../../utils/deviceInfo'; import { isAndroid } from '../../utils/deviceInfo';
import CommandPreview from './CommandPreview';
const MENTIONS_TRACKING_TYPE_USERS = '@'; const MENTIONS_TRACKING_TYPE_USERS = '@';
const MENTIONS_TRACKING_TYPE_EMOJIS = ':'; const MENTIONS_TRACKING_TYPE_EMOJIS = ':';
const MENTIONS_TRACKING_TYPE_COMMANDS = '/';
const MENTIONS_COUNT_TO_DISPLAY = 4;
const onlyUnique = function onlyUnique(value, index, self) { const onlyUnique = function onlyUnique(value, index, self) {
return self.indexOf(({ _id }) => value._id === _id) === index; return self.indexOf(({ _id }) => value._id === _id) === index;
@ -93,8 +96,11 @@ class MessageBox extends Component {
trackingType: '', trackingType: '',
file: { file: {
isVisible: false isVisible: false
} },
commandPreview: []
}; };
this.showCommandPreview = false;
this.commands = [];
this.users = []; this.users = [];
this.rooms = []; this.rooms = [];
this.emojis = []; this.emojis = [];
@ -147,7 +153,7 @@ class MessageBox extends Component {
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
const { const {
showEmojiKeyboard, showSend, recording, mentions, file showEmojiKeyboard, showSend, recording, mentions, file, commandPreview
} = this.state; } = this.state;
const { const {
roomType, replying, editing, isFocused roomType, replying, editing, isFocused
@ -176,6 +182,9 @@ class MessageBox extends Component {
if (!equal(nextState.mentions, mentions)) { if (!equal(nextState.mentions, mentions)) {
return true; return true;
} }
if (!equal(nextState.commandPreview, commandPreview)) {
return true;
}
if (!equal(nextState.file, file)) { if (!equal(nextState.file, file)) {
return true; return true;
} }
@ -187,20 +196,36 @@ class MessageBox extends Component {
this.setShowSend(!isTextEmpty); this.setShowSend(!isTextEmpty);
this.handleTyping(!isTextEmpty); this.handleTyping(!isTextEmpty);
this.setInput(text); this.setInput(text);
// matches if their is text that stats with '/' and group the command and params so we can use it "/command params"
const slashCommand = text.match(/^\/([a-z0-9._-]+) (.+)/im);
if (slashCommand) {
const [, name, params] = slashCommand;
const command = database.objects('slashCommand').filtered('command == $0', name);
if (command && command[0] && command[0].providesPreview) {
return this.setCommandPreview(name, params);
}
}
if (!isTextEmpty) { if (!isTextEmpty) {
const { start, end } = this.component._lastNativeSelection; const { start, end } = this.component._lastNativeSelection;
const cursor = Math.max(start, end); const cursor = Math.max(start, end);
const lastNativeText = this.component._lastNativeText; const lastNativeText = this.component._lastNativeText;
const regexp = /(#|@|:)([a-z0-9._-]+)$/im; // matches if text either starts with '/' or have (@,#,:) then it groups whatever comes next of mention type
const regexp = /(#|@|:|^\/)([a-z0-9._-]+)$/im;
const result = lastNativeText.substr(0, cursor).match(regexp); const result = lastNativeText.substr(0, cursor).match(regexp);
this.showCommandPreview = false;
if (!result) { if (!result) {
const slash = lastNativeText.match(/^\/$/); // matches only '/' in input
if (slash) {
return this.identifyMentionKeyword('', MENTIONS_TRACKING_TYPE_COMMANDS);
}
return this.stopTrackingMention(); return this.stopTrackingMention();
} }
const [, lastChar, name] = result; const [, lastChar, name] = result;
this.identifyMentionKeyword(name, lastChar); this.identifyMentionKeyword(name, lastChar);
} else { } else {
this.stopTrackingMention(); this.stopTrackingMention();
this.showCommandPreview = false;
} }
}, 100) }, 100)
@ -220,13 +245,32 @@ class MessageBox extends Component {
const result = msg.substr(0, cursor).replace(regexp, ''); const result = msg.substr(0, cursor).replace(regexp, '');
const mentionName = trackingType === MENTIONS_TRACKING_TYPE_EMOJIS const mentionName = trackingType === MENTIONS_TRACKING_TYPE_EMOJIS
? `${ item.name || item }:` ? `${ item.name || item }:`
: (item.username || item.name); : (item.username || item.name || item.command);
const text = `${ result }${ mentionName } ${ msg.slice(cursor) }`; const text = `${ result }${ mentionName } ${ msg.slice(cursor) }`;
if ((trackingType === MENTIONS_TRACKING_TYPE_COMMANDS) && item.providesPreview) {
this.showCommandPreview = true;
}
this.setInput(text); this.setInput(text);
this.focus(); this.focus();
requestAnimationFrame(() => this.stopTrackingMention()); requestAnimationFrame(() => this.stopTrackingMention());
} }
onPressCommandPreview = (item) => {
const { rid } = this.props;
const { text } = this;
const command = text.substr(0, text.indexOf(' ')).slice(1);
const params = text.substr(text.indexOf(' ') + 1) || 'params';
this.showCommandPreview = false;
this.setState({ commandPreview: [] });
this.stopTrackingMention();
this.clearInput();
try {
RocketChat.executeCommandPreview(command, params, rid, item);
} catch (e) {
log('onPressCommandPreview', e);
}
}
onEmojiSelected = (keyboardId, params) => { onEmojiSelected = (keyboardId, params) => {
const { text } = this; const { text } = this;
const { emoji } = params; const { emoji } = params;
@ -301,7 +345,7 @@ class MessageBox extends Component {
console.warn('spotlight canceled'); console.warn('spotlight canceled');
} finally { } finally {
delete this.oldPromise; delete this.oldPromise;
this.users = database.objects('users').filtered('username CONTAINS[c] $0', keyword).slice(); this.users = database.objects('users').filtered('username CONTAINS[c] $0', keyword).slice(0, MENTIONS_COUNT_TO_DISPLAY);
this.getFixedMentions(keyword); this.getFixedMentions(keyword);
this.setState({ mentions: this.users }); this.setState({ mentions: this.users });
} }
@ -351,13 +395,18 @@ class MessageBox extends Component {
getEmojis = (keyword) => { getEmojis = (keyword) => {
if (keyword) { if (keyword) {
this.customEmojis = database.objects('customEmojis').filtered('name CONTAINS[c] $0', keyword).slice(0, 4); this.customEmojis = database.objects('customEmojis').filtered('name CONTAINS[c] $0', keyword).slice(0, MENTIONS_COUNT_TO_DISPLAY);
this.emojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, 4); this.emojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY);
const mergedEmojis = [...this.customEmojis, ...this.emojis]; const mergedEmojis = [...this.customEmojis, ...this.emojis].slice(0, MENTIONS_COUNT_TO_DISPLAY);
this.setState({ mentions: mergedEmojis }); this.setState({ mentions: mergedEmojis });
} }
} }
getSlashCommands = (keyword) => {
this.commands = database.objects('slashCommand').filtered('command CONTAINS[c] $0', keyword);
this.setState({ mentions: this.commands });
}
focus = () => { focus = () => {
if (this.component && this.component.focus) { if (this.component && this.component.focus) {
this.component.focus(); this.component.focus();
@ -385,6 +434,18 @@ class MessageBox extends Component {
}, 1000); }, 1000);
} }
setCommandPreview = async(command, params) => {
const { rid } = this.props;
try {
const { preview } = await RocketChat.getCommandPreview(command, rid, params);
this.showCommandPreview = true;
this.setState({ commandPreview: preview.items });
} catch (e) {
this.showCommandPreview = false;
log('command Preview', e);
}
}
setInput = (text) => { setInput = (text) => {
this.text = text; this.text = text;
if (this.component && this.component.setNativeProps) { if (this.component && this.component.setNativeProps) {
@ -505,7 +566,7 @@ class MessageBox extends Component {
submit = async() => { submit = async() => {
const { const {
message: editingMessage, editRequest, onSubmit message: editingMessage, editRequest, onSubmit, rid: roomId
} = this.props; } = this.props;
const message = this.text; const message = this.text;
@ -521,6 +582,22 @@ class MessageBox extends Component {
editing, replying editing, replying
} = this.props; } = this.props;
// Slash command
if (message[0] === MENTIONS_TRACKING_TYPE_COMMANDS) {
const command = message.replace(/ .*/, '').slice(1);
const slashCommand = database.objects('slashCommand').filtered('command CONTAINS[c] $0', command);
if (slashCommand.length > 0) {
try {
const messageWithoutCommand = message.substr(message.indexOf(' ') + 1);
RocketChat.runSlashCommand(command, roomId, messageWithoutCommand);
} catch (e) {
log('slashCommand', e);
}
this.clearInput();
return;
}
}
// Edit // Edit
if (editing) { if (editing) {
const { _id, rid } = editingMessage; const { _id, rid } = editingMessage;
@ -561,6 +638,8 @@ class MessageBox extends Component {
this.getUsers(keyword); this.getUsers(keyword);
} else if (type === MENTIONS_TRACKING_TYPE_EMOJIS) { } else if (type === MENTIONS_TRACKING_TYPE_EMOJIS) {
this.getEmojis(keyword); this.getEmojis(keyword);
} else if (type === MENTIONS_TRACKING_TYPE_COMMANDS) {
this.getSlashCommands(keyword);
} else { } else {
this.getRooms(keyword); this.getRooms(keyword);
} }
@ -579,15 +658,16 @@ class MessageBox extends Component {
if (!trackingType) { if (!trackingType) {
return; return;
} }
this.setState({ this.setState({
mentions: [], mentions: [],
trackingType: '' trackingType: '',
commandPreview: []
}); });
this.users = []; this.users = [];
this.rooms = []; this.rooms = [];
this.customEmojis = []; this.customEmojis = [];
this.emojis = []; this.emojis = [];
this.commands = [];
} }
renderFixedMentionItem = item => ( renderFixedMentionItem = item => (
@ -623,31 +703,55 @@ class MessageBox extends Component {
); );
} }
renderMentionItem = (item) => { renderMentionItem = ({ item }) => {
const { trackingType } = this.state; const { trackingType } = this.state;
const { baseUrl, user } = this.props; const { baseUrl, user } = this.props;
if (item.username === 'all' || item.username === 'here') { if (item.username === 'all' || item.username === 'here') {
return this.renderFixedMentionItem(item); return this.renderFixedMentionItem(item);
} }
const defineTestID = (type) => {
switch (type) {
case MENTIONS_TRACKING_TYPE_EMOJIS:
return `mention-item-${ item.name || item }`;
case MENTIONS_TRACKING_TYPE_COMMANDS:
return `mention-item-${ item.command || item }`;
default:
return `mention-item-${ item.username || item.name || item }`;
}
};
const testID = defineTestID(trackingType);
return ( return (
<TouchableOpacity <TouchableOpacity
style={styles.mentionItem} style={styles.mentionItem}
onPress={() => this.onPressMention(item)} onPress={() => this.onPressMention(item)}
testID={`mention-item-${ trackingType === MENTIONS_TRACKING_TYPE_EMOJIS ? item.name || item : item.username || item.name }`} testID={testID}
> >
{trackingType === MENTIONS_TRACKING_TYPE_EMOJIS
? ( {(() => {
switch (trackingType) {
case MENTIONS_TRACKING_TYPE_EMOJIS:
return (
<React.Fragment> <React.Fragment>
{this.renderMentionEmoji(item)} {this.renderMentionEmoji(item)}
<Text key='mention-item-name' style={styles.mentionText}>:{ item.name || item }:</Text> <Text key='mention-item-name' style={styles.mentionText}>:{ item.name || item }:</Text>
</React.Fragment> </React.Fragment>
) );
: ( case MENTIONS_TRACKING_TYPE_COMMANDS:
return (
<React.Fragment>
<Text key='mention-item-command' style={styles.slash}>/</Text>
<Text key='mention-item-param'>{ item.command}</Text>
</React.Fragment>
);
default:
return (
<React.Fragment> <React.Fragment>
<Avatar <Avatar
key='mention-item-avatar' key='mention-item-avatar'
style={{ margin: 8 }} style={styles.avatar}
text={item.username || item.name} text={item.username || item.name}
size={30} size={30}
type={item.username ? 'd' : 'c'} type={item.username ? 'd' : 'c'}
@ -655,9 +759,11 @@ class MessageBox extends Component {
userId={user.id} userId={user.id}
token={user.token} token={user.token}
/> />
<Text key='mention-item-name' style={styles.mentionText}>{ item.username || item.name }</Text> <Text key='mention-item-name' style={styles.mentionText}>{ item.username || item.name || item }</Text>
</React.Fragment> </React.Fragment>
) );
}
})()
} }
</TouchableOpacity> </TouchableOpacity>
); );
@ -669,17 +775,45 @@ class MessageBox extends Component {
return null; return null;
} }
return ( return (
<View testID='messagebox-container'> <ScrollView
testID='messagebox-container'
style={styles.scrollViewMention}
keyboardShouldPersistTaps='always'
>
<FlatList <FlatList
style={styles.mentionList} style={styles.mentionList}
data={mentions} data={mentions}
renderItem={({ item }) => this.renderMentionItem(item)} renderItem={this.renderMentionItem}
keyExtractor={item => item._id || item.username || item} keyExtractor={item => item._id || item.username || item.command || item}
keyboardShouldPersistTaps='always' keyboardShouldPersistTaps='always'
/> />
</ScrollView>
);
};
renderCommandPreviewItem = ({ item }) => (
<CommandPreview item={item} onPress={this.onPressCommandPreview} />
);
renderCommandPreview = () => {
const { commandPreview } = this.state;
if (!this.showCommandPreview) {
return null;
}
return (
<View key='commandbox-container' testID='commandbox-container'>
<FlatList
style={styles.mentionList}
data={commandPreview}
renderItem={this.renderCommandPreviewItem}
keyExtractor={item => item.id}
keyboardShouldPersistTaps='always'
horizontal
showsHorizontalScrollIndicator={false}
/>
</View> </View>
); );
}; }
renderReplyPreview = () => { renderReplyPreview = () => {
const { const {
@ -700,6 +834,7 @@ class MessageBox extends Component {
} }
return ( return (
<React.Fragment> <React.Fragment>
{this.renderCommandPreview()}
{this.renderMentions()} {this.renderMentions()}
<View style={styles.composer} key='messagebox'> <View style={styles.composer} key='messagebox'>
{this.renderReplyPreview()} {this.renderReplyPreview()}

View File

@ -3,10 +3,11 @@ import { StyleSheet } from 'react-native';
import { isIOS } from '../../utils/deviceInfo'; import { isIOS } from '../../utils/deviceInfo';
import sharedStyles from '../../views/Styles'; import sharedStyles from '../../views/Styles';
import { import {
COLOR_BORDER, COLOR_SEPARATOR, COLOR_BACKGROUND_CONTAINER, COLOR_WHITE COLOR_BORDER, COLOR_SEPARATOR, COLOR_BACKGROUND_CONTAINER, COLOR_WHITE, COLOR_PRIMARY
} from '../../constants/colors'; } from '../../constants/colors';
const MENTION_HEIGHT = 50; const MENTION_HEIGHT = 50;
const SCROLLVIEW_MENTION_HEIGHT = 4 * MENTION_HEIGHT;
export default StyleSheet.create({ export default StyleSheet.create({
textBox: { textBox: {
@ -100,5 +101,35 @@ export default StyleSheet.create({
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0 right: 0
},
slash: {
color: COLOR_PRIMARY,
backgroundColor: COLOR_BORDER,
height: 30,
width: 30,
padding: 5,
paddingHorizontal: 12,
marginHorizontal: 10,
borderRadius: 2
},
commandPreviewImage: {
justifyContent: 'center',
margin: 3,
width: 120,
height: 80,
borderRadius: 4
},
commandPreview: {
backgroundColor: COLOR_BACKGROUND_CONTAINER,
height: 100,
flex: 1,
flexDirection: 'row',
alignItems: 'center'
},
avatar: {
margin: 8
},
scrollViewMention: {
maxHeight: SCROLLVIEW_MENTION_HEIGHT
} }
}); });

View File

@ -0,0 +1,31 @@
import { InteractionManager } from 'react-native';
import database from '../realm';
import log from '../../utils/log';
export default async function() {
try {
// RC 0.60.2
const result = await this.sdk.get('commands.list');
if (!result.success) {
return log('getSlashCommand fetch', result);
}
const { commands } = result;
if (commands && commands.length) {
InteractionManager.runAfterInteractions(() => {
database.write(() => commands.forEach((command) => {
try {
database.create('slashCommand', command, true);
} catch (e) {
log('get_slash_command', e);
}
}));
});
}
} catch (e) {
log('err_get_slash_command', e);
}
}

View File

@ -273,6 +273,18 @@ const frequentlyUsedEmojiSchema = {
} }
}; };
const slashCommandSchema = {
name: 'slashCommand',
primaryKey: 'command',
properties: {
command: 'string',
params: { type: 'string', optional: true },
description: { type: 'string', optional: true },
clientOnly: { type: 'bool', optional: true },
providesPreview: { type: 'bool', optional: true }
}
};
const customEmojisSchema = { const customEmojisSchema = {
name: 'customEmojis', name: 'customEmojis',
primaryKey: '_id', primaryKey: '_id',
@ -347,7 +359,8 @@ const schema = [
customEmojisSchema, customEmojisSchema,
messagesReactionsSchema, messagesReactionsSchema,
rolesSchema, rolesSchema,
uploadsSchema uploadsSchema,
slashCommandSchema
]; ];
const inMemorySchema = [usersTypingSchema, activeUsersSchema]; const inMemorySchema = [usersTypingSchema, activeUsersSchema];

View File

@ -25,6 +25,7 @@ import getSettings from './methods/getSettings';
import getRooms from './methods/getRooms'; import getRooms from './methods/getRooms';
import getPermissions from './methods/getPermissions'; import getPermissions from './methods/getPermissions';
import getCustomEmoji from './methods/getCustomEmojis'; import getCustomEmoji from './methods/getCustomEmojis';
import getSlashCommands from './methods/getSlashCommands';
import getRoles from './methods/getRoles'; import getRoles from './methods/getRoles';
import canOpenRoom from './methods/canOpenRoom'; import canOpenRoom from './methods/canOpenRoom';
@ -153,6 +154,7 @@ const RocketChat = {
this.getPermissions(); this.getPermissions();
this.getCustomEmoji(); this.getCustomEmoji();
this.getRoles(); this.getRoles();
this.getSlashCommands();
this.registerPushToken().catch(e => console.log(e)); this.registerPushToken().catch(e => console.log(e));
this.getUserPresence(); this.getUserPresence();
}, },
@ -467,6 +469,7 @@ const RocketChat = {
getSettings, getSettings,
getPermissions, getPermissions,
getCustomEmoji, getCustomEmoji,
getSlashCommands,
getRoles, getRoles,
parseSettings: settings => settings.reduce((ret, item) => { parseSettings: settings => settings.reduce((ret, item) => {
ret[item._id] = item[defaultSettings[item._id].type]; ret[item._id] = item[defaultSettings[item._id].type];
@ -803,6 +806,24 @@ const RocketChat = {
rid, updatedSince rid, updatedSince
}); });
}, },
runSlashCommand(command, roomId, params) {
// RC 0.60.2
return this.sdk.post('commands.run', {
command, roomId, params
});
},
getCommandPreview(command, roomId, params) {
// RC 0.65.0
return this.sdk.get('commands.preview', {
command, roomId, params
});
},
executeCommandPreview(command, params, roomId, previewItem) {
// RC 0.65.0
return this.sdk.post('commands.preview', {
command, params, roomId, previewItem
});
},
async getUserPresence() { async getUserPresence() {
const serverVersion = reduxStore.getState().server.version; const serverVersion = reduxStore.getState().server.version;

View File

@ -158,6 +158,31 @@ describe('Room screen', () => {
await expect(element(by.id('messagebox-input'))).toHaveText('#general '); await expect(element(by.id('messagebox-input'))).toHaveText('#general ');
await element(by.id('messagebox-input')).clearText(); await element(by.id('messagebox-input')).clearText();
}); });
it('should show and tap on slash command autocomplete and send slash command', async() => {
await element(by.id('messagebox-input')).tap();
await element(by.id('messagebox-input')).typeText('/');
await waitFor(element(by.id('messagebox-container'))).toBeVisible().withTimeout(10000);
await expect(element(by.id('messagebox-container'))).toBeVisible();
await element(by.id('mention-item-shrug')).tap();
await expect(element(by.id('messagebox-input'))).toHaveText('/shrug ');
await element(by.id('messagebox-input')).typeText('joy'); // workaround for number keyboard
await element(by.id('messagebox-send-message')).tap();
await waitFor(element(by.text(`joy ¯\_(ツ)_/¯`))).toBeVisible().withTimeout(60000);
});
it('should show command Preview', async() => {
await element(by.id('messagebox-input')).tap();
await element(by.id('messagebox-input')).replaceText('/giphy');
await waitFor(element(by.id('messagebox-container'))).toBeVisible().withTimeout(10000);
await expect(element(by.id('messagebox-container'))).toBeVisible();
await element(by.id('mention-item-giphy')).tap();
await expect(element(by.id('messagebox-input'))).toHaveText('/giphy ');
await element(by.id('messagebox-input')).typeText('no'); // workaround for number keyboard
await waitFor(element(by.id('commandbox-container'))).toBeVisible().withTimeout(10000);
await expect(element(by.id('commandbox-container'))).toBeVisible();
await element(by.id('messagebox-input')).clearText();
});
}); });
describe('Message', async() => { describe('Message', async() => {