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

This commit is contained in:
Will Hunt 2019-05-07 20:04:29 +01:00 committed by GitHub
commit efc93abb50
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2034 additions and 1165 deletions

View file

@ -50,7 +50,6 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import TimelineExplosionDialog from "../views/dialogs/TimelineExplosionDialog";
const AutoDiscovery = Matrix.AutoDiscovery;
@ -250,17 +249,6 @@ export default React.createClass({
return this.state.defaultIsUrl || "https://vector.im";
},
/**
* Whether to skip the server details phase of registration and start at the
* actual form.
* @return {boolean}
* If there was a configured default HS or default server name, skip the
* the server details.
*/
skipServerDetailsForRegistration() {
return !!this.state.defaultHsUrl;
},
componentWillMount: function() {
SdkConfig.put(this.props.config);
@ -1307,17 +1295,6 @@ export default React.createClass({
return self._loggedInView.child.canResetTimelineInRoom(roomId);
});
cli.on('sync.unexpectedError', function(err) {
if (err.message && err.message.includes("live timeline ") && err.message.includes(" is no longer live ")) {
console.error("Caught timeline explosion - trying to ask user for more information");
if (Modal.hasDialogs()) {
console.warn("User has another dialog open - skipping prompt");
return;
}
Modal.createTrackedDialog('Timeline exploded', '', TimelineExplosionDialog, {});
}
});
cli.on('sync', function(state, prevState, data) {
// LifecycleStore and others cannot directly subscribe to matrix client for
// events because flux only allows store state changes during flux dispatches.
@ -1985,7 +1962,6 @@ export default React.createClass({
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
skipServerDetails={this.skipServerDetailsForRegistration()}
brand={this.props.config.brand}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}

View file

@ -44,11 +44,10 @@ const READ_RECEIPT_INTERVAL_MS = 500;
const DEBUG = false;
let debuglog = function() {};
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function() {};
debuglog = console.log.bind(console);
}
/*
@ -56,7 +55,7 @@ if (DEBUG) {
*
* Also responsible for handling and sending read receipts.
*/
var TimelinePanel = React.createClass({
const TimelinePanel = React.createClass({
displayName: 'TimelinePanel',
propTypes: {
@ -445,6 +444,7 @@ var TimelinePanel = React.createClass({
const updatedState = {events: events};
let callRMUpdated;
if (this.props.manageReadMarkers) {
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
@ -456,7 +456,7 @@ var TimelinePanel = React.createClass({
//
const myUserId = MatrixClientPeg.get().credentials.userId;
const sender = ev.sender ? ev.sender.userId : null;
var callRMUpdated = false;
callRMUpdated = false;
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true;
} else if (lastEv && this.getReadMarkerPosition() === 0) {
@ -566,7 +566,7 @@ var TimelinePanel = React.createClass({
UserActivity.sharedInstance().timeWhileActiveRecently(this._readMarkerActivityTimer);
try {
await this._readMarkerActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
} catch (e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.updateReadMarker();
}
@ -578,7 +578,7 @@ var TimelinePanel = React.createClass({
UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer);
try {
await this._readReceiptActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
} catch (e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.sendReadReceipt();
}
@ -732,7 +732,8 @@ var TimelinePanel = React.createClass({
const events = this._timelineWindow.getEvents();
// first find where the current RM is
for (var i = 0; i < events.length; i++) {
let i;
for (i = 0; i < events.length; i++) {
if (events[i].getId() == this.state.readMarkerEventId) {
break;
}
@ -744,7 +745,7 @@ var TimelinePanel = React.createClass({
// now think about advancing it
const myUserId = MatrixClientPeg.get().credentials.userId;
for (i++; i < events.length; i++) {
var ev = events[i];
const ev = events[i];
if (!ev.sender || ev.sender.userId != myUserId) {
break;
}
@ -752,7 +753,7 @@ var TimelinePanel = React.createClass({
// i is now the first unread message which we didn't send ourselves.
i--;
var ev = events[i];
const ev = events[i];
this._setReadMarker(ev.getId(), ev.getTs());
},
@ -882,7 +883,7 @@ var TimelinePanel = React.createClass({
return ret;
},
/**
/*
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
@ -975,11 +976,10 @@ var TimelinePanel = React.createClass({
};
const onError = (error) => {
this.setState({timelineLoading: false});
this.setState({ timelineLoading: false });
console.error(
`Error loading timeline panel at ${eventId}: ${error}`,
);
const msg = error.message ? error.message : JSON.stringify(error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
let onFinished;
@ -997,9 +997,18 @@ var TimelinePanel = React.createClass({
});
};
}
const message = (error.errcode == 'M_FORBIDDEN')
? _t("Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.")
: _t("Tried to load a specific point in this room's timeline, but was unable to find it.");
let message;
if (error.errcode == 'M_FORBIDDEN') {
message = _t(
"Tried to load a specific point in this room's timeline, but you " +
"do not have permission to view the message in question.",
);
} else {
message = _t(
"Tried to load a specific point in this room's timeline, but was " +
"unable to find it.",
);
}
Modal.createTrackedDialog('Failed to load timeline position', '', ErrorDialog, {
title: _t("Failed to load timeline position"),
description: message,
@ -1104,12 +1113,13 @@ var TimelinePanel = React.createClass({
},
/**
* get the id of the event corresponding to our user's latest read-receipt.
* Get the id of the event corresponding to our user's latest read-receipt.
*
* @param {Boolean} ignoreSynthesized If true, return only receipts that
* have been sent by the server, not
* implicit ones generated by the JS
* SDK.
* @return {String} the event ID
*/
_getCurrentReadReceipt: function(ignoreSynthesized) {
const client = MatrixClientPeg.get();

View file

@ -28,8 +28,6 @@ import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector';
const MIN_PASSWORD_LENGTH = 6;
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
@ -60,7 +58,6 @@ module.exports = React.createClass({
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
skipServerDetails: PropTypes.bool,
brand: PropTypes.string,
email: PropTypes.string,
// registration shouldn't know or care how login is done.
@ -71,26 +68,6 @@ module.exports = React.createClass({
getInitialState: function() {
const serverType = ServerType.getTypeFromHsUrl(this.props.customHsUrl);
const customURLsAllowed = !SdkConfig.get()['disable_custom_urls'];
let initialPhase = this.getDefaultPhaseForServerType(serverType);
if (
// if we have these two, skip to the good bit
// (they could come in from the URL params in a
// registration email link)
(this.props.clientSecret && this.props.sessionId) ||
// if custom URLs aren't allowed, skip to form
!customURLsAllowed ||
// if other logic says to, skip to form
this.props.skipServerDetails
) {
// TODO: It would seem we've now added enough conditions here that the initial
// phase will _always_ be the form. It's tempting to remove the complexity and
// just do that, but we keep tweaking and changing auth, so let's wait until
// things settle a bit.
// Filed https://github.com/vector-im/riot-web/issues/8886 to track this.
initialPhase = PHASE_REGISTRATION;
}
return {
busy: false,
errorText: null,
@ -113,7 +90,7 @@ module.exports = React.createClass({
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
// Phase of the overall registration dialog.
phase: initialPhase,
phase: PHASE_REGISTRATION,
flows: null,
};
},
@ -308,58 +285,6 @@ module.exports = React.createClass({
});
},
onFormValidationChange: function(fieldErrors) {
// `fieldErrors` is an object mapping field IDs to error codes when there is an
// error or `null` for no error, so the values array will be something like:
// `[ null, "RegistrationForm.ERR_PASSWORD_MISSING", null]`
// Find the first non-null error code and show that.
const errCode = Object.values(fieldErrors).find(value => !!value);
if (!errCode) {
this.setState({
errorText: null,
});
return;
}
let errMsg;
switch (errCode) {
case "RegistrationForm.ERR_PASSWORD_MISSING":
errMsg = _t('Missing password.');
break;
case "RegistrationForm.ERR_PASSWORD_MISMATCH":
errMsg = _t('Passwords don\'t match.');
break;
case "RegistrationForm.ERR_PASSWORD_LENGTH":
errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH});
break;
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = _t('This doesn\'t look like a valid email address.');
break;
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = _t('This doesn\'t look like a valid phone number.');
break;
case "RegistrationForm.ERR_MISSING_EMAIL":
errMsg = _t('An email address is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_MISSING_PHONE_NUMBER":
errMsg = _t('A phone number is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = _t("A username can only contain lower case letters, numbers and '=_-./'");
break;
case "RegistrationForm.ERR_USERNAME_BLANK":
errMsg = _t('You need to enter a username.');
break;
default:
console.error("Unknown error code: %s", errCode);
errMsg = _t('An unknown error occurred.');
break;
}
this.setState({
errorText: errMsg,
});
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
@ -534,8 +459,6 @@ module.exports = React.createClass({
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
minPasswordLength={MIN_PASSWORD_LENGTH}
onValidationChange={this.onFormValidationChange}
onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}

View file

@ -25,6 +25,7 @@ import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation';
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number';
@ -32,6 +33,8 @@ const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
/**
* A pure UI component which displays a registration form.
*/
@ -45,8 +48,6 @@ module.exports = React.createClass({
defaultPhoneNumber: PropTypes.string,
defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string,
minPasswordLength: PropTypes.number,
onValidationChange: PropTypes.func,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
@ -59,7 +60,6 @@ module.exports = React.createClass({
getDefaultProps: function() {
return {
minPasswordLength: 6,
onValidationChange: console.error,
};
},
@ -67,7 +67,7 @@ module.exports = React.createClass({
getInitialState: function() {
return {
// Field error codes by field ID
fieldErrors: {},
fieldValid: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
username: "",
@ -75,44 +75,37 @@ module.exports = React.createClass({
phoneNumber: "",
password: "",
passwordConfirm: "",
passwordComplexity: null,
};
},
onSubmit: function(ev) {
onSubmit: async function(ev) {
ev.preventDefault();
// validate everything, in reverse order so
// the error that ends up being displayed
// is the one from the first invalid field.
// It's not super ideal that this just calls
// onValidationChange once for each invalid field.
this.validateField(FIELD_PHONE_NUMBER, ev.type);
this.validateField(FIELD_EMAIL, ev.type);
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
this.validateField(FIELD_PASSWORD, ev.type);
this.validateField(FIELD_USERNAME, ev.type);
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
return;
}
const self = this;
if (this.allFieldsValid()) {
if (this.state.email == '') {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
title: _t("Warning!"),
description:
<div>
{ _t("If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?") }
</div>,
button: _t("Continue"),
onFinished: function(confirmed) {
if (confirmed) {
self._doSubmit(ev);
}
},
});
} else {
self._doSubmit(ev);
}
if (this.state.email == '') {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
title: _t("Warning!"),
description:
<div>
{ _t("If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?") }
</div>,
button: _t("Continue"),
onFinished: function(confirmed) {
if (confirmed) {
self._doSubmit(ev);
}
},
});
} else {
self._doSubmit(ev);
}
},
@ -134,118 +127,81 @@ module.exports = React.createClass({
}
},
async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
FIELD_USERNAME,
FIELD_PASSWORD,
FIELD_PASSWORD_CONFIRM,
FIELD_EMAIL,
FIELD_PHONE_NUMBER,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
if (!invalidField) {
return true;
}
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
return false;
},
/**
* @returns {boolean} true if all fields were valid last time they were validated.
*/
allFieldsValid: function() {
const keys = Object.keys(this.state.fieldErrors);
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (this.state.fieldErrors[keys[i]]) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
},
validateField: function(fieldID, eventType) {
const pwd1 = this.state.password.trim();
const pwd2 = this.state.passwordConfirm.trim();
const allowEmpty = eventType === "blur";
switch (fieldID) {
case FIELD_EMAIL: {
const email = this.state.email;
const emailValid = email === '' || Email.looksValid(email);
if (this._authStepIsRequired('m.login.email.identity') && (!emailValid || email === '')) {
this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_EMAIL");
} else this.markFieldValid(fieldID, emailValid, "RegistrationForm.ERR_EMAIL_INVALID");
break;
findFirstInvalidField(fieldIDs) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
case FIELD_PHONE_NUMBER: {
const phoneNumber = this.state.phoneNumber;
const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber);
if (this._authStepIsRequired('m.login.msisdn') && (!phoneNumberValid || phoneNumber === '')) {
this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_PHONE_NUMBER");
} else this.markFieldValid(fieldID, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID");
break;
}
case FIELD_USERNAME: {
const username = this.state.username;
if (allowEmpty && username === '') {
this.markFieldValid(fieldID, true);
} else if (!SAFE_LOCALPART_REGEX.test(username)) {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_USERNAME_INVALID",
);
} else if (username == '') {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_USERNAME_BLANK",
);
} else {
this.markFieldValid(fieldID, true);
}
break;
}
case FIELD_PASSWORD:
if (allowEmpty && pwd1 === "") {
this.markFieldValid(fieldID, true);
} else if (pwd1 == '') {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_PASSWORD_MISSING",
);
} else if (pwd1.length < this.props.minPasswordLength) {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_PASSWORD_LENGTH",
);
} else {
this.markFieldValid(fieldID, true);
}
break;
case FIELD_PASSWORD_CONFIRM:
if (allowEmpty && pwd2 === "") {
this.markFieldValid(fieldID, true);
} else {
this.markFieldValid(
fieldID, pwd1 == pwd2,
"RegistrationForm.ERR_PASSWORD_MISMATCH",
);
}
break;
}
return null;
},
markFieldValid: function(fieldID, valid, errorCode) {
const { fieldErrors } = this.state;
if (valid) {
fieldErrors[fieldID] = null;
} else {
fieldErrors[fieldID] = errorCode;
}
markFieldValid: function(fieldID, valid) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldErrors,
fieldValid,
});
this.props.onValidationChange(fieldErrors);
},
_classForField: function(fieldID, ...baseClasses) {
let cls = baseClasses.join(' ');
if (this.state.fieldErrors[fieldID]) {
if (cls) cls += ' ';
cls += 'error';
}
return cls;
},
onEmailBlur(ev) {
this.validateField(FIELD_EMAIL, ev.type);
},
onEmailChange(ev) {
@ -254,26 +210,113 @@ module.exports = React.createClass({
});
},
onPasswordBlur(ev) {
this.validateField(FIELD_PASSWORD, ev.type);
async onEmailValidate(fieldState) {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(FIELD_EMAIL, result.valid);
return result;
},
validateEmailRules: withValidation({
description: () => _t("Use an email address to recover your account"),
rules: [
{
key: "required",
test: function({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
},
invalid: () => _t("Enter email address (required on this homeserver)"),
},
{
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
}),
onPasswordChange(ev) {
this.setState({
password: ev.target.value,
});
},
onPasswordConfirmBlur(ev) {
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
async onPasswordValidate(fieldState) {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(FIELD_PASSWORD, result.valid);
return result;
},
validatePasswordRules: withValidation({
description: function() {
const complexity = this.state.passwordComplexity;
const score = complexity ? complexity.score : 0;
return <progress
className="mx_AuthBody_passwordScore"
max={PASSWORD_MIN_SCORE}
value={score}
/>;
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Enter password"),
},
{
key: "complexity",
test: async function({ value }) {
if (!value) {
return false;
}
const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value);
this.setState({
passwordComplexity: complexity,
});
return complexity.score >= PASSWORD_MIN_SCORE;
},
valid: () => _t("Nice, strong password!"),
invalid: function() {
const complexity = this.state.passwordComplexity;
if (!complexity) {
return null;
}
const { feedback } = complexity;
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
},
},
],
}),
onPasswordConfirmChange(ev) {
this.setState({
passwordConfirm: ev.target.value,
});
},
async onPasswordConfirmValidate(fieldState) {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
return result;
},
validatePasswordConfirmRules: withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Confirm password"),
},
{
key: "match",
test: function({ value }) {
return !value || value === this.state.password;
},
invalid: () => _t("Passwords don't match"),
},
],
}),
onPhoneCountryChange(newVal) {
this.setState({
phoneCountry: newVal.iso2,
@ -281,26 +324,64 @@ module.exports = React.createClass({
});
},
onPhoneNumberBlur(ev) {
this.validateField(FIELD_PHONE_NUMBER, ev.type);
},
onPhoneNumberChange(ev) {
this.setState({
phoneNumber: ev.target.value,
});
},
onUsernameBlur(ev) {
this.validateField(FIELD_USERNAME, ev.type);
async onPhoneNumberValidate(fieldState) {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
return result;
},
validatePhoneNumberRules: withValidation({
description: () => _t("Other users can invite you to rooms using your contact details"),
rules: [
{
key: "required",
test: function({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
},
invalid: () => _t("Enter phone number (required on this homeserver)"),
},
{
key: "email",
test: ({ value }) => !value || phoneNumberLooksValid(value),
invalid: () => _t("Doesn't look like a valid phone number"),
},
],
}),
onUsernameChange(ev) {
this.setState({
username: ev.target.value,
});
},
async onUsernameValidate(fieldState) {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(FIELD_USERNAME, result.valid);
return result;
},
validateUsernameRules: withValidation({
description: () => _t("Use letters, numbers, dashes and underscores only"),
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Enter username"),
},
{
key: "safeLocalpart",
test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value),
invalid: () => _t("Some characters not allowed"),
},
],
}),
/**
* A step is required if all flows include that step.
*
@ -325,9 +406,99 @@ module.exports = React.createClass({
});
},
render: function() {
renderEmail() {
if (!this._authStepIsUsed('m.login.email.identity')) {
return null;
}
const Field = sdk.getComponent('elements.Field');
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
return <Field
id="mx_RegistrationForm_email"
ref={field => this[FIELD_EMAIL] = field}
type="text"
label={emailPlaceholder}
defaultValue={this.props.defaultEmail}
value={this.state.email}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
/>;
},
renderPassword() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_password"
ref={field => this[FIELD_PASSWORD] = field}
type="password"
label={_t("Password")}
defaultValue={this.props.defaultPassword}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
/>;
},
renderPasswordConfirm() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_passwordConfirm"
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
type="password"
label={_t("Confirm")}
defaultValue={this.props.defaultPassword}
value={this.state.passwordConfirm}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
/>;
},
renderPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
if (!threePidLogin || !this._authStepIsUsed('m.login.msisdn')) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
const Field = sdk.getComponent('elements.Field');
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>;
return <Field
id="mx_RegistrationForm_phoneNumber"
ref={field => this[FIELD_PHONE_NUMBER] = field}
type="text"
label={phoneLabel}
defaultValue={this.props.defaultPhoneNumber}
value={this.state.phoneNumber}
prefix={phoneCountry}
onChange={this.onPhoneNumberChange}
onValidate={this.onPhoneNumberValidate}
/>;
},
renderUsername() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_username"
ref={field => this[FIELD_USERNAME] = field}
type="text"
autoFocus={true}
label={_t("Username")}
defaultValue={this.props.defaultUsername}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
/>;
},
render: function() {
let yourMatrixAccountText = _t('Create your Matrix account');
if (this.props.hsName) {
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
@ -353,53 +524,6 @@ module.exports = React.createClass({
</a>;
}
let emailSection;
if (this._authStepIsUsed('m.login.email.identity')) {
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
emailSection = (
<Field
className={this._classForField(FIELD_EMAIL)}
id="mx_RegistrationForm_email"
type="text"
label={emailPlaceholder}
defaultValue={this.props.defaultEmail}
value={this.state.email}
onBlur={this.onEmailBlur}
onChange={this.onEmailChange}
/>
);
}
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
let phoneSection;
if (threePidLogin && this._authStepIsUsed('m.login.msisdn')) {
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>;
phoneSection = <Field
className={this._classForField(FIELD_PHONE_NUMBER)}
id="mx_RegistrationForm_phoneNumber"
type="text"
label={phoneLabel}
defaultValue={this.props.defaultPhoneNumber}
value={this.state.phoneNumber}
prefix={phoneCountry}
onBlur={this.onPhoneNumberBlur}
onChange={this.onPhoneNumberChange}
/>;
}
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} />
);
@ -412,48 +536,18 @@ module.exports = React.createClass({
</h3>
<form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow">
<Field
className={this._classForField(FIELD_USERNAME)}
id="mx_RegistrationForm_username"
type="text"
autoFocus={true}
label={_t("Username")}
defaultValue={this.props.defaultUsername}
value={this.state.username}
onBlur={this.onUsernameBlur}
onChange={this.onUsernameChange}
/>
{this.renderUsername()}
</div>
<div className="mx_AuthBody_fieldRow">
<Field
className={this._classForField(FIELD_PASSWORD)}
id="mx_RegistrationForm_password"
type="password"
label={_t("Password")}
defaultValue={this.props.defaultPassword}
value={this.state.password}
onBlur={this.onPasswordBlur}
onChange={this.onPasswordChange}
/>
<Field
className={this._classForField(FIELD_PASSWORD_CONFIRM)}
id="mx_RegistrationForm_passwordConfirm"
type="password"
label={_t("Confirm")}
defaultValue={this.props.defaultPassword}
value={this.state.passwordConfirm}
onBlur={this.onPasswordConfirmBlur}
onChange={this.onPasswordConfirmChange}
/>
{this.renderPassword()}
{this.renderPasswordConfirm()}
</div>
<div className="mx_AuthBody_fieldRow">
{ emailSection }
{ phoneSection }
{this.renderEmail()}
{this.renderPhoneNumber()}
</div>
{_t(
"Use an email address to recover your account. Other users " +
"can invite you to rooms using your contact details.",
)}
{_t("Use an email address to recover your account.") + " "}
{_t("Other users can invite you to rooms using your contact details.")}
{ registerButton }
</form>
</div>

View file

@ -27,6 +27,7 @@ import Modal from '../../../Modal';
import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
module.exports = React.createClass({
displayName: 'MessageContextMenu',
@ -201,14 +202,6 @@ module.exports = React.createClass({
this.closeMenu();
},
onReplyClick: function() {
dis.dispatch({
action: 'reply_to_event',
event: this.props.mxEvent,
});
this.closeMenu();
},
onCollapseReplyThreadClick: function() {
this.props.collapseReplyThread();
this.closeMenu();
@ -226,7 +219,6 @@ module.exports = React.createClass({
let unhidePreviewButton;
let externalURLButton;
let quoteButton;
let replyButton;
let collapseReplyThread;
// status is SENT before remote-echo, null after
@ -256,28 +248,19 @@ module.exports = React.createClass({
);
}
if (isSent && mxEvent.getType() === 'm.room.message') {
const content = mxEvent.getContent();
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
if (isContentActionable(mxEvent)) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
</div>
);
if (this.state.canPin) {
pinButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</div>
);
replyButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}>
{ _t('Reply') }
</div>
);
if (this.state.canPin) {
pinButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</div>
);
}
}
}
@ -368,7 +351,6 @@ module.exports = React.createClass({
{ unhidePreviewButton }
{ permalinkButton }
{ quoteButton }
{ replyButton }
{ externalURLButton }
{ collapseReplyThread }
{ e2eInfo }

View file

@ -130,7 +130,7 @@ export default class LogoutDialog extends React.Component {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let setupButtonCaption;
if (this.state.backupInfo) {
setupButtonCaption = _t("Use Key Backup");
setupButtonCaption = _t("Connect this device to Key Backup");
} else {
// if there's an error fetching the backup info, we'll just assume there's
// no backup for the purpose of the button caption

View file

@ -1,130 +0,0 @@
/*
Copyright 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import { _t } from '../../../languageHandler';
// Dev note: this should be a temporary dialog while we work out what is
// actually going on. See https://github.com/vector-im/riot-web/issues/8593
// for more details. This dialog is almost entirely a copy/paste job of
// BugReportDialog.
export default class TimelineExplosionDialog extends React.Component {
static propTypes = {
onFinished: React.PropTypes.func.isRequired,
};
constructor(props, context) {
super(props, context);
this.state = {
busy: false,
progress: null,
};
}
_onCancel() {
console.log("Reloading without sending logs for timeline explosion");
window.location.reload();
}
_onSubmit = () => {
const userText = "Caught timeline explosion\n\nhttps://github.com/vector-im/riot-web/issues/8593";
this.setState({busy: true, progress: null});
this._sendProgressCallback(_t("Preparing to send logs"));
require(['../../../rageshake/submit-rageshake'], (s) => {
s(SdkConfig.get().bug_report_endpoint_url, {
userText,
sendLogs: true,
progressCallback: this._sendProgressCallback,
}).then(() => {
console.log("Logs sent for timeline explosion - reloading Riot");
window.location.reload();
}, (err) => {
console.error("Error sending logs for timeline explosion - reloading anyways.", err);
window.location.reload();
});
});
};
_sendProgressCallback = (progress) => {
this.setState({progress: progress});
};
render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let progress = null;
if (this.state.busy) {
progress = (
<div className="progress">
{this.state.progress} ...
<Loader />
</div>
);
}
return (
<BaseDialog className="mx_TimelineExplosionDialog" onFinished={this._onCancel}
title={_t('Error showing you your room')} contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>
{_t(
"Riot has run into a problem which makes it difficult to show you " +
"your messages right now. Nothing has been lost and reloading the app " +
"should fix this for you. In order to assist us in troubleshooting the " +
"problem, we'd like to take a look at your debug logs. You do not need " +
"to send your logs unless you want to, but we would really appreciate " +
"it if you did. We'd also like to apologize for having to show this " +
"message to you - we hope your debug logs are the key to solving the " +
"issue once and for all. If you'd like more information on the bug you've " +
"accidentally run into, please visit <a>the issue</a>.",
{},
{
'a': (sub) => {
return <a href="https://github.com/vector-im/riot-web/issues/8593"
target="_blank" rel="noopener">{sub}</a>;
},
},
)}
</p>
<p>
{_t(
"Debug logs contain application usage data including your " +
"username, the IDs or aliases of the rooms or groups you " +
"have visited and the usernames of other users. They do " +
"not contain messages.",
)}
</p>
{progress}
</div>
<DialogButtons primaryButton={_t("Send debug logs and reload Riot")}
onPrimaryButtonClick={this._onSubmit}
cancelButton={_t("Reload Riot without sending logs")}
focus={true}
onCancel={this._onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}

View file

@ -18,6 +18,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import sdk from '../../../index';
import { throttle } from 'lodash';
// Invoke validation from user input (when typing, etc.) at most once every N ms.
const VALIDATION_THROTTLE_MS = 200;
export default class Field extends React.PureComponent {
static propTypes = {
@ -53,20 +57,73 @@ export default class Field extends React.PureComponent {
};
}
onChange = (ev) => {
if (this.props.onValidate) {
const result = this.props.onValidate(ev.target.value);
this.setState({
valid: result.valid,
feedback: result.feedback,
});
onFocus = (ev) => {
this.validate({
focused: true,
});
// Parent component may have supplied its own `onFocus` as well
if (this.props.onFocus) {
this.props.onFocus(ev);
}
};
onChange = (ev) => {
this.validateOnChange();
// Parent component may have supplied its own `onChange` as well
if (this.props.onChange) {
this.props.onChange(ev);
}
};
onBlur = (ev) => {
this.validate({
focused: false,
});
// Parent component may have supplied its own `onBlur` as well
if (this.props.onBlur) {
this.props.onBlur(ev);
}
};
focus() {
this.input.focus();
}
async validate({ focused, allowEmpty = true }) {
if (!this.props.onValidate) {
return;
}
const value = this.input ? this.input.value : null;
const { valid, feedback } = await this.props.onValidate({
value,
focused,
allowEmpty,
});
if (feedback) {
this.setState({
valid,
feedback,
feedbackVisible: true,
});
} else {
// When we receive null `feedback`, we want to hide the tooltip.
// We leave the previous `feedback` content in state without updating it,
// so that we can hide the tooltip containing the most recent feedback
// via CSS animation.
this.setState({
valid,
feedbackVisible: false,
});
}
}
validateOnChange = throttle(() => {
this.validate({
focused: true,
});
}, VALIDATION_THROTTLE_MS);
render() {
const { element, prefix, onValidate, children, ...inputProps } = this.props;
@ -74,10 +131,12 @@ export default class Field extends React.PureComponent {
// Set some defaults for the <input> element
inputProps.type = inputProps.type || "text";
inputProps.ref = "fieldInput";
inputProps.ref = input => this.input = input;
inputProps.placeholder = inputProps.placeholder || inputProps.label;
inputProps.onFocus = this.onFocus;
inputProps.onChange = this.onChange;
inputProps.onBlur = this.onBlur;
const fieldInput = React.createElement(inputElement, inputProps, children);
@ -95,12 +154,13 @@ export default class Field extends React.PureComponent {
mx_Field_invalid: onValidate && this.state.valid === false,
});
// handle displaying feedback on validity
// Handle displaying feedback on validity
const Tooltip = sdk.getComponent("elements.Tooltip");
let feedback;
let tooltip;
if (this.state.feedback) {
feedback = <Tooltip
tooltip = <Tooltip
tooltipClassName="mx_Field_tooltip"
visible={this.state.feedbackVisible}
label={this.state.feedback}
/>;
}
@ -109,7 +169,7 @@ export default class Field extends React.PureComponent {
{prefixContainer}
{fieldInput}
<label htmlFor={this.props.id}>{this.props.label}</label>
{feedback}
{tooltip}
</div>;
}
}

View file

@ -31,10 +31,20 @@ module.exports = React.createClass({
className: React.PropTypes.string,
// Class applied to the tooltip itself
tooltipClassName: React.PropTypes.string,
// Whether the tooltip is visible or hidden.
// The hidden state allows animating the tooltip away via CSS.
// Defaults to visible if unset.
visible: React.PropTypes.bool,
// the react element to put into the tooltip
label: React.PropTypes.node,
},
getDefaultProps() {
return {
visible: true,
};
},
// Create a wrapper for the tooltip outside the parent and attach it to the body element
componentDidMount: function() {
this.tooltipContainer = document.createElement("div");
@ -85,7 +95,10 @@ module.exports = React.createClass({
style = this._updatePosition(style);
style.display = "block";
const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName);
const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, {
"mx_Tooltip_visible": this.props.visible,
"mx_Tooltip_invisible": !this.props.visible,
});
const tooltip = (
<div className={tooltipClasses} style={style}>

View file

@ -0,0 +1,131 @@
/*
Copyright 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* eslint-disable babel/no-invalid-this */
import classNames from 'classnames';
/**
* Creates a validation function from a set of rules describing what to validate.
*
* @param {Function} description
* Function that returns a string summary of the kind of value that will
* meet the validation rules. Shown at the top of the validation feedback.
* @param {Object} rules
* An array of rules describing how to check to input value. Each rule in an object
* and may have the following properties:
* - `key`: A unique ID for the rule. Required.
* - `test`: A function used to determine the rule's current validity. Required.
* - `valid`: Function returning text to show when the rule is valid. Only shown if set.
* - `invalid`: Function returning text to show when the rule is invalid. Only shown if set.
* @returns {Function}
* A validation function that takes in the current input value and returns
* the overall validity and a feedback UI that can be rendered for more detail.
*/
export default function withValidation({ description, rules }) {
return async function onValidate({ value, focused, allowEmpty = true }) {
if (!value && allowEmpty) {
return {
valid: null,
feedback: null,
};
}
const results = [];
let valid = true;
if (rules && rules.length) {
for (const rule of rules) {
if (!rule.key || !rule.test) {
continue;
}
// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const ruleValid = await rule.test.call(this, { value, allowEmpty });
valid = valid && ruleValid;
if (ruleValid && rule.valid) {
// If the rule's result is valid and has text to show for
// the valid state, show it.
const text = rule.valid.call(this);
if (!text) {
continue;
}
results.push({
key: rule.key,
valid: true,
text,
});
} else if (!ruleValid && rule.invalid) {
// If the rule's result is invalid and has text to show for
// the invalid state, show it.
const text = rule.invalid.call(this);
if (!text) {
continue;
}
results.push({
key: rule.key,
valid: false,
text,
});
}
}
}
// Hide feedback when not focused
if (!focused) {
return {
valid,
feedback: null,
};
}
let details;
if (results && results.length) {
details = <ul className="mx_Validation_details">
{results.map(result => {
const classes = classNames({
"mx_Validation_detail": true,
"mx_Validation_valid": result.valid,
"mx_Validation_invalid": !result.valid,
});
return <li key={result.key} className={classes}>
{result.text}
</li>;
})}
</ul>;
}
let summary;
if (description) {
// We're setting `this` to whichever component holds the validation
// function. That allows rules to access the state of the component.
const content = description.call(this);
summary = <div className="mx_Validation_description">{content}</div>;
}
let feedback;
if (summary || details) {
feedback = <div className="mx_Validation">
{summary}
{details}
</div>;
}
return {
valid,
feedback,
};
};
}

View file

@ -0,0 +1,223 @@
/*
Copyright 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import Modal from '../../../Modal';
import { createMenu } from '../../structures/ContextualMenu';
import SettingsStore from '../../../settings/SettingsStore';
import { isContentActionable } from '../../../utils/EventUtils';
export default class MessageActionBar extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
permalinkCreator: PropTypes.object,
getTile: PropTypes.func,
getReplyThread: PropTypes.func,
onFocusChange: PropTypes.func,
};
constructor(props) {
super(props);
this.state = {
agreeDimension: null,
likeDimension: null,
};
}
onFocusChange = (focused) => {
if (!this.props.onFocusChange) {
return;
}
this.props.onFocusChange(focused);
}
onCryptoClicked = () => {
const event = this.props.mxEvent;
Modal.createTrackedDialogAsync('Encrypted Event Dialog', '',
import('../../../async-components/views/dialogs/EncryptedEventDialog'),
{event},
);
}
onAgreeClick = (ev) => {
this.toggleDimensionValue("agreeDimension", "agree");
}
onDisagreeClick = (ev) => {
this.toggleDimensionValue("agreeDimension", "disagree");
}
onLikeClick = (ev) => {
this.toggleDimensionValue("likeDimension", "like");
}
onDislikeClick = (ev) => {
this.toggleDimensionValue("likeDimension", "dislike");
}
toggleDimensionValue(dimension, value) {
const state = this.state[dimension];
const newState = state !== value ? value : null;
this.setState({
[dimension]: newState,
});
// TODO: Send the reaction event
}
onReplyClick = (ev) => {
dis.dispatch({
action: 'reply_to_event',
event: this.props.mxEvent,
});
}
onOptionsClick = (ev) => {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const buttonRect = ev.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = buttonRect.right + window.pageXOffset;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
const { getTile, getReplyThread } = this.props;
const tile = getTile && getTile();
const replyThread = getReplyThread && getReplyThread();
let e2eInfoCallback = null;
if (this.props.mxEvent.isEncrypted()) {
e2eInfoCallback = () => this.onCryptoClicked();
}
createMenu(MessageContextMenu, {
chevronOffset: 10,
mxEvent: this.props.mxEvent,
left: x,
top: y,
permalinkCreator: this.props.permalinkCreator,
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
e2eInfoCallback: e2eInfoCallback,
onFinished: () => {
this.onFocusChange(false);
},
});
this.onFocusChange(true);
}
isReactionsEnabled() {
return SettingsStore.isFeatureEnabled("feature_reactions");
}
renderAgreeDimension() {
if (!this.isReactionsEnabled()) {
return null;
}
const state = this.state.agreeDimension;
const options = [
{
key: "agree",
content: "👍",
onClick: this.onAgreeClick,
},
{
key: "disagree",
content: "👎",
onClick: this.onDisagreeClick,
},
];
return <span className="mx_MessageActionBar_reactionDimension"
title={_t("Agree or Disagree")}
>
{this.renderReactionDimensionItems(state, options)}
</span>;
}
renderLikeDimension() {
if (!this.isReactionsEnabled()) {
return null;
}
const state = this.state.likeDimension;
const options = [
{
key: "like",
content: "🙂",
onClick: this.onLikeClick,
},
{
key: "dislike",
content: "😔",
onClick: this.onDislikeClick,
},
];
return <span className="mx_MessageActionBar_reactionDimension"
title={_t("Like or Dislike")}
>
{this.renderReactionDimensionItems(state, options)}
</span>;
}
renderReactionDimensionItems(state, options) {
return options.map(option => {
const disabled = state && state !== option.key;
const classes = classNames({
mx_MessageActionBar_reactionDisabled: disabled,
});
return <span key={option.key}
className={classes}
onClick={option.onClick}
>
{option.content}
</span>;
});
}
render() {
let agreeDimensionReactionButtons;
let likeDimensionReactionButtons;
let replyButton;
if (isContentActionable(this.props.mxEvent)) {
agreeDimensionReactionButtons = this.renderAgreeDimension();
likeDimensionReactionButtons = this.renderLikeDimension();
replyButton = <span className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
/>;
}
return <div className="mx_MessageActionBar">
{agreeDimensionReactionButtons}
{likeDimensionReactionButtons}
{replyButton}
<span className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")}
onClick={this.onOptionsClick}
/>
</div>;
}
}

View file

@ -0,0 +1,65 @@
/*
Copyright 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import 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,
};
export default class ReactionsRow extends React.PureComponent {
static propTypes = {
// The event we're displaying reactions for
mxEvent: PropTypes.object.isRequired,
}
render() {
const { mxEvent } = this.props;
if (!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;
}
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
const items = Object.entries(SAMPLE_REACTIONS).map(([content, count]) => {
return <ReactionsRowButton
key={content}
content={content}
count={count}
/>;
});
return <div className="mx_ReactionsRow">
{items}
</div>;
}
}

View file

@ -0,0 +1,65 @@
/*
Copyright 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
export default class ReactionsRowButton extends React.PureComponent {
static propTypes = {
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,
};
}
onClick = (ev) => {
const state = this.state.selected;
this.setState({
selected: !state,
});
// TODO: Send the reaction event
};
render() {
const { content, count } = this.props;
const { selected } = this.state;
const classes = classNames({
mx_ReactionsRowButton: true,
mx_ReactionsRowButton_selected: selected,
});
let adjustedCount = count;
if (selected) {
adjustedCount++;
}
return <span className={classes}
onClick={this.onClick}
>
{content} {adjustedCount}
</span>;
}
}

View file

@ -17,7 +17,6 @@ limitations under the License.
'use strict';
import ReplyThread from "../elements/ReplyThread";
const React = require('react');
@ -30,7 +29,6 @@ const sdk = require('../../../index');
const TextForEvent = require('../../../TextForEvent');
import withMatrixClient from '../../../wrappers/withMatrixClient';
const ContextualMenu = require('../../structures/ContextualMenu');
import dis from '../../../dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import {EventStatus} from 'matrix-js-sdk';
@ -172,8 +170,8 @@ module.exports = withMatrixClient(React.createClass({
getInitialState: function() {
return {
// Whether the context menu is being displayed.
menu: false,
// Whether the action bar is focused.
actionBarFocused: false,
// Whether all read receipts are being displayed. If not, only display
// a truncation of them.
allReadAvatars: false,
@ -309,36 +307,6 @@ module.exports = withMatrixClient(React.createClass({
return actions.tweaks.highlight;
},
onEditClicked: function(e) {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const buttonRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = buttonRect.right + window.pageXOffset;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
const self = this;
const {tile, replyThread} = this.refs;
let e2eInfoCallback = null;
if (this.props.mxEvent.isEncrypted()) e2eInfoCallback = () => this.onCryptoClicked();
ContextualMenu.createMenu(MessageContextMenu, {
chevronOffset: 10,
mxEvent: this.props.mxEvent,
left: x,
top: y,
permalinkCreator: this.props.permalinkCreator,
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
e2eInfoCallback: e2eInfoCallback,
onFinished: function() {
self.setState({menu: false});
},
});
this.setState({menu: true});
},
toggleAllReadAvatars: function() {
this.setState({
allReadAvatars: !this.state.allReadAvatars,
@ -490,6 +458,20 @@ module.exports = withMatrixClient(React.createClass({
return null;
},
onActionBarFocusChange(focused) {
this.setState({
actionBarFocused: focused,
});
},
getTile() {
return this.refs.tile;
},
getReplyThread() {
return this.refs.replyThread;
},
render: function() {
const MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
const SenderProfile = sdk.getComponent('messages.SenderProfile');
@ -536,7 +518,7 @@ module.exports = withMatrixClient(React.createClass({
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual,
menu: this.state.menu,
mx_EventTile_actionBarFocused: this.state.actionBarFocused,
mx_EventTile_verified: this.state.verified === true,
mx_EventTile_unverified: this.state.verified === false,
mx_EventTile_bad: isEncryptionFailure,
@ -602,9 +584,14 @@ module.exports = withMatrixClient(React.createClass({
}
}
const editButton = (
<span className="mx_EventTile_editButton" title={_t("Options")} onClick={this.onEditClicked} />
);
const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
const actionBar = <MessageActionBar
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator}
getTile={this.getTile}
getReplyThread={this.getReplyThread}
onFocusChange={this.onActionBarFocusChange}
/>;
const timestamp = this.props.mxEvent.getTs() ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
@ -643,6 +630,14 @@ module.exports = withMatrixClient(React.createClass({
<ToolTipButton helpText={keyRequestHelpText} />
</div> : null;
let reactions;
if (SettingsStore.isFeatureEnabled("feature_reactions")) {
const ReactionsRow = sdk.getComponent('messages.ReactionsRow');
reactions = <ReactionsRow
mxEvent={this.props.mxEvent}
/>;
}
switch (this.props.tileShape) {
case 'notif': {
const EmojiText = sdk.getComponent('elements.EmojiText');
@ -755,7 +750,8 @@ module.exports = withMatrixClient(React.createClass({
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} />
{ keyRequestInfo }
{ editButton }
{ reactions }
{ actionBar }
</div>
{
// The avatar goes after the event tile as it's absolutly positioned to be over the

View file

@ -117,6 +117,7 @@ export default class RoomBreadcrumbs extends React.Component {
};
onRoomTimeline = (event, room) => {
if (!room) return; // Can be null for the notification timeline, etc.
if (this.state.rooms.map(r => r.room.roomId).includes(room.roomId)) {
this._calculateRoomBadges(room);
}

View file

@ -24,7 +24,6 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import {getUserNameColorClass} from '../../../utils/FormattingUtils';
const MessageCase = Object.freeze({
NotLoggedIn: "NotLoggedIn",
@ -105,12 +104,6 @@ module.exports = React.createClass({
}
},
_onInviterClick(evt) {
evt.preventDefault();
const member = this._getInviteMember();
dis.dispatch({action: 'view_user_info', userId: member.userId});
},
_getMessageCase() {
const isGuest = MatrixClientPeg.get().isGuest();
@ -118,8 +111,7 @@ module.exports = React.createClass({
return MessageCase.NotLoggedIn;
}
const myMember = this.props.room &&
this.props.room.getMember(MatrixClientPeg.get().getUserId());
const myMember = this._getMyMember();
if (myMember) {
if (myMember.isKicked()) {
@ -158,9 +150,7 @@ module.exports = React.createClass({
},
_getKickOrBanInfo() {
const myMember = this.props.room ?
this.props.room.getMember(MatrixClientPeg.get().getUserId()) :
null;
const myMember = this._getMyMember();
if (!myMember) {
return {};
}
@ -194,6 +184,13 @@ module.exports = React.createClass({
}
},
_getMyMember() {
return (
this.props.room &&
this.props.room.getMember(MatrixClientPeg.get().getUserId())
);
},
_getInviteMember: function() {
const {room} = this.props;
if (!room) {
@ -208,6 +205,16 @@ module.exports = React.createClass({
return room.currentState.getMember(inviterUserId);
},
_isDMInvite() {
const myMember = this._getMyMember();
if (!myMember) {
return false;
}
const memberEvent = myMember.events.member;
const memberContent = memberEvent.getContent();
return memberContent.membership === "invite" && memberContent.is_direct;
},
onLoginClick: function() {
dis.dispatch({ action: 'start_login' });
},
@ -279,7 +286,8 @@ module.exports = React.createClass({
break;
}
case MessageCase.OtherThreePIDError: {
title = _t("Something went wrong with your invite to this room");
title = _t("Something went wrong with your invite to %(roomName)s",
{roomName: this._roomName()});
const joinRule = this._joinRule();
const errCodeMessage = _t("%(errcode)s was returned while trying to valide your invite. You could try to pass this information on to a room admin.",
{errcode: this.state.threePidFetchError.errcode},
@ -305,14 +313,19 @@ module.exports = React.createClass({
break;
}
case MessageCase.InvitedEmailMismatch: {
title = _t("The room invite wasn't sent to your account");
title = _t("This invite to %(roomName)s wasn't sent to your account",
{roomName: this._roomName()});
const joinRule = this._joinRule();
if (joinRule === "public") {
subTitle = _t("You can still join it because this is a public room.");
primaryActionLabel = _t("Join the discussion");
primaryActionHandler = this.props.onJoinClick;
} else {
subTitle = _t("Sign in with a different account, ask for another invite, or add the e-mail address %(email)s to this account.", {email: this.props.invitedEmail});
subTitle = _t(
"Sign in with a different account, ask for another invite, or " +
"add the e-mail address %(email)s to this account.",
{email: this.props.invitedEmail},
);
if (joinRule !== "invite") {
primaryActionLabel = _t("Try to join anyway");
primaryActionHandler = this.props.onJoinClick;
@ -321,26 +334,29 @@ module.exports = React.createClass({
break;
}
case MessageCase.Invite: {
const RoomAvatar = sdk.getComponent("views.avatars.RoomAvatar");
const avatar = <RoomAvatar room={this.props.room} />;
const inviteMember = this._getInviteMember();
let avatar;
let inviterElement;
if (inviteMember) {
const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
avatar = (<MemberAvatar member={inviteMember} onClick={this._onInviterClick} />);
const inviterClasses = [
"mx_RoomPreviewBar_inviter",
getUserNameColorClass(inviteMember.userId),
].join(" ");
inviterElement = (
<a onClick={this._onInviterClick} className={inviterClasses}>
{inviteMember.name}
</a>
);
inviterElement = <span>
<span className="mx_RoomPreviewBar_inviter">
{inviteMember.rawDisplayName}
</span> ({inviteMember.userId})
</span>;
} else {
inviterElement = (<span className="mx_RoomPreviewBar_inviter">{this.props.inviterName}</span>);
}
title = _t("Do you want to join this room?");
const isDM = this._isDMInvite();
if (isDM) {
title = _t("Do you want to chat with %(user)s?",
{ user: inviteMember.name });
} else {
title = _t("Do you want to join %(roomName)s?",
{ roomName: this._roomName() });
}
subTitle = [
avatar,
_t("<userName/> invited you", {}, {userName: () => inviterElement}),
@ -354,7 +370,8 @@ module.exports = React.createClass({
}
case MessageCase.ViewingRoom: {
if (this.props.canPreview) {
title = _t("You're previewing this room. Want to join it?");
title = _t("You're previewing %(roomName)s. Want to join it?",
{roomName: this._roomName()});
} else {
title = _t("%(roomName)s can't be previewed. Do you want to join it?",
{roomName: this._roomName(true)});
@ -372,7 +389,10 @@ module.exports = React.createClass({
title = _t("%(roomName)s is not accessible at this time.", {roomName: this._roomName(true)});
subTitle = [
_t("Try again later, or ask a room admin to check if you have access."),
_t("%(errcode)s was returned while trying to access the room. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.",
_t(
"%(errcode)s was returned while trying to access the room. " +
"If you think you're seeing this message in error, please " +
"<issueLink>submit a bug report</issueLink>.",
{ errcode: this.props.error.errcode },
{ issueLink: label => <a href="https://github.com/vector-im/riot-web/issues/new/choose"
target="_blank" rel="noopener">{ label }</a> },

View file

@ -119,7 +119,7 @@ export default class RoomRecoveryReminder extends React.PureComponent {
let setupCaption;
if (this.state.backupInfo) {
setupCaption = _t("Use Key Backup");
setupCaption = _t("Connect this device to Key Backup");
} else {
setupCaption = _t("Start using Key Backup");
}

View file

@ -507,6 +507,7 @@ module.exports = React.createClass({
//'.m.rule.member_event': 'vector',
'.m.rule.call': 'vector',
'.m.rule.suppress_notices': 'vector',
'.m.rule.tombstone': 'vector',
// Others go to others
};
@ -562,6 +563,7 @@ module.exports = React.createClass({
//'im.vector.rule.member_event',
'.m.rule.call',
'.m.rule.suppress_notices',
'.m.rule.tombstone',
];
for (const i in vectorRuleIds) {
const vectorRuleId = vectorRuleIds[i];
@ -702,6 +704,10 @@ module.exports = React.createClass({
const rows = [];
for (const i in this.state.vectorPushRules) {
const rule = this.state.vectorPushRules[i];
if (rule.rule === undefined && rule.vectorRuleId.startsWith(".m.")) {
console.warn(`Skipping render of rule ${rule.vectorRuleId} due to no underlying rule`);
continue;
}
//console.log("rendering: " + rule.description + ", " + rule.vectorRuleId + ", " + rule.vectorState);
rows.push(this.renderNotifRulesTableRow(rule.description, rule.vectorRuleId, rule.vectorState));
}