diff --git a/res/css/views/messages/_ReactionsRowButton.scss b/res/css/views/messages/_ReactionsRowButton.scss
index 49e3930979..3c6d019b30 100644
--- a/res/css/views/messages/_ReactionsRowButton.scss
+++ b/res/css/views/messages/_ReactionsRowButton.scss
@@ -24,6 +24,7 @@ limitations under the License.
border-radius: 10px;
background-color: $reaction-row-button-bg-color;
cursor: pointer;
+ user-select: none;
&:hover {
border-color: $reaction-row-button-hover-border-color;
diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js
index 763eddbd5d..cd40c7874e 100644
--- a/src/MatrixClientPeg.js
+++ b/src/MatrixClientPeg.js
@@ -175,6 +175,8 @@ class MatrixClientPeg {
}
_createClient(creds: MatrixClientCreds) {
+ const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions");
+
const opts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
@@ -183,7 +185,8 @@ class MatrixClientPeg {
deviceId: creds.deviceId,
timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
- verificationMethods: [verificationMethods.SAS]
+ verificationMethods: [verificationMethods.SAS],
+ unstableClientRelationAggregation: aggregateRelations,
};
this.matrixClient = createMatrixClient(opts);
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index b57b659136..2037217710 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -92,6 +92,9 @@ module.exports = React.createClass({
// show timestamps always
alwaysShowTimestamps: PropTypes.bool,
+
+ // helper function to access relations for an event
+ getRelationsForEvent: PropTypes.func,
},
componentWillMount: function() {
@@ -511,22 +514,27 @@ module.exports = React.createClass({
readReceipts = this._getReadReceiptsForEvent(mxEv);
}
ret.push(
-
-
- ,
+
+
+ ,
);
return ret;
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js
index aa278f2349..17a062be98 100644
--- a/src/components/structures/TimelinePanel.js
+++ b/src/components/structures/TimelinePanel.js
@@ -1168,6 +1168,10 @@ const TimelinePanel = React.createClass({
});
},
+ getRelationsForEvent(...args) {
+ return this.props.timelineSet.getRelationsForEvent(...args);
+ },
+
render: function() {
const MessagePanel = sdk.getComponent("structures.MessagePanel");
const Loader = sdk.getComponent("elements.Spinner");
@@ -1193,9 +1197,9 @@ const TimelinePanel = React.createClass({
if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) {
return (
-
+
);
}
@@ -1217,28 +1221,29 @@ const TimelinePanel = React.createClass({
);
return (
);
},
diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js
index 9a482c9e6e..52630d7b0e 100644
--- a/src/components/views/messages/MessageActionBar.js
+++ b/src/components/views/messages/MessageActionBar.js
@@ -28,6 +28,8 @@ import { isContentActionable } from '../../../utils/EventUtils';
export default class MessageActionBar extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
+ // The Relations model from the JS SDK for reactions to `mxEvent`
+ reactions: PropTypes.object,
permalinkCreator: PropTypes.object,
getTile: PropTypes.func,
getReplyThread: PropTypes.func,
@@ -100,19 +102,11 @@ export default class MessageActionBar extends React.PureComponent {
}
const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
- const options = [
- {
- key: "agree",
- content: "👍",
- },
- {
- key: "disagree",
- content: "👎",
- },
- ];
return ;
}
@@ -122,19 +116,11 @@ export default class MessageActionBar extends React.PureComponent {
}
const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
- const options = [
- {
- key: "like",
- content: "🙂",
- },
- {
- key: "dislike",
- content: "😔",
- },
- ];
return ;
}
diff --git a/src/components/views/messages/ReactionDimension.js b/src/components/views/messages/ReactionDimension.js
index 3b72aabe15..a0cf5a86ec 100644
--- a/src/components/views/messages/ReactionDimension.js
+++ b/src/components/views/messages/ReactionDimension.js
@@ -18,49 +18,141 @@ 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 = {
- selected: null,
- };
+ this.state = this.getSelection();
+
+ if (props.reactions) {
+ props.reactions.on("Relations.add", 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.redaction", this.onReactionsChange);
+ this.onReactionsChange();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.props.reactions) {
+ this.props.reactions.removeListener(
+ "Relations.add",
+ 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.getContent()["m.relates_to"].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();
+ return reactions.getAnnotationsBySender()[userId];
}
onOptionClick = (ev) => {
const { key } = ev.target.dataset;
- this.toggleDimensionValue(key);
+ this.toggleDimension(key);
}
- toggleDimensionValue(value) {
- const state = this.state.selected;
- const newState = state !== value ? value : null;
+ toggleDimension(key) {
+ const { selectedOption, selectedReactionEvent } = this.state;
+ const newSelectedOption = selectedOption !== key ? key : null;
this.setState({
- selected: newState,
+ selectedOption: newSelectedOption,
});
- // TODO: Send the reaction event
+ 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 { selected } = this.state;
+ const { selectedOption } = this.state;
const { options } = this.props;
const items = options.map(option => {
- const disabled = selected && selected !== option.key;
+ const disabled = selectedOption && selectedOption !== option;
const classes = classNames({
mx_ReactionDimension_disabled: disabled,
});
- return
- {option.content}
+ {option}
;
});
diff --git a/src/components/views/messages/ReactionsRow.js b/src/components/views/messages/ReactionsRow.js
index a4299b9853..ffb81e1a38 100644
--- a/src/components/views/messages/ReactionsRow.js
+++ b/src/components/views/messages/ReactionsRow.js
@@ -19,42 +19,96 @@ 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,
-};
+import MatrixClientPeg from '../../../MatrixClientPeg';
export default class ReactionsRow extends React.PureComponent {
static propTypes = {
// The event we're displaying reactions for
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.redaction", this.onReactionsChange);
+ }
+
+ this.state = {
+ myReactions: this.getMyReactions(),
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.reactions !== this.props.reactions) {
+ this.props.reactions.on("Relations.add", 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.redaction",
+ this.onReactionsChange,
+ );
+ }
+ }
+
+ onReactionsChange = () => {
+ // TODO: Call `onHeightChanged` as needed
+ this.setState({
+ myReactions: this.getMyReactions(),
+ });
+ // Using `forceUpdate` for the moment, since we know the overall set of reactions
+ // has changed (this is triggered by events for that purpose only) and
+ // `PureComponent`s shallow state / props compare would otherwise filter this out.
+ this.forceUpdate();
+ }
+
+ getMyReactions() {
+ const reactions = this.props.reactions;
+ if (!reactions) {
+ return null;
+ }
+ const userId = MatrixClientPeg.get().getUserId();
+ return reactions.getAnnotationsBySender()[userId];
}
render() {
- const { mxEvent } = this.props;
+ const { mxEvent, reactions } = this.props;
+ const { myReactions } = this.state;
- if (!isContentActionable(mxEvent)) {
- return null;
- }
-
- const content = mxEvent.getContent();
- // TODO: Remove this once we load real reactions
- if (!content.body || content.body !== "reactions test") {
+ if (!reactions || !isContentActionable(mxEvent)) {
return null;
}
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
- const items = Object.entries(SAMPLE_REACTIONS).map(([content, count]) => {
+ const items = reactions.getSortedAnnotationsByKey().map(([content, events]) => {
+ const count = events.size;
+ if (!count) {
+ return null;
+ }
+ const myReactionEvent = myReactions && myReactions.find(mxEvent => {
+ if (mxEvent.isRedacted()) {
+ return false;
+ }
+ return mxEvent.getContent()["m.relates_to"].key === content;
+ });
return ;
});
diff --git a/src/components/views/messages/ReactionsRowButton.js b/src/components/views/messages/ReactionsRowButton.js
index 985479a237..721147cdb8 100644
--- a/src/components/views/messages/ReactionsRowButton.js
+++ b/src/components/views/messages/ReactionsRowButton.js
@@ -18,48 +18,48 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
+import MatrixClientPeg from '../../../MatrixClientPeg';
+
export default class ReactionsRowButton extends React.PureComponent {
static propTypes = {
+ // The event we're displaying reactions for
+ mxEvent: PropTypes.object.isRequired,
content: PropTypes.string.isRequired,
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,
- };
+ // A possible Matrix event if the current user has voted for this type
+ myReactionEvent: PropTypes.object,
}
onClick = (ev) => {
- const state = this.state.selected;
- this.setState({
- selected: !state,
- });
- // TODO: Send the reaction event
+ 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, count } = this.props;
- const { selected } = this.state;
+ const { content, count, myReactionEvent } = this.props;
const classes = classNames({
mx_ReactionsRowButton: true,
- mx_ReactionsRowButton_selected: selected,
+ mx_ReactionsRowButton_selected: !!myReactionEvent,
});
- let adjustedCount = count;
- if (selected) {
- adjustedCount++;
- }
-
return
- {content} {adjustedCount}
+ {content} {count}
;
}
}
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index 59025bf431..1706019e94 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -159,6 +159,9 @@ module.exports = withMatrixClient(React.createClass({
// show twelve hour timestamps
isTwelveHour: PropTypes.bool,
+
+ // helper function to access relations for an event
+ getRelationsForEvent: PropTypes.func,
},
getDefaultProps: function() {
@@ -179,6 +182,8 @@ module.exports = withMatrixClient(React.createClass({
verified: null,
// Whether onRequestKeysClick has been called since mounting.
previouslyRequestedKeys: false,
+ // The Relations model from the JS SDK for reactions to `mxEvent`
+ reactions: this.getReactions(),
};
},
@@ -190,9 +195,12 @@ module.exports = withMatrixClient(React.createClass({
componentDidMount: function() {
this._suppressReadReceiptAnimation = false;
- this.props.matrixClient.on("deviceVerificationChanged",
- this.onDeviceVerificationChanged);
+ const client = this.props.matrixClient;
+ client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
+ if (SettingsStore.isFeatureEnabled("feature_reactions")) {
+ this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
+ }
},
componentWillReceiveProps: function(nextProps) {
@@ -215,6 +223,9 @@ module.exports = withMatrixClient(React.createClass({
const client = this.props.matrixClient;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
+ if (SettingsStore.isFeatureEnabled("feature_reactions")) {
+ this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
+ }
},
/** called when the event is decrypted after we show it.
@@ -472,6 +483,27 @@ module.exports = withMatrixClient(React.createClass({
return this.refs.replyThread;
},
+ getReactions() {
+ if (
+ !this.props.getRelationsForEvent ||
+ !SettingsStore.isFeatureEnabled("feature_reactions")
+ ) {
+ return null;
+ }
+ const eventId = this.props.mxEvent.getId();
+ return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
+ },
+
+ _onReactionsCreated(relationType, eventType) {
+ if (relationType !== "m.annotation" || eventType !== "m.reaction") {
+ return;
+ }
+ this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
+ this.setState({
+ reactions: this.getReactions(),
+ });
+ },
+
render: function() {
const MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
const SenderProfile = sdk.getComponent('messages.SenderProfile');
@@ -587,6 +619,7 @@ module.exports = withMatrixClient(React.createClass({
const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
const actionBar =
: null;
- let reactions;
+ let reactionsRow;
if (SettingsStore.isFeatureEnabled("feature_reactions")) {
const ReactionsRow = sdk.getComponent('messages.ReactionsRow');
- reactions = ;
}
@@ -750,7 +784,7 @@ module.exports = withMatrixClient(React.createClass({
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} />
{ keyRequestInfo }
- { reactions }
+ { reactionsRow }
{ actionBar }
{
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 6d91d999da..31ac646926 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -300,7 +300,7 @@
"Show recent room avatars above the room list": "Show recent room avatars above the room list",
"Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)",
"Render simple counters in room header": "Render simple counters in room header",
- "React to messages with emoji": "React to messages with emoji",
+ "React to messages with emoji (refresh to apply changes)": "React to messages with emoji (refresh to apply changes)",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
"Use compact timeline layout": "Use compact timeline layout",
"Show a placeholder for removed messages": "Show a placeholder for removed messages",
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index 4be1b67227..1c3ca4fd0f 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -120,7 +120,7 @@ export const SETTINGS = {
},
"feature_reactions": {
isFeature: true,
- displayName: _td("React to messages with emoji"),
+ displayName: _td("React to messages with emoji (refresh to apply changes)"),
supportedLevels: LEVELS_FEATURE,
default: false,
},