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",