diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js
index f309681d64..c83b406dfa 100644
--- a/app/components/post_body/post_body.js
+++ b/app/components/post_body/post_body.js
@@ -329,7 +329,14 @@ export default class PostBody extends PureComponent {
};
renderReactions = () => {
- const {hasReactions, isSearchResult, postId, onAddReaction, showLongPost} = this.props;
+ const {
+ hasReactions,
+ isSearchResult,
+ navigator,
+ onAddReaction,
+ postId,
+ showLongPost,
+ } = this.props;
if (!hasReactions || isSearchResult || showLongPost) {
return null;
@@ -343,6 +350,7 @@ export default class PostBody extends PureComponent {
);
};
diff --git a/app/components/reactions/reaction.js b/app/components/reactions/reaction.js
index c88de226b7..0cfedf5f12 100644
--- a/app/components/reactions/reaction.js
+++ b/app/components/reactions/reaction.js
@@ -17,6 +17,7 @@ export default class Reaction extends PureComponent {
emojiName: PropTypes.string.isRequired,
highlight: PropTypes.bool.isRequired,
onPress: PropTypes.func.isRequired,
+ onLongPress: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
}
@@ -26,12 +27,19 @@ export default class Reaction extends PureComponent {
}
render() {
- const {count, emojiName, highlight, theme} = this.props;
+ const {
+ count,
+ emojiName,
+ highlight,
+ onLongPress,
+ theme,
+ } = this.props;
const styles = getStyleSheet(theme);
return (
{
+ this.closeButton = source;
+ });
+ }
+
componentDidMount() {
const {actions, postId} = this.props;
actions.getReactionsForPost(postId);
@@ -50,8 +66,37 @@ export default class Reactions extends PureComponent {
}
};
+ showReactionList = () => {
+ const {navigator, postId, theme} = this.props;
+ const {formatMessage} = this.context.intl;
+ const options = {
+ screen: 'ReactionList',
+ title: formatMessage({id: 'mobile.reaction_list.title', defaultMessage: 'Reactions'}),
+ animationType: 'slide-up',
+ animated: true,
+ backButtonTitle: '',
+ navigatorStyle: {
+ navBarTextColor: theme.sidebarHeaderTextColor,
+ navBarBackgroundColor: theme.sidebarHeaderBg,
+ navBarButtonColor: theme.sidebarHeaderTextColor,
+ screenBackgroundColor: theme.centerChannelBg,
+ },
+ navigatorButtons: {
+ leftButtons: [{
+ id: 'close-reaction-list',
+ icon: this.closeButton,
+ }],
+ },
+ passProps: {
+ postId,
+ },
+ };
+
+ navigator.showModal(options);
+ }
+
renderReactions = () => {
- const {highlightedReactions, reactions, theme} = this.props;
+ const {highlightedReactions, navigator, reactions, theme, postId} = this.props;
return Array.from(reactions.keys()).map((r) => {
return (
@@ -60,7 +105,10 @@ export default class Reactions extends PureComponent {
count={reactions.get(r).length}
emojiName={r}
highlight={highlightedReactions.includes(r)}
+ navigator={navigator}
onPress={this.handleReactionPress}
+ onLongPress={this.showReactionList}
+ postId={postId}
theme={theme}
/>
);
diff --git a/app/constants/emoji.js b/app/constants/emoji.js
new file mode 100644
index 0000000000..e3742cfa77
--- /dev/null
+++ b/app/constants/emoji.js
@@ -0,0 +1,4 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export const ALL_EMOJIS = 'all_emojis';
diff --git a/app/screens/index.js b/app/screens/index.js
index 4d5ce4186c..a24c63ee2a 100644
--- a/app/screens/index.js
+++ b/app/screens/index.js
@@ -46,6 +46,7 @@ export function registerScreens(store, Provider) {
Navigation.registerComponent('NotificationSettingsMobile', () => wrapWithContextProvider(require('app/screens/settings/notification_settings_mobile').default), store, Provider);
Navigation.registerComponent('OptionsModal', () => wrapWithContextProvider(require('app/screens/options_modal').default), store, Provider);
Navigation.registerComponent('Permalink', () => wrapWithContextProvider(require('app/screens/permalink').default), store, Provider);
+ Navigation.registerComponent('ReactionList', () => wrapWithContextProvider(require('app/screens/reaction_list').default), store, Provider);
Navigation.registerComponent('RecentMentions', () => wrapWithContextProvider(require('app/screens/recent_mentions').default), store, Provider);
Navigation.registerComponent('Root', () => require('app/screens/root').default, store, Provider);
Navigation.registerComponent('Search', () => wrapWithContextProvider(require('app/screens/search').default), store, Provider);
diff --git a/app/screens/reaction_list/__snapshots__/reaction_header.test.js.snap b/app/screens/reaction_list/__snapshots__/reaction_header.test.js.snap
new file mode 100644
index 0000000000..14689ca7b6
--- /dev/null
+++ b/app/screens/reaction_list/__snapshots__/reaction_header.test.js.snap
@@ -0,0 +1,161 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ReactionHeader should match snapshot 1`] = `
+
+
+
+
+
+
+`;
+
+exports[`ReactionHeader should match snapshot, renderContent 1`] = `
+Array [
+ ,
+ ,
+]
+`;
diff --git a/app/screens/reaction_list/__snapshots__/reaction_header_item.test.js.snap b/app/screens/reaction_list/__snapshots__/reaction_header_item.test.js.snap
new file mode 100644
index 0000000000..28efd5cd61
--- /dev/null
+++ b/app/screens/reaction_list/__snapshots__/reaction_header_item.test.js.snap
@@ -0,0 +1,94 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ReactionHeaderItem should match snapshot 1`] = `
+
+
+
+
+ 3
+
+
+
+`;
+
+exports[`ReactionHeaderItem should match snapshot, renderContent 1`] = `
+
+
+
+ 3
+
+
+`;
+
+exports[`ReactionHeaderItem should match snapshot, renderContent 2`] = `
+
+
+
+ 3
+
+
+`;
diff --git a/app/screens/reaction_list/__snapshots__/reaction_list.test.js.snap b/app/screens/reaction_list/__snapshots__/reaction_list.test.js.snap
new file mode 100644
index 0000000000..38442c6b7b
--- /dev/null
+++ b/app/screens/reaction_list/__snapshots__/reaction_list.test.js.snap
@@ -0,0 +1,388 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ReactionList should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`ReactionList should match snapshot, renderReactionRows 1`] = `
+Array [
+
+
+
+ ,
+
+
+
+ ,
+]
+`;
diff --git a/app/screens/reaction_list/__snapshots__/reaction_row.test.js.snap b/app/screens/reaction_list/__snapshots__/reaction_row.test.js.snap
new file mode 100644
index 0000000000..118578377a
--- /dev/null
+++ b/app/screens/reaction_list/__snapshots__/reaction_row.test.js.snap
@@ -0,0 +1,93 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ReactionRow should match snapshot, renderContent 1`] = `
+
+
+
+
+
+
+
+
+
+
+ @username
+
+
+
+
+
+ username
+
+
+
+
+
+
+`;
diff --git a/app/screens/reaction_list/index.js b/app/screens/reaction_list/index.js
new file mode 100644
index 0000000000..fd5f9c4d0d
--- /dev/null
+++ b/app/screens/reaction_list/index.js
@@ -0,0 +1,42 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {bindActionCreators} from 'redux';
+import {connect} from 'react-redux';
+
+import {getMissingProfilesByIds} from 'mattermost-redux/actions/users';
+import {makeGetReactionsForPost} from 'mattermost-redux/selectors/entities/posts';
+import {getCurrentUserId, makeGetProfilesByIdsAndUsernames} from 'mattermost-redux/selectors/entities/users';
+import {getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences';
+
+import {getUniqueUserIds} from 'app/utils/reaction';
+
+import ReactionList from './reaction_list';
+
+function makeMapStateToProps() {
+ const getReactionsForPostSelector = makeGetReactionsForPost();
+ const getProfilesByIdsAndUsernames = makeGetProfilesByIdsAndUsernames();
+
+ return function mapStateToProps(state, ownProps) {
+ const reactions = getReactionsForPostSelector(state, ownProps.postId);
+ const allUserIds = getUniqueUserIds(reactions);
+
+ return {
+ currentUserId: getCurrentUserId(state),
+ reactions,
+ teammateNameDisplay: getTeammateNameDisplaySetting(state),
+ theme: getTheme(state),
+ userProfiles: getProfilesByIdsAndUsernames(state, {allUserIds}) || [],
+ };
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators({
+ getMissingProfilesByIds,
+ }, dispatch),
+ };
+}
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReactionList);
diff --git a/app/screens/reaction_list/reaction_header.js b/app/screens/reaction_list/reaction_header.js
new file mode 100644
index 0000000000..8f72ca7369
--- /dev/null
+++ b/app/screens/reaction_list/reaction_header.js
@@ -0,0 +1,68 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {PureComponent} from 'react';
+import PropTypes from 'prop-types';
+import {
+ ScrollView,
+ View,
+} from 'react-native';
+
+import {makeStyleSheetFromTheme} from 'app/utils/theme';
+
+import ReactionHeaderItem from './reaction_header_item';
+
+export default class ReactionHeader extends PureComponent {
+ static propTypes = {
+ selected: PropTypes.string.isRequired,
+ onSelectReaction: PropTypes.func.isRequired,
+ reactions: PropTypes.array.isRequired,
+ theme: PropTypes.object.isRequired,
+ }
+
+ handleOnPress = (emoji) => {
+ this.props.onSelectReaction(emoji);
+ };
+
+ renderReactionHeaderItems = () => {
+ const {selected, reactions, theme} = this.props;
+
+ return reactions.map((reaction) => (
+
+ ));
+ }
+
+ render() {
+ const {theme} = this.props;
+ const styles = getStyleSheet(theme);
+
+ return (
+
+
+ {this.renderReactionHeaderItems()}
+
+
+ );
+ }
+}
+
+const getStyleSheet = makeStyleSheetFromTheme((theme) => {
+ return {
+ container: {
+ backgroundColor: theme.centerChannelBg,
+ height: 37,
+ paddingHorizontal: 0,
+ },
+ };
+});
diff --git a/app/screens/reaction_list/reaction_header.test.js b/app/screens/reaction_list/reaction_header.test.js
new file mode 100644
index 0000000000..e965faecb4
--- /dev/null
+++ b/app/screens/reaction_list/reaction_header.test.js
@@ -0,0 +1,48 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+import React from 'react';
+import {shallow} from 'enzyme';
+import {ScrollView} from 'react-native';
+
+import Preferences from 'mattermost-redux/constants/preferences';
+
+import ReactionHeader from './reaction_header';
+
+describe('ReactionHeader', () => {
+ const baseProps = {
+ selected: 'smile',
+ onSelectReaction: jest.fn(),
+ reactions: [{name: 'smile', count: 2}, {name: '+1', count: 1}],
+ theme: Preferences.THEMES.default,
+ };
+
+ test('should match snapshot', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ expect(wrapper.find(ScrollView).exists()).toEqual(true);
+ });
+
+ test('should match snapshot, renderContent', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.instance().renderReactionHeaderItems()).toMatchSnapshot();
+ });
+
+ test('should call props.onSelectReaction on handlePress', () => {
+ const onSelectReaction = jest.fn();
+ const wrapper = shallow(
+
+ );
+
+ wrapper.instance().handleOnPress();
+ expect(onSelectReaction).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/screens/reaction_list/reaction_header_item.js b/app/screens/reaction_list/reaction_header_item.js
new file mode 100644
index 0000000000..887d4a7458
--- /dev/null
+++ b/app/screens/reaction_list/reaction_header_item.js
@@ -0,0 +1,105 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {PureComponent} from 'react';
+import PropTypes from 'prop-types';
+import {
+ Text,
+ TouchableOpacity,
+} from 'react-native';
+
+import Emoji from 'app/components/emoji';
+import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
+
+import FormattedText from 'app/components/formatted_text';
+
+import {ALL_EMOJIS} from 'app/constants/emoji';
+
+export default class ReactionHeaderItem extends PureComponent {
+ static propTypes = {
+ count: PropTypes.number.isRequired,
+ emojiName: PropTypes.string.isRequired,
+ highlight: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired,
+ theme: PropTypes.object.isRequired,
+ }
+
+ handleOnPress = () => {
+ const {emojiName, highlight, onPress} = this.props;
+ onPress(emojiName, highlight);
+ }
+
+ renderContent = () => {
+ const {count, emojiName, theme} = this.props;
+ const styles = getStyleSheet(theme);
+
+ if (emojiName === ALL_EMOJIS) {
+ return (
+
+
+ {count}
+
+ );
+ }
+
+ return (
+
+
+ {count}
+
+ );
+ }
+
+ render() {
+ const {emojiName, highlight, theme} = this.props;
+ const styles = getStyleSheet(theme);
+
+ return (
+
+ {this.renderContent()}
+
+ );
+ }
+}
+
+const getStyleSheet = makeStyleSheetFromTheme((theme) => {
+ return {
+ allText: {
+ marginLeft: 7,
+ },
+ text: {
+ color: theme.linkColor,
+ marginLeft: 4,
+ fontSize: 16,
+ },
+ highlight: {
+ borderColor: changeOpacity(theme.linkColor, 1),
+ borderBottomWidth: 2,
+ },
+ regular: {
+ borderColor: theme.centerChannelBg,
+ borderBottomWidth: 2,
+ },
+ reaction: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ height: 35,
+ marginRight: 6,
+ marginBottom: 5,
+ marginTop: 3,
+ paddingVertical: 2,
+ paddingHorizontal: 6,
+ },
+ };
+});
diff --git a/app/screens/reaction_list/reaction_header_item.test.js b/app/screens/reaction_list/reaction_header_item.test.js
new file mode 100644
index 0000000000..8aaa79815a
--- /dev/null
+++ b/app/screens/reaction_list/reaction_header_item.test.js
@@ -0,0 +1,55 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+import React from 'react';
+import {shallow} from 'enzyme';
+import {TouchableOpacity} from 'react-native';
+
+import Preferences from 'mattermost-redux/constants/preferences';
+
+import ReactionHeaderItem from './reaction_header_item';
+
+import {ALL_EMOJIS} from 'app/constants/emoji';
+
+describe('ReactionHeaderItem', () => {
+ const baseProps = {
+ count: 3,
+ emojiName: 'smile',
+ highlight: false,
+ onPress: jest.fn(),
+ theme: Preferences.THEMES.default,
+ };
+
+ test('should match snapshot', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ expect(wrapper.find(TouchableOpacity).exists()).toEqual(true);
+ });
+
+ test('should match snapshot, renderContent', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.instance().renderContent()).toMatchSnapshot();
+
+ wrapper.setProps({emojiName: ALL_EMOJIS});
+ expect(wrapper.instance().renderContent()).toMatchSnapshot();
+ });
+
+ test('should call props.onPress on handleOnPress', () => {
+ const onPress = jest.fn();
+ const wrapper = shallow(
+
+ );
+
+ wrapper.instance().handleOnPress();
+ expect(onPress).toHaveBeenCalledTimes(1);
+ expect(onPress).toHaveBeenCalledWith(baseProps.emojiName, baseProps.highlight);
+ });
+});
diff --git a/app/screens/reaction_list/reaction_list.js b/app/screens/reaction_list/reaction_list.js
new file mode 100644
index 0000000000..936d3bfdc6
--- /dev/null
+++ b/app/screens/reaction_list/reaction_list.js
@@ -0,0 +1,210 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {PureComponent} from 'react';
+import PropTypes from 'prop-types';
+import {View} from 'react-native';
+import {intlShape} from 'react-intl';
+import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
+
+import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
+import {
+ generateUserProfilesById,
+ getMissingUserIds,
+ getReactionsByName,
+ getSortedReactionsForHeader,
+ getUniqueUserIds,
+ sortReactions,
+} from 'app/utils/reaction';
+
+import ReactionHeader from './reaction_header';
+import ReactionRow from './reaction_row';
+
+import {ALL_EMOJIS} from 'app/constants/emoji';
+
+export default class ReactionList extends PureComponent {
+ static propTypes = {
+ actions: PropTypes.shape({
+ getMissingProfilesByIds: PropTypes.func.isRequired,
+ }).isRequired,
+ navigator: PropTypes.object,
+ reactions: PropTypes.array.isRequired,
+ theme: PropTypes.object.isRequired,
+ teammateNameDisplay: PropTypes.string,
+ userProfiles: PropTypes.array,
+ };
+
+ static contextTypes = {
+ intl: intlShape.isRequired,
+ };
+
+ constructor(props) {
+ super(props);
+ const {reactions, userProfiles} = props;
+
+ const reactionsByName = getReactionsByName(reactions);
+
+ this.state = {
+ allUserIds: getUniqueUserIds(reactions),
+ reactions,
+ reactionsByName,
+ selected: ALL_EMOJIS,
+ sortedReactions: sortReactions(reactionsByName),
+ sortedReactionsForHeader: getSortedReactionsForHeader(reactionsByName),
+ userProfiles,
+ userProfilesById: generateUserProfilesById(userProfiles),
+ };
+
+ props.navigator.setOnNavigatorEvent(this.onNavigatorEvent);
+ }
+
+ static getDerivedStateFromProps(nextProps, prevState) {
+ let newState = null;
+ if (nextProps.reactions !== prevState.reactions) {
+ const {reactions} = nextProps;
+ const reactionsByName = getReactionsByName(reactions);
+
+ newState = {
+ allUserIds: getUniqueUserIds(reactions),
+ reactions,
+ reactionsByName,
+ sortedReactions: sortReactions(reactionsByName),
+ sortedReactionsForHeader: getSortedReactionsForHeader(reactionsByName),
+ };
+ }
+
+ if (nextProps.userProfiles !== prevState.userProfiles) {
+ const userProfilesById = generateUserProfilesById(nextProps.userProfiles);
+ if (newState) {
+ newState.userProfilesById = userProfilesById;
+ } else {
+ newState = {userProfilesById};
+ }
+ }
+
+ return newState;
+ }
+
+ componentDidMount() {
+ this.getMissingProfiles();
+ }
+
+ componentDidUpdate(_, prevState) {
+ if (prevState.allUserIds !== this.state.allUserIds) {
+ this.getMissingProfiles();
+ }
+ }
+
+ onNavigatorEvent = (event) => {
+ if (event.type === 'NavBarButtonPress') {
+ if (event.id === 'close-reaction-list') {
+ this.props.navigator.dismissModal({
+ animationType: 'slide-down',
+ });
+ }
+ }
+ };
+
+ getMissingProfiles = () => {
+ const {allUserIds, userProfiles, userProfilesById} = this.state;
+ if (userProfiles.length !== allUserIds.length) {
+ const missingUserIds = getMissingUserIds(userProfilesById, allUserIds);
+
+ if (missingUserIds.length > 0) {
+ this.props.actions.getMissingProfilesByIds(missingUserIds);
+ }
+ }
+ }
+
+ scrollViewRef = (ref) => {
+ this.scrollView = ref;
+ };
+
+ handleOnSelectReaction = (emoji) => {
+ this.setState({selected: emoji});
+ }
+
+ renderReactionRows = () => {
+ const {
+ navigator,
+ teammateNameDisplay,
+ theme,
+ } = this.props;
+ const {
+ reactionsByName,
+ selected,
+ sortedReactions,
+ userProfilesById,
+ } = this.state;
+ const style = getStyleSheet(theme);
+ const reactions = selected === ALL_EMOJIS ? sortedReactions : reactionsByName[selected];
+
+ return reactions.map(({emoji_name: emojiName, user_id: userId}) => (
+
+
+
+
+ ));
+ }
+
+ render() {
+ const {
+ theme,
+ } = this.props;
+ const {
+ selected,
+ sortedReactionsForHeader,
+ } = this.state;
+ const style = getStyleSheet(theme);
+
+ return (
+
+
+
+
+
+ {this.renderReactionRows()}
+
+
+ );
+ }
+}
+
+const getStyleSheet = makeStyleSheetFromTheme((theme) => {
+ return {
+ flex: {
+ backgroundColor: theme.centerChannelBg,
+ flex: 1,
+ },
+ headerContainer: {
+ height: 38,
+ borderColor: changeOpacity(theme.centerChannelColor, 0.2),
+ borderBottomWidth: 1,
+ },
+ rowContainer: {
+ justifyContent: 'center',
+ height: 45,
+ },
+ separator: {
+ height: 1,
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
+ },
+ };
+});
diff --git a/app/screens/reaction_list/reaction_list.test.js b/app/screens/reaction_list/reaction_list.test.js
new file mode 100644
index 0000000000..a8db6e20ba
--- /dev/null
+++ b/app/screens/reaction_list/reaction_list.test.js
@@ -0,0 +1,55 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+import React from 'react';
+import {shallow} from 'enzyme';
+import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
+
+import Preferences from 'mattermost-redux/constants/preferences';
+
+import ReactionList from './reaction_list';
+
+jest.mock('react-intl');
+
+describe('ReactionList', () => {
+ const baseProps = {
+ actions: {
+ getMissingProfilesByIds: jest.fn(),
+ },
+ allUserIds: ['user_id_1', 'user_id_2'],
+ navigator: {setOnNavigatorEvent: jest.fn()},
+ reactions: [{emoji_name: 'smile', user_id: 'user_id_1'}, {emoji_name: '+1', user_id: 'user_id_2'}],
+ theme: Preferences.THEMES.default,
+ teammateNameDisplay: 'username',
+ userProfiles: [{id: 'user_id_1', username: 'username_1'}, {id: 'user_id_2', username: 'username_2'}],
+ };
+
+ test('should match snapshot', () => {
+ const wrapper = shallow(
+ ,
+ {context: {intl: {formatMessage: jest.fn()}}},
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ expect(wrapper.find(KeyboardAwareScrollView).exists()).toEqual(true);
+ });
+
+ test('should match snapshot, renderReactionRows', () => {
+ const wrapper = shallow(
+ ,
+ {context: {intl: {formatMessage: jest.fn()}}},
+ );
+
+ expect(wrapper.instance().renderReactionRows()).toMatchSnapshot();
+ });
+
+ test('should match state on handleOnSelectReaction', () => {
+ const wrapper = shallow(
+ ,
+ {context: {intl: {formatMessage: jest.fn()}}},
+ );
+
+ wrapper.setState({selected: 'smile'});
+ wrapper.instance().handleOnSelectReaction('+1');
+ expect(wrapper.state('selected')).toEqual('+1');
+ });
+});
diff --git a/app/screens/reaction_list/reaction_row.js b/app/screens/reaction_list/reaction_row.js
new file mode 100644
index 0000000000..f8c238947e
--- /dev/null
+++ b/app/screens/reaction_list/reaction_row.js
@@ -0,0 +1,153 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import {intlShape} from 'react-intl';
+import {
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+
+import {displayUsername} from 'mattermost-redux/utils/user_utils';
+
+import ProfilePicture from 'app/components/profile_picture';
+import {preventDoubleTap} from 'app/utils/tap';
+import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
+
+import Emoji from 'app/components/emoji';
+
+export default class ReactionRow extends React.PureComponent {
+ static propTypes = {
+ emojiName: PropTypes.string.isRequired,
+ navigator: PropTypes.object,
+ teammateNameDisplay: PropTypes.string.isRequired,
+ theme: PropTypes.object.isRequired,
+ user: PropTypes.object.isRequired,
+ };
+
+ static defaultProps = {
+ user: {},
+ }
+
+ static contextTypes = {
+ intl: intlShape,
+ };
+
+ goToUserProfile = () => {
+ const {navigator, theme, user} = this.props;
+ const {formatMessage} = this.context.intl;
+
+ const options = {
+ screen: 'UserProfile',
+ title: formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}),
+ animated: true,
+ backButtonTitle: '',
+ passProps: {
+ userId: user.id,
+ },
+ navigatorStyle: {
+ navBarTextColor: theme.sidebarHeaderTextColor,
+ navBarBackgroundColor: theme.sidebarHeaderBg,
+ navBarButtonColor: theme.sidebarHeaderTextColor,
+ screenBackgroundColor: theme.centerChannelBg,
+ },
+ };
+
+ navigator.push(options);
+ };
+
+ render() {
+ const {
+ emojiName,
+ teammateNameDisplay,
+ theme,
+ user,
+ } = this.props;
+
+ if (!user.id) {
+ return null;
+ }
+
+ const {id, username} = user;
+ const style = getStyleFromTheme(theme);
+ const usernameDisplay = '@' + username;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {usernameDisplay}
+
+ {' '}
+
+ {displayUsername(user, teammateNameDisplay)}
+
+
+
+
+
+
+ );
+ }
+}
+
+const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
+ return {
+ container: {
+ backgroundColor: theme.centerChannelBg,
+ flexDirection: 'row',
+ justifyContent: 'flex-start',
+ height: 44,
+ width: '100%',
+ alignItems: 'center',
+ },
+ profileContainer: {
+ alignItems: 'center',
+ width: '13%',
+ },
+ profile: {
+ paddingTop: 3,
+ },
+ textContainer: {
+ width: '74%',
+ flexDirection: 'row',
+ },
+ username: {
+ fontSize: 14,
+ color: theme.centerChannelColor,
+ paddingRight: 5,
+ },
+ displayName: {
+ fontSize: 14,
+ color: changeOpacity(theme.centerChannelColor, 0.5),
+ },
+ emoji: {
+ alignItems: 'center',
+ width: '13%',
+ justifyContent: 'center',
+ },
+ };
+});
diff --git a/app/screens/reaction_list/reaction_row.test.js b/app/screens/reaction_list/reaction_row.test.js
new file mode 100644
index 0000000000..471b676581
--- /dev/null
+++ b/app/screens/reaction_list/reaction_row.test.js
@@ -0,0 +1,26 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+import React from 'react';
+import {shallow} from 'enzyme';
+
+import Preferences from 'mattermost-redux/constants/preferences';
+
+import ReactionRow from './reaction_row';
+
+describe('ReactionRow', () => {
+ const baseProps = {
+ emojiName: 'smile',
+ navigator: {},
+ teammateNameDisplay: 'username',
+ theme: Preferences.THEMES.default,
+ user: {id: 'user_id', username: 'username'},
+ };
+
+ test('should match snapshot, renderContent', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+});
diff --git a/app/utils/reaction.js b/app/utils/reaction.js
new file mode 100644
index 0000000000..984764b1cf
--- /dev/null
+++ b/app/utils/reaction.js
@@ -0,0 +1,67 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ALL_EMOJIS} from 'app/constants/emoji';
+
+export function generateUserProfilesById(userProfiles = []) {
+ return userProfiles.reduce((acc, userProfile) => {
+ acc[userProfile.id] = userProfile;
+
+ return acc;
+ }, []);
+}
+
+export function getMissingUserIds(userProfilesById = {}, allUserIds = []) {
+ return allUserIds.reduce((acc, userId) => {
+ if (userProfilesById[userId]) {
+ acc.push(userId);
+ }
+
+ return acc;
+ }, []);
+}
+
+export function compareReactions(a, b) {
+ if (a.count !== b.count) {
+ return b.count - a.count;
+ }
+
+ return a.name.localeCompare(b.name);
+}
+
+export function getReactionsByName(reactions = []) {
+ return reactions.reduce((acc, reaction) => {
+ const byName = acc[reaction.emoji_name] || [];
+ acc[reaction.emoji_name] = [...byName, reaction];
+
+ return acc;
+ }, {});
+}
+
+export function sortReactionsByName(reactionsByName = {}) {
+ return Object.entries(reactionsByName).
+ map(([name, reactions]) => ({name, reactions, count: reactions.length})).
+ sort(compareReactions);
+}
+
+export function sortReactions(reactionsByName = {}) {
+ return sortReactionsByName(reactionsByName).
+ reduce((acc, {reactions}) => {
+ reactions.forEach((r) => acc.push(r));
+ return acc;
+ }, []);
+}
+
+export function getSortedReactionsForHeader(reactionsByName = {}) {
+ const sortedReactionsForHeader = sortReactionsByName(reactionsByName);
+
+ const totalCount = sortedReactionsForHeader.reduce((acc, reaction) => {
+ return acc + reaction.count;
+ }, 0);
+
+ return [{name: ALL_EMOJIS, count: totalCount}, ...sortedReactionsForHeader];
+}
+
+export function getUniqueUserIds(reactions = []) {
+ return reactions.map((reaction) => reaction.user_id).filter((id, index, arr) => arr.indexOf(id) === index);
+}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 8fa2a11aed..32c6c5e98f 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -333,6 +333,7 @@
"mobile.post.failed_title": "Unable to send your message",
"mobile.post.retry": "Refresh",
"mobile.posts_view.moreMsg": "More New Messages Above",
+ "mobile.reaction_list.title": "Reactions",
"mobile.recent_mentions.empty_description": "Messages containing your username and other words that trigger mentions will appear here.",
"mobile.recent_mentions.empty_title": "No Recent Mentions",
"mobile.rename_channel.display_name_maxLength": "Channel name must be less than {maxLength, number} characters",