From 82afb63327f1a8cc48ee8ba2e70d8713f7e055a5 Mon Sep 17 00:00:00 2001
From: pranavpandey1998official
<44601530+pranavpandey1998official@users.noreply.github.com>
Date: Tue, 11 Jun 2019 00:06:56 +0530
Subject: [PATCH] [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
---
app/containers/MessageBox/CommandPreview.js | 47 +++++
app/containers/MessageBox/index.js | 215 ++++++++++++++++----
app/containers/MessageBox/styles.js | 33 ++-
app/lib/methods/getSlashCommands.js | 31 +++
app/lib/realm.js | 15 +-
app/lib/rocketchat.js | 21 ++
e2e/08-room.spec.js | 27 ++-
7 files changed, 346 insertions(+), 43 deletions(-)
create mode 100644 app/containers/MessageBox/CommandPreview.js
create mode 100644 app/lib/methods/getSlashCommands.js
diff --git a/app/containers/MessageBox/CommandPreview.js b/app/containers/MessageBox/CommandPreview.js
new file mode 100644
index 00000000..51cc64e4
--- /dev/null
+++ b/app/containers/MessageBox/CommandPreview.js
@@ -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 (
+ onPress(item)}
+ testID={`command-preview-item${ item.id }`}
+ >
+ {item.type === 'image'
+ ? (
+ this.setState({ loading: true })}
+ onLoad={() => this.setState({ loading: false })}
+ >
+ { loading ? : null }
+
+ )
+ :
+ }
+
+ );
+ }
+}
diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js
index db5bdf10..1e6974d9 100644
--- a/app/containers/MessageBox/index.js
+++ b/app/containers/MessageBox/index.js
@@ -1,7 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
- View, TextInput, FlatList, Text, TouchableOpacity, Alert
+ View, TextInput, FlatList, Text, TouchableOpacity, Alert, ScrollView
} from 'react-native';
import { connect } from 'react-redux';
import { emojify } from 'react-emojione';
@@ -32,9 +32,12 @@ import { COLOR_TEXT_DESCRIPTION } from '../../constants/colors';
import LeftButtons from './LeftButtons';
import RightButtons from './RightButtons';
import { isAndroid } from '../../utils/deviceInfo';
+import CommandPreview from './CommandPreview';
const MENTIONS_TRACKING_TYPE_USERS = '@';
const MENTIONS_TRACKING_TYPE_EMOJIS = ':';
+const MENTIONS_TRACKING_TYPE_COMMANDS = '/';
+const MENTIONS_COUNT_TO_DISPLAY = 4;
const onlyUnique = function onlyUnique(value, index, self) {
return self.indexOf(({ _id }) => value._id === _id) === index;
@@ -93,8 +96,11 @@ class MessageBox extends Component {
trackingType: '',
file: {
isVisible: false
- }
+ },
+ commandPreview: []
};
+ this.showCommandPreview = false;
+ this.commands = [];
this.users = [];
this.rooms = [];
this.emojis = [];
@@ -147,7 +153,7 @@ class MessageBox extends Component {
shouldComponentUpdate(nextProps, nextState) {
const {
- showEmojiKeyboard, showSend, recording, mentions, file
+ showEmojiKeyboard, showSend, recording, mentions, file, commandPreview
} = this.state;
const {
roomType, replying, editing, isFocused
@@ -176,6 +182,9 @@ class MessageBox extends Component {
if (!equal(nextState.mentions, mentions)) {
return true;
}
+ if (!equal(nextState.commandPreview, commandPreview)) {
+ return true;
+ }
if (!equal(nextState.file, file)) {
return true;
}
@@ -187,20 +196,36 @@ class MessageBox extends Component {
this.setShowSend(!isTextEmpty);
this.handleTyping(!isTextEmpty);
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) {
const { start, end } = this.component._lastNativeSelection;
const cursor = Math.max(start, end);
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);
+ this.showCommandPreview = false;
if (!result) {
+ const slash = lastNativeText.match(/^\/$/); // matches only '/' in input
+ if (slash) {
+ return this.identifyMentionKeyword('', MENTIONS_TRACKING_TYPE_COMMANDS);
+ }
return this.stopTrackingMention();
}
const [, lastChar, name] = result;
this.identifyMentionKeyword(name, lastChar);
} else {
this.stopTrackingMention();
+ this.showCommandPreview = false;
}
}, 100)
@@ -220,13 +245,32 @@ class MessageBox extends Component {
const result = msg.substr(0, cursor).replace(regexp, '');
const mentionName = trackingType === MENTIONS_TRACKING_TYPE_EMOJIS
? `${ item.name || item }:`
- : (item.username || item.name);
+ : (item.username || item.name || item.command);
const text = `${ result }${ mentionName } ${ msg.slice(cursor) }`;
+ if ((trackingType === MENTIONS_TRACKING_TYPE_COMMANDS) && item.providesPreview) {
+ this.showCommandPreview = true;
+ }
this.setInput(text);
this.focus();
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) => {
const { text } = this;
const { emoji } = params;
@@ -301,7 +345,7 @@ class MessageBox extends Component {
console.warn('spotlight canceled');
} finally {
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.setState({ mentions: this.users });
}
@@ -351,13 +395,18 @@ class MessageBox extends Component {
getEmojis = (keyword) => {
if (keyword) {
- this.customEmojis = database.objects('customEmojis').filtered('name CONTAINS[c] $0', keyword).slice(0, 4);
- this.emojis = emojis.filter(emoji => emoji.indexOf(keyword) !== -1).slice(0, 4);
- const mergedEmojis = [...this.customEmojis, ...this.emojis];
+ 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, MENTIONS_COUNT_TO_DISPLAY);
+ const mergedEmojis = [...this.customEmojis, ...this.emojis].slice(0, MENTIONS_COUNT_TO_DISPLAY);
this.setState({ mentions: mergedEmojis });
}
}
+ getSlashCommands = (keyword) => {
+ this.commands = database.objects('slashCommand').filtered('command CONTAINS[c] $0', keyword);
+ this.setState({ mentions: this.commands });
+ }
+
focus = () => {
if (this.component && this.component.focus) {
this.component.focus();
@@ -385,6 +434,18 @@ class MessageBox extends Component {
}, 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) => {
this.text = text;
if (this.component && this.component.setNativeProps) {
@@ -505,7 +566,7 @@ class MessageBox extends Component {
submit = async() => {
const {
- message: editingMessage, editRequest, onSubmit
+ message: editingMessage, editRequest, onSubmit, rid: roomId
} = this.props;
const message = this.text;
@@ -521,6 +582,22 @@ class MessageBox extends Component {
editing, replying
} = 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
if (editing) {
const { _id, rid } = editingMessage;
@@ -561,6 +638,8 @@ class MessageBox extends Component {
this.getUsers(keyword);
} else if (type === MENTIONS_TRACKING_TYPE_EMOJIS) {
this.getEmojis(keyword);
+ } else if (type === MENTIONS_TRACKING_TYPE_COMMANDS) {
+ this.getSlashCommands(keyword);
} else {
this.getRooms(keyword);
}
@@ -579,15 +658,16 @@ class MessageBox extends Component {
if (!trackingType) {
return;
}
-
this.setState({
mentions: [],
- trackingType: ''
+ trackingType: '',
+ commandPreview: []
});
this.users = [];
this.rooms = [];
this.customEmojis = [];
this.emojis = [];
+ this.commands = [];
}
renderFixedMentionItem = item => (
@@ -623,41 +703,67 @@ class MessageBox extends Component {
);
}
- renderMentionItem = (item) => {
+ renderMentionItem = ({ item }) => {
const { trackingType } = this.state;
const { baseUrl, user } = this.props;
if (item.username === 'all' || item.username === 'here') {
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 (
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
- ? (
-
- {this.renderMentionEmoji(item)}
- :{ item.name || item }:
-
- )
- : (
-
-
- { item.username || item.name }
-
- )
+
+ {(() => {
+ switch (trackingType) {
+ case MENTIONS_TRACKING_TYPE_EMOJIS:
+ return (
+
+ {this.renderMentionEmoji(item)}
+ :{ item.name || item }:
+
+ );
+ case MENTIONS_TRACKING_TYPE_COMMANDS:
+ return (
+
+ /
+ { item.command}
+
+ );
+ default:
+ return (
+
+
+ { item.username || item.name || item }
+
+ );
+ }
+ })()
}
);
@@ -669,17 +775,45 @@ class MessageBox extends Component {
return null;
}
return (
-
+
this.renderMentionItem(item)}
- keyExtractor={item => item._id || item.username || item}
+ renderItem={this.renderMentionItem}
+ keyExtractor={item => item._id || item.username || item.command || item}
keyboardShouldPersistTaps='always'
/>
+
+ );
+ };
+
+ renderCommandPreviewItem = ({ item }) => (
+
+ );
+
+ renderCommandPreview = () => {
+ const { commandPreview } = this.state;
+ if (!this.showCommandPreview) {
+ return null;
+ }
+ return (
+
+ item.id}
+ keyboardShouldPersistTaps='always'
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ />
);
- };
+ }
renderReplyPreview = () => {
const {
@@ -700,6 +834,7 @@ class MessageBox extends Component {
}
return (
+ {this.renderCommandPreview()}
{this.renderMentions()}
{this.renderReplyPreview()}
diff --git a/app/containers/MessageBox/styles.js b/app/containers/MessageBox/styles.js
index ec4cb3e7..79bd730b 100644
--- a/app/containers/MessageBox/styles.js
+++ b/app/containers/MessageBox/styles.js
@@ -3,10 +3,11 @@ import { StyleSheet } from 'react-native';
import { isIOS } from '../../utils/deviceInfo';
import sharedStyles from '../../views/Styles';
import {
- COLOR_BORDER, COLOR_SEPARATOR, COLOR_BACKGROUND_CONTAINER, COLOR_WHITE
+ COLOR_BORDER, COLOR_SEPARATOR, COLOR_BACKGROUND_CONTAINER, COLOR_WHITE, COLOR_PRIMARY
} from '../../constants/colors';
const MENTION_HEIGHT = 50;
+const SCROLLVIEW_MENTION_HEIGHT = 4 * MENTION_HEIGHT;
export default StyleSheet.create({
textBox: {
@@ -100,5 +101,35 @@ export default StyleSheet.create({
bottom: 0,
left: 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
}
});
diff --git a/app/lib/methods/getSlashCommands.js b/app/lib/methods/getSlashCommands.js
new file mode 100644
index 00000000..401c1180
--- /dev/null
+++ b/app/lib/methods/getSlashCommands.js
@@ -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);
+ }
+}
diff --git a/app/lib/realm.js b/app/lib/realm.js
index d750d7ab..f296752b 100644
--- a/app/lib/realm.js
+++ b/app/lib/realm.js
@@ -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 = {
name: 'customEmojis',
primaryKey: '_id',
@@ -347,7 +359,8 @@ const schema = [
customEmojisSchema,
messagesReactionsSchema,
rolesSchema,
- uploadsSchema
+ uploadsSchema,
+ slashCommandSchema
];
const inMemorySchema = [usersTypingSchema, activeUsersSchema];
diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js
index 4bf66959..156e34b7 100644
--- a/app/lib/rocketchat.js
+++ b/app/lib/rocketchat.js
@@ -25,6 +25,7 @@ import getSettings from './methods/getSettings';
import getRooms from './methods/getRooms';
import getPermissions from './methods/getPermissions';
import getCustomEmoji from './methods/getCustomEmojis';
+import getSlashCommands from './methods/getSlashCommands';
import getRoles from './methods/getRoles';
import canOpenRoom from './methods/canOpenRoom';
@@ -153,6 +154,7 @@ const RocketChat = {
this.getPermissions();
this.getCustomEmoji();
this.getRoles();
+ this.getSlashCommands();
this.registerPushToken().catch(e => console.log(e));
this.getUserPresence();
},
@@ -467,6 +469,7 @@ const RocketChat = {
getSettings,
getPermissions,
getCustomEmoji,
+ getSlashCommands,
getRoles,
parseSettings: settings => settings.reduce((ret, item) => {
ret[item._id] = item[defaultSettings[item._id].type];
@@ -803,6 +806,24 @@ const RocketChat = {
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() {
const serverVersion = reduxStore.getState().server.version;
diff --git a/e2e/08-room.spec.js b/e2e/08-room.spec.js
index 9b896c02..9a00ca46 100644
--- a/e2e/08-room.spec.js
+++ b/e2e/08-room.spec.js
@@ -158,6 +158,31 @@ describe('Room screen', () => {
await expect(element(by.id('messagebox-input'))).toHaveText('#general ');
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() => {
@@ -360,4 +385,4 @@ describe('Room screen', () => {
await expect(element(by.id('rooms-list-view'))).toBeVisible();
});
});
-});
+});
\ No newline at end of file