diff --git a/res/css/views/emojipicker/_EmojiPicker.scss b/res/css/views/emojipicker/_EmojiPicker.scss
index 88606538d5..549a7c3621 100644
--- a/res/css/views/emojipicker/_EmojiPicker.scss
+++ b/res/css/views/emojipicker/_EmojiPicker.scss
@@ -133,6 +133,12 @@ limitations under the License.
}
}
+.mx_EmojiPicker_item_selected {
+ color: rgba(0, 0, 0, .75);
+ border: 1px solid $input-valid-border-color;
+ margin: 0;
+}
+
.mx_EmojiPicker_category_label, .mx_EmojiPicker_preview_name {
font-size: 16px;
font-weight: 600;
diff --git a/src/components/views/emojipicker/Category.js b/src/components/views/emojipicker/Category.js
index dcda106719..b8e56488d8 100644
--- a/src/components/views/emojipicker/Category.js
+++ b/src/components/views/emojipicker/Category.js
@@ -27,10 +27,11 @@ class Category extends React.PureComponent {
onMouseEnter: PropTypes.func.isRequired,
onMouseLeave: PropTypes.func.isRequired,
onClick: PropTypes.func.isRequired,
+ selectedEmojis: PropTypes.instanceOf(Set),
};
render() {
- const { onClick, onMouseEnter, onMouseLeave, emojis, name } = this.props;
+ const { onClick, onMouseEnter, onMouseLeave, emojis, name, selectedEmojis } = this.props;
if (!emojis || emojis.length === 0) {
return null;
}
@@ -42,11 +43,11 @@ class Category extends React.PureComponent {
{name}
- {emojis.map(emoji => )}
- )
+ );
}
}
diff --git a/src/components/views/emojipicker/Emoji.js b/src/components/views/emojipicker/Emoji.js
index 3bbbe3a771..3db5882fb3 100644
--- a/src/components/views/emojipicker/Emoji.js
+++ b/src/components/views/emojipicker/Emoji.js
@@ -23,18 +23,20 @@ class Emoji extends React.PureComponent {
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
emoji: PropTypes.object.isRequired,
+ selectedEmojis: PropTypes.instanceOf(Set),
};
render() {
- const { onClick, onMouseEnter, onMouseLeave, emoji } = this.props;
+ const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
+ const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);
return (
onClick(emoji)}
onMouseEnter={() => onMouseEnter(emoji)}
onMouseLeave={() => onMouseLeave(emoji)}
- className="mx_EmojiPicker_item">
+ className={`mx_EmojiPicker_item ${isSelected ? 'mx_EmojiPicker_item_selected' : ''}`}>
{emoji.unicode}
- )
+ );
}
}
diff --git a/src/components/views/emojipicker/EmojiPicker.js b/src/components/views/emojipicker/EmojiPicker.js
index d6d79d7b8c..1d5b11edb1 100644
--- a/src/components/views/emojipicker/EmojiPicker.js
+++ b/src/components/views/emojipicker/EmojiPicker.js
@@ -61,7 +61,8 @@ EMOJIBASE.forEach(emoji => {
class EmojiPicker extends React.Component {
static propTypes = {
onChoose: PropTypes.func.isRequired,
- closeMenu: PropTypes.func,
+ selectedEmojis: PropTypes.instanceOf(Set),
+ showQuickReactions: PropTypes.bool,
};
constructor(props) {
@@ -204,10 +205,8 @@ class EmojiPicker extends React.Component {
}
onClickEmoji(emoji) {
- recent.add(emoji.unicode);
- this.props.onChoose(emoji.unicode);
- if (this.props.closeMenu) {
- this.props.closeMenu();
+ if (this.props.onChoose(emoji.unicode) !== false) {
+ recent.add(emoji.unicode);
}
}
@@ -225,14 +224,15 @@ class EmojiPicker extends React.Component {
{this.categories.map(category => (
+ onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd}
+ selectedEmojis={this.props.selectedEmojis} />
))}
- {this.state.previewEmoji
+ {this.state.previewEmoji || !this.props.showQuickReactions
?
- : }
+ : }
- )
+ );
}
}
diff --git a/src/components/views/emojipicker/Preview.js b/src/components/views/emojipicker/Preview.js
index 1757a04801..75d3e35f31 100644
--- a/src/components/views/emojipicker/Preview.js
+++ b/src/components/views/emojipicker/Preview.js
@@ -19,21 +19,26 @@ import PropTypes from 'prop-types';
class Preview extends React.PureComponent {
static propTypes = {
- emoji: PropTypes.object.isRequired,
+ emoji: PropTypes.object,
};
render() {
+ const {
+ unicode = "",
+ annotation = "",
+ shortcodes: [shortcode = ""]
+ } = this.props.emoji || {};
return (
- {this.props.emoji.unicode}
+ {unicode}
- {this.props.emoji.annotation}
+ {annotation}
- {this.props.emoji.shortcodes[0]}
+ {shortcode}
diff --git a/src/components/views/emojipicker/QuickReactions.js b/src/components/views/emojipicker/QuickReactions.js
index 58c3095d34..2357345460 100644
--- a/src/components/views/emojipicker/QuickReactions.js
+++ b/src/components/views/emojipicker/QuickReactions.js
@@ -32,6 +32,7 @@ EMOJIBASE.forEach(emoji => {
class QuickReactions extends React.Component {
static propTypes = {
onClick: PropTypes.func.isRequired,
+ selectedEmojis: PropTypes.instanceOf(Set),
};
constructor(props) {
@@ -72,7 +73,8 @@ class QuickReactions extends React.Component {
{QUICK_REACTIONS.map(emoji => )}
+ onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}
+ selectedEmojis={this.props.selectedEmojis}/>)}
)
diff --git a/src/components/views/emojipicker/ReactionPicker.js b/src/components/views/emojipicker/ReactionPicker.js
new file mode 100644
index 0000000000..d539fa30e6
--- /dev/null
+++ b/src/components/views/emojipicker/ReactionPicker.js
@@ -0,0 +1,120 @@
+/*
+Copyright 2019 Tulir Asokan
+
+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 EmojiPicker from "./EmojiPicker";
+import MatrixClientPeg from "../../../MatrixClientPeg";
+
+class ReactionPicker extends React.Component {
+ static propTypes = {
+ mxEvent: PropTypes.object.isRequired,
+ onFinished: PropTypes.func.isRequired,
+ closeMenu: PropTypes.func.isRequired,
+ reactions: PropTypes.object.isRequired,
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ selectedEmojis: new Set(Object.keys(this.getReactions())),
+ };
+ this.onChoose = this.onChoose.bind(this);
+ this.onReactionsChange = this.onReactionsChange.bind(this);
+ this.addListeners();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.reactions !== this.props.reactions) {
+ this.addListeners();
+ this.onReactionsChange();
+ }
+ }
+
+ addListeners() {
+ if (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);
+ }
+ }
+
+ 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,
+ );
+ }
+ }
+
+ getReactions() {
+ if (!this.props.reactions) {
+ return {};
+ }
+ const userId = MatrixClientPeg.get().getUserId();
+ const myAnnotations = this.props.reactions.getAnnotationsBySender()[userId];
+ return Object.fromEntries([...myAnnotations]
+ .filter(event => !event.isRedacted())
+ .map(event => [event.getRelation().key, event.getId()]));
+ };
+
+ onReactionsChange() {
+ this.setState({
+ selectedEmojis: new Set(Object.keys(this.getReactions()))
+ });
+ }
+
+ onChoose(reaction) {
+ this.componentWillUnmount();
+ this.props.closeMenu();
+ this.props.onFinished();
+ const myReactions = this.getReactions();
+ if (myReactions.hasOwnProperty(reaction)) {
+ MatrixClientPeg.get().redactEvent(
+ this.props.mxEvent.getRoomId(),
+ myReactions[reaction],
+ );
+ // Tell the emoji picker not to bump this in the more frequently used list.
+ return false;
+ } else {
+ MatrixClientPeg.get().sendEvent(this.props.mxEvent.getRoomId(), "m.reaction", {
+ "m.relates_to": {
+ "rel_type": "m.annotation",
+ "event_id": this.props.mxEvent.getId(),
+ "key": reaction,
+ },
+ });
+ return true;
+ }
+ }
+
+ render() {
+ return
+ }
+}
+
+export default ReactionPicker
diff --git a/src/components/views/emojipicker/Search.js b/src/components/views/emojipicker/Search.js
index 19da7c2e6c..547f673815 100644
--- a/src/components/views/emojipicker/Search.js
+++ b/src/components/views/emojipicker/Search.js
@@ -45,7 +45,7 @@ class Search extends React.PureComponent {
{this.props.query ? icons.search.delete() : icons.search.search()}
- )
+ );
}
}
diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js
index 48554d8cc0..df1bc9a294 100644
--- a/src/components/views/messages/MessageActionBar.js
+++ b/src/components/views/messages/MessageActionBar.js
@@ -85,45 +85,9 @@ export default class MessageActionBar extends React.PureComponent {
});
};
- onReactClick = (ev) => {
- const EmojiPicker = sdk.getComponent('emojipicker.EmojiPicker');
+ getMenuOptions = (ev) => {
+ const menuOptions = {};
const buttonRect = ev.target.getBoundingClientRect();
-
- const getReactions = () => {
- if (!this.props.reactions) {
- return [];
- }
- const userId = MatrixClientPeg.get().getUserId();
- const myAnnotations = this.props.reactions.getAnnotationsBySender()[userId];
- return Object.fromEntries([...myAnnotations]
- .filter(event => !event.isRedacted())
- .map(event => [event.getRelation().key, event.getId()]));
- };
-
- const menuOptions = {
- reactions: this.props.reactions,
- chevronFace: "none",
- onFinished: () => this.onFocusChange(false),
- onChoose: reaction => {
- this.onFocusChange(false);
- const myReactions = getReactions();
- if (myReactions.hasOwnProperty(reaction)) {
- MatrixClientPeg.get().redactEvent(
- this.props.mxEvent.getRoomId(),
- myReactions[reaction],
- );
- } else {
- MatrixClientPeg.get().sendEvent(this.props.mxEvent.getRoomId(), "m.reaction", {
- "m.relates_to": {
- "rel_type": "m.annotation",
- "event_id": this.props.mxEvent.getId(),
- "key": reaction,
- },
- });
- }
- },
- };
-
// The window X and Y offsets are to adjust position when zoomed in to page
const buttonRight = buttonRect.right + window.pageXOffset;
const buttonBottom = buttonRect.bottom + window.pageYOffset;
@@ -137,15 +101,27 @@ export default class MessageActionBar extends React.PureComponent {
} else {
menuOptions.bottom = window.innerHeight - buttonTop;
}
+ return menuOptions;
+ };
- createMenu(EmojiPicker, menuOptions);
+ onReactClick = (ev) => {
+ const ReactionPicker = sdk.getComponent('emojipicker.ReactionPicker');
+
+ const menuOptions = {
+ ...this.getMenuOptions(ev),
+ mxEvent: this.props.mxEvent,
+ reactions: this.props.reactions,
+ chevronFace: "none",
+ onFinished: () => this.onFocusChange(false),
+ };
+
+ createMenu(ReactionPicker, menuOptions);
this.onFocusChange(true);
};
onOptionsClick = (ev) => {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
- const buttonRect = ev.target.getBoundingClientRect();
const { getTile, getReplyThread } = this.props;
const tile = getTile && getTile();
@@ -157,6 +133,7 @@ export default class MessageActionBar extends React.PureComponent {
}
const menuOptions = {
+ ...this.getMenuOptions(ev),
mxEvent: this.props.mxEvent,
chevronFace: "none",
permalinkCreator: this.props.permalinkCreator,
@@ -168,20 +145,6 @@ export default class MessageActionBar extends React.PureComponent {
},
};
- // The window X and Y offsets are to adjust position when zoomed in to page
- const buttonRight = buttonRect.right + window.pageXOffset;
- const buttonBottom = buttonRect.bottom + window.pageYOffset;
- const buttonTop = buttonRect.top + window.pageYOffset;
- // Align the right edge of the menu to the right edge of the button
- menuOptions.right = window.innerWidth - buttonRight;
- // Align the menu vertically on whichever side of the button has more
- // space available.
- if (buttonBottom < window.innerHeight / 2) {
- menuOptions.top = buttonBottom;
- } else {
- menuOptions.bottom = window.innerHeight - buttonTop;
- }
-
createMenu(MessageContextMenu, menuOptions);
this.onFocusChange(true);