[MM-9490] Add reaction list (#2125)

* add reaction list

* update styles and add tests

* update styles

* update per comment
This commit is contained in:
Saturnino Abril
2018-09-27 21:54:17 +08:00
committed by GitHub
parent 48b945d6f9
commit ce47c23100
20 changed files with 1638 additions and 3 deletions

View File

@@ -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 {
<Reactions
postId={postId}
onAddReaction={onAddReaction}
navigator={navigator}
/>
);
};

View File

@@ -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 (
<TouchableOpacity
onPress={this.handlePress}
onLongPress={onLongPress}
style={[styles.reaction, (highlight && styles.highlight)]}
>
<Emoji

View File

@@ -9,6 +9,9 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import {intlShape} from 'react-intl';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import addReactionIcon from 'assets/images/icons/reaction.png';
@@ -23,6 +26,7 @@ export default class Reactions extends PureComponent {
removeReaction: PropTypes.func.isRequired,
}).isRequired,
highlightedReactions: PropTypes.array.isRequired,
navigator: PropTypes.object.isRequired,
onAddReaction: PropTypes.func.isRequired,
position: PropTypes.oneOf(['right', 'left']),
postId: PropTypes.string.isRequired,
@@ -36,6 +40,18 @@ export default class Reactions extends PureComponent {
position: 'right',
};
static contextTypes = {
intl: intlShape,
};
constructor(props) {
super(props);
MaterialIcon.getImageSource('close', 20, props.theme.sidebarHeaderTextColor).then((source) => {
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}
/>
);

4
app/constants/emoji.js Normal file
View File

@@ -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';

View File

@@ -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);

View File

