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