Better support for inviting multiple people

Pasting a list of addresses into the box will now pop up a dialog to confirm and show you the success / failure state of each address. It will also not die if it gets rate limited.

Fixes https://github.com/vector-im/vector-web/issues/1713
This commit is contained in:
David Baker 2016-08-10 17:11:49 +01:00
parent e0f71977b4
commit 54b3638a85
4 changed files with 302 additions and 56 deletions

45
src/Invite.js Normal file
View file

@ -0,0 +1,45 @@
/*
Copyright 2016 OpenMarket 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 MatrixClientPeg from './MatrixClientPeg';
const emailRegex = /^\S+@\S+\.\S+$/;
export function getAddressType(inputText) {
const isEmailAddress = /^\S+@\S+\.\S+$/.test(inputText);
const isMatrixId = inputText[0] === '@' && inputText.indexOf(":") > 0;
// sanity check the input for user IDs
if (isEmailAddress) {
return 'email';
} else if (isMatrixId) {
return 'mx';
} else {
return null;
}
}
export function inviteToRoom(roomId, addr) {
const addrType = getAddressType(addr);
if (addrType == 'email') {
return MatrixClientPeg.get().inviteByEmail(roomId, addr);
} else if (addrType == 'mx') {
return MatrixClientPeg.get().invite(roomId, addr);
} else {
throw new Error('Unsupported address');
}
}

View file

@ -48,6 +48,7 @@ module.exports.components['views.create_room.RoomAlias'] = require('./components
module.exports.components['views.dialogs.DeactivateAccountDialog'] = require('./components/views/dialogs/DeactivateAccountDialog'); module.exports.components['views.dialogs.DeactivateAccountDialog'] = require('./components/views/dialogs/DeactivateAccountDialog');
module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog'); module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog');
module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt'); module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt');
module.exports.components['views.dialogs.MultiInviteDialog'] = require('./components/views/dialogs/MultiInviteDialog');
module.exports.components['views.dialogs.NeedToRegisterDialog'] = require('./components/views/dialogs/NeedToRegisterDialog'); module.exports.components['views.dialogs.NeedToRegisterDialog'] = require('./components/views/dialogs/NeedToRegisterDialog');
module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog'); module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog');
module.exports.components['views.dialogs.SetDisplayNameDialog'] = require('./components/views/dialogs/SetDisplayNameDialog'); module.exports.components['views.dialogs.SetDisplayNameDialog'] = require('./components/views/dialogs/SetDisplayNameDialog');

View file

@ -0,0 +1,199 @@
/*
Copyright 2016 OpenMarket 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 {getAddressType, inviteToRoom} from '../../../Invite';
import sdk from '../../../index';
export default class MultiInviteDialog extends React.Component {
constructor(props, context) {
super(props, context);
this._onCancel = this._onCancel.bind(this);
this._startInviting = this._startInviting.bind(this);
this.state = {
busy: false,
completionStates: [], // State of each address (invited or error)
errorTexts: [], // Textual error per address
done: false,
};
for (let i = 0; i < this.props.inputs.length; ++i) {
const input = this.props.inputs[i];
if (getAddressType(input) === null) {
this.state.completionStates[i] = 'error';
this.state.errorTexts[i] = 'Unrecognised address';
}
}
}
_onCancel() {
this.props.onFinished(false);
}
_startInviting() {
this.setState({
completionStates: [],
busy: true,
done: false,
});
this._inviteMore(0);
}
_inviteMore(nextIndex) {
if (nextIndex == this.props.inputs.length) {
this.setState({
busy: false,
done: true,
});
return;
}
const input = this.props.inputs[nextIndex];
// don't try to invite it if it's an invalid address
// (it will already be marked as an error though,
// so no need top do so again
if (getAddressType(input) === null) {
this._inviteMore(nextIndex + 1);
return;
}
// don't re-invite (there's no way in the UI to do this, but
// for sanity's sake)
if (this.state.completionStates[nextIndex] == 'invited') {
this._inviteMore(nextIndex + 1);
return;
}
inviteToRoom(this.props.roomId, input).then(() => {
this.setState((s) => {
s.completionStates[nextIndex] = 'invited'
return s;
});
this._inviteMore(nextIndex + 1);
}, (err) => {
let errorText;
let fatal = false;
if (err.errcode == 'M_FORBIDDEN') {
fatal = true;
errorText = 'You do not have permission to invite people to this room.';
} else if (err.errcode == 'M_LIMIT_EXCEEDED') {
// we're being throttled so wait a bit & try again
setTimeout(() => {
this._inviteMore(nextIndex);
}, 5000);
return;
} else {
errorText = 'Unknown server error';
}
this.setState((s) => {
s.completionStates[nextIndex] = 'error';
s.errorTexts[nextIndex] = errorText;
s.busy = !fatal;
s.done = fatal;
return s;
});
if (!fatal) {
this._inviteMore(nextIndex + 1);
}
});
}
_getProgressIndicator() {
const numErrors = this.state.completionStates.filter((s) => {
return s == 'error';
}).length;
let errorText;
if (numErrors > 0) {
const plural = numErrors > 1 ? 's' : '';
errorText = <span className="error">({numErrors} error{plural})</span>
}
return <span>
{this.state.completionStates.length} / {this.props.inputs.length} {errorText}
</span>;
}
render() {
const Spinner = sdk.getComponent("elements.Spinner");
const inviteTiles = [];
for (let i = 0; i < this.props.inputs.length; ++i) {
const input = this.props.inputs[i];
let statusClass = '';
let statusElement;
if (this.state.completionStates[i] == 'error') {
statusClass = 'error';
statusElement = <p className="mx_MultiInviteDialog_statusText">{this.state.errorTexts[i]}</p>;
} else if (this.state.completionStates[i] == 'invited') {
statusClass = 'invited';
}
inviteTiles.push(
<li key={i}>
<p className={statusClass}>{input}</p>
{statusElement}
</li>
);
}
let controls = [];
if (this.state.busy) {
controls.push(<Spinner key="spinner" />);
controls.push(<span key="progr">{this._getProgressIndicator()}</span>);
} else if (this.state.done) {
controls.push(
<button
key="cancel"
className="mx_Dialog_primary"
onClick={this._onCancel}
>Done</button>
);
controls.push(<span key="progr">{this._getProgressIndicator()}</span>);
} else {
controls.push(
<button
key="invite"
onClick={this._startInviting}
autoFocus={true}
className="mx_Dialog_primary"
>
Invite
</button>);
controls.push(<button key="cancel" onClick={this._onCancel}>Cancel</button>);
}
return (
<div className="mx_MultiInviteDialog">
<div className="mx_Dialog_title">
Inviting {this.props.inputs.length} People
</div>
<div className="mx_Dialog_content">
<ul>
{inviteTiles}
</ul>
</div>
<div className="mx_Dialog_buttons">
{controls}
</div>
</div>
);
}
}
MultiInviteDialog.propTypes = {
onFinished: React.PropTypes.func.isRequired,
};

View file

@ -24,6 +24,7 @@ var sdk = require('../../../index');
var GeminiScrollbar = require('react-gemini-scrollbar'); var GeminiScrollbar = require('react-gemini-scrollbar');
var rate_limited_func = require('../../../ratelimitedfunc'); var rate_limited_func = require('../../../ratelimitedfunc');
var CallHandler = require("../../../CallHandler"); var CallHandler = require("../../../CallHandler");
var Invite = require("../../../Invite");
var INITIAL_LOAD_NUM_MEMBERS = 30; var INITIAL_LOAD_NUM_MEMBERS = 30;
var SHARE_HISTORY_WARNING = var SHARE_HISTORY_WARNING =
@ -191,22 +192,20 @@ module.exports = React.createClass({
// email addresses and user IDs do not allow space, comma, semicolon so split // email addresses and user IDs do not allow space, comma, semicolon so split
// on them for bulk inviting. // on them for bulk inviting.
var separators =[ ";", " ", "," ]; // '+' here will treat multiple consecutive separators as one separator, so
for (var i = 0; i < separators.length; i++) { // ', ' separators will also do the right thing.
if (inputText.indexOf(separators[i]) >= 0) { const inputs = inputText.split(/[, ;]+/).filter((x) => {
var inputs = inputText.split(separators[i]); return x.trim().length > 0;
inputs.forEach(function(input) {
self.onInvite(input);
}); });
return;
let validInputs = 0;
for (const input of inputs) {
if (Invite.getAddressType(input) != null) {
++validInputs;
} }
} }
var isEmailAddress = /^\S+@\S+\.\S+$/.test(inputText); if (validInputs == 0) {
// sanity check the input for user IDs
if (!isEmailAddress && (inputText[0] !== '@' || inputText.indexOf(":") === -1)) {
console.error("Bad ID to invite: %s", inputText);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Invite Error", title: "Invite Error",
description: "Malformed ID. Should be an email address or a Matrix ID like '@localpart:domain'" description: "Malformed ID. Should be an email address or a Matrix ID like '@localpart:domain'"
@ -243,27 +242,13 @@ module.exports = React.createClass({
inviteWarningDefer.resolve(); inviteWarningDefer.resolve();
} }
var promise = inviteWarningDefer.promise; const promise = inviteWarningDefer.promise;
if (isEmailAddress) {
promise = promise.then(function() {
return MatrixClientPeg.get().inviteByEmail(self.props.roomId, inputText);
});
}
else {
promise = promise.then(function() {
return MatrixClientPeg.get().invite(self.props.roomId, inputText);
});
}
self.setState({ if (inputs.length == 1) {
inviting: true // for a single address, we just send the invite
}); promise.then(() => {
console.log( return Invite.inviteToRoom(self.props.roomId, inputs[0]);
"Invite %s to %s - isEmail=%s", inputText, this.props.roomId, isEmailAddress }).catch((err) => {
);
promise.then(function(res) {
console.log("Invited %s", inputText);
}, function(err) {
if (err !== null) { if (err !== null) {
console.error("Failed to invite: %s", JSON.stringify(err)); console.error("Failed to invite: %s", JSON.stringify(err));
if (err.errcode == 'M_FORBIDDEN') { if (err.errcode == 'M_FORBIDDEN') {
@ -278,7 +263,10 @@ module.exports = React.createClass({
}); });
} }
} }
}).finally(function() { self.setState({
inviting: false
});
}).finally(() => {
self.setState({ self.setState({
inviting: false inviting: false
}); });
@ -289,7 +277,20 @@ module.exports = React.createClass({
inviteBox.focus(); inviteBox.focus();
} }
}, 0); }, 0);
}).done();
self.setState({
inviting: true
}); });
} else {
// if there are several, display the confirmation/progress dialog
promise.done(() => {
const MultiInviteDialog = sdk.getComponent('views.dialogs.MultiInviteDialog');
Modal.createDialog(MultiInviteDialog, {
roomId: this.props.roomId,
inputs: inputs,
});
});
}
}, },
getMemberDict: function() { getMemberDict: function() {