Merge pull request #2954 from matrix-org/jryans/aggregations

Send and undo reaction events
This commit is contained in:
J. Ryan Stinnett 2019-05-13 15:20:28 +01:00 committed by GitHub
commit f5aa32bc96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 311 additions and 128 deletions

View file

@ -24,6 +24,7 @@ limitations under the License.
border-radius: 10px; border-radius: 10px;
background-color: $reaction-row-button-bg-color; background-color: $reaction-row-button-bg-color;
cursor: pointer; cursor: pointer;
user-select: none;
&:hover { &:hover {
border-color: $reaction-row-button-hover-border-color; border-color: $reaction-row-button-hover-border-color;

View file

@ -175,6 +175,8 @@ class MatrixClientPeg {
} }
_createClient(creds: MatrixClientCreds) { _createClient(creds: MatrixClientCreds) {
const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions");
const opts = { const opts = {
baseUrl: creds.homeserverUrl, baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl, idBaseUrl: creds.identityServerUrl,
@ -183,7 +185,8 @@ class MatrixClientPeg {
deviceId: creds.deviceId, deviceId: creds.deviceId,
timelineSupport: true, timelineSupport: true,
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
verificationMethods: [verificationMethods.SAS] verificationMethods: [verificationMethods.SAS],
unstableClientRelationAggregation: aggregateRelations,
}; };
this.matrixClient = createMatrixClient(opts); this.matrixClient = createMatrixClient(opts);

View file

@ -92,6 +92,9 @@ module.exports = React.createClass({
// show timestamps always // show timestamps always
alwaysShowTimestamps: PropTypes.bool, alwaysShowTimestamps: PropTypes.bool,
// helper function to access relations for an event
getRelationsForEvent: PropTypes.func,
}, },
componentWillMount: function() { componentWillMount: function() {
@ -513,8 +516,10 @@ module.exports = React.createClass({
ret.push( ret.push(
<li key={eventId} <li key={eventId}
ref={this._collectEventNode.bind(this, eventId)} ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}> data-scroll-tokens={scrollToken}
<EventTile mxEvent={mxEv} continuation={continuation} >
<EventTile mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()} isRedacted={mxEv.isRedacted()}
onHeightChanged={this._onHeightChanged} onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts} readReceipts={readReceipts}
@ -525,7 +530,10 @@ module.exports = React.createClass({
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour} isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
last={last} isSelectedEvent={highlight} /> last={last}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
/>
</li>, </li>,
); );

View file

@ -1168,6 +1168,10 @@ const TimelinePanel = React.createClass({
}); });
}, },
getRelationsForEvent(...args) {
return this.props.timelineSet.getRelationsForEvent(...args);
},
render: function() { render: function() {
const MessagePanel = sdk.getComponent("structures.MessagePanel"); const MessagePanel = sdk.getComponent("structures.MessagePanel");
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
@ -1239,6 +1243,7 @@ const TimelinePanel = React.createClass({
className={this.props.className} className={this.props.className}
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}
getRelationsForEvent={this.getRelationsForEvent}
/> />
); );
}, },

View file

@ -28,6 +28,8 @@ import { isContentActionable } from '../../../utils/EventUtils';
export default class MessageActionBar extends React.PureComponent { export default class MessageActionBar extends React.PureComponent {
static propTypes = { static propTypes = {
mxEvent: PropTypes.object.isRequired, mxEvent: PropTypes.object.isRequired,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: PropTypes.object,
permalinkCreator: PropTypes.object, permalinkCreator: PropTypes.object,
getTile: PropTypes.func, getTile: PropTypes.func,
getReplyThread: PropTypes.func, getReplyThread: PropTypes.func,
@ -100,19 +102,11 @@ export default class MessageActionBar extends React.PureComponent {
} }
const ReactionDimension = sdk.getComponent('messages.ReactionDimension'); const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
const options = [
{
key: "agree",
content: "👍",
},
{
key: "disagree",
content: "👎",
},
];
return <ReactionDimension return <ReactionDimension
title={_t("Agree or Disagree")} title={_t("Agree or Disagree")}
options={options} options={["👍", "👎"]}
reactions={this.props.reactions}
mxEvent={this.props.mxEvent}
/>; />;
} }
@ -122,19 +116,11 @@ export default class MessageActionBar extends React.PureComponent {
} }
const ReactionDimension = sdk.getComponent('messages.ReactionDimension'); const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
const options = [
{
key: "like",
content: "🙂",
},
{
key: "dislike",
content: "😔",
},
];
return <ReactionDimension return <ReactionDimension
title={_t("Like or Dislike")} title={_t("Like or Dislike")}
options={options} options={["🙂", "😔"]}
reactions={this.props.reactions}
mxEvent={this.props.mxEvent}
/>; />;
} }

