From d03d504d0065ae0bd6e2017894786f047efb2112 Mon Sep 17 00:00:00 2001 From: Andre Vasconcelos Date: Thu, 2 Apr 2020 04:56:49 +0900 Subject: [PATCH] MM-14038 Access recent reactions from the long press menu (#4058) * WIP: Implementing quick reaction functionality * Making closing animations smoother - Added haptic feedback + timeouts for a smooth flow * Fixing clipping of post options on android * updating snapshots * Refactoring code & fixing styles - Fixed paddings element sizes to comply with design specs - Replaced reaction image to one with a higher resolution - Made sizes of emojis to be conditional on deviceWidth * Apply suggestions from code review * Adding component to avoid extra re-renders * Refactoring function call in reaction_button Am ashamed i didn't catch this before pushing the rest Co-authored-by: Elias Nahum --- .../reaction_picker.test.js.snap | 529 ++++++++++++++++++ app/components/reaction_picker/index.js | 18 + .../reaction_picker/reaction_button.js | 98 ++++ .../reaction_picker/reaction_picker.js | 117 ++++ .../reaction_picker/reaction_picker.test.js | 35 ++ .../__snapshots__/reactions.test.js.snap | 2 +- app/components/reactions/reactions.js | 2 +- app/constants/reaction_picker.js | 17 + .../__snapshots__/post_options.test.js.snap | 132 +---- app/screens/post_options/post_options.js | 45 +- assets/base/images/icons/reaction.png | Bin 929 -> 2157 bytes 11 files changed, 860 insertions(+), 135 deletions(-) create mode 100644 app/components/reaction_picker/__snapshots__/reaction_picker.test.js.snap create mode 100644 app/components/reaction_picker/index.js create mode 100644 app/components/reaction_picker/reaction_button.js create mode 100644 app/components/reaction_picker/reaction_picker.js create mode 100644 app/components/reaction_picker/reaction_picker.test.js create mode 100644 app/constants/reaction_picker.js diff --git a/app/components/reaction_picker/__snapshots__/reaction_picker.test.js.snap b/app/components/reaction_picker/__snapshots__/reaction_picker.test.js.snap new file mode 100644 index 000000000..c81fd78e8 --- /dev/null +++ b/app/components/reaction_picker/__snapshots__/reaction_picker.test.js.snap @@ -0,0 +1,529 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Reactions Should match snapshot with default emojis 1`] = ` + + + + + + + + + + + + + +`; + +exports[`Reactions Should match snapshot with default emojis 2`] = ` + + + + + + + + + + + + + +`; diff --git a/app/components/reaction_picker/index.js b/app/components/reaction_picker/index.js new file mode 100644 index 000000000..2d39f8308 --- /dev/null +++ b/app/components/reaction_picker/index.js @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getDimensions} from 'app/selectors/device'; +import ReactionPicker from './reaction_picker'; + +function mapStateToProps(state) { + const {deviceWidth} = getDimensions(state); + return { + theme: getTheme(state), + recentEmojis: state.views.recentEmojis, + deviceWidth, + }; +} + +export default connect(mapStateToProps)(ReactionPicker); diff --git a/app/components/reaction_picker/reaction_button.js b/app/components/reaction_picker/reaction_button.js new file mode 100644 index 000000000..7c4b35003 --- /dev/null +++ b/app/components/reaction_picker/reaction_button.js @@ -0,0 +1,98 @@ +// 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, + TouchableWithoutFeedback, +} from 'react-native'; + +import Emoji from 'app/components/emoji'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +import {hapticFeedback} from 'app/utils/general'; +import { + LARGE_CONTAINER_SIZE, + LARGE_ICON_SIZE, +} from 'app/constants/reaction_picker'; + +export default class ReactionButton extends PureComponent { + static propTypes = { + addReaction: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + emoji: PropTypes.string.isRequired, + iconSize: PropTypes.number, + containerSize: PropTypes.number, + } + + static defaultProps = { + iconSize: LARGE_ICON_SIZE, + containerSize: LARGE_CONTAINER_SIZE, + }; + + constructor(props) { + super(props); + this.state = { + isSelected: false, + }; + } + + handlePress = () => { + hapticFeedback(); + this.setState({isSelected: true}, () => { + this.props.addReaction(this.props.emoji); + }); + } + + render() { + const { + theme, + emoji, + iconSize, + containerSize, + } = this.props; + const style = getStyleSheet(theme); + + return ( + + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + emoji: { + color: '#000', + fontWeight: 'bold', + }, + highlight: { + backgroundColor: changeOpacity(theme.linkColor, 0.1), + }, + reactionContainer: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + borderRadius: 4, + alignItems: 'center', + justifyContent: 'center', + }, + }; +}); diff --git a/app/components/reaction_picker/reaction_picker.js b/app/components/reaction_picker/reaction_picker.js new file mode 100644 index 000000000..e61003f66 --- /dev/null +++ b/app/components/reaction_picker/reaction_picker.js @@ -0,0 +1,117 @@ +// 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 { + Image, + View, + TouchableWithoutFeedback, +} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +import addReactionIcon from 'assets/images/icons/reaction.png'; +import ReactionButton from './reaction_button'; +import { + REACTION_PICKER_HEIGHT, + DEFAULT_EMOJIS, + SMALL_ICON_BREAKPOINT, + SMALL_CONTAINER_SIZE, + LARGE_CONTAINER_SIZE, + SMALL_ICON_SIZE, + LARGE_ICON_SIZE, +} from 'app/constants/reaction_picker'; + +export default class ReactionPicker extends PureComponent { + static propTypes = { + addReaction: PropTypes.func.isRequired, + openReactionScreen: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + recentEmojis: PropTypes.array, + deviceWidth: PropTypes.number, + } + + handlePress = (emoji) => { + this.props.addReaction(emoji); + } + + render() { + const { + theme, + deviceWidth, + } = this.props; + const style = getStyleSheet(theme); + const isSmallDevice = deviceWidth < SMALL_ICON_BREAKPOINT; + + let containerSize = LARGE_CONTAINER_SIZE; + let iconSize = LARGE_ICON_SIZE; + + if (isSmallDevice) { + containerSize = SMALL_CONTAINER_SIZE; + iconSize = SMALL_ICON_SIZE; + } + + // Mixing recent emojis with default list and removing duplicates + const emojis = Array.from(new Set(this.props.recentEmojis.concat(DEFAULT_EMOJIS))).splice(0, 6); + + const list = emojis.map((emoji) => { + return ( + + ); + }); + + return ( + + {list} + + + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + iconImage: { + tintColor: theme.centerChannelColor, + }, + reactionListContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingLeft: 12, + paddingRight: 12, + height: REACTION_PICKER_HEIGHT, + justifyContent: 'space-between', + }, + reactionContainer: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + borderRadius: 4, + alignItems: 'center', + justifyContent: 'center', + }, + }; +}); diff --git a/app/components/reaction_picker/reaction_picker.test.js b/app/components/reaction_picker/reaction_picker.test.js new file mode 100644 index 000000000..476231621 --- /dev/null +++ b/app/components/reaction_picker/reaction_picker.test.js @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import ReactionPicker from './reaction_picker'; + +import Preferences from 'mattermost-redux/constants/preferences'; + +describe('Reactions', () => { + const baseProps = { + theme: Preferences.THEMES.default, + recentEmojis: [], + addReaction: jest.fn(), + openReactionScreen: jest.fn(), + }; + + test('Should match snapshot with default emojis', () => { + const wrapper = shallow(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('Should match snapshot with default emojis', () => { + const props = { + ...baseProps, + recentEmojis: [ + 'rocket', + '100', + ], + }; + const wrapper = shallow(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/components/reactions/__snapshots__/reactions.test.js.snap b/app/components/reactions/__snapshots__/reactions.test.js.snap index 5e9a4d9ba..f7f0aad32 100644 --- a/app/components/reactions/__snapshots__/reactions.test.js.snap +++ b/app/components/reactions/__snapshots__/reactions.test.js.snap @@ -153,7 +153,7 @@ exports[`Reactions should match snapshot 1`] = ` } style={ Object { - "height": 20, + "height": 23, "tintColor": "rgba(61,60,64,0.5)", "width": 23, } diff --git a/app/components/reactions/reactions.js b/app/components/reactions/reactions.js index 508e6efff..5406cac1a 100644 --- a/app/components/reactions/reactions.js +++ b/app/components/reactions/reactions.js @@ -186,7 +186,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { addReaction: { tintColor: changeOpacity(theme.centerChannelColor, 0.5), width: 23, - height: 20, + height: 23, }, reaction: { alignItems: 'center', diff --git a/app/constants/reaction_picker.js b/app/constants/reaction_picker.js new file mode 100644 index 000000000..c357cadd8 --- /dev/null +++ b/app/constants/reaction_picker.js @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const REACTION_PICKER_HEIGHT = 72; +export const SMALL_ICON_BREAKPOINT = 410; +export const SMALL_CONTAINER_SIZE = 44; +export const LARGE_CONTAINER_SIZE = 48; +export const SMALL_ICON_SIZE = 28; +export const LARGE_ICON_SIZE = 32; +export const DEFAULT_EMOJIS = [ + 'thumbsup', + 'smiley', + 'white_check_mark', + 'heart', + 'eyes', + 'raised_hands', +]; diff --git a/app/screens/post_options/__snapshots__/post_options.test.js.snap b/app/screens/post_options/__snapshots__/post_options.test.js.snap index a706fa1f6..15a24c292 100644 --- a/app/screens/post_options/__snapshots__/post_options.test.js.snap +++ b/app/screens/post_options/__snapshots__/post_options.test.js.snap @@ -10,8 +10,8 @@ exports[`PostOptions should match snapshot, no option for system message to user > + - + - + - { - const {canAddReaction} = this.props; - - if (canAddReaction) { - const key = 'reaction'; - const icon = 'emoji'; - const message = {id: t('mobile.post_info.add_reaction'), defaultMessage: 'Add Reaction'}; - const onPress = this.handleAddReaction; - - return this.getOption(key, icon, message, onPress); - } - - return null; - }; - getReplyOption = () => { const {canReply} = this.props; @@ -249,7 +237,6 @@ export default class PostOptions extends PureComponent { getPostOptions = () => { const actions = [ this.getReplyOption(), - this.getAddReactionOption(), this.getMarkAsUnreadOption(), this.getCopyPermalink(), this.getFlagOption(), @@ -262,7 +249,7 @@ export default class PostOptions extends PureComponent { return actions.filter((a) => a !== null); }; - handleAddReaction = () => { + handleAddReactionScreen = () => { const {theme} = this.props; const {formatMessage} = this.context.intl; @@ -287,6 +274,12 @@ export default class PostOptions extends PureComponent { }); }; + handleAddReaction = (emoji) => { + this.closeWithAnimation(() => { + this.handleAddReactionToPost(emoji); + }); + } + handleAddReactionToPost = (emoji) => { const {actions, post} = this.props; @@ -402,13 +395,26 @@ export default class PostOptions extends PureComponent { }; render() { - const {deviceHeight, theme} = this.props; + const {deviceHeight, theme, canAddReaction} = this.props; const options = this.getPostOptions(); + let reactionHeight = 0; + let reactionPicker; + if (!options || !options.length) { return null; } - const marginFromTop = deviceHeight - BOTTOM_MARGIN - ((options.length + 1) * OPTION_HEIGHT); + if (canAddReaction) { + reactionHeight = REACTION_PICKER_HEIGHT; + reactionPicker = ( + + ); + } + + const marginFromTop = deviceHeight - BOTTOM_MARGIN - ((options.length + 1) * OPTION_HEIGHT) - reactionHeight; const initialPosition = getInitialPosition(deviceHeight, marginFromTop); return ( @@ -422,6 +428,7 @@ export default class PostOptions extends PureComponent { key={marginFromTop} theme={theme} > + {reactionPicker} {options} diff --git a/assets/base/images/icons/reaction.png b/assets/base/images/icons/reaction.png index acf54ce1345bcaba3a97f272a235dd08dd847ed3..eeec698d34d49c76d5353b1858c46a3485a0de63 100644 GIT binary patch literal 2157 zcmV-z2$J`SP)0Rx`7L>}H2K0dNA85Av85&IvFd z2k`9X5nGz7wj89kY<26)GVb3dsFB=OS9eu)cXb)CSS%Kc#bU8oEEbE!VzI0W0yV~U z-SuzZ&~hByb%1sNY|uB}lK&Z?6}wrWF9Hnco&mkor4M|h(#!L6&qImq?Ycqq`s~~@ zYSSia1~>X}h)47m4@N_5H5{yS`Sn`{Gae*?1mACQC#^sY#D6^Dpi9C$dH>$)qej$1 z07|2~B@9b9@ZqjCr9h-GPEWm;r~wOALEP~E5Qo%bkLYq8RT7ZO66iWj9DANOKt`5Q z1x*^BdV6}qlWvC(FA(TagMAu}(D!`|R8_LG)~tJ|OL_F?HL0aHx;i^@Uyq-il)EIDkwYrTkt)uO)bW%I zbO#HjG8H6y{RmH}cOK2Sg*}+#W)si7l6s{SMgtw{`Mr1Vyyqx@rBFfg#YcEMLmiaf z@Dsf8DA`AiC|v+jY?8MROi{qW{it)nON{rvV-Sk>{eA>pDwwKFd5&E zQ32@Abme?uZRYCeM3DE28cAi=w=g5T_p7_F_WrdsbakGc+ZYV(R;@MgmU}G#-YCWq4teo( zZ!D-jBaG((yvG|WAn(V-eIs>sB6NqkgWQ`pW!Gowc1~AmNEq}Kv+znNx^>*nwAEgCm zS@fvg5v66LE_=e3jF<~R@uzpjE2ArktG;`nG2v6lJpvr3;;DDRQx@ zi1-K)!hAo$cr8dqK@VjfhHC?KHAH*fjF{GsI#kgUED^%N{=DUB8S2uM?Ql(&y#eD9 z5Jo@;UmO|8#A_(nXsyc z$od?njRMZiu!zV5ZODyG%O4@%(TN9e$Ll%?pJz0jruNf&_E9Q{r-QqZnb_=0-tuM?Bw(3et+Jx^#2~+C$xj6N4KX_?&avDz$X#lg-)!!d~vZbn=rsQVMr(KMmhWEGX_yK47Lur z1t4uffP+{ervn@5Ej{qiZw#@m6}?sv?1|Cx$+m9lgv2w@LV4$m6JMJUh&)^|VqoGZ2Sagi$Kt{kd(Kb;V{N1Orz7%{0I#*<`O1MJEp^7p6 zpCx)Ol5FqL@Q}qd;R4x7mUL(w1y1$8Rf-3!x{8d2DpUZ>j4z(LygQGHFz7S3{Bb(U zmZFo{pt3FM(UATW_~-cGYqQR+Gwq}e`)>0a|qN3b~SI^Z2ggx>5J_LHU6I{fl@}m zgjpk@=!4jKk!n-WBrD-KYuRbmN)-STrn2D~XmeNlo)NhZsl)5Efp3zjYb zW^Ss0aU=m}QtVchKQuLIc!eXh5&G3TVi^XEG jSS%Kc#bU8oEcM}k4&?aBOxwet00000NkvXXu0mjf`z;bh literal 929 zcmV;S177@zP)7|600qX$l^mHEr6orRHQ2unwYIkCX^# zVbKlOOP4M^M)Rlc-Md%eL9%DhzFKpl@Hh*B7h1!Ta^=dEKqD(hb}a;-Q3Td!(Z~*j zzzan|!EkFFWo4Z%jZto7^H zC*E2V6QUx0#8R)D}ByM=FFKl*_%^t5M+c|harlpcIN0?i0%oJTd{%F zfZ!2q^%J5P1+$Vs460Z$vI`-Y1Y6yL0Cx8n;ouHYF z#D!Wk_(2dIrgeefD2&a4zzz3t8auECi!c*YF$oj&$7z^}g;;~_IElOX2EjY1)ra5~ zY?c%PZ^VS!D2NMOhuV4wM#FAbA!rA+tPmF%Xt=2S0YPrqtt$kt5Z+{+w#o-Y!M}}# z;I^aNMp!(9pdZu{;5EbozJ?Q;L~w(kG90cE1V51;YB})Hf)VXxxHq;Ff)jA65I6?G zBgBAO3S5E&z$a9N+E@tOkR47J8&4rPf=EyciFSAlalj8uM@*=-g6J9`z-7iy2(BZk z$xc52%d5mKeJry3tl18583&M0Xr{;B3(` z9_|php*}1x0>;4&qPv8_$PBY$qA@n%7esdy+2GO=VF`R73Qs)8Sscb4e1jO=Kn48O zVxu{>;}67!>llt42=YQB5%Qoa>Z2I`nJTyxpaB2?@POf}kxq$U00000NkvXXu0mjf DtJ$91