diff --git a/src/Invite.js b/src/Invite.js new file mode 100644 index 00000000000..3b52d6a1f46 --- /dev/null +++ b/src/Invite.js @@ -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'); + } +} diff --git a/src/component-index.js b/src/component-index.js index 97f8882b82b..d61192de332 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -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.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog'); 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.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog'); module.exports.components['views.dialogs.SetDisplayNameDialog'] = require('./components/views/dialogs/SetDisplayNameDialog'); diff --git a/src/components/views/dialogs/MultiInviteDialog.js b/src/components/views/dialogs/MultiInviteDialog.js new file mode 100644 index 00000000000..a8d7aec495a --- /dev/null +++ b/src/components/views/dialogs/MultiInviteDialog.js @@ -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 = ({numErrors} error{plural}) + } + return + {Object.keys(this.state.completionStates).length} / {this.props.inputs.length} {errorText} + ; + } + + 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 =

{this.state.errorTexts[i]}

; + } else if (this.state.completionStates[i] == 'invited') { + statusClass = 'invited'; + } + inviteTiles.push( +
  • +

    {input}

    + {statusElement} +
  • + ); + } + + let controls = []; + if (this.state.busy) { + controls.push(); + controls.push(); + controls.push({this._getProgressIndicator()}); + } else if (this.state.done) { + controls.push( + + ); + controls.push({this._getProgressIndicator()}); + } else { + controls.push( + ); + controls.push(); + } + + return ( +
    +
    + Inviting {this.props.inputs.length} People +
    +
    +
      + {inviteTiles} +
    +
    +
    + {controls} +
    +
    + ); + } +} + +MultiInviteDialog.propTypes = { + onFinished: React.PropTypes.func.isRequired, + inputs: React.PropTypes.array.isRequired, + roomId: React.PropTypes.string.isRequired, +}; diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index 5536aeddd68..2451026bcfe 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -24,6 +24,7 @@ var sdk = require('../../../index'); var GeminiScrollbar = require('react-gemini-scrollbar'); var rate_limited_func = require('../../../ratelimitedfunc'); var CallHandler = require("../../../CallHandler"); +var Invite = require("../../../Invite"); var INITIAL_LOAD_NUM_MEMBERS = 30; var SHARE_HISTORY_WARNING = @@ -175,6 +176,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) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); @@ -191,22 +225,20 @@ module.exports = React.createClass({ // email addresses and user IDs do not allow space, comma, semicolon so split // on them for bulk inviting. - var separators =[ ";", " ", "," ]; - for (var i = 0; i < separators.length; i++) { - if (inputText.indexOf(separators[i]) >= 0) { - var inputs = inputText.split(separators[i]); - inputs.forEach(function(input) { - self.onInvite(input); - }); - return; + // '+' here will treat multiple consecutive separators as one separator, so + // ', ' separators will also do the right thing. + const inputs = inputText.split(/[, ;]+/).filter((x) => { + return x.trim().length > 0; + }); + + let validInputs = 0; + for (const input of inputs) { + if (Invite.getAddressType(input) != null) { + ++validInputs; } } - var isEmailAddress = /^\S+@\S+\.\S+$/.test(inputText); - - // sanity check the input for user IDs - if (!isEmailAddress && (inputText[0] !== '@' || inputText.indexOf(":") === -1)) { - console.error("Bad ID to invite: %s", inputText); + if (validInputs == 0) { Modal.createDialog(ErrorDialog, { title: "Invite Error", description: "Malformed ID. Should be an email address or a Matrix ID like '@localpart:domain'" @@ -243,53 +275,23 @@ module.exports = React.createClass({ inviteWarningDefer.resolve(); } - var promise = inviteWarningDefer.promise; - if (isEmailAddress) { - promise = promise.then(function() { - return MatrixClientPeg.get().inviteByEmail(self.props.roomId, inputText); + const promise = inviteWarningDefer.promise; + + if (inputs.length == 1) { + // for a single address, we just send the invite + promise.done(() => { + this.doInvite(inputs[0]); }); - } - else { - promise = promise.then(function() { - return MatrixClientPeg.get().invite(self.props.roomId, inputText); + } 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, + }); }); } - - self.setState({ - inviting: true - }); - console.log( - "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 - setTimeout(function() { - var inviteBox = document.getElementById("mx_SearchableEntityList_query"); - if (inviteBox) { - inviteBox.focus(); - } - }, 0); - }); }, getMemberDict: function() {