From 0636fd0266b7b3a126dbd9a4eed080f60642e1ab Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 16 Jan 2018 16:48:05 -0200 Subject: [PATCH] Emoji picker (#185) * Emoji picker working * Gif support on Android --- android/app/build.gradle | 2 + app/actions/actionsTypes.js | 1 + app/actions/index.js | 7 + app/actions/keyboard.js | 13 ++ app/constants/types.js | 1 + app/containers/CustomEmoji.js | 25 +++ .../MessageBox/EmojiPicker/EmojiCategory.js | 49 +++++ .../MessageBox/EmojiPicker/TabBar.js | 25 +++ .../MessageBox/EmojiPicker/categories.js | 44 ++++ .../MessageBox/EmojiPicker/index.js | 143 +++++++++++++ .../MessageBox/EmojiPicker/styles.js | 62 ++++++ app/containers/MessageBox/index.js | 73 +++++-- app/containers/MessageBox/style.js | 6 + app/containers/message/Markdown.js | 196 ++++++++++-------- app/containers/message/index.js | 9 +- app/containers/message/styles.js | 17 +- app/lib/realm.js | 36 +++- app/lib/rocketchat.js | 24 +++ app/presentation/KeyboardView.js | 16 +- app/reducers/customEmojis.js | 17 ++ app/reducers/index.js | 17 +- app/reducers/keyboard.js | 22 ++ app/sagas/init.js | 2 + app/utils/scrollPersistTaps.js | 4 + app/views/LoginView.js | 4 +- app/views/NewServerView.js | 4 +- app/views/RoomView/index.js | 4 +- package-lock.json | 20 ++ package.json | 4 + 29 files changed, 725 insertions(+), 122 deletions(-) create mode 100644 app/actions/keyboard.js create mode 100644 app/containers/CustomEmoji.js create mode 100644 app/containers/MessageBox/EmojiPicker/EmojiCategory.js create mode 100644 app/containers/MessageBox/EmojiPicker/TabBar.js create mode 100644 app/containers/MessageBox/EmojiPicker/categories.js create mode 100644 app/containers/MessageBox/EmojiPicker/index.js create mode 100644 app/containers/MessageBox/EmojiPicker/styles.js create mode 100644 app/reducers/customEmojis.js create mode 100644 app/reducers/keyboard.js create mode 100644 app/utils/scrollPersistTaps.js diff --git a/android/app/build.gradle b/android/app/build.gradle index 8001a718..1d5816d8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -158,6 +158,8 @@ dependencies { compile "com.android.support:appcompat-v7:23.0.1" compile 'com.android.support:customtabs:23.0.1' compile "com.facebook.react:react-native:+" // From node_modules + compile 'com.facebook.fresco:fresco:1.7.1' + compile 'com.facebook.fresco:animated-gif:1.7.1' } // Run this once to be able to run the application with BUCK diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js index 890c1896..b4123946 100644 --- a/app/actions/actionsTypes.js +++ b/app/actions/actionsTypes.js @@ -81,3 +81,4 @@ export const ACTIVE_USERS = createRequestTypes('ACTIVE_USERS', ['SET', 'REQUEST' export const INCREMENT = 'INCREMENT'; export const DECREMENT = 'DECREMENT'; +export const KEYBOARD = createRequestTypes('KEYBOARD', ['OPEN', 'CLOSE']); diff --git a/app/actions/index.js b/app/actions/index.js index c4474437..b597699b 100644 --- a/app/actions/index.js +++ b/app/actions/index.js @@ -39,6 +39,13 @@ export function setAllPermissions(permissions) { }; } +export function setCustomEmojis(emojis) { + return { + type: types.SET_CUSTOM_EMOJIS, + payload: emojis + }; +} + export function login() { return { type: 'LOGIN' diff --git a/app/actions/keyboard.js b/app/actions/keyboard.js new file mode 100644 index 00000000..6c598d02 --- /dev/null +++ b/app/actions/keyboard.js @@ -0,0 +1,13 @@ +import * as types from './actionsTypes'; + +export function setKeyboardOpen() { + return { + type: types.KEYBOARD.OPEN + }; +} + +export function setKeyboardClosed() { + return { + type: types.KEYBOARD.CLOSE + }; +} diff --git a/app/constants/types.js b/app/constants/types.js index d1e73f8e..3e65838c 100644 --- a/app/constants/types.js +++ b/app/constants/types.js @@ -1,4 +1,5 @@ export const SET_CURRENT_SERVER = 'SET_CURRENT_SERVER'; export const SET_ALL_SETTINGS = 'SET_ALL_SETTINGS'; export const SET_ALL_PERMISSIONS = 'SET_ALL_PERMISSIONS'; +export const SET_CUSTOM_EMOJIS = 'SET_CUSTOM_EMOJIS'; export const ADD_SETTINGS = 'ADD_SETTINGS'; diff --git a/app/containers/CustomEmoji.js b/app/containers/CustomEmoji.js new file mode 100644 index 00000000..166e065a --- /dev/null +++ b/app/containers/CustomEmoji.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { CachedImage } from 'react-native-img-cache'; +import { connect } from 'react-redux'; + +@connect(state => ({ + baseUrl: state.settings.Site_Url +})) +export default class extends React.PureComponent { + static propTypes = { + baseUrl: PropTypes.string.isRequired, + emoji: PropTypes.object.isRequired, + style: PropTypes.object + } + + render() { + const { baseUrl, emoji, style } = this.props; + return ( + + ); + } +} diff --git a/app/containers/MessageBox/EmojiPicker/EmojiCategory.js b/app/containers/MessageBox/EmojiPicker/EmojiCategory.js new file mode 100644 index 00000000..e4c648b1 --- /dev/null +++ b/app/containers/MessageBox/EmojiPicker/EmojiCategory.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Text, View, TouchableOpacity, StyleSheet } from 'react-native'; +import styles from './styles'; +import CustomEmoji from '../../CustomEmoji'; + +export default class extends React.PureComponent { + static propTypes = { + emojis: PropTypes.any, + finishedLoading: PropTypes.func, + onEmojiSelected: PropTypes.func + }; + + componentDidMount() { + this.props.finishedLoading(); + } + + renderEmoji = (emoji) => { + if (emoji.isCustom) { + const style = StyleSheet.flatten(styles.customCategoryEmoji); + return ; + } + return ( + + {emoji} + + ); + } + + render() { + const { emojis } = this.props; + return ( + + + {emojis.map(emoji => + ( + this.props.onEmojiSelected(emoji)} + > + {this.renderEmoji(emoji)} + + ))} + + + ); + } +} diff --git a/app/containers/MessageBox/EmojiPicker/TabBar.js b/app/containers/MessageBox/EmojiPicker/TabBar.js new file mode 100644 index 00000000..9bc131a4 --- /dev/null +++ b/app/containers/MessageBox/EmojiPicker/TabBar.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View, TouchableOpacity, Text } from 'react-native'; +import styles from './styles'; + +export default class extends React.PureComponent { + static propTypes = { + goToPage: PropTypes.func, + activeTab: PropTypes.number, + tabs: PropTypes.array + } + + render() { + return ( + + {this.props.tabs.map((tab, i) => ( + this.props.goToPage(i)} style={styles.tab}> + {tab} + {this.props.activeTab === i ? : } + + ))} + + ); + } +} diff --git a/app/containers/MessageBox/EmojiPicker/categories.js b/app/containers/MessageBox/EmojiPicker/categories.js new file mode 100644 index 00000000..341c6a83 --- /dev/null +++ b/app/containers/MessageBox/EmojiPicker/categories.js @@ -0,0 +1,44 @@ +const list = ['Frequently Used', 'Custom', 'Smileys & People', 'Animals & Nature', 'Food & Drink', 'Activities', 'Travel & Places', 'Objects', 'Symbols', 'Flags']; +const tabs = [ + { + tabLabel: '🕒', + category: list[0] + }, + { + tabLabel: '🚀', + category: list[1] + }, + { + tabLabel: '😃', + category: list[2] + }, + { + tabLabel: '🐶', + category: list[3] + }, + { + tabLabel: '🍔', + category: list[4] + }, + { + tabLabel: '⚽', + category: list[5] + }, + { + tabLabel: '🚌', + category: list[6] + }, + { + tabLabel: '💡', + category: list[7] + }, + { + tabLabel: '💛', + category: list[8] + }, + { + tabLabel: '🏁', + category: list[9] + } +]; +export default { list, tabs }; diff --git a/app/containers/MessageBox/EmojiPicker/index.js b/app/containers/MessageBox/EmojiPicker/index.js new file mode 100644 index 00000000..fbddf4c7 --- /dev/null +++ b/app/containers/MessageBox/EmojiPicker/index.js @@ -0,0 +1,143 @@ +import 'string.fromcodepoint'; +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { ScrollView, View } from 'react-native'; +import ScrollableTabView from 'react-native-scrollable-tab-view'; +import emojiDatasource from 'emoji-datasource/emoji.json'; +import _ from 'lodash'; +import { groupBy, orderBy } from 'lodash/collection'; +import { mapValues } from 'lodash/object'; +import TabBar from './TabBar'; +import EmojiCategory from './EmojiCategory'; +import styles from './styles'; +import categories from './categories'; +import scrollPersistTaps from '../../../utils/scrollPersistTaps'; +import database from '../../../lib/realm'; + +const charFromUtf16 = utf16 => String.fromCodePoint(...utf16.split('-').map(u => `0x${ u }`)); +const charFromEmojiObj = obj => charFromUtf16(obj.unified); + +const filteredEmojis = emojiDatasource.filter(e => parseFloat(e.added_in) < 10.0); +const groupedAndSorted = groupBy(orderBy(filteredEmojis, 'sort_order'), 'category'); +const emojisByCategory = mapValues(groupedAndSorted, group => group.map(charFromEmojiObj)); + +export default class extends PureComponent { + static propTypes = { + onEmojiSelected: PropTypes.func + }; + + constructor(props) { + super(props); + this.state = { + categories: categories.list.slice(0, 1), + frequentlyUsed: [], + customEmojis: [] + }; + this.frequentlyUsed = database.objects('frequentlyUsedEmoji').sorted('count', true); + this.customEmojis = database.objects('customEmojis'); + this.updateFrequentlyUsed = this.updateFrequentlyUsed.bind(this); + this.updateCustomEmojis = this.updateCustomEmojis.bind(this); + } + + componentWillMount() { + this.frequentlyUsed.addListener(this.updateFrequentlyUsed); + this.customEmojis.addListener(this.updateCustomEmojis); + this.updateFrequentlyUsed(); + this.updateCustomEmojis(); + } + + componentWillUnmount() { + clearTimeout(this._timeout); + } + + onEmojiSelected(emoji) { + if (emoji.isCustom) { + const count = this._getFrequentlyUsedCount(emoji.content); + this._addFrequentlyUsed({ + content: emoji.content, extension: emoji.extension, count, isCustom: true + }); + this.props.onEmojiSelected(`:${ emoji.content }:`); + } else { + const content = emoji.codePointAt(0).toString(); + const count = this._getFrequentlyUsedCount(content); + this._addFrequentlyUsed({ content, count, isCustom: false }); + this.props.onEmojiSelected(emoji); + } + } + _addFrequentlyUsed = (emoji) => { + database.write(() => { + database.create('frequentlyUsedEmoji', emoji, true); + }); + } + _getFrequentlyUsedCount = (content) => { + const emojiRow = this.frequentlyUsed.filtered('content == $0', content); + return emojiRow.length ? emojiRow[0].count + 1 : 1; + } + updateFrequentlyUsed() { + const frequentlyUsed = _.map(this.frequentlyUsed.slice(), (item) => { + if (item.isCustom) { + return item; + } + return String.fromCodePoint(item.content); + }); + this.setState({ frequentlyUsed }); + } + + updateCustomEmojis() { + const customEmojis = _.map(this.customEmojis.slice(), item => ({ content: item.name, extension: item.extension, isCustom: true })); + this.setState({ customEmojis }); + } + + loadNextCategory() { + if (this.state.categories.length < categories.list.length) { + this.setState({ categories: categories.list.slice(0, this.state.categories.length + 1) }); + } + } + + renderCategory(category, i) { + let emojis = []; + if (i === 0) { + emojis = this.state.frequentlyUsed; + } else if (i === 1) { + emojis = this.state.customEmojis; + } else { + emojis = emojisByCategory[category]; + } + return ( + + this.onEmojiSelected(emoji)} + finishedLoading={() => { this._timeout = setTimeout(this.loadNextCategory.bind(this), 100); }} + /> + + ); + } + + render() { + const scrollProps = { + keyboardShouldPersistTaps: 'always' + }; + return ( + + } + contentProps={scrollProps} + > + { + _.map(categories.tabs, (tab, i) => ( + + {this.renderCategory(tab.category, i)} + + )) + } + + + ); + } +} diff --git a/app/containers/MessageBox/EmojiPicker/styles.js b/app/containers/MessageBox/EmojiPicker/styles.js new file mode 100644 index 00000000..a4ffdb59 --- /dev/null +++ b/app/containers/MessageBox/EmojiPicker/styles.js @@ -0,0 +1,62 @@ +import { StyleSheet, Dimensions, Platform } from 'react-native'; + +const { width } = Dimensions.get('window'); +const EMOJI_SIZE = width / (Platform.OS === 'ios' ? 8 : 9); + +export default StyleSheet.create({ + container: { + flex: 1 + }, + tabsContainer: { + height: 45, + flexDirection: 'row', + paddingTop: 5 + }, + tab: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingBottom: 10 + }, + tabEmoji: { + fontSize: 20, + color: 'black' + }, + activeTabLine: { + position: 'absolute', + left: 0, + right: 0, + height: 2, + backgroundColor: '#007aff', + bottom: 0 + }, + tabLine: { + position: 'absolute', + left: 0, + right: 0, + height: 2, + backgroundColor: 'rgba(0,0,0,0.05)', + bottom: 0 + }, + categoryContainer: { + flex: 1, + alignItems: 'flex-start' + }, + categoryInner: { + flexWrap: 'wrap', + flexDirection: 'row', + alignItems: 'center' + }, + categoryEmoji: { + fontSize: EMOJI_SIZE - 14, + color: 'black', + height: EMOJI_SIZE, + width: EMOJI_SIZE, + textAlign: 'center' + }, + customCategoryEmoji: { + height: EMOJI_SIZE - 8, + width: EMOJI_SIZE - 8, + margin: 4 + } +}); diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js index 4319fd1d..af1a196d 100644 --- a/app/containers/MessageBox/index.js +++ b/app/containers/MessageBox/index.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { View, TextInput, SafeAreaView, Platform, FlatList, Text, TouchableOpacity } from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; +import { View, TextInput, SafeAreaView, Platform, FlatList, Text, TouchableOpacity, Keyboard } from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialIcons'; import ImagePicker from 'react-native-image-picker'; import { connect } from 'react-redux'; import { userTyping } from '../../actions/room'; @@ -12,6 +12,8 @@ import MyIcon from '../icons'; import database from '../../lib/realm'; import Avatar from '../Avatar'; import AnimatedContainer from './AnimatedContainer'; +import EmojiPicker from './EmojiPicker'; +import scrollPersistTaps from '../../utils/scrollPersistTaps'; const MENTIONS_TRACKING_TYPE_USERS = '@'; @@ -23,7 +25,8 @@ const onlyUnique = function onlyUnique(value, index, self) { room: state.room, message: state.messages.message, editing: state.messages.editing, - baseUrl: state.settings.Site_Url || state.server ? state.server.server : '' + baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', + isKeyboardOpen: state.keyboard.isOpen }), dispatch => ({ editCancel: () => dispatch(editCancel()), editRequest: message => dispatch(editRequest(message)), @@ -40,7 +43,8 @@ export default class MessageBox extends React.PureComponent { message: PropTypes.object, editing: PropTypes.bool, typing: PropTypes.func, - clearInput: PropTypes.func + clearInput: PropTypes.func, + isKeyboardOpen: PropTypes.bool } constructor(props) { @@ -50,7 +54,8 @@ export default class MessageBox extends React.PureComponent { messageboxHeight: 0, text: '', mentions: [], - showAnimatedContainer: false + showMentionsContainer: false, + showEmojiContainer: false }; this.users = []; this.rooms = []; @@ -61,6 +66,8 @@ export default class MessageBox extends React.PureComponent { this.component.focus(); } else if (!nextProps.message) { this.setState({ text: '' }); + } else if (this.props.isKeyboardOpen !== nextProps.isKeyboardOpen && nextProps.isKeyboardOpen) { + this.closeEmoji(); } } @@ -95,24 +102,24 @@ export default class MessageBox extends React.PureComponent { if (editing) { return ( this.editCancel()} />); } - return !this.state.emoji ? ( this.openEmoji()} accessibilityLabel='Open emoji selector' accessibilityTraits='button' - name='md-happy' + name='mood' />) : ( this.openEmoji()} + onPress={() => this.closeEmoji()} style={styles.actionButtons} accessibilityLabel='Close emoji selector' accessibilityTraits='button' - name='md-sad' + name='keyboard' />); } get rightButtons() { @@ -176,11 +183,16 @@ export default class MessageBox extends React.PureComponent { this.props.editCancel(); this.setState({ text: '' }); } - openEmoji() { - this.setState({ emoji: !this.state.emoji }); + async openEmoji() { + await this.setState({ showEmojiContainer: !this.state.showEmojiContainer }); + Keyboard.dismiss(); + } + closeEmoji() { + this.setState({ showEmojiContainer: false }); } submit(message) { this.setState({ text: '' }); + this.closeEmoji(); this.stopTrackingMention(); requestAnimationFrame(() => { this.props.typing(false); @@ -279,7 +291,7 @@ export default class MessageBox extends React.PureComponent { stopTrackingMention() { this.setState({ - showAnimatedContainer: false, + showMentionsContainer: false, mentions: [] }); this.users = []; @@ -289,7 +301,7 @@ export default class MessageBox extends React.PureComponent { identifyMentionKeyword(keyword, type) { this.updateMentions(keyword, type); this.setState({ - showAnimatedContainer: true + showMentionsContainer: true }); } @@ -317,6 +329,22 @@ export default class MessageBox extends React.PureComponent { this.component.focus(); requestAnimationFrame(() => this.stopTrackingMention()); } + _onEmojiSelected(emoji) { + const { text } = this.state; + let newText = ''; + + // if messagebox has an active cursor + if (this.component._lastNativeSelection) { + const { start, end } = this.component._lastNativeSelection; + const cursor = Math.max(start, end); + newText = `${ text.substr(0, cursor) }${ emoji }${ text.substr(cursor) }`; + } else { + // if messagebox doesn't have a cursor, just append selected emoji + newText = `${ text }${ emoji }`; + } + this.component.setNativeProps({ text: newText }); + this.setState({ text: newText }); + } renderMentionItem = item => ( {item.username || item.name } ) + renderEmoji() { + const emojiContainer = ( + + this._onEmojiSelected(emoji)} /> + + ); + const { showEmojiContainer, messageboxHeight } = this.state; + return ; + } renderMentions() { const usersList = ( this.renderMentionItem(item)} keyExtractor={item => item._id} - keyboardShouldPersistTaps='always' - keyboardDismissMode='interactive' + {...scrollPersistTaps} /> ); - const { showAnimatedContainer, messageboxHeight } = this.state; - return ; + const { showMentionsContainer, messageboxHeight } = this.state; + return ; } render() { const { height } = this.state; @@ -374,6 +410,7 @@ export default class MessageBox extends React.PureComponent { {this.renderMentions()} + {this.renderEmoji()} ); } diff --git a/app/containers/MessageBox/style.js b/app/containers/MessageBox/style.js index 03c07672..bee61609 100644 --- a/app/containers/MessageBox/style.js +++ b/app/containers/MessageBox/style.js @@ -79,5 +79,11 @@ export default StyleSheet.create({ borderBottomColor: '#ECECEC', flexDirection: 'row', alignItems: 'center' + }, + emojiContainer: { + height: 200, + borderTopColor: '#ECECEC', + borderTopWidth: 1, + backgroundColor: '#fff' } }); diff --git a/app/containers/message/Markdown.js b/app/containers/message/Markdown.js index 23bd69ee..3fab10a4 100644 --- a/app/containers/message/Markdown.js +++ b/app/containers/message/Markdown.js @@ -1,110 +1,127 @@ import React from 'react'; -import { Text, Platform } from 'react-native'; +import { Text, StyleSheet } from 'react-native'; import PropTypes from 'prop-types'; import EasyMarkdown from 'react-native-easy-markdown'; // eslint-disable-line import SimpleMarkdown from 'simple-markdown'; import { emojify } from 'react-emojione'; - -const codeStyle = { - ...Platform.select({ - ios: { fontFamily: 'Courier New' }, - android: { fontFamily: 'monospace' } - }), - backgroundColor: '#f8f8f8', - borderColor: '#cccccc', - borderWidth: 1, - borderRadius: 5, - padding: 5 -}; +import styles from './styles'; +import CustomEmoji from '../CustomEmoji'; const BlockCode = ({ node, state }) => ( {node.content} ); const mentionStyle = { color: '#13679a' }; -const rules = { - username: { - order: -1, - match: SimpleMarkdown.inlineRegex(/^@[0-9a-zA-Z-_.]+/), - parse: capture => ({ content: capture[0] }), - react: (node, output, state) => ({ - type: 'custom', - key: state.key, - props: { - children: ( - alert('Username')} - > - {node.content} - - ) - } - }) - }, - heading: { - order: -2, - match: SimpleMarkdown.inlineRegex(/^#[0-9a-zA-Z-_.]+/), - parse: capture => ({ content: capture[0] }), - react: (node, output, state) => ({ - type: 'custom', - key: state.key, - props: { - children: ( - alert('Room')} - > - {node.content} - - ) - } - }) - }, - fence: { - order: -5, - match: SimpleMarkdown.blockRegex(/^ *(`{3,}|~{3,}) *(\S+)? *\n([\s\S]+?)\s*\1 *(?:\n *)+\n/), - parse: capture => ({ - lang: capture[2] || undefined, - content: capture[3] - }), - react: (node, output, state) => ({ - type: 'custom', - key: state.key, - props: { - children: ( - - ) - } - }) - }, - blockCode: { - order: -6, - match: SimpleMarkdown.blockRegex(/^(```)\s*([\s\S]*?[^`])\s*\1(?!```)/), - parse: capture => ({ content: capture[2] }), - react: (node, output, state) => ({ - type: 'custom', - key: state.key, - props: { - children: ( - - ) - } - }) - } -}; -const Markdown = ({ msg }) => { +const Markdown = ({ msg, customEmojis }) => { if (!msg) { return null; } msg = emojify(msg, { output: 'unicode' }); + + const rules = { + username: { + order: -1, + match: SimpleMarkdown.inlineRegex(/^@[0-9a-zA-Z-_.]+/), + parse: capture => ({ content: capture[0] }), + react: (node, output, state) => ({ + type: 'custom', + key: state.key, + props: { + children: ( + alert('Username')} + > + {node.content} + + ) + } + }) + }, + heading: { + order: -2, + match: SimpleMarkdown.inlineRegex(/^#[0-9a-zA-Z-_.]+/), + parse: capture => ({ content: capture[0] }), + react: (node, output, state) => ({ + type: 'custom', + key: state.key, + props: { + children: ( + alert('Room')} + > + {node.content} + + ) + } + }) + }, + fence: { + order: -3, + match: SimpleMarkdown.blockRegex(/^ *(`{3,}|~{3,}) *(\S+)? *\n([\s\S]+?)\s*\1 *(?:\n *)+\n/), + parse: capture => ({ + lang: capture[2] || undefined, + content: capture[3] + }), + react: (node, output, state) => ({ + type: 'custom', + key: state.key, + props: { + children: ( + + ) + } + }) + }, + blockCode: { + order: -4, + match: SimpleMarkdown.blockRegex(/^(```)\s*([\s\S]*?[^`])\s*\1(?!```)/), + parse: capture => ({ content: capture[2] }), + react: (node, output, state) => ({ + type: 'custom', + key: state.key, + props: { + children: ( + + ) + } + }) + }, + customEmoji: { + order: -5, + match: SimpleMarkdown.inlineRegex(/^:([0-9a-zA-Z-_.]+):/), + parse: capture => ({ content: capture }), + react: (node, output, state) => { + const element = { + type: 'custom', + key: state.key, + props: { + children: {node.content[0]} + } + }; + const content = node.content[1]; + const emojiExtension = customEmojis[content]; + if (emojiExtension) { + const emoji = { extension: emojiExtension, content }; + const style = StyleSheet.flatten(styles.customEmoji); + element.props.children = ( + + ); + } + return element; + } + } + }; + + const codeStyle = StyleSheet.flatten(styles.codeStyle); return ( { }; Markdown.propTypes = { - msg: PropTypes.string.isRequired + msg: PropTypes.string.isRequired, + customEmojis: PropTypes.object }; BlockCode.propTypes = { diff --git a/app/containers/message/index.js b/app/containers/message/index.js index 9e54017f..da060ef5 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -23,7 +23,8 @@ const flex = { flexDirection: 'row', flex: 1 }; @connect(state => ({ message: state.messages.message, - editing: state.messages.editing + editing: state.messages.editing, + customEmojis: state.customEmojis }), dispatch => ({ actionsShow: actionMessage => dispatch(actionsShow(actionMessage)), errorActionsShow: actionMessage => dispatch(errorActionsShow(actionMessage)) @@ -38,7 +39,8 @@ export default class Message extends React.Component { editing: PropTypes.bool, actionsShow: PropTypes.func, errorActionsShow: PropTypes.func, - animate: PropTypes.bool + animate: PropTypes.bool, + customEmojis: PropTypes.object } componentWillMount() { @@ -135,7 +137,8 @@ export default class Message extends React.Component { if (this.isInfoMessage()) { return {this.getInfoMessage()}; } - return ; + const { item, customEmojis, baseUrl } = this.props; + return ; } renderUrl() { diff --git a/app/containers/message/styles.js b/app/containers/message/styles.js index 10b2a666..6236adf0 100644 --- a/app/containers/message/styles.js +++ b/app/containers/message/styles.js @@ -1,4 +1,4 @@ -import { StyleSheet } from 'react-native'; +import { StyleSheet, Platform } from 'react-native'; export default StyleSheet.create({ content: { @@ -18,5 +18,20 @@ export default StyleSheet.create({ }, editing: { backgroundColor: '#fff5df' + }, + customEmoji: { + width: 16, + height: 16 + }, + codeStyle: { + ...Platform.select({ + ios: { fontFamily: 'Courier New' }, + android: { fontFamily: 'monospace' } + }), + backgroundColor: '#f8f8f8', + borderColor: '#cccccc', + borderWidth: 1, + borderRadius: 5, + padding: 5 } }); diff --git a/app/lib/realm.js b/app/lib/realm.js index aace9e7f..4cf45933 100644 --- a/app/lib/realm.js +++ b/app/lib/realm.js @@ -175,6 +175,37 @@ const messagesSchema = { editedBy: 'messagesEditedBy' } }; + +const frequentlyUsedEmojiSchema = { + name: 'frequentlyUsedEmoji', + primaryKey: 'content', + properties: { + content: { type: 'string', optional: true }, + extension: { type: 'string', optional: true }, + isCustom: 'bool', + count: 'int' + } +}; + +const customEmojiAliasesSchema = { + name: 'customEmojiAliases', + properties: { + value: 'string' + } +}; + +const customEmojisSchema = { + name: 'customEmojis', + primaryKey: '_id', + properties: { + _id: 'string', + name: 'string', + aliases: { type: 'list', objectType: 'customEmojiAliases' }, + extension: 'string', + _updatedAt: { type: 'date', optional: true } + } +}; + const schema = [ settingsSchema, subscriptionSchema, @@ -187,7 +218,10 @@ const schema = [ messagesEditedBySchema, permissionsSchema, permissionsRolesSchema, - url + url, + frequentlyUsedEmojiSchema, + customEmojiAliasesSchema, + customEmojisSchema ]; class DB { databases = { diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index de6ed12d..f6e7077b 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -83,6 +83,7 @@ const RocketChat = { this.ddp.on('connected', () => { RocketChat.getSettings(); RocketChat.getPermissions(); + RocketChat.getCustomEmoji(); }); this.ddp.on('error', (err) => { @@ -513,6 +514,29 @@ const RocketChat = { }); return permissions; }, + async getCustomEmoji() { + const temp = database.objects('customEmojis').sorted('_updatedAt', true)[0]; + let emojis = await call('listEmojiCustom'); + emojis = emojis.filter(emoji => !temp || emoji._updatedAt > temp._updatedAt); + emojis = RocketChat._prepareEmojis(emojis); + database.write(() => { + emojis.forEach(emoji => database.create('customEmojis', emoji, true)); + }); + reduxStore.dispatch(actions.setCustomEmojis(RocketChat.parseEmojis(emojis))); + }, + parseEmojis: emojis => emojis.reduce((ret, item) => { + ret[item.name] = item.extension; + item.aliases.forEach((alias) => { + ret[alias.value] = item.extension; + }); + return ret; + }, {}), + _prepareEmojis(emojis) { + emojis.forEach((emoji) => { + emoji.aliases = emoji.aliases.map(alias => ({ value: alias })); + }); + return emojis; + }, deleteMessage(message) { return call('deleteMessage', { _id: message._id }); }, diff --git a/app/presentation/KeyboardView.js b/app/presentation/KeyboardView.js index f3e29d9c..6e405c66 100644 --- a/app/presentation/KeyboardView.js +++ b/app/presentation/KeyboardView.js @@ -2,7 +2,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { ViewPropTypes } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; +import { connect } from 'react-redux'; +import scrollPersistTaps from '../utils/scrollPersistTaps'; +import { setKeyboardOpen, setKeyboardClosed } from '../actions/keyboard'; +@connect(null, dispatch => ({ + setKeyboardOpen: () => dispatch(setKeyboardOpen()), + setKeyboardClosed: () => dispatch(setKeyboardClosed()) +})) export default class KeyboardView extends React.PureComponent { static propTypes = { style: ViewPropTypes.style, @@ -12,20 +19,23 @@ export default class KeyboardView extends React.PureComponent { children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node - ]) + ]), + setKeyboardOpen: PropTypes.func, + setKeyboardClosed: PropTypes.func } render() { return ( this.props.setKeyboardOpen()} + onKeyboardWillHide={() => this.props.setKeyboardClosed()} > {this.props.children} diff --git a/app/reducers/customEmojis.js b/app/reducers/customEmojis.js new file mode 100644 index 00000000..e3208a91 --- /dev/null +++ b/app/reducers/customEmojis.js @@ -0,0 +1,17 @@ +import * as types from '../constants/types'; + +const initialState = { + customEmojis: {} +}; + + +export default function customEmojis(state = initialState.customEmojis, action) { + if (action.type === types.SET_CUSTOM_EMOJIS) { + return { + ...state, + ...action.payload + }; + } + + return state; +} diff --git a/app/reducers/index.js b/app/reducers/index.js index 88ca8ae6..60c2be01 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -10,8 +10,23 @@ import navigator from './navigator'; import createChannel from './createChannel'; import app from './app'; import permissions from './permissions'; +import customEmojis from './customEmojis'; import activeUsers from './activeUsers'; +import keyboard from './keyboard'; export default combineReducers({ - settings, login, meteor, messages, server, navigator, createChannel, app, room, rooms, permissions, activeUsers + settings, + login, + meteor, + messages, + server, + navigator, + createChannel, + app, + room, + rooms, + permissions, + customEmojis, + activeUsers, + keyboard }); diff --git a/app/reducers/keyboard.js b/app/reducers/keyboard.js new file mode 100644 index 00000000..0885be77 --- /dev/null +++ b/app/reducers/keyboard.js @@ -0,0 +1,22 @@ +import * as types from '../actions/actionsTypes'; + +const initialState = { + isOpen: false +}; + +export default function messages(state = initialState, action) { + switch (action.type) { + case types.KEYBOARD.OPEN: + return { + ...state, + isOpen: true + }; + case types.KEYBOARD.CLOSE: + return { + ...state, + isOpen: false + }; + default: + return state; + } +} diff --git a/app/sagas/init.js b/app/sagas/init.js index 673a7626..15299112 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -21,6 +21,8 @@ const restore = function* restore() { yield put(actions.setAllSettings(RocketChat.parseSettings(settings.slice(0, settings.length)))); const permissions = database.objects('permissions'); yield put(actions.setAllPermissions(RocketChat.parsePermissions(permissions.slice(0, permissions.length)))); + const emojis = database.objects('customEmojis'); + yield put(actions.setCustomEmojis(RocketChat.parseEmojis(emojis.slice(0, emojis.length)))); } yield put(actions.appReady({})); } catch (e) { diff --git a/app/utils/scrollPersistTaps.js b/app/utils/scrollPersistTaps.js new file mode 100644 index 00000000..a08e17af --- /dev/null +++ b/app/utils/scrollPersistTaps.js @@ -0,0 +1,4 @@ +export default { + keyboardShouldPersistTaps: 'always', + keyboardDismissMode: 'interactive' +}; diff --git a/app/views/LoginView.js b/app/views/LoginView.js index 303aee5e..1cb7da24 100644 --- a/app/views/LoginView.js +++ b/app/views/LoginView.js @@ -8,6 +8,7 @@ import * as loginActions from '../actions/login'; import KeyboardView from '../presentation/KeyboardView'; import styles from './Styles'; +import scrollPersistTaps from '../utils/scrollPersistTaps'; import { showToast } from '../utils/info'; class LoginView extends React.Component { @@ -87,8 +88,7 @@ class LoginView extends React.Component { > diff --git a/app/views/NewServerView.js b/app/views/NewServerView.js index 879c19b9..d87cf3b2 100644 --- a/app/views/NewServerView.js +++ b/app/views/NewServerView.js @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import { serverRequest, addServer } from '../actions/server'; import KeyboardView from '../presentation/KeyboardView'; import styles from './Styles'; +import scrollPersistTaps from '../utils/scrollPersistTaps'; @connect(state => ({ validInstance: !state.server.failure && !state.server.connecting, @@ -115,8 +116,7 @@ export default class NewServerView extends React.Component { > this.inputElement = ref} diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index 72816705..145cacf2 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -23,6 +23,7 @@ import Banner from './banner'; import styles from './styles'; import debounce from '../../utils/debounce'; +import scrollPersistTaps from '../../utils/scrollPersistTaps'; const ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1._id !== r2._id }); @@ -187,8 +188,7 @@ export default class RoomView extends React.Component { dataSource={this.state.dataSource} renderRow={item => this.renderItem(item)} initialListSize={10} - keyboardShouldPersistTaps='always' - keyboardDismissMode='interactive' + {...scrollPersistTaps} /> {this.renderFooter()} diff --git a/package-lock.json b/package-lock.json index 4c9d7d87..c2a1dc12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4610,6 +4610,11 @@ "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-1.1.1.tgz", "integrity": "sha512-vkcJJZEb7JXDY883Nx1Lkmb6noM3j1SfSt8L9tVFhZPnPQiFq+Nkd5evc77+tRVS4ChTUSr34voThsglI/ja/A==" }, + "emoji-datasource": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/emoji-datasource/-/emoji-datasource-4.0.3.tgz", + "integrity": "sha1-1gDnDwVoMnyyjPp79B1T88SGeQE=" + }, "emoji-regex": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.5.1.tgz", @@ -12600,6 +12605,16 @@ "resolved": "https://registry.npmjs.org/react-native-push-notification/-/react-native-push-notification-3.0.1.tgz", "integrity": "sha1-DiPbMC0Du0o/KNwHLcryqaEXjtg=" }, + "react-native-scrollable-tab-view": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/react-native-scrollable-tab-view/-/react-native-scrollable-tab-view-0.8.0.tgz", + "integrity": "sha512-8Q7v4f1WyV5cKqvV3QHxnLFRWV8gi24JW2T+Cfx++b3ctHxtJCkGg5Zs15ufYMxaN4W68iDkJrftVVAq0tqb8w==", + "requires": { + "create-react-class": "15.6.2", + "prop-types": "15.6.0", + "react-timer-mixin": "0.13.3" + } + }, "react-native-slider": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-native-slider/-/react-native-slider-0.11.0.tgz", @@ -14425,6 +14440,11 @@ "strip-ansi": "4.0.0" } }, + "string.fromcodepoint": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.fromcodepoint/-/string.fromcodepoint-0.2.1.tgz", + "integrity": "sha1-jZeDM8C8klOPUPOD5IiPPlYZ1lM=" + }, "string_decoder": { "version": "0.10.31", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", diff --git a/package.json b/package.json index f130d059..688bc0f1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "babel-polyfill": "^6.26.0", "deep-equal": "^1.0.1", "ejson": "^2.1.2", + "emoji-datasource": "^4.0.3", + "lodash": "^4.17.4", "moment": "^2.20.1", "prop-types": "^15.6.0", "react": "^16.2.0", @@ -48,6 +50,7 @@ "react-native-modal": "^4.1.1", "react-native-optimized-flatlist": "^1.0.3", "react-native-push-notification": "^3.0.1", + "react-native-scrollable-tab-view": "^0.8.0", "react-native-slider": "^0.11.0", "react-native-splash-screen": "^3.0.6", "react-native-svg": "^6.0.0", @@ -68,6 +71,7 @@ "remote-redux-devtools": "^0.5.12", "simple-markdown": "^0.3.1", "snyk": "^1.61.1", + "string.fromcodepoint": "^0.2.1", "strip-ansi": "^4.0.0" }, "devDependencies": {