From 44e9ca6c522aeb9de8db8f3018e5fe2fe3890334 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" <jryans@gmail.com> Date: Wed, 1 May 2019 13:58:32 +0100 Subject: [PATCH 1/3] Extract `isContentActionable` to a separate helper This moves the check about whether an event is actionable (for the purpose of replies, edits, reactions, etc.) to shared utils module. --- .../views/context_menus/MessageContextMenu.js | 26 +++++------ .../views/messages/MessageActionBar.js | 27 ++--------- src/utils/EventUtils.js | 45 +++++++++++++++++++ 3 files changed, 60 insertions(+), 38 deletions(-) create mode 100644 src/utils/EventUtils.js diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 1191b6d66e..2e4611f7d0 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -27,6 +27,7 @@ import Modal from '../../../Modal'; import Resend from '../../../Resend'; import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; +import { isContentActionable } from '../../../utils/EventUtils'; module.exports = React.createClass({ displayName: 'MessageContextMenu', @@ -247,22 +248,19 @@ module.exports = React.createClass({ ); } - if (isSent && mxEvent.getType() === 'm.room.message') { - const content = mxEvent.getContent(); - if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) { - forwardButton = ( - <div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}> - { _t('Forward Message') } + if (isContentActionable(mxEvent)) { + forwardButton = ( + <div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}> + { _t('Forward Message') } + </div> + ); + + if (this.state.canPin) { + pinButton = ( + <div className="mx_MessageContextMenu_field" onClick={this.onPinClick}> + { this._isPinned() ? _t('Unpin Message') : _t('Pin Message') } </div> ); - - if (this.state.canPin) { - pinButton = ( - <div className="mx_MessageContextMenu_field" onClick={this.onPinClick}> - { this._isPinned() ? _t('Unpin Message') : _t('Pin Message') } - </div> - ); - } } } diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 276a142ccb..76bae137f5 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -16,7 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {EventStatus} from 'matrix-js-sdk'; +import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import sdk from '../../../index'; @@ -24,7 +24,7 @@ import dis from '../../../dispatcher'; import Modal from '../../../Modal'; import { createMenu } from '../../structures/ContextualMenu'; import SettingsStore from '../../../settings/SettingsStore'; -import classNames from 'classnames'; +import { isContentActionable } from '../../../utils/EventUtils'; export default class MessageActionBar extends React.PureComponent { static propTypes = { @@ -123,27 +123,6 @@ export default class MessageActionBar extends React.PureComponent { this.onFocusChange(true); } - isContentActionable() { - const { mxEvent } = this.props; - const { status: eventStatus } = mxEvent; - - // status is SENT before remote-echo, null after - const isSent = !eventStatus || eventStatus === EventStatus.SENT; - - if (isSent && mxEvent.getType() === 'm.room.message') { - const content = mxEvent.getContent(); - if ( - content.msgtype && - content.msgtype !== 'm.bad.encrypted' && - content.hasOwnProperty('body') - ) { - return true; - } - } - - return false; - } - isReactionsEnabled() { return SettingsStore.isFeatureEnabled("feature_reactions"); } @@ -220,7 +199,7 @@ export default class MessageActionBar extends React.PureComponent { let likeDimensionReactionButtons; let replyButton; - if (this.isContentActionable()) { + if (isContentActionable(this.props.mxEvent)) { agreeDimensionReactionButtons = this.renderAgreeDimension(); likeDimensionReactionButtons = this.renderLikeDimension(); replyButton = <span className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton" diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js new file mode 100644 index 0000000000..911257f95c --- /dev/null +++ b/src/utils/EventUtils.js @@ -0,0 +1,45 @@ +/* +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 { EventStatus } from 'matrix-js-sdk'; + +/** + * Returns whether an event should allow actions like reply, reactions, edit, etc. + * which effectively checks whether it's a regular message that has been sent and that we + * can display. + * + * @param {MatrixEvent} mxEvent The event to check + * @returns {boolean} true if actionable + */ +export function isContentActionable(mxEvent) { + const { status: eventStatus } = mxEvent; + + // status is SENT before remote-echo, null after + const isSent = !eventStatus || eventStatus === EventStatus.SENT; + + if (isSent && mxEvent.getType() === 'm.room.message') { + const content = mxEvent.getContent(); + if ( + content.msgtype && + content.msgtype !== 'm.bad.encrypted' && + content.hasOwnProperty('body') + ) { + return true; + } + } + + return false; +} From 15c589327877b85036cc4e4f9ba5380b1c633538 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" <jryans@gmail.com> Date: Wed, 1 May 2019 18:05:11 +0100 Subject: [PATCH 2/3] Display existing reactions below the message This displays the existing reactions a message has from all users below the message. Since we don't currently have an API to actually get these events yet, adds a temporary hook that looks for a specific message to inject some sample data. This helps build out the UI for now and can be removed once it exists. Fixes https://github.com/vector-im/riot-web/issues/9573 --- res/css/_components.scss | 2 + res/css/views/messages/_ReactionsRow.scss | 19 ++++++ .../views/messages/_ReactionsRowButton.scss | 30 +++++++++ res/themes/dark/css/_dark.scss | 4 ++ res/themes/light/css/_light.scss | 4 ++ src/components/views/messages/ReactionsRow.js | 65 +++++++++++++++++++ .../views/messages/ReactionsRowButton.js | 33 ++++++++++ src/components/views/rooms/EventTile.js | 9 +++ 8 files changed, 166 insertions(+) create mode 100644 res/css/views/messages/_ReactionsRow.scss create mode 100644 res/css/views/messages/_ReactionsRowButton.scss create mode 100644 src/components/views/messages/ReactionsRow.js create mode 100644 src/components/views/messages/ReactionsRowButton.js diff --git a/res/css/_components.scss b/res/css/_components.scss index bb09b873a3..36648f4982 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -115,6 +115,8 @@ @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; +@import "./views/messages/_ReactionsRow.scss"; +@import "./views/messages/_ReactionsRowButton.scss"; @import "./views/messages/_RoomAvatarEvent.scss"; @import "./views/messages/_SenderProfile.scss"; @import "./views/messages/_TextualEvent.scss"; diff --git a/res/css/views/messages/_ReactionsRow.scss b/res/css/views/messages/_ReactionsRow.scss new file mode 100644 index 0000000000..fb66ffbb8c --- /dev/null +++ b/res/css/views/messages/_ReactionsRow.scss @@ -0,0 +1,19 @@ +/* +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. +*/ + +.mx_ReactionsRow { + margin: 6px 0; +} diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss new file mode 100644 index 0000000000..9cbf839f21 --- /dev/null +++ b/res/css/views/messages/_ReactionsRowButton.scss @@ -0,0 +1,30 @@ +/* +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. +*/ + +.mx_ReactionsRowButton { + display: inline-block; + height: 20px; + line-height: 21px; + margin-right: 6px; + padding: 0 6px; + border: 1px solid $reaction-row-button-border-color; + border-radius: 10px; + background-color: $reaction-row-button-bg-color; + + &:hover { + border-color: $reaction-row-button-hover-border-color; + } +} diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 7c0f8ef9ab..30066c7af4 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -151,6 +151,10 @@ $message-action-bar-fg-color: $header-panel-text-primary-color; $message-action-bar-border-color: #616b7f; $message-action-bar-hover-border-color: $header-panel-text-primary-color; +$reaction-row-button-bg-color: $header-panel-bg-color; +$reaction-row-button-border-color: #616b7f; +$reaction-row-button-hover-border-color: $header-panel-text-primary-color; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 7451a23991..223d0fc80c 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -259,6 +259,10 @@ $message-action-bar-fg-color: $primary-fg-color; $message-action-bar-border-color: #e9edf1; $message-action-bar-hover-border-color: #b8c1d2; +$reaction-row-button-bg-color: $header-panel-bg-color; +$reaction-row-button-border-color: #e9edf1; +$reaction-row-button-hover-border-color: #bebebe; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/src/components/views/messages/ReactionsRow.js b/src/components/views/messages/ReactionsRow.js new file mode 100644 index 0000000000..a4299b9853 --- /dev/null +++ b/src/components/views/messages/ReactionsRow.js @@ -0,0 +1,65 @@ +/* +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 sdk from '../../../index'; +import { isContentActionable } from '../../../utils/EventUtils'; + +// TODO: Actually load reactions from the timeline +// Since we don't yet load reactions, let's inject some dummy data for testing the UI +// only. The UI assumes these are already sorted into the order we want to present, +// presumably highest vote first. +const SAMPLE_REACTIONS = { + "👍": 4, + "👎": 2, + "🙂": 1, +}; + +export default class ReactionsRow extends React.PureComponent { + static propTypes = { + // The event we're displaying reactions for + mxEvent: PropTypes.object.isRequired, + } + + render() { + const { mxEvent } = this.props; + + if (!isContentActionable(mxEvent)) { + return null; + } + + const content = mxEvent.getContent(); + // TODO: Remove this once we load real reactions + if (!content.body || content.body !== "reactions test") { + return null; + } + + const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton'); + const items = Object.entries(SAMPLE_REACTIONS).map(([content, count]) => { + return <ReactionsRowButton + key={content} + content={content} + count={count} + />; + }); + + return <div className="mx_ReactionsRow"> + {items} + </div>; + } +} diff --git a/src/components/views/messages/ReactionsRowButton.js b/src/components/views/messages/ReactionsRowButton.js new file mode 100644 index 0000000000..4afcf93fff --- /dev/null +++ b/src/components/views/messages/ReactionsRowButton.js @@ -0,0 +1,33 @@ +/* +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'; + +export default class ReactionsRowButton extends React.PureComponent { + static propTypes = { + content: PropTypes.string.isRequired, + count: PropTypes.number.isRequired, + } + + render() { + const { content, count } = this.props; + + return <span className="mx_ReactionsRowButton"> + {content} {count} + </span>; + } +} diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index dd0a7aa47b..6bec3f4fff 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -622,6 +622,14 @@ module.exports = withMatrixClient(React.createClass({ <ToolTipButton helpText={keyRequestHelpText} /> </div> : null; + let reactions; + if (SettingsStore.isFeatureEnabled("feature_reactions")) { + const ReactionsRow = sdk.getComponent('messages.ReactionsRow'); + reactions = <ReactionsRow + mxEvent={this.props.mxEvent} + />; + } + switch (this.props.tileShape) { case 'notif': { const EmojiText = sdk.getComponent('elements.EmojiText'); @@ -734,6 +742,7 @@ module.exports = withMatrixClient(React.createClass({ showUrlPreview={this.props.showUrlPreview} onHeightChanged={this.props.onHeightChanged} /> { keyRequestInfo } + { reactions } { actionBar } </div> { From 87f737b8a38454c674d786674af7669cc808e0c9 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" <jryans@gmail.com> Date: Thu, 2 May 2019 11:48:32 +0100 Subject: [PATCH 3/3] Increment an existing reaction This allows you to increment an existing reaction below a message by clicking on it. At the moment, this is not linked to the action bar, so they each are using local state. We'll likely want to add some mechanism so that we can local echo to both of these UI areas at the same time, but that can be done separately. Fixes https://github.com/vector-im/riot-web/issues/9486 --- .../views/messages/_ReactionsRowButton.scss | 6 ++++ res/themes/dark/css/_dark.scss | 2 ++ res/themes/light/css/_light.scss | 2 ++ .../views/messages/ReactionsRowButton.js | 36 +++++++++++++++++-- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss index 9cbf839f21..49e3930979 100644 --- a/res/css/views/messages/_ReactionsRowButton.scss +++ b/res/css/views/messages/_ReactionsRowButton.scss @@ -23,8 +23,14 @@ limitations under the License. border: 1px solid $reaction-row-button-border-color; border-radius: 10px; background-color: $reaction-row-button-bg-color; + cursor: pointer; &:hover { border-color: $reaction-row-button-hover-border-color; } + + &.mx_ReactionsRowButton_selected { + background-color: $reaction-row-button-selected-bg-color; + border-color: $reaction-row-button-selected-border-color; + } } diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 30066c7af4..592b1a1887 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -154,6 +154,8 @@ $message-action-bar-hover-border-color: $header-panel-text-primary-color; $reaction-row-button-bg-color: $header-panel-bg-color; $reaction-row-button-border-color: #616b7f; $reaction-row-button-hover-border-color: $header-panel-text-primary-color; +$reaction-row-button-selected-bg-color: #1f6954; +$reaction-row-button-selected-border-color: $accent-color; // ***** Mixins! ***** diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 223d0fc80c..adadd39333 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -262,6 +262,8 @@ $message-action-bar-hover-border-color: #b8c1d2; $reaction-row-button-bg-color: $header-panel-bg-color; $reaction-row-button-border-color: #e9edf1; $reaction-row-button-hover-border-color: #bebebe; +$reaction-row-button-selected-bg-color: #e9fff9; +$reaction-row-button-selected-border-color: $accent-color; // ***** Mixins! ***** diff --git a/src/components/views/messages/ReactionsRowButton.js b/src/components/views/messages/ReactionsRowButton.js index 4afcf93fff..985479a237 100644 --- a/src/components/views/messages/ReactionsRowButton.js +++ b/src/components/views/messages/ReactionsRowButton.js @@ -16,6 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; export default class ReactionsRowButton extends React.PureComponent { static propTypes = { @@ -23,11 +24,42 @@ export default class ReactionsRowButton extends React.PureComponent { count: PropTypes.number.isRequired, } + constructor(props) { + super(props); + + // TODO: This should be derived from actual reactions you may have sent + // once we have some API to read them. + this.state = { + selected: false, + }; + } + + onClick = (ev) => { + const state = this.state.selected; + this.setState({ + selected: !state, + }); + // TODO: Send the reaction event + }; + render() { const { content, count } = this.props; + const { selected } = this.state; - return <span className="mx_ReactionsRowButton"> - {content} {count} + const classes = classNames({ + mx_ReactionsRowButton: true, + mx_ReactionsRowButton_selected: selected, + }); + + let adjustedCount = count; + if (selected) { + adjustedCount++; + } + + return <span className={classes} + onClick={this.onClick} + > + {content} {adjustedCount} </span>; } }