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,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket 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");
you may not use this file except in compliance with the License.
@ -76,6 +76,7 @@ module.exports = React.createClass({
password: "",
passwordConfirm: "",
passwordComplexity: null,
passwordSafe: false,
};
},
@ -150,7 +151,11 @@ module.exports = React.createClass({
if (!field) {
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
@ -270,12 +275,23 @@ module.exports = React.createClass({
}
const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value);
const safe = complexity.score >= PASSWORD_MIN_SCORE;
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
this.setState({
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() {
const complexity = this.state.passwordComplexity;
if (!complexity) {

View file

@ -197,12 +197,18 @@ export default class MImageBody extends React.Component {
// synapse only supports 800x600 thumbnails for now though,
// 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
// downloading the original:
if (pixelRatio === 1.0 ||
(!content.info || !content.info.w ||
!content.info.h || !content.info.size)) {
// always thumbnail. it may look a bit worse, but it'll save bandwidth.
// which is probably desirable on a lo-dpi device anyway.
// downloading the original, including:
// - When displaying a GIF, we always want to thumbnail so that we can
// properly respect the user's GIF autoplay setting (which relies on
// thumbnailing to produce the static preview image)
// - On a low DPI device, always thumbnail to save bandwidth
// - 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);
} else {
// 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).
const isLargerThanThumbnail = (
content.info.w > thumbWidth ||
content.info.h > thumbHeight
info.w > thumbWidth ||
info.h > thumbHeight
);
const isLargeFileSize = content.info.size > 1*1024*1024;
const isLargeFileSize = info.size > 1*1024*1024;
if (isLargeFileSize && isLargerThanThumbnail) {
// 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 {
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 <ReactionDimension
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 options = [
{
key: "like",
content: "🙂",
},
{
key: "dislike",
content: "😔",
},
];
return <ReactionDimension
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 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 <span key={option.key}
data-key={option.key}
return <span key={option}
data-key={option}
className={classes}
onClick={this.onOptionClick}
>
{option.content}
{option}
</span>;
});

View file

@ -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 <ReactionsRowButton
key={content}
content={content}
count={count}
mxEvent={mxEvent}
myReactionEvent={myReactionEvent}
/>;
});

View file

@ -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 <span className={classes}
onClick={this.onClick}
>
{content} {adjustedCount}
{content} {count}
</span>;
}
}

View file

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

View file

@ -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 = <MessageActionBar
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
getTile={this.getTile}
getReplyThread={this.getReplyThread}
@ -630,11 +663,12 @@ module.exports = withMatrixClient(React.createClass({
<ToolTipButton helpText={keyRequestHelpText} />
</div> : null;
let reactions;
let reactionsRow;
if (SettingsStore.isFeatureEnabled("feature_reactions")) {
const ReactionsRow = sdk.getComponent('messages.ReactionsRow');
reactions = <ReactionsRow
reactionsRow = <ReactionsRow
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
/>;
}
@ -750,7 +784,7 @@ module.exports = withMatrixClient(React.createClass({
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} />
{ keyRequestInfo }
{ reactions }
{ reactionsRow }
{ actionBar }
</div>
{