@@ -0,0 +1,161 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReactionHeader should match snapshot 1`] = `
<Component
style={
Object {
"backgroundColor": "#ffffff",
"height": 37,
"paddingHorizontal": 0,
}
}
>
<ScrollView
alwaysBounceHorizontal={false}
horizontal={true}
overScrollMode="never"
>
<ReactionHeaderItem
count={2}
emojiName="smile"
highlight={true}
onPress={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
<ReactionHeaderItem
count={1}
emojiName="+1"
highlight={false}
onPress={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</ScrollView>
</Component>
`;
exports[`ReactionHeader should match snapshot, renderContent 1`] = `
Array [
<ReactionHeaderItem
count={2}
emojiName="smile"
highlight={true}
onPress={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>,
<ReactionHeaderItem
count={1}
emojiName="+1"
highlight={false}
onPress={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>,
]
`;

View File

@@ -0,0 +1,94 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReactionHeaderItem should match snapshot 1`] = `
<TouchableOpacity
activeOpacity={0.2}
onPress={[Function]}
style={
Array [
Object {
"alignItems": "center",
"flexDirection": "row",
"height": 35,
"marginBottom": 5,
"marginRight": 6,
"marginTop": 3,
"paddingHorizontal": 6,
"paddingVertical": 2,
},
Object {
"borderBottomWidth": 2,
"borderColor": "#ffffff",
},
false,
]
}
>
<React.Fragment>
<Connect(Emoji)
emojiName="smile"
padding={5}
size={16}
/>
<Component
style={
Object {
"color": "#2389d7",
"fontSize": 16,
"marginLeft": 4,
}
}
>
3
</Component>
</React.Fragment>
</TouchableOpacity>
`;
exports[`ReactionHeaderItem should match snapshot, renderContent 1`] = `
<React.Fragment>
<Connect(Emoji)
emojiName="smile"
padding={5}
size={16}
/>
<Component
style={
Object {
"color": "#2389d7",
"fontSize": 16,
"marginLeft": 4,
}
}
>
3
</Component>
</React.Fragment>
`;
exports[`ReactionHeaderItem should match snapshot, renderContent 2`] = `
<React.Fragment>
<FormattedText
defaultMessage="All"
id="mobile.reaction_header.all_emojis"
style={
Object {
"color": "#2389d7",
"fontSize": 16,
"marginLeft": 4,
}
}
/>
<Component
style={
Object {
"color": "#2389d7",
"fontSize": 16,
"marginLeft": 4,
}
}
>
3
</Component>
</React.Fragment>
`;

View File

@@ -0,0 +1,388 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReactionList should match snapshot 1`] = `
<Component
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
>
<Component
style={
Object {
"borderBottomWidth": 1,
"borderColor": "rgba(61,60,64,0.2)",
"height": 38,
}
}
>
<ReactionHeader
onSelectReaction={[Function]}
reactions={
Array [
Object {
"count": 2,
"name": "all_emojis",
},
Object {
"count": 1,
"name": "+1",
"reactions": Array [
Object {
"emoji_name": "+1",
"user_id": "user_id_2",
},
],
},
Object {
"count": 1,
"name": "smile",
"reactions": Array [
Object {
"emoji_name": "smile",
"user_id": "user_id_1",
},
],
},
]
}
selected="all_emojis"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</Component>
<KeyboardAwareScrollView
bounces={true}
enableAutomaticScroll={true}
enableOnAndroid={false}
enableResetScrollToCoords={true}
extraHeight={75}
extraScrollHeight={0}
innerRef={[Function]}
keyboardOpeningTime={250}
viewIsInsideTabBar={false}
>
<Component
style={
Object {
"height": 45,
"justifyContent": "center",
}
}
>
<ReactionRow
emojiName="+1"
navigator={
Object {
"setOnNavigatorEvent": [MockFunction] {
"calls": Array [
Array [
[Function],
],
],
"results": Array [
Object {
"isThrow": false,
"value": undefined,
},
],
},
}
}
teammateNameDisplay="username"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
user={
Object {
"id": "user_id_2",
"username": "username_2",
}
}
/>
<Component
style={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"height": 1,
}
}
/>
</Component>
<Component
style={
Object {
"height": 45,
"justifyContent": "center",
}
}
>
<ReactionRow
emojiName="smile"
navigator={
Object {
"setOnNavigatorEvent": [MockFunction] {
"calls": Array [
Array [
[Function],
],
],
"results": Array [
Object {
"isThrow": false,
"value": undefined,
},
],
},
}
}
teammateNameDisplay="username"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
user={
Object {
"id": "user_id_1",
"username": "username_1",
}
}
/>
<Component
style={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"height": 1,
}
}
/>
</Component>
</KeyboardAwareScrollView>
</Component>
`;
exports[`ReactionList should match snapshot, renderReactionRows 1`] = `
Array [
<Component
style={
Object {
"height": 45,
"justifyContent": "center",
}
}
>
<ReactionRow
emojiName="+1"
navigator={
Object {
"setOnNavigatorEvent": [MockFunction] {
"calls": Array [
Array [
[Function],
],
],
"results": Array [
Object {
"isThrow": false,
"value": undefined,
},
],
},
}
}
teammateNameDisplay="username"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
user={
Object {
"id": "user_id_2",
"username": "username_2",
}
}
/>
<Component
style={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"height": 1,
}
}
/>
</Component>,
<Component
style={
Object {
"height": 45,
"justifyContent": "center",
}
}
>
<ReactionRow
emojiName="smile"
navigator={
Object {
"setOnNavigatorEvent": [MockFunction] {
"calls": Array [
Array [
[Function],
],
],
"results": Array [
Object {
"isThrow": false,
"value": undefined,
},
],
},
}
}
teammateNameDisplay="username"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
user={
Object {
"id": "user_id_1",
"username": "username_1",
}
}
/>
<Component
style={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"height": 1,
}
}
/>
</Component>,
]
`;

View File

@@ -0,0 +1,93 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReactionRow should match snapshot, renderContent 1`] = `
<Component
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"flexDirection": "row",
"height": 44,
"justifyContent": "flex-start",
"width": "100%",
}
}
>
<Component
style={
Object {
"alignItems": "center",
"width": "13%",
}
}
>
<TouchableOpacity
activeOpacity={0.2}
onPress={[Function]}
>
<Component
style={
Object {
"paddingTop": 3,
}
}
>
<Connect(ProfilePicture)
showStatus={false}
size={24}
userId="user_id"
/>
</Component>
</TouchableOpacity>
</Component>
<Component
ellipsizeMode="tail"
numberOfLines={1}
style={
Object {
"flexDirection": "row",
"width": "74%",
}
}
>
<Component
style={
Object {
"color": "#3d3c40",
"fontSize": 14,
"paddingRight": 5,
}
}
>
@username
</Component>
<Component>
</Component>
<Component
style={
Object {
"color": "rgba(61,60,64,0.5)",
"fontSize": 14,
}
}
>
username
</Component>
</Component>
<Component
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"width": "13%",
}
}
>
<Connect(Emoji)
emojiName="smile"
size={24}
/>
</Component>
</Component>
`;

View File

@@ -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);

View File

@@ -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) => (
<ReactionHeaderItem
key={reaction.name}
count={reaction.count}
emojiName={reaction.name}
highlight={selected === reaction.name}
onPress={this.handleOnPress}
theme={theme}
/>
));
}
render() {
const {theme} = this.props;
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<ScrollView
alwaysBounceHorizontal={false}
horizontal={true}
overScrollMode='never'
>
{this.renderReactionHeaderItems()}
</ScrollView>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
backgroundColor: theme.centerChannelBg,
height: 37,
paddingHorizontal: 0,
},
};
});

