Merge branch 'develop' into hs/custom-notif-sounds

This commit is contained in:
Will Hunt 2019-05-14 21:07:03 +01:00 committed by GitHub
commit 277c4ab809
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 413 additions and 177 deletions

View file

@ -1,3 +1,12 @@
Changes in [1.1.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.1) (2019-05-14)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.0...v1.1.1)
* Fix registration with email
[\#2970](https://github.com/matrix-org/matrix-react-sdk/pull/2970)
* Fix bug where email was not required where it shouldn't have been
[\#2969](https://github.com/matrix-org/matrix-react-sdk/pull/2969)
Changes in [1.1.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.0) (2019-05-07) Changes in [1.1.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.1.0) (2019-05-07)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.0-rc.1...v1.1.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.1.0-rc.1...v1.1.0)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "1.1.0", "version": "1.1.1",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {

View file

@ -23,7 +23,7 @@ limitations under the License.
line-height: 24px; line-height: 24px;
border-radius: 4px; border-radius: 4px;
background: $message-action-bar-bg-color; background: $message-action-bar-bg-color;
top: -13px; top: -18px;
right: 8px; right: 8px;
user-select: none; user-select: none;

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

@ -126,6 +126,15 @@ export function getStoredSessionOwner() {
return hsUrl && userId && accessToken ? userId : null; return hsUrl && userId && accessToken ? userId : null;
} }
/**
* @returns {bool} True if the stored session is for a guest user or false if it is
* for a real user. If there is no stored session, return null.
*/
export function getStoredSessionIsGuest() {
const sessVars = _getLocalStorageSessionVars();
return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null;
}
/** /**
* @param {Object} queryParams string->string map of the * @param {Object} queryParams string->string map of the
* query-parameters extracted from the real query-string of the starting * query-parameters extracted from the real query-string of the starting
@ -235,7 +244,15 @@ function _getLocalStorageSessionVars() {
const userId = localStorage.getItem("mx_user_id"); const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id"); const deviceId = localStorage.getItem("mx_device_id");
return {hsUrl, isUrl, accessToken, userId, deviceId}; let isGuest;
if (localStorage.getItem("mx_is_guest") !== null) {
isGuest = localStorage.getItem("mx_is_guest") === "true";
} else {
// legacy key name
isGuest = localStorage.getItem("matrix-is-guest") === "true";
}
return {hsUrl, isUrl, accessToken, userId, deviceId, isGuest};
} }
// returns a promise which resolves to true if a session is found in // returns a promise which resolves to true if a session is found in
@ -253,15 +270,7 @@ async function _restoreFromLocalStorage() {
return false; return false;
} }
const {hsUrl, isUrl, accessToken, userId, deviceId} = _getLocalStorageSessionVars(); const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = _getLocalStorageSessionVars();
let isGuest;
if (localStorage.getItem("mx_is_guest") !== null) {
isGuest = localStorage.getItem("mx_is_guest") === "true";
} else {
// legacy key name
isGuest = localStorage.getItem("matrix-is-guest") === "true";
}
if (accessToken && userId && hsUrl) { if (accessToken && userId && hsUrl) {
console.log(`Restoring session for ${userId}`); console.log(`Restoring session for ${userId}`);

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

@ -1710,14 +1710,15 @@ export default React.createClass({
// returns a promise which resolves to the new MatrixClient // returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials) { onRegistered: function(credentials) {
// XXX: This should be in state or ideally store(s) because we risk not
// rendering the most up-to-date view of state otherwise.
this._is_registered = true;
if (this.state.register_session_id) { if (this.state.register_session_id) {
// The user came in through an email validation link. To avoid overwriting // The user came in through an email validation link. To avoid overwriting
// their session, check to make sure the session isn't someone else. // their session, check to make sure the session isn't someone else, and
// isn't a guest user since we'll usually have set a guest user session before
// starting the registration process. This isn't perfect since it's possible
// the user had a separate guest session they didn't actually mean to replace.
const sessionOwner = Lifecycle.getStoredSessionOwner(); const sessionOwner = Lifecycle.getStoredSessionOwner();
if (sessionOwner && sessionOwner !== credentials.userId) { const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
if (sessionOwner && !sessionIsGuest && sessionOwner !== credentials.userId) {
console.log( console.log(
`Found a session for ${sessionOwner} but ${credentials.userId} is trying to verify their ` + `Found a session for ${sessionOwner} but ${credentials.userId} is trying to verify their ` +
`email address. Restoring the session for ${sessionOwner} with warning.`, `email address. Restoring the session for ${sessionOwner} with warning.`,
@ -1748,6 +1749,9 @@ export default React.createClass({
return MatrixClientPeg.get(); return MatrixClientPeg.get();
} }
} }
// XXX: This should be in state or ideally store(s) because we risk not
// rendering the most up-to-date view of state otherwise.
this._is_registered = true;
return Lifecycle.setLoggedIn(credentials); return Lifecycle.setLoggedIn(credentials);
}, },

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");
@ -1194,7 +1198,7 @@ const TimelinePanel = React.createClass({
if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) { if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) {
return ( return (
<div className={this.props.className + " mx_RoomView_messageListWrapper"}> <div className={this.props.className + " mx_RoomView_messageListWrapper"}>
<div className="mx_RoomView_empty">{ this.props.empty }</div> <div className="mx_RoomView_empty">{this.props.empty}</div>
</div> </div>
); );
} }
@ -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

@ -1,7 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd Copyright 2018, 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -76,6 +76,7 @@ module.exports = React.createClass({
password: "", password: "",
passwordConfirm: "", passwordConfirm: "",
passwordComplexity: null, passwordComplexity: null,
passwordSafe: false,
}; };
}, },
@ -150,7 +151,11 @@ module.exports = React.createClass({
if (!field) { if (!field) {
continue; continue;
} }
field.validate({ allowEmpty: false }); // We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
} }
// Validation and state updates are async, so we need to wait for them to complete // Validation and state updates are async, so we need to wait for them to complete
@ -270,12 +275,23 @@ module.exports = React.createClass({
} }
const { scorePassword } = await import('../../../utils/PasswordScorer'); const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value); const complexity = scorePassword(value);
const safe = complexity.score >= PASSWORD_MIN_SCORE;
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
this.setState({ this.setState({
passwordComplexity: complexity, passwordComplexity: complexity,
passwordSafe: safe,
}); });
return complexity.score >= PASSWORD_MIN_SCORE; return allowUnsafe || safe;
},
valid: function() {
// Unsafe passwords that are valid are only possible through a
// configuration flag. We'll print some helper text to signal
// to the user that their password is allowed, but unsafe.
if (!this.state.passwordSafe) {
return _t("Password is allowed, but unsafe");
}
return _t("Nice, strong password!");
}, },
valid: () => _t("Nice, strong password!"),
invalid: function() { invalid: function() {
const complexity = this.state.passwordComplexity; const complexity = this.state.passwordComplexity;
if (!complexity) { if (!complexity) {

View file

@ -197,12 +197,18 @@ export default class MImageBody extends React.Component {
// synapse only supports 800x600 thumbnails for now though, // synapse only supports 800x600 thumbnails for now though,
// so we'll need to download the original image for this to work // so we'll need to download the original image for this to work
// well for now. First, let's try a few cases that let us avoid // well for now. First, let's try a few cases that let us avoid
// downloading the original: // downloading the original, including:
if (pixelRatio === 1.0 || // - When displaying a GIF, we always want to thumbnail so that we can
(!content.info || !content.info.w || // properly respect the user's GIF autoplay setting (which relies on
!content.info.h || !content.info.size)) { // thumbnailing to produce the static preview image)
// always thumbnail. it may look a bit worse, but it'll save bandwidth. // - On a low DPI device, always thumbnail to save bandwidth
// which is probably desirable on a lo-dpi device anyway. // - If there's no sizing info in the event, default to thumbnail
const info = content.info;
if (
this._isGif() ||
pixelRatio === 1.0 ||
(!info || !info.w || !info.h || !info.size)
) {
return this.context.matrixClient.mxcUrlToHttp(content.url, thumbWidth, thumbHeight); return this.context.matrixClient.mxcUrlToHttp(content.url, thumbWidth, thumbHeight);
} else { } else {
// we should only request thumbnails if the image is bigger than 800x600 // we should only request thumbnails if the image is bigger than 800x600
@ -215,10 +221,10 @@ export default class MImageBody extends React.Component {
// timeline (e.g. >1MB). // timeline (e.g. >1MB).
const isLargerThanThumbnail = ( const isLargerThanThumbnail = (
content.info.w > thumbWidth || info.w > thumbWidth ||
content.info.h > thumbHeight info.h > thumbHeight
); );
const isLargeFileSize = content.info.size > 1*1024*1024; const isLargeFileSize = info.size > 1*1024*1024;
if (isLargeFileSize && isLargerThanThumbnail) { if (isLargeFileSize && isLargerThanThumbnail) {
// image is too large physically and bytewise to clutter our timeline so // image is too large physically and bytewise to clutter our timeline so

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

@ -49,7 +49,7 @@ module.exports = React.createClass({
return <div />; // We should never have been instaniated in this case return <div />; // We should never have been instaniated in this case
} }
const prevRoom = MatrixClientPeg.get().getRoom(predecessor['room_id']); const prevRoom = MatrixClientPeg.get().getRoom(predecessor['room_id']);
const permalinkCreator = new RoomPermalinkCreator(prevRoom); const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor['room_id']);
permalinkCreator.load(); permalinkCreator.load();
const predecessorPermalink = permalinkCreator.forEvent(predecessor['event_id']); const predecessorPermalink = permalinkCreator.forEvent(predecessor['event_id']);
return <div className="mx_CreateEvent"> return <div className="mx_CreateEvent">

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

@ -302,6 +302,7 @@
"Render simple counters in room header": "Render simple counters in room header", "Render simple counters in room header": "Render simple counters in room header",
"Custom Notification Sounds": "Custom Notification Sounds", "Custom Notification Sounds": "Custom Notification Sounds",
"React to messages with emoji": "React to messages with emoji", "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",
@ -1336,6 +1337,7 @@
"Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)", "Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)",
"Doesn't look like a valid email address": "Doesn't look like a valid email address", "Doesn't look like a valid email address": "Doesn't look like a valid email address",
"Enter password": "Enter password", "Enter password": "Enter password",
"Password is allowed, but unsafe": "Password is allowed, but unsafe",
"Nice, strong password!": "Nice, strong password!", "Nice, strong password!": "Nice, strong password!",
"Keep going...": "Keep going...", "Keep going...": "Keep going...",
"Passwords don't match": "Passwords don't match", "Passwords don't match": "Passwords don't match",

View file

@ -70,8 +70,12 @@ const MAX_SERVER_CANDIDATES = 3;
// the list and magically have the link work. // the list and magically have the link work.
export class RoomPermalinkCreator { export class RoomPermalinkCreator {
constructor(room) { // We support being given a roomId as a fallback in the event the `room` object
// doesn't exist or is not healthy for us to rely on. For example, loading a
// permalink to a room which the MatrixClient doesn't know about.
constructor(room, roomId=null) {
this._room = room; this._room = room;
this._roomId = room ? room.roomId : roomId;
this._highestPlUserId = null; this._highestPlUserId = null;
this._populationMap = null; this._populationMap = null;
this._bannedHostsRegexps = null; this._bannedHostsRegexps = null;
@ -79,6 +83,10 @@ export class RoomPermalinkCreator {
this._serverCandidates = null; this._serverCandidates = null;
this._started = false; this._started = false;
if (!this._roomId) {
throw new Error("Failed to resolve a roomId for the permalink creator to use");
}
this.onMembership = this.onMembership.bind(this); this.onMembership = this.onMembership.bind(this);
this.onRoomState = this.onRoomState.bind(this); this.onRoomState = this.onRoomState.bind(this);
} }
@ -116,13 +124,13 @@ export class RoomPermalinkCreator {
} }
forEvent(eventId) { forEvent(eventId) {
const roomId = this._room.roomId; const roomId = this._roomId;
const permalinkBase = `${baseUrl}/#/${roomId}/${eventId}`; const permalinkBase = `${baseUrl}/#/${roomId}/${eventId}`;
return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`; return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`;
} }
forRoom() { forRoom() {
const roomId = this._room.roomId; const roomId = this._roomId;
const permalinkBase = `${baseUrl}/#/${roomId}`; const permalinkBase = `${baseUrl}/#/${roomId}`;
return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`; return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`;
} }
@ -246,7 +254,6 @@ export class RoomPermalinkCreator {
} }
} }
export function makeUserPermalink(userId) { export function makeUserPermalink(userId) {
return `${baseUrl}/#/${userId}`; return `${baseUrl}/#/${userId}`;
} }

View file

@ -125,7 +125,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,
}, },

View file

@ -74,7 +74,7 @@ describe('matrix-to', function() {
}); });
it('should pick no candidate servers when the room has no members', function() { it('should pick no candidate servers when the room has no members', function() {
const room = mockRoom(null, []); const room = mockRoom("!fake:example.org", []);
const creator = new RoomPermalinkCreator(room); const creator = new RoomPermalinkCreator(room);
creator.load(); creator.load();
expect(creator._serverCandidates).toBeTruthy(); expect(creator._serverCandidates).toBeTruthy();
@ -82,7 +82,7 @@ describe('matrix-to', function() {
}); });
it('should pick a candidate server for the highest power level user in the room', function() { it('should pick a candidate server for the highest power level user in the room', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:pl_50", userId: "@alice:pl_50",
powerLevel: 50, powerLevel: 50,
@ -109,7 +109,7 @@ describe('matrix-to', function() {
userId: "@alice:pl_95", userId: "@alice:pl_95",
powerLevel: 95, powerLevel: 95,
}; };
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:pl_50", userId: "@alice:pl_50",
powerLevel: 50, powerLevel: 50,
@ -132,7 +132,7 @@ describe('matrix-to', function() {
}); });
it('should pick candidate servers based on user population', function() { it('should pick candidate servers based on user population', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:first", userId: "@alice:first",
powerLevel: 0, powerLevel: 0,
@ -168,7 +168,7 @@ describe('matrix-to', function() {
}); });
it('should pick prefer candidate servers with higher power levels', function() { it('should pick prefer candidate servers with higher power levels', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:first", userId: "@alice:first",
powerLevel: 100, powerLevel: 100,
@ -195,7 +195,7 @@ describe('matrix-to', function() {
}); });
it('should pick a maximum of 3 candidate servers', function() { it('should pick a maximum of 3 candidate servers', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:alpha", userId: "@alice:alpha",
powerLevel: 100, powerLevel: 100,
@ -224,7 +224,7 @@ describe('matrix-to', function() {
}); });
it('should not consider IPv4 hosts', function() { it('should not consider IPv4 hosts', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:127.0.0.1", userId: "@alice:127.0.0.1",
powerLevel: 100, powerLevel: 100,
@ -237,7 +237,7 @@ describe('matrix-to', function() {
}); });
it('should not consider IPv6 hosts', function() { it('should not consider IPv6 hosts', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:[::1]", userId: "@alice:[::1]",
powerLevel: 100, powerLevel: 100,
@ -250,7 +250,7 @@ describe('matrix-to', function() {
}); });
it('should not consider IPv4 hostnames with ports', function() { it('should not consider IPv4 hostnames with ports', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:127.0.0.1:8448", userId: "@alice:127.0.0.1:8448",
powerLevel: 100, powerLevel: 100,
@ -263,7 +263,7 @@ describe('matrix-to', function() {
}); });
it('should not consider IPv6 hostnames with ports', function() { it('should not consider IPv6 hostnames with ports', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:[::1]:8448", userId: "@alice:[::1]:8448",
powerLevel: 100, powerLevel: 100,
@ -276,7 +276,7 @@ describe('matrix-to', function() {
}); });
it('should work with hostnames with ports', function() { it('should work with hostnames with ports', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:example.org:8448", userId: "@alice:example.org:8448",
powerLevel: 100, powerLevel: 100,
@ -291,7 +291,7 @@ describe('matrix-to', function() {
}); });
it('should not consider servers explicitly denied by ACLs', function() { it('should not consider servers explicitly denied by ACLs', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:evilcorp.com", userId: "@alice:evilcorp.com",
powerLevel: 100, powerLevel: 100,
@ -311,7 +311,7 @@ describe('matrix-to', function() {
}); });
it('should not consider servers not allowed by ACLs', function() { it('should not consider servers not allowed by ACLs', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:evilcorp.com", userId: "@alice:evilcorp.com",
powerLevel: 100, powerLevel: 100,
@ -331,7 +331,7 @@ describe('matrix-to', function() {
}); });
it('should consider servers not explicitly banned by ACLs', function() { it('should consider servers not explicitly banned by ACLs', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:evilcorp.com", userId: "@alice:evilcorp.com",
powerLevel: 100, powerLevel: 100,
@ -352,7 +352,7 @@ describe('matrix-to', function() {
}); });
it('should consider servers not disallowed by ACLs', function() { it('should consider servers not disallowed by ACLs', function() {
const room = mockRoom(null, [ const room = mockRoom("!fake:example.org", [
{ {
userId: "@alice:evilcorp.com", userId: "@alice:evilcorp.com",
powerLevel: 100, powerLevel: 100,