diff --git a/android/app/build.gradle b/android/app/build.gradle
index 8001a7184..1d5816d81 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 890c18966..b4123946b 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 c44744370..b597699b4 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 000000000..6c598d024
--- /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 d1e73f8e8..3e65838cf 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 000000000..166e065ab
--- /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 000000000..e4c648b1b
--- /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 000000000..9bc131a46
--- /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 000000000..341c6a83e
--- /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 000000000..fbddf4c78
--- /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 000000000..a4ffdb598
--- /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 4319fd1dd..af1a196d5 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 03c076729..bee61609e 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 23bd69ee9..3fab10a45 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 9e54017fc..da060ef56 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 10b2a6663..6236adf06 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 aace9e7fd..4cf45933a 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 de6ed12d5..f6e7077b8 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 f3e29d9c2..6e405c664 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 000000000..e3208a910
--- /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 88ca8ae6f..60c2be016 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 000000000..0885be777
--- /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 673a7626e..152991124 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 000000000..a08e17af8
--- /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 303aee5e4..1cb7da245 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 879c19b9e..d87cf3b25 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 728167057..145cacf2d 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 4c9d7d873..c2a1dc120 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 f130d059c..688bc0f10 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": {