View File

@@ -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(
<ReactionHeader {...baseProps}/>
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find(ScrollView).exists()).toEqual(true);
});
test('should match snapshot, renderContent', () => {
const wrapper = shallow(
<ReactionHeader {...baseProps}/>
);
expect(wrapper.instance().renderReactionHeaderItems()).toMatchSnapshot();
});
test('should call props.onSelectReaction on handlePress', () => {
const onSelectReaction = jest.fn();
const wrapper = shallow(
<ReactionHeader
{...baseProps}
onSelectReaction={onSelectReaction}
/>
);
wrapper.instance().handleOnPress();
expect(onSelectReaction).toHaveBeenCalledTimes(1);
});
});

View File

@@ -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 (
<React.Fragment>
<FormattedText
id='mobile.reaction_header.all_emojis'
defaultMessage={'All'}
style={styles.text}
/>
<Text style={styles.text}>{count}</Text>
</React.Fragment>
);
}
return (
<React.Fragment>
<Emoji
emojiName={emojiName}
size={16}
padding={5}
/>
<Text style={styles.text}>{count}</Text>
</React.Fragment>
);
}
render() {
const {emojiName, highlight, theme} = this.props;
const styles = getStyleSheet(theme);
return (
<TouchableOpacity
onPress={this.handleOnPress}
style={[styles.reaction, (highlight ? styles.highlight : styles.regular), (emojiName === ALL_EMOJIS && styles.allText)]}
>
{this.renderContent()}
</TouchableOpacity>
);
}
}
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,
},
};
});

View File

@@ -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(
<ReactionHeaderItem {...baseProps}/>
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find(TouchableOpacity).exists()).toEqual(true);
});
test('should match snapshot, renderContent', () => {
const wrapper = shallow(
<ReactionHeaderItem {...baseProps}/>
);
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(
<ReactionHeaderItem
{...baseProps}
onPress={onPress}
/>
);
wrapper.instance().handleOnPress();
expect(onPress).toHaveBeenCalledTimes(1);
expect(onPress).toHaveBeenCalledWith(baseProps.emojiName, baseProps.highlight);
});
});

View File

@@ -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}) => (
<View
key={emojiName + userId}
style={style.rowContainer}
>
<ReactionRow
emojiName={emojiName}
navigator={navigator}
teammateNameDisplay={teammateNameDisplay}
theme={theme}
user={userProfilesById[userId]}
/>
<View style={style.separator}/>
</View>
));
}
render() {
const {
theme,
} = this.props;
const {
selected,
sortedReactionsForHeader,
} = this.state;
const style = getStyleSheet(theme);
return (
<View style={style.flex}>
<View style={style.headerContainer}>
<ReactionHeader
selected={selected}
onSelectReaction={this.handleOnSelectReaction}
reactions={sortedReactionsForHeader}
theme={theme}
/>
</View>
<KeyboardAwareScrollView
bounces={true}
innerRef={this.scrollViewRef}
>
{this.renderReactionRows()}
</KeyboardAwareScrollView>
</View>
);
}
}
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),
},
};
});

View File

@@ -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(
<ReactionList {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find(KeyboardAwareScrollView).exists()).toEqual(true);
});
test('should match snapshot, renderReactionRows', () => {
const wrapper = shallow(
<ReactionList {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.instance().renderReactionRows()).toMatchSnapshot();
});
test('should match state on handleOnSelectReaction', () => {
const wrapper = shallow(
<ReactionList {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
wrapper.setState({selected: 'smile'});
wrapper.instance().handleOnSelectReaction('+1');
expect(wrapper.state('selected')).toEqual('+1');
});
});

View File

@@ -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 (
<View style={style.container}>
<View style={style.profileContainer}>
<TouchableOpacity
key={user.id}
onPress={preventDoubleTap(this.goToUserProfile)}
>
<View style={style.profile}>
<ProfilePicture
userId={id}
showStatus={false}
size={24}
/>
</View>
</TouchableOpacity>
</View>
<Text
style={style.textContainer}
ellipsizeMode='tail'
numberOfLines={1}
>
<Text style={style.username}>
{usernameDisplay}
</Text>
<Text>{' '}</Text>
<Text style={style.displayName}>
{displayUsername(user, teammateNameDisplay)}
</Text>
</Text>
<View style={style.emoji}>
<Emoji
emojiName={emojiName}
size={24}
/>
</View>
</View>
);
}
}
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',
},
};
});

View File

@@ -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(
<ReactionRow {...baseProps}/>
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

67
app/utils/reaction.js Normal file
View File

@@ -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);
}

View File

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