View file

@ -18,49 +18,141 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default class ReactionDimension extends React.PureComponent { export default class ReactionDimension extends React.PureComponent {
static propTypes = { static propTypes = {
mxEvent: PropTypes.object.isRequired,
// Array of strings containing the emoji for each option
options: PropTypes.array.isRequired, options: PropTypes.array.isRequired,
title: PropTypes.string, title: PropTypes.string,
// The Relations model from the JS SDK for reactions
reactions: PropTypes.object,
}; };
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = this.getSelection();
selected: null,
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) => { onOptionClick = (ev) => {
const { key } = ev.target.dataset; const { key } = ev.target.dataset;
this.toggleDimensionValue(key); this.toggleDimension(key);
} }
toggleDimensionValue(value) { toggleDimension(key) {
const state = this.state.selected; const { selectedOption, selectedReactionEvent } = this.state;
const newState = state !== value ? value : null; const newSelectedOption = selectedOption !== key ? key : null;
this.setState({ 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() { render() {
const { selected } = this.state; const { selectedOption } = this.state;
const { options } = this.props; const { options } = this.props;
const items = options.map(option => { const items = options.map(option => {
const disabled = selected && selected !== option.key; const disabled = selectedOption && selectedOption !== option;
const classes = classNames({ const classes = classNames({
mx_ReactionDimension_disabled: disabled, mx_ReactionDimension_disabled: disabled,
}); });
return <span key={option.key} return <span key={option}
data-key={option.key} data-key={option}
className={classes} className={classes}
onClick={this.onOptionClick} onClick={this.onOptionClick}
> >
{option.content} {option}
</span>; </span>;
}); });

View file

@ -19,42 +19,96 @@ import PropTypes from 'prop-types';
import sdk from '../../../index'; import sdk from '../../../index';
import { isContentActionable } from '../../../utils/EventUtils'; import { isContentActionable } from '../../../utils/EventUtils';
import MatrixClientPeg from '../../../MatrixClientPeg';
// 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 { export default class ReactionsRow extends React.PureComponent {
static propTypes = { static propTypes = {
// The event we're displaying reactions for // The event we're displaying reactions for
mxEvent: PropTypes.object.isRequired, 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() { render() {
const { mxEvent } = this.props; const { mxEvent, reactions } = this.props;
const { myReactions } = this.state;
if (!isContentActionable(mxEvent)) { if (!reactions || !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; return null;
} }
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton'); 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 <ReactionsRowButton return <ReactionsRowButton
key={content} key={content}
content={content} content={content}
count={count} count={count}
mxEvent={mxEvent}
myReactionEvent={myReactionEvent}
/>; />;
}); });

View file

@ -18,48 +18,48 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default class ReactionsRowButton extends React.PureComponent { export default class ReactionsRowButton extends React.PureComponent {
static propTypes = { static propTypes = {
// The event we're displaying reactions for
mxEvent: PropTypes.object.isRequired,
content: PropTypes.string.isRequired, content: PropTypes.string.isRequired,
count: PropTypes.number.isRequired, count: PropTypes.number.isRequired,
} // A possible Matrix event if the current user has voted for this type
myReactionEvent: PropTypes.object,
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) => { onClick = (ev) => {
const state = this.state.selected; const { mxEvent, myReactionEvent, content } = this.props;
this.setState({ if (myReactionEvent) {
selected: !state, 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,
},
}); });
// TODO: Send the reaction event }
}; };
render() { render() {
const { content, count } = this.props; const { content, count, myReactionEvent } = this.props;
const { selected } = this.state;
const classes = classNames({ const classes = classNames({
mx_ReactionsRowButton: true, mx_ReactionsRowButton: true,
mx_ReactionsRowButton_selected: selected, mx_ReactionsRowButton_selected: !!myReactionEvent,
}); });
let adjustedCount = count;
if (selected) {
adjustedCount++;
}
return <span className={classes} return <span className={classes}
onClick={this.onClick} onClick={this.onClick}
> >
{content} {adjustedCount} {content} {count}
</span>; </span>;
} }
} }

View file

@ -159,6 +159,9 @@ module.exports = withMatrixClient(React.createClass({
// show twelve hour timestamps // show twelve hour timestamps
isTwelveHour: PropTypes.bool, isTwelveHour: PropTypes.bool,
// helper function to access relations for an event
getRelationsForEvent: PropTypes.func,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -179,6 +182,8 @@ module.exports = withMatrixClient(React.createClass({
verified: null, verified: null,
// Whether onRequestKeysClick has been called since mounting. // Whether onRequestKeysClick has been called since mounting.
previouslyRequestedKeys: false, 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() { componentDidMount: function() {
this._suppressReadReceiptAnimation = false; this._suppressReadReceiptAnimation = false;
this.props.matrixClient.on("deviceVerificationChanged", const client = this.props.matrixClient;
this.onDeviceVerificationChanged); client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted); this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
if (SettingsStore.isFeatureEnabled("feature_reactions")) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
}
}, },
componentWillReceiveProps: function(nextProps) { componentWillReceiveProps: function(nextProps) {
@ -215,6 +223,9 @@ module.exports = withMatrixClient(React.createClass({
const client = this.props.matrixClient; const client = this.props.matrixClient;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted); 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. /** called when the event is decrypted after we show it.
@ -472,6 +483,27 @@ module.exports = withMatrixClient(React.createClass({
return this.refs.replyThread; 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() { render: function() {
const MessageTimestamp = sdk.getComponent('messages.MessageTimestamp'); const MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
const SenderProfile = sdk.getComponent('messages.SenderProfile'); const SenderProfile = sdk.getComponent('messages.SenderProfile');
@ -587,6 +619,7 @@ module.exports = withMatrixClient(React.createClass({
const MessageActionBar = sdk.getComponent('messages.MessageActionBar'); const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
const actionBar = <MessageActionBar const actionBar = <MessageActionBar
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
getTile={this.getTile} getTile={this.getTile}
getReplyThread={this.getReplyThread} getReplyThread={this.getReplyThread}
@ -630,11 +663,12 @@ module.exports = withMatrixClient(React.createClass({
<ToolTipButton helpText={keyRequestHelpText} /> <ToolTipButton helpText={keyRequestHelpText} />
</div> : null; </div> : null;
let reactions; let reactionsRow;
if (SettingsStore.isFeatureEnabled("feature_reactions")) { if (SettingsStore.isFeatureEnabled("feature_reactions")) {
const ReactionsRow = sdk.getComponent('messages.ReactionsRow'); const ReactionsRow = sdk.getComponent('messages.ReactionsRow');
reactions = <ReactionsRow reactionsRow = <ReactionsRow
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
/>; />;
} }
@ -750,7 +784,7 @@ module.exports = withMatrixClient(React.createClass({
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} /> onHeightChanged={this.props.onHeightChanged} />
{ keyRequestInfo } { keyRequestInfo }
{ reactions } { reactionsRow }
{ actionBar } { actionBar }
</div> </div>
{ {

View file

@ -300,7 +300,7 @@
"Show recent room avatars above the room list": "Show recent room avatars above the room list", "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)", "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", "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", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
"Use compact timeline layout": "Use compact timeline layout", "Use compact timeline layout": "Use compact timeline layout",
"Show a placeholder for removed messages": "Show a placeholder for removed messages", "Show a placeholder for removed messages": "Show a placeholder for removed messages",

View file

@ -120,7 +120,7 @@ export const SETTINGS = {
}, },
"feature_reactions": { "feature_reactions": {
isFeature: true, isFeature: true,
displayName: _td("React to messages with emoji"), displayName: _td("React to messages with emoji (refresh to apply changes)"),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },