Merge pull request #403 from matrix-org/dbkr/multi_invite

Better support for inviting multiple people
This commit is contained in:
David Baker 2016-08-11 12:34:16 +01:00 committed by GitHub
commit 4780f9000d
4 changed files with 324 additions and 58 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,218 @@
/*
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._canceled = false;
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';
}
}
}
componentWillUnmount() {
this._canceled = true;
}
_onCancel() {
this._canceled = true;
this.props.onFinished(false);
}
_startInviting() {
this.setState({
busy: true,
done: false,
});
this._inviteMore(0);
}
_inviteMore(nextIndex) {
if (this._canceled) {
return;
}
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 to 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(() => {
if (this._canceled) { return; }
this.setState((s) => {
s.completionStates[nextIndex] = 'invited'
return s;
});
this._inviteMore(nextIndex + 1);
}, (err) => {
if (this._canceled) { return; }
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() {
let numErrors = 0;
for (const k of Object.keys(this.state.completionStates)) {
if (this.state.completionStates[k] == 'error') {
++numErrors;
}
}
let errorText;
if (numErrors > 0) {
const plural = numErrors > 1 ? 's' : '';
errorText = <span className="error">({numErrors} error{plural})</span>
}
return <span>
{Object.keys(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(<button key="cancel" onClick={this._onCancel}>Cancel</button>);
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,
inputs: React.PropTypes.array.isRequired,
roomId: React.PropTypes.string.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 =
@ -178,6 +179,39 @@ module.exports = React.createClass({
}); });
}, },
_doInvite(address) {
Invite.inviteToRoom(self.props.roomId, address).catch((err) => {
if (err !== null) {
console.error("Failed to invite: %s", JSON.stringify(err));
if (err.errcode == 'M_FORBIDDEN') {
Modal.createDialog(ErrorDialog, {
title: "Unable to Invite",
description: "You do not have permission to invite people to this room."
});
} else {
Modal.createDialog(ErrorDialog, {
title: "Server error whilst inviting",
description: err.message
});
}
}
}).finally(() => {
self.setState({
inviting: false
});
// XXX: hacky focus on the invite box
setTimeout(function() {
var inviteBox = document.getElementById("mx_SearchableEntityList_query");
if (inviteBox) {
inviteBox.focus();
}
}, 0);
}).done();
self.setState({
inviting: true
});
},
onInvite: function(inputText) { onInvite: function(inputText) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
@ -194,22 +228,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);
}); let validInputs = 0;
return; 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'"
@ -246,53 +278,23 @@ 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.done(() => {
console.log( this.doInvite(inputs[0]);
"Invite %s to %s - isEmail=%s", inputText, this.props.roomId, isEmailAddress
);
promise.then(function(res) {
console.log("Invited %s", inputText);
}, function(err) {
if (err !== null) {
console.error("Failed to invite: %s", JSON.stringify(err));
if (err.errcode == 'M_FORBIDDEN') {
Modal.createDialog(ErrorDialog, {
title: "Unable to Invite",
description: "You do not have permission to invite people to this room."
});
} else {
Modal.createDialog(ErrorDialog, {
title: "Server error whilst inviting",
description: err.message
});
}
}
}).finally(function() {
self.setState({
inviting: false
}); });
// XXX: hacky focus on the invite box } else {
setTimeout(function() { // if there are several, display the confirmation/progress dialog
var inviteBox = document.getElementById("mx_SearchableEntityList_query"); promise.done(() => {
if (inviteBox) { const MultiInviteDialog = sdk.getComponent('views.dialogs.MultiInviteDialog');
inviteBox.focus(); Modal.createDialog(MultiInviteDialog, {
} roomId: this.props.roomId,
}, 0); inputs: inputs,
}); });
});
}
}, },
getMemberDict: function() { getMemberDict: function() {