diff --git a/res/css/_components.scss b/res/css/_components.scss index fa388c4e6a..4986ca837f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -117,7 +117,8 @@ @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; -@import "./views/messages/_ReactionDimension.scss"; +@import "./views/messages/_ReactionQuickTooltip.scss"; +@import "./views/messages/_ReactionTooltipButton.scss"; @import "./views/messages/_ReactionsRow.scss"; @import "./views/messages/_ReactionsRowButton.scss"; @import "./views/messages/_ReactionsRowButtonTooltip.scss"; diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 685c2bb018..7ac0e95e81 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -67,6 +67,10 @@ limitations under the License. background-color: $message-action-bar-fg-color; } +.mx_MessageActionBar_reactButton::after { + mask-image: url('$(res)/img/react.svg'); +} + .mx_MessageActionBar_replyButton::after { mask-image: url('$(res)/img/reply.svg'); } diff --git a/res/css/views/messages/_ReactionQuickTooltip.scss b/res/css/views/messages/_ReactionQuickTooltip.scss new file mode 100644 index 0000000000..7b1611483b --- /dev/null +++ b/res/css/views/messages/_ReactionQuickTooltip.scss @@ -0,0 +1,29 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ReactionsQuickTooltip_buttons { + display: grid; + grid-template-columns: repeat(4, auto); +} + +.mx_ReactionsQuickTooltip_label { + text-align: center; +} + +.mx_ReactionsQuickTooltip_shortcode { + padding-left: 6px; + opacity: 0.7; +} diff --git a/res/css/views/messages/_ReactionDimension.scss b/res/css/views/messages/_ReactionTooltipButton.scss similarity index 68% rename from res/css/views/messages/_ReactionDimension.scss rename to res/css/views/messages/_ReactionTooltipButton.scss index 9a891d05cf..bf1c25e126 100644 --- a/res/css/views/messages/_ReactionDimension.scss +++ b/res/css/views/messages/_ReactionTooltipButton.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,12 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ReactionDimension { - width: 42px; - display: flex; - justify-content: space-evenly; +.mx_ReactionTooltipButton { + font-size: 16px; + padding: 6px; + user-select: none; + transition: transform 0.25s; + + &:hover { + transform: scale(1.2); + } } -.mx_ReactionDimension_disabled { +.mx_ReactionTooltipButton_selected { opacity: 0.4; } diff --git a/res/img/react.svg b/res/img/react.svg new file mode 100644 index 0000000000..dd23c41c2c --- /dev/null +++ b/res/img/react.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 80f0ba538c..e7843c1505 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -57,7 +57,7 @@ export default class MessageActionBar extends React.PureComponent { this.props.onFocusChange(focused); } - onCryptoClicked = () => { + onCryptoClick = () => { const event = this.props.mxEvent; Modal.createTrackedDialogAsync('Encrypted Event Dialog', '', import('../../../async-components/views/dialogs/EncryptedEventDialog'), @@ -89,7 +89,7 @@ export default class MessageActionBar extends React.PureComponent { let e2eInfoCallback = null; if (this.props.mxEvent.isEncrypted()) { - e2eInfoCallback = () => this.onCryptoClicked(); + e2eInfoCallback = () => this.onCryptoClick(); } const menuOptions = { @@ -131,43 +131,28 @@ export default class MessageActionBar extends React.PureComponent { return SettingsStore.isFeatureEnabled("feature_message_editing"); } - renderAgreeDimension() { + renderReactButton() { if (!this.isReactionsEnabled()) { return null; } - const ReactionDimension = sdk.getComponent('messages.ReactionDimension'); - return ; - } + const ReactMessageAction = sdk.getComponent('messages.ReactMessageAction'); + const { mxEvent, reactions } = this.props; - renderLikeDimension() { - if (!this.isReactionsEnabled()) { - return null; - } - - const ReactionDimension = sdk.getComponent('messages.ReactionDimension'); - return ; } render() { - let agreeDimensionReactionButtons; - let likeDimensionReactionButtons; + let reactButton; let replyButton; let editButton; if (isContentActionable(this.props.mxEvent)) { - agreeDimensionReactionButtons = this.renderAgreeDimension(); - likeDimensionReactionButtons = this.renderLikeDimension(); + reactButton = this.renderReactButton(); replyButton = - {agreeDimensionReactionButtons} - {likeDimensionReactionButtons} + {reactButton} {replyButton} {editButton} { + if (!this.props.onFocusChange) { + return; + } + this.props.onFocusChange(focused); + } + + componentDidUpdate(prevProps) { + if (prevProps.reactions !== this.props.reactions) { + this.props.reactions.on("Relations.add", this.onReactionsChange); + this.props.reactions.on("Relations.remove", this.onReactionsChange); + this.props.reactions.on("Relations.redaction", this.onReactionsChange); + this.onReactionsChange(); + } + } + + componentWillUnmount() { + if (this.props.reactions) { + this.props.reactions.removeListener( + "Relations.add", + this.onReactionsChange, + ); + this.props.reactions.removeListener( + "Relations.remove", + this.onReactionsChange, + ); + this.props.reactions.removeListener( + "Relations.redaction", + this.onReactionsChange, + ); + } + } + + onReactionsChange = () => { + // Force a re-render of the tooltip because a change in the reactions + // set means the event tile's layout may have changed and possibly + // altered the location where the tooltip should be shown. + this.forceUpdate(); + } + + render() { + const ReactionsQuickTooltip = sdk.getComponent('messages.ReactionsQuickTooltip'); + const InteractiveTooltip = sdk.getComponent('elements.InteractiveTooltip'); + const { mxEvent, reactions } = this.props; + + const content = ; + + return + + ; + } +} diff --git a/src/components/views/messages/ReactionDimension.js b/src/components/views/messages/ReactionDimension.js deleted file mode 100644 index de33ad1a57..0000000000 --- a/src/components/views/messages/ReactionDimension.js +++ /dev/null @@ -1,176 +0,0 @@ -/* -Copyright 2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; - -import MatrixClientPeg from '../../../MatrixClientPeg'; - -export default class ReactionDimension extends React.PureComponent { - static propTypes = { - mxEvent: PropTypes.object.isRequired, - // Array of strings containing the emoji for each option - options: PropTypes.array.isRequired, - title: PropTypes.string, - // The Relations model from the JS SDK for reactions - reactions: PropTypes.object, - }; - - constructor(props) { - super(props); - - this.state = this.getSelection(); - - if (props.reactions) { - props.reactions.on("Relations.add", this.onReactionsChange); - props.reactions.on("Relations.remove", this.onReactionsChange); - props.reactions.on("Relations.redaction", this.onReactionsChange); - } - } - - componentDidUpdate(prevProps) { - if (prevProps.reactions !== this.props.reactions) { - this.props.reactions.on("Relations.add", this.onReactionsChange); - this.props.reactions.on("Relations.remove", this.onReactionsChange); - this.props.reactions.on("Relations.redaction", this.onReactionsChange); - this.onReactionsChange(); - } - } - - componentWillUnmount() { - if (this.props.reactions) { - this.props.reactions.removeListener( - "Relations.add", - this.onReactionsChange, - ); - this.props.reactions.removeListener( - "Relations.remove", - this.onReactionsChange, - ); - this.props.reactions.removeListener( - "Relations.redaction", - this.onReactionsChange, - ); - } - } - - onReactionsChange = () => { - this.setState(this.getSelection()); - } - - getSelection() { - const myReactions = this.getMyReactions(); - if (!myReactions) { - return { - selectedOption: null, - selectedReactionEvent: null, - }; - } - const { options } = this.props; - let selectedOption = null; - let selectedReactionEvent = null; - for (const option of options) { - const reactionForOption = myReactions.find(mxEvent => { - if (mxEvent.isRedacted()) { - return false; - } - return mxEvent.getRelation().key === option; - }); - if (!reactionForOption) { - continue; - } - if (selectedOption) { - // If there are multiple selected values (only expected to occur via - // non-Riot clients), then act as if none are selected. - return { - selectedOption: null, - selectedReactionEvent: null, - }; - } - selectedOption = option; - selectedReactionEvent = reactionForOption; - } - return { selectedOption, selectedReactionEvent }; - } - - getMyReactions() { - const reactions = this.props.reactions; - if (!reactions) { - return null; - } - const userId = MatrixClientPeg.get().getUserId(); - const myReactions = reactions.getAnnotationsBySender()[userId]; - if (!myReactions) { - return null; - } - return [...myReactions.values()]; - } - - onOptionClick = (ev) => { - const { key } = ev.target.dataset; - this.toggleDimension(key); - } - - toggleDimension(key) { - const { selectedOption, selectedReactionEvent } = this.state; - const newSelectedOption = selectedOption !== key ? key : null; - this.setState({ - selectedOption: newSelectedOption, - }); - if (selectedReactionEvent) { - MatrixClientPeg.get().redactEvent( - this.props.mxEvent.getRoomId(), - selectedReactionEvent.getId(), - ); - } - if (newSelectedOption) { - MatrixClientPeg.get().sendEvent(this.props.mxEvent.getRoomId(), "m.reaction", { - "m.relates_to": { - "rel_type": "m.annotation", - "event_id": this.props.mxEvent.getId(), - "key": newSelectedOption, - }, - }); - } - } - - render() { - const { selectedOption } = this.state; - const { options } = this.props; - - const items = options.map(option => { - const disabled = selectedOption && selectedOption !== option; - const classes = classNames({ - mx_ReactionDimension_disabled: disabled, - }); - return - {option} - ; - }); - - return - {items} - ; - } -} diff --git a/src/components/views/messages/ReactionTooltipButton.js b/src/components/views/messages/ReactionTooltipButton.js new file mode 100644 index 0000000000..e09b9ade69 --- /dev/null +++ b/src/components/views/messages/ReactionTooltipButton.js @@ -0,0 +1,68 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import MatrixClientPeg from '../../../MatrixClientPeg'; + +export default class ReactionTooltipButton extends React.PureComponent { + static propTypes = { + mxEvent: PropTypes.object.isRequired, + // The reaction content / key / emoji + content: PropTypes.string.isRequired, + title: PropTypes.string, + // A possible Matrix event if the current user has voted for this type + myReactionEvent: PropTypes.object, + }; + + onClick = (ev) => { + const { mxEvent, myReactionEvent, content } = this.props; + if (myReactionEvent) { + MatrixClientPeg.get().redactEvent( + mxEvent.getRoomId(), + myReactionEvent.getId(), + ); + } else { + MatrixClientPeg.get().sendEvent(mxEvent.getRoomId(), "m.reaction", { + "m.relates_to": { + "rel_type": "m.annotation", + "event_id": mxEvent.getId(), + "key": content, + }, + }); + } + } + + render() { + const { content, myReactionEvent } = this.props; + + const classes = classNames({ + mx_ReactionTooltipButton: true, + mx_ReactionTooltipButton_selected: !!myReactionEvent, + }); + + return + {content} + ; + } +} diff --git a/src/components/views/messages/ReactionsQuickTooltip.js b/src/components/views/messages/ReactionsQuickTooltip.js new file mode 100644 index 0000000000..0505bbd2df --- /dev/null +++ b/src/components/views/messages/ReactionsQuickTooltip.js @@ -0,0 +1,195 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { _t } from '../../../languageHandler'; +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import { unicodeToShortcode } from '../../../HtmlUtils'; + +export default class ReactionsQuickTooltip extends React.PureComponent { + static propTypes = { + mxEvent: PropTypes.object.isRequired, + // The Relations model from the JS SDK for reactions to `mxEvent` + reactions: PropTypes.object, + }; + + constructor(props) { + super(props); + + if (props.reactions) { + props.reactions.on("Relations.add", this.onReactionsChange); + props.reactions.on("Relations.remove", this.onReactionsChange); + props.reactions.on("Relations.redaction", this.onReactionsChange); + } + + this.state = { + hoveredItem: null, + myReactions: this.getMyReactions(), + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.reactions !== this.props.reactions) { + this.props.reactions.on("Relations.add", this.onReactionsChange); + this.props.reactions.on("Relations.remove", this.onReactionsChange); + this.props.reactions.on("Relations.redaction", this.onReactionsChange); + this.onReactionsChange(); + } + } + + componentWillUnmount() { + if (this.props.reactions) { + this.props.reactions.removeListener( + "Relations.add", + this.onReactionsChange, + ); + this.props.reactions.removeListener( + "Relations.remove", + this.onReactionsChange, + ); + this.props.reactions.removeListener( + "Relations.redaction", + this.onReactionsChange, + ); + } + } + + onReactionsChange = () => { + this.setState({ + myReactions: this.getMyReactions(), + }); + } + + getMyReactions() { + const reactions = this.props.reactions; + if (!reactions) { + return null; + } + const userId = MatrixClientPeg.get().getUserId(); + const myReactions = reactions.getAnnotationsBySender()[userId]; + if (!myReactions) { + return null; + } + return [...myReactions.values()]; + } + + onMouseOver = (ev) => { + const { key } = ev.target.dataset; + const item = this.items.find(({ content }) => content === key); + this.setState({ + hoveredItem: item, + }); + } + + onMouseOut = (ev) => { + this.setState({ + hoveredItem: null, + }); + } + + get items() { + return [ + { + content: "👍", + title: _t("Agree"), + }, + { + content: "👎", + title: _t("Disagree"), + }, + { + content: "😄", + title: _t("Happy"), + }, + { + content: "🎉", + title: _t("Party Popper"), + }, + { + content: "😕", + title: _t("Confused"), + }, + { + content: "❤️", + title: _t("Heart"), + }, + { + content: "🚀", + title: _t("Rocket"), + }, + { + content: "👀", + title: _t("Eyes"), + }, + ]; + } + + render() { + const { mxEvent } = this.props; + const { myReactions, hoveredItem } = this.state; + const ReactionTooltipButton = sdk.getComponent('messages.ReactionTooltipButton'); + + const buttons = this.items.map(({ content, title }) => { + const myReactionEvent = myReactions && myReactions.find(mxEvent => { + if (mxEvent.isRedacted()) { + return false; + } + return mxEvent.getRelation().key === content; + }); + + return ; + }); + + let label = " "; // non-breaking space to keep layout the same when empty + if (hoveredItem) { + const { content, title } = hoveredItem; + + let shortcodeLabel; + const shortcode = unicodeToShortcode(content); + if (shortcode) { + shortcodeLabel = + {shortcode} + ; + } + + label =
+ + {title} + + {shortcodeLabel} +
; + } + + return
+
+ {buttons} +
+ {label} +
; + } +} diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index a997e7a3de..988bf7eb3c 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -404,7 +404,7 @@ module.exports = withMatrixClient(React.createClass({ }); }, - onCryptoClicked: function(e) { + onCryptoClick: function(e) { const event = this.props.mxEvent; Modal.createTrackedDialogAsync('Encrypted Event Dialog', '', @@ -440,7 +440,7 @@ module.exports = withMatrixClient(React.createClass({ _renderE2EPadlock: function() { const ev = this.props.mxEvent; - const props = {onClick: this.onCryptoClicked}; + const props = {onClick: this.onCryptoClick}; // event could not be decrypted if (ev.getContent().msgtype === 'm.bad.encrypted') { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3edaaf6241..010ad29da0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -928,8 +928,6 @@ "Today": "Today", "Yesterday": "Yesterday", "Error decrypting audio": "Error decrypting audio", - "Agree or Disagree": "Agree or Disagree", - "Like or Dislike": "Like or Dislike", "Reply": "Reply", "Edit": "Edit", "Options": "Options", @@ -940,6 +938,12 @@ "Invalid file%(extra)s": "Invalid file%(extra)s", "Error decrypting image": "Error decrypting image", "Error decrypting video": "Error decrypting video", + "Agree": "Agree", + "Disagree": "Disagree", + "Happy": "Happy", + "Party Popper": "Party Popper", + "Confused": "Confused", + "Eyes": "Eyes", "reacted with %(shortName)s": "reacted with %(shortName)s", "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s", "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.",