From 157b85c2cbf4f12b014b274f93bfbc33f31b5353 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 20 Dec 2017 18:14:07 -0200 Subject: [PATCH] Mention autocomplete (#150) * Mentions working --- .../MessageBox/AnimatedContainer.js | 65 +++++ app/containers/MessageBox/index.js | 262 +++++++++++++++--- app/containers/MessageBox/style.js | 19 +- app/lib/rocketchat.js | 4 +- app/views/RoomView/index.js | 2 + 5 files changed, 315 insertions(+), 37 deletions(-) create mode 100644 app/containers/MessageBox/AnimatedContainer.js diff --git a/app/containers/MessageBox/AnimatedContainer.js b/app/containers/MessageBox/AnimatedContainer.js new file mode 100644 index 00000000..eb5cbbe4 --- /dev/null +++ b/app/containers/MessageBox/AnimatedContainer.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Animated } from 'react-native'; + +export default class MessageBox extends React.PureComponent { + static propTypes = { + subview: PropTypes.object.isRequired, + visible: PropTypes.bool.isRequired, + messageboxHeight: PropTypes.number.isRequired + } + + constructor(props) { + super(props); + this.animatedBottom = new Animated.Value(0); + } + + componentWillReceiveProps(nextProps) { + if (this.props.visible === nextProps.visible) { + return; + } + if (nextProps.visible) { + return this.show(); + } + this.hide(); + } + + show() { + this.animatedBottom.setValue(0); + Animated.timing(this.animatedBottom, { + toValue: 1, + duration: 300, + useNativeDriver: true + }).start(); + } + + hide() { + Animated.timing(this.animatedBottom, { + toValue: 0, + duration: 300, + useNativeDriver: true + }).start(); + } + + render() { + const bottom = this.animatedBottom.interpolate({ + inputRange: [0, 1], + outputRange: [0, -this.props.messageboxHeight - 200] + }); + + return ( + + {this.props.subview} + + ); + } +} diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js index e84c9aef..edae5f16 100644 --- a/app/containers/MessageBox/index.js +++ b/app/containers/MessageBox/index.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { View, TextInput, SafeAreaView, Platform } from 'react-native'; +import { View, TextInput, SafeAreaView, Platform, FlatList, Text, TouchableOpacity } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; import ImagePicker from 'react-native-image-picker'; import { connect } from 'react-redux'; @@ -9,10 +9,21 @@ import RocketChat from '../../lib/rocketchat'; import { editRequest, editCancel, clearInput } from '../../actions/messages'; import styles from './style'; import MyIcon from '../icons'; +import realm from '../../lib/realm'; +import Avatar from '../Avatar'; +import AnimatedContainer from './AnimatedContainer'; + +const MENTIONS_TRACKING_TYPE_USERS = '@'; + +const onlyUnique = function onlyUnique(value, index, self) { + return self.indexOf(({ _id }) => value._id === _id) === index; +}; + @connect(state => ({ room: state.room, message: state.messages.message, - editing: state.messages.editing + editing: state.messages.editing, + baseUrl: state.settings.Site_Url }), dispatch => ({ editCancel: () => dispatch(editCancel()), editRequest: message => dispatch(editRequest(message)), @@ -25,6 +36,7 @@ export default class MessageBox extends React.Component { rid: PropTypes.string.isRequired, editCancel: PropTypes.func.isRequired, editRequest: PropTypes.func.isRequired, + baseUrl: PropTypes.string.isRequired, message: PropTypes.object, editing: PropTypes.bool, typing: PropTypes.func, @@ -35,22 +47,49 @@ export default class MessageBox extends React.Component { super(props); this.state = { height: 20, - text: '' + messageboxHeight: 0, + text: '', + mentions: [], + showAnimatedContainer: false }; + this.users = []; + this.rooms = []; } - componentWillReceiveProps(nextProps) { - if (this.props.message !== nextProps.message && nextProps.message) { - this.component.setNativeProps({ text: nextProps.message.msg }); + if (this.props.message !== nextProps.message && nextProps.message.msg) { + this.setState({ text: nextProps.message.msg }); this.component.focus(); } else if (!nextProps.message) { - this.component.setNativeProps({ text: '' }); + this.setState({ text: '' }); } } + + onChange() { + requestAnimationFrame(() => { + const { start, end } = this.component._lastNativeSelection; + + const cursor = Math.max(start, end); + + const text = this.component._lastNativeText; + + const regexp = /(#|@)([a-z._-]+)$/im; + + const result = text.substr(0, cursor).match(regexp); + + if (!result) { + return this.stopTrackingMention(); + } + const [, lastChar, name] = result; + + this.identifyMentionKeyword(name, lastChar); + }); + } + + onChangeText(text) { this.setState({ text }); - this.props.typing(text.length > 0); } + get leftButtons() { const { editing } = this.props; if (editing) { @@ -79,14 +118,14 @@ export default class MessageBox extends React.Component { get rightButtons() { const icons = []; - if (this.state.text.length) { + if (this.state.text) { icons.push( this.submit(this.component._lastNativeText)} + onPress={() => this.submit(this.state.text)} />); } icons.push( { this.setState({ height: height + (Platform.OS === 'ios' ? 0 : 0) }); } + addFile = () => { const options = { customButtons: [{ @@ -133,14 +170,14 @@ export default class MessageBox extends React.Component { } editCancel() { this.props.editCancel(); - this.component.setNativeProps({ text: '' }); + this.setState({ text: '' }); } openEmoji() { this.setState({ emoji: !this.state.emoji }); } submit(message) { - this.component.setNativeProps({ text: '' }); this.setState({ text: '' }); + this.stopTrackingMention(); requestAnimationFrame(() => { this.props.typing(false); if (message.trim() === '') { @@ -159,28 +196,185 @@ export default class MessageBox extends React.Component { }); } + async _getUsers(keyword) { + this.users = realm.objects('users'); + if (keyword) { + this.users = this.users.filtered('username CONTAINS[c] $0', keyword); + } + this.setState({ mentions: this.users.slice() }); + + const usernames = []; + + if (keyword && this.users.length > 7) { + return; + } + + this.users.forEach(user => usernames.push(user.username)); + + if (this.oldPromise) { + this.oldPromise(); + } + try { + const results = await Promise.race([ + RocketChat.spotlight(keyword, usernames, { users: true }), + new Promise((resolve, reject) => (this.oldPromise = reject)) + ]); + realm.write(() => { + results.users.forEach((user) => { + user._server = { + id: this.props.baseUrl, + current: true + }; + realm.create('users', user, true); + }); + }); + } catch (e) { + console.log('spotlight canceled'); + } finally { + delete this.oldPromise; + this.users = realm.objects('users').filtered('username CONTAINS[c] $0', keyword); + this.setState({ mentions: this.users.slice() }); + } + } + + async _getRooms(keyword = '') { + this.roomsCache = this.roomsCache || []; + this.rooms = realm.objects('subscriptions') + .filtered('_server.id = $0 AND t != $1', this.props.baseUrl, 'd'); + if (keyword) { + this.rooms = this.rooms.filtered('name CONTAINS[c] $0', keyword); + } + + const rooms = []; + this.rooms.forEach(room => rooms.push(room)); + + this.roomsCache.forEach((room) => { + if (room.name && room.name.toUpperCase().indexOf(keyword.toUpperCase()) !== -1) { + rooms.push(room); + } + }); + + if (rooms.length > 3) { + this.setState({ mentions: rooms }); + return; + } + + if (this.oldPromise) { + this.oldPromise(); + } + + try { + const results = await Promise.race([ + RocketChat.spotlight(keyword, [...rooms, ...this.roomsCache].map(r => r.name), { rooms: true }), + new Promise((resolve, reject) => (this.oldPromise = reject)) + ]); + this.roomsCache = [...this.roomsCache, ...results.rooms].filter(onlyUnique); + this.setState({ mentions: [...rooms.slice(), ...results.rooms] }); + } catch (e) { + console.log('spotlight canceled'); + } finally { + delete this.oldPromise; + } + } + + stopTrackingMention() { + this.setState({ + showAnimatedContainer: false, + mentions: [] + }); + this.users = []; + this.rooms = []; + } + + identifyMentionKeyword(keyword, type) { + this.updateMentions(keyword, type); + this.setState({ + showAnimatedContainer: true + }); + } + + updateMentions = (keyword, type) => { + if (type === MENTIONS_TRACKING_TYPE_USERS) { + this._getUsers(keyword); + } else { + this._getRooms(keyword); + } + } + + _onPressMention(item) { + const msg = this.component._lastNativeText; + + const { start, end } = this.component._lastNativeSelection; + + const cursor = Math.max(start, end); + + const regexp = /([a-z._-]+)$/im; + + const result = msg.substr(0, cursor).replace(regexp, ''); + const text = `${ result }${ item.username || item.name } ${ msg.slice(cursor) }`; + this.component.setNativeProps({ text }); + this.setState({ text }); + this.component.focus(); + requestAnimationFrame(() => this.stopTrackingMention()); + } + renderMentionItem = item => ( + this._onPressMention(item)} + > + + {item.username || item.name } + + ) + renderMentions() { + const usersList = ( + this.renderMentionItem(item)} + keyExtractor={item => item._id} + keyboardShouldPersistTaps='always' + keyboardDismissMode='interactive' + /> + ); + const { showAnimatedContainer, messageboxHeight } = this.state; + return ; + } render() { const { height } = this.state; return ( - - - {this.leftButtons} - this.component = component} - style={[styles.textBoxInput, { height }]} - returnKeyType='default' - blurOnSubmit={false} - placeholder='New Message' - onChangeText={text => this.onChangeText(text)} - underlineColorAndroid='transparent' - defaultValue='' - multiline - placeholderTextColor='#9EA2A8' - onContentSizeChange={e => this.updateSize(e.nativeEvent.contentSize.height)} - /> - {this.rightButtons} - - + + this.setState({ messageboxHeight: event.nativeEvent.layout.height })} + > + + {this.leftButtons} + this.component = component} + style={[styles.textBoxInput, { height }]} + returnKeyType='default' + blurOnSubmit={false} + placeholder='New Message' + onChangeText={text => this.onChangeText(text)} + onChange={event => this.onChange(event)} + value={this.state.text} + underlineColorAndroid='transparent' + defaultValue='' + multiline + placeholderTextColor='#9EA2A8' + onContentSizeChange={e => this.updateSize(e.nativeEvent.contentSize.height)} + /> + {this.rightButtons} + + + {this.renderMentions()} + ); } } diff --git a/app/containers/MessageBox/style.js b/app/containers/MessageBox/style.js index d6c521df..03c07672 100644 --- a/app/containers/MessageBox/style.js +++ b/app/containers/MessageBox/style.js @@ -1,5 +1,6 @@ import { StyleSheet } from 'react-native'; +const MENTION_HEIGHT = 50; export default StyleSheet.create({ textBox: { @@ -9,7 +10,8 @@ export default StyleSheet.create({ borderTopWidth: 1, borderTopColor: '#D8D8D8', paddingHorizontal: 15, - paddingVertical: 15 + paddingVertical: 15, + zIndex: 2 }, safeAreaView: { flexDirection: 'row', @@ -62,5 +64,20 @@ export default StyleSheet.create({ borderBottomWidth: 1, borderBottomColor: '#ECECEC', color: '#2F343D' + }, + mentionList: { + maxHeight: MENTION_HEIGHT * 4, + borderTopColor: '#ECECEC', + borderTopWidth: 1, + paddingHorizontal: 5, + backgroundColor: '#fff' + }, + mentionItem: { + height: MENTION_HEIGHT, + backgroundColor: '#F7F8FA', + borderBottomWidth: 1, + borderBottomColor: '#ECECEC', + flexDirection: 'row', + alignItems: 'center' } }); diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 884d1b3f..0759037f 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -346,8 +346,8 @@ const RocketChat = { return RocketChat._sendMessageCall(message); }, - spotlight(search, usernames) { - return call('spotlight', search, usernames); + spotlight(search, usernames, type) { + return call('spotlight', search, usernames, type); }, createDirectMessage(username) { diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index e8a2647a..ae1a1465 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -184,6 +184,8 @@ export default class RoomView extends React.Component { dataSource={this.state.dataSource} renderRow={item => this.renderItem({ item })} initialListSize={10} + keyboardShouldPersistTaps='always' + keyboardDismissMode='interactive' /> {this.renderFooter()}