diff --git a/src/Invite.js b/src/Invite.js index 3b52d6a1f46..64228127347 100644 --- a/src/Invite.js +++ b/src/Invite.js @@ -15,6 +15,7 @@ limitations under the License. */ import MatrixClientPeg from './MatrixClientPeg'; +import MultiInviter from './utils/MultiInviter'; const emailRegex = /^\S+@\S+\.\S+$/; @@ -43,3 +44,40 @@ export function inviteToRoom(roomId, addr) { throw new Error('Unsupported address'); } } + +/** + * Invites multiple addresses to a room + * Simpler interface to utils/MultiInviter but with + * no option to cancel. + * + * @param {roomId} The ID of the room to invite to + * @param {array} Array of strings of addresses to invite. May be matrix IDs or 3pids. + * @returns Promise + */ +export function inviteMultipleToRoom(roomId, addrs) { + this.inviter = new MultiInviter(roomId); + return this.inviter.invite(addrs); +} + +/** + * Checks is the supplied address is valid + * + * @param {addr} The mx userId or email address to check + * @returns true, false, or null for unsure + */ +export function isValidAddress(addr) { + // Check if the addr is a valid type + var addrType = this.getAddressType(addr); + if (addrType === "mx") { + let user = MatrixClientPeg.get().getUser(addr); + if (user) { + return true; + } else { + return null; + } + } else if (addrType === "email") { + return true; + } else { + return false; + } +} diff --git a/src/component-index.js b/src/component-index.js index 751332de1bd..11c711d239c 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -57,6 +57,7 @@ module.exports.components['views.dialogs.NeedToRegisterDialog'] = require('./com 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.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog'); +module.exports.components['views.elements.AddressSelector'] = require('./components/views/elements/AddressSelector'); module.exports.components['views.elements.AddressTile'] = require('./components/views/elements/AddressTile'); module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText'); module.exports.components['views.elements.EditableTextContainer'] = require('./components/views/elements/EditableTextContainer'); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 39e1d9b54cf..346ceefc895 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -385,6 +385,9 @@ module.exports = React.createClass({ case 'view_create_chat': this._createChat(); break; + case 'view_invite': + this._invite(payload.roomId); + break; case 'notifier_enabled': this.forceUpdate(); break; @@ -524,7 +527,17 @@ module.exports = React.createClass({ _createChat: function() { var ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog"); Modal.createDialog(ChatInviteDialog, { - title: "Start a one to one chat", + title: "Start a new chat", + }); + }, + + _invite: function(roomId) { + var ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog"); + Modal.createDialog(ChatInviteDialog, { + title: "Invite new room members", + button: "Send Invites", + description: "Who would you like to add to this room?", + roomId: roomId, }); }, diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js index b26c22f9a7c..7f04986b6b4 100644 --- a/src/components/views/dialogs/ChatInviteDialog.js +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -37,6 +37,7 @@ module.exports = React.createClass({ ]), value: React.PropTypes.string, placeholder: React.PropTypes.string, + roomId: React.PropTypes.string, button: React.PropTypes.string, focus: React.PropTypes.bool, onFinished: React.PropTypes.func.isRequired @@ -55,11 +56,9 @@ module.exports = React.createClass({ getInitialState: function() { return { - user: null, + error: false, + inviteList: [], queryList: [], - addressSelected: false, - selected: 0, - hover: false, }; }, @@ -71,44 +70,29 @@ module.exports = React.createClass({ this._updateUserList(); }, - componentDidUpdate: function() { - // As the user scrolls with the arrow keys keep the selected item - // at the top of the window. - if (this.scrollElement && !this.state.hover) { - var elementHeight = this.queryListElement.getBoundingClientRect().height; - this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight; - } - }, - - onStartChat: function() { - var addr; - - // Either an address tile was created, or text input is being used - if (this.state.user) { - addr = this.state.user.userId; - } else { - addr = this.refs.textinput.value; - } - - // Check if the addr is a valid type - if (Invite.getAddressType(addr) === "mx") { - var room = this._getDirectMessageRoom(addr); - if (room) { - // A Direct Message room already exists for this user and you - // so go straight to that room - dis.dispatch({ - action: 'view_room', - room_id: room.roomId, - }); - this.props.onFinished(true, addr); + onButtonClick: function() { + if (this.state.inviteList.length > 0) { + if (this._isDmChat()) { + // Direct Message chat + var room = this._getDirectMessageRoom(this.state.inviteList[0]); + if (room) { + // A Direct Message room already exists for this user and you + // so go straight to that room + dis.dispatch({ + action: 'view_room', + room_id: room.roomId, + }); + this.props.onFinished(true, this.state.inviteList[0]); + } else { + this._startChat(this.state.inviteList); + } } else { - this._startChat(addr); + // Multi invite chat + this._startChat(this.state.inviteList); } - } else if (Invite.getAddressType(addr) === "email") { - this._startChat(addr); } else { - // Nothing to do, so focus back on the textinput - this.refs.textinput.focus(); + // No addresses supplied + this.setState({ error: true }); } }, @@ -124,31 +108,28 @@ module.exports = React.createClass({ } else if (e.keyCode === 38) { // up arrow e.stopPropagation(); e.preventDefault(); - if (this.state.selected > 0) { - this.setState({ - selected: this.state.selected - 1, - hover : false, - }); - } + this.addressSelector.onKeyUpArrow(); } else if (e.keyCode === 40) { // down arrow e.stopPropagation(); e.preventDefault(); - if (this.state.selected < this._maxSelected(this.state.queryList)) { - this.setState({ - selected: this.state.selected + 1, - hover : false, - }); - } + this.addressSelector.onKeyDownArrow(); } else if (e.keyCode === 13) { // enter e.stopPropagation(); e.preventDefault(); - if (this.state.queryList.length > 0) { + this.addressSelector.onKeyReturn(); + } else if (e.keyCode === 32 || e.keyCode === 188) { // space or comma + e.stopPropagation(); + e.preventDefault(); + var check = Invite.isValidAddress(this.refs.textinput.value); + if (check === true || check === null) { + var inviteList = this.state.inviteList.slice(); + inviteList.push(this.refs.textinput.value); this.setState({ - user: this.state.queryList[this.state.selected], - addressSelected: true, + inviteList: inviteList, queryList: [], - hover : false, }); + } else { + this.setState({ error: true }); } } }, @@ -164,80 +145,38 @@ module.exports = React.createClass({ }); } - // Make sure the selected item isn't outside the list bounds - var selected = this.state.selected; - var maxSelected = this._maxSelected(queryList); - if (selected > maxSelected) { - selected = maxSelected; - } - this.setState({ queryList: queryList, - selected: selected, - }); - }, - - onDismissed: function() { - this.setState({ - user: null, - addressSelected: false, - selected: 0, - queryList: [], + error: false, }); }, - onClick: function(index) { + onDismissed: function(index) { var self = this; return function() { + var inviteList = self.state.inviteList.slice(); + inviteList.splice(index, 1); self.setState({ - user: self.state.queryList[index], - addressSelected: true, + inviteList: inviteList, queryList: [], - hover: false, }); - }; + } }, - onMouseEnter: function(index) { + onClick: function(index) { var self = this; return function() { - self.setState({ - selected: index, - hover: true, - }); + self.onSelected(index); }; }, - onMouseLeave: function() { - this.setState({ hover : false }); - }, - - createQueryListTiles: function() { - var self = this; - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - var AddressTile = sdk.getComponent("elements.AddressTile"); - var maxSelected = this._maxSelected(this.state.queryList); - var queryList = []; - - // Only create the query elements if there are queries - if (this.state.queryList.length > 0) { - for (var i = 0; i <= maxSelected; i++) { - var classes = classNames({ - "mx_ChatInviteDialog_queryListElement": true, - "mx_ChatInviteDialog_selected": this.state.selected === i, - }); - - // NOTE: Defaulting to "vector" as the network, until the network backend stuff is done. - // Saving the queryListElement so we can use it to work out, in the componentDidUpdate - // method, how far to scroll when using the arrow keys - queryList.push( -
{ this.queryListElement = ref; }} > - -
- ); - } - } - return queryList; + onSelected: function(index) { + var inviteList = this.state.inviteList.slice(); + inviteList.push(this.state.queryList[index].userId); + this.setState({ + inviteList: inviteList, + queryList: [], + }); }, _getDirectMessageRoom: function(addr) { @@ -258,21 +197,50 @@ module.exports = React.createClass({ return null; }, - _startChat: function(addr) { - // Start the chat - createRoom({dmUserId: addr}) - .catch(function(err) { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Failure to invite user", - description: err.toString() - }); - return null; - }) - .done(); + _startChat: function(addrs) { + if (this.props.roomId) { + // Invite new user to a room + Invite.inviteMultipleToRoom(this.props.roomId, addrs) + .catch(function(err) { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failure to invite user", + description: err.toString() + }); + return null; + }) + .done(); + } else if (this._isDmChat()) { + // Start the DM chat + createRoom({dmUserId: addrs[0]}) + .catch(function(err) { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failure to invite user", + description: err.toString() + }); + return null; + }) + .done(); + } else { + // Start multi user chat + var self = this; + createRoom().then(function(roomId) { + return Invite.inviteMultipleToRoom(roomId, addrs); + }) + .catch(function(err) { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failure to invite user", + description: err.toString() + }); + return null; + }) + .done(); + } // Close - this will happen before the above, as that is async - this.props.onFinished(true, addr); + this.props.onFinished(true, addrs); }, _updateUserList: new rate_limited_func(function() { @@ -280,18 +248,17 @@ module.exports = React.createClass({ this._userList = MatrixClientPeg.get().getUsers(); }, 500), - _maxSelected: function(list) { - var listSize = list.length === 0 ? 0 : list.length - 1; - var maxSelected = listSize > (TRUNCATE_QUERY_LIST - 1) ? (TRUNCATE_QUERY_LIST - 1) : listSize - return maxSelected; - }, - // This is the search algorithm for matching users _matches: function(query, user) { var name = user.displayName.toLowerCase(); var uid = user.userId.toLowerCase(); query = query.toLowerCase(); + // dount match any that are already on the invite list + if (this._isOnInviteList(uid)) { + return false; + } + // direct prefix matches if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) { return true; @@ -312,37 +279,63 @@ module.exports = React.createClass({ return false; }, + _isOnInviteList: function(uid) { + for (let i = 0; i < this.state.inviteList.length; i++) { + if (this.state.inviteList[i].toLowerCase() === uid) { + return true; + } + } + return false; + }, + + _isDmChat: function() { + if (this.state.inviteList.length === 1 && Invite.getAddressType(this.state.inviteList[0]) === "mx" && !this.props.roomId) { + return true; + } else { + return false; + } + }, + render: function() { var TintableSvg = sdk.getComponent("elements.TintableSvg"); + var AddressSelector = sdk.getComponent("elements.AddressSelector"); this.scrollElement = null; - var query; - if (this.state.addressSelected) { + var query = []; + // create the invite list + if (this.state.inviteList.length > 0) { var AddressTile = sdk.getComponent("elements.AddressTile"); - query = ( - - ); - } else { - query = ( - - ); + for (let i = 0; i < this.state.inviteList.length; i++) { + query.push( + + ); + } } - var queryList; - var queryListElements = this.createQueryListTiles(); - if (queryListElements.length > 0) { - queryList = ( -
{this.scrollElement = ref}}> - { queryListElements } -
+ // Add the query at the end + query.push( + + ); + + var error; + var addressSelector; + if (this.state.error) { + error =
You have entered an invalid contact. Try using their Matrix ID or email address.
+ } else { + addressSelector = ( + {this.addressSelector = ref}} + addressList={ this.state.queryList } + onSelected={ this.onSelected } + truncateAt={ TRUNCATE_QUERY_LIST } /> ); } @@ -359,10 +352,11 @@ module.exports = React.createClass({
{ query }
- { queryList } + { error } + { addressSelector }
-
diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js new file mode 100644 index 00000000000..204e08404e7 --- /dev/null +++ b/src/components/views/elements/AddressSelector.js @@ -0,0 +1,154 @@ +/* +Copyright 2015, 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. +*/ + +'use strict'; + +var React = require("react"); +var sdk = require("../../../index"); +var classNames = require('classnames'); + +module.exports = React.createClass({ + displayName: 'AddressSelector', + + propTypes: { + onSelected: React.PropTypes.func.isRequired, + addressList: React.PropTypes.array.isRequired, + truncateAt: React.PropTypes.number.isRequired, + selected: React.PropTypes.number, + }, + + getInitialState: function() { + return { + selected: this.props.selected === undefined ? 0 : this.props.selected, + hover: false, + }; + }, + + componentWillReceiveProps: function(props) { + // Make sure the selected item isn't outside the list bounds + var selected = this.state.selected; + var maxSelected = this._maxSelected(props.addressList); + if (selected > maxSelected) { + this.setState({ selected: maxSelected }); + } + }, + + componentDidUpdate: function() { + // As the user scrolls with the arrow keys keep the selected item + // at the top of the window. + if (this.scrollElement && this.props.addressList.length > 0 && !this.state.hover) { + var elementHeight = this.addressListElement.getBoundingClientRect().height; + this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight; + } + }, + + onKeyUpArrow: function() { + if (this.state.selected > 0) { + this.setState({ + selected: this.state.selected - 1, + hover : false, + }); + } + }, + + onKeyDownArrow: function() { + if (this.state.selected < this._maxSelected(this.props.addressList)) { + this.setState({ + selected: this.state.selected + 1, + hover : false, + }); + } + }, + + onKeyReturn: function() { + this.selectAddress(this.state.selected); + }, + + onClick: function(index) { + var self = this; + return function() { + self.selectAddress(index); + }; + }, + + onMouseEnter: function(index) { + var self = this; + return function() { + self.setState({ + selected: index, + hover: true, + }); + }; + }, + + onMouseLeave: function() { + this.setState({ hover : false }); + }, + + selectAddress: function(index) { + // Only try to select an address if one exists + if (this.props.addressList.length !== 0) { + this.props.onSelected(index); + this.setState({ hover: false }); + } + }, + + createAddressListTiles: function() { + var self = this; + var AddressTile = sdk.getComponent("elements.AddressTile"); + var maxSelected = this._maxSelected(this.props.addressList); + var addressList = []; + + // Only create the address elements if there are address + if (this.props.addressList.length > 0) { + for (var i = 0; i <= maxSelected; i++) { + var classes = classNames({ + "mx_AddressSelector_addressListElement": true, + "mx_AddressSelector_selected": this.state.selected === i, + }); + + // NOTE: Defaulting to "vector" as the network, until the network backend stuff is done. + // Saving the addressListElement so we can use it to work out, in the componentDidUpdate + // method, how far to scroll when using the arrow keys + addressList.push( +
{ this.addressListElement = ref; }} > + +
+ ); + } + } + return addressList; + }, + + _maxSelected: function(list) { + var listSize = list.length === 0 ? 0 : list.length - 1; + var maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize + return maxSelected; + }, + + render: function() { + var classes = classNames({ + "mx_AddressSelector": true, + "mx_AddressSelector_empty": this.props.addressList.length === 0, + }); + + return ( +
{this.scrollElement = ref}}> + { this.createAddressListTiles() } +
+ ); + } +}); diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index e0a5dbbc801..f81bb3dff08 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -19,13 +19,15 @@ limitations under the License. var React = require('react'); var classNames = require('classnames'); var sdk = require("../../../index"); +var Invite = require("../../../Invite"); +var MatrixClientPeg = require("../../../MatrixClientPeg"); var Avatar = require('../../../Avatar'); module.exports = React.createClass({ displayName: 'AddressTile', propTypes: { - user: React.PropTypes.object.isRequired, + address: React.PropTypes.string.isRequired, canDismiss: React.PropTypes.bool, onDismissed: React.PropTypes.func, justified: React.PropTypes.bool, @@ -44,11 +46,30 @@ module.exports = React.createClass({ }, render: function() { + var userId, name, imgUrl, email; var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); var TintableSvg = sdk.getComponent("elements.TintableSvg"); - var userId = this.props.user.userId; - var name = this.props.user.displayName || userId; - var imgUrl = Avatar.avatarUrlForUser(this.props.user, 25, 25, "crop"); + + // Check if the addr is a valid type + var addrType = Invite.getAddressType(this.props.address); + if (addrType === "mx") { + let user = MatrixClientPeg.get().getUser(this.props.address); + if (user) { + userId = user.userId; + name = user.displayName || userId; + imgUrl = Avatar.avatarUrlForUser(user, 25, 25, "crop"); + } else { + name=this.props.address; + imgUrl = "img/icon-mx-user.svg"; + } + } else if (addrType === "email") { + email = this.props.address; + name="email"; + imgUrl = "img/icon-email-user.svg"; + } else { + name="Unknown"; + imgUrl = "img/avatar-error.svg"; + } var network; if (this.props.networkUrl !== "") { @@ -59,6 +80,60 @@ module.exports = React.createClass({ ); } + var info; + var error = false; + if (addrType === "mx" && userId) { + var nameClasses = classNames({ + "mx_AddressTile_name": true, + "mx_AddressTile_justified": this.props.justified, + }); + + var idClasses = classNames({ + "mx_AddressTile_id": true, + "mx_AddressTile_justified": this.props.justified, + }); + + info = ( +
+
{ name }
+
{ userId }
+
+ ); + } else if (addrType === "mx") { + var unknownMxClasses = classNames({ + "mx_AddressTile_unknownMx": true, + "mx_AddressTile_justified": this.props.justified, + }); + + info = ( +
{ this.props.address }
+ ); + } else if (email) { + var emailClasses = classNames({ + "mx_AddressTile_email": true, + "mx_AddressTile_justified": this.props.justified, + }); + + info = ( +
{ email }
+ ); + } else { + error = true; + var unknownClasses = classNames({ + "mx_AddressTile_unknown": true, + "mx_AddressTile_justified": this.props.justified, + }); + + info = ( +
Unknown Address
+ ); + } + + var classes = classNames({ + "mx_AddressTile": true, + "mx_AddressTile_error": error, + }); + var dismiss; if (this.props.canDismiss) { dismiss = ( @@ -68,24 +143,13 @@ module.exports = React.createClass({ ); } - var nameClasses = classNames({ - "mx_AddressTile_name": true, - "mx_AddressTile_justified": this.props.justified, - }); - - var idClasses = classNames({ - "mx_AddressTile_id": true, - "mx_AddressTile_justified": this.props.justified, - }); - return ( -
+
{ network }
-
{ name }
-
{ userId }
+ { info } { dismiss }
); diff --git a/src/utils/MultiInviter.js b/src/utils/MultiInviter.js new file mode 100644 index 00000000000..68a0800ed7d --- /dev/null +++ b/src/utils/MultiInviter.js @@ -0,0 +1,144 @@ +/* +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 {getAddressType, inviteToRoom} from '../Invite'; +import q from 'q'; + +/** + * Invites multiple addresses to a room, handling rate limiting from the server + */ +export default class MultiInviter { + constructor(roomId) { + this.roomId = roomId; + + this.canceled = false; + this.addrs = []; + this.busy = false; + this.completionStates = {}; // State of each address (invited or error) + this.errorTexts = {}; // Textual error per address + this.deferred = null; + } + + /** + * Invite users to this room. This may only be called once per + * instance of the class. + * + * The promise is given progress when each address completes, with an + * object argument with each completed address with value either + * 'invited' or 'error'. + * + * @param {array} addresses Array of addresses to invite + * @returns {Promise} Resolved when all invitations in the queue are complete + */ + invite(addrs) { + if (this.addrs.length > 0) { + throw new Error("Already inviting/invited"); + } + this.addrs.push(...addrs); + + for (const addr of this.addrs) { + if (getAddressType(addr) === null) { + this.completionStates[addr] = 'error'; + this.errorTexts[addr] = 'Unrecognised address'; + } + } + this.deferred = q.defer(); + this._inviteMore(0); + + return this.deferred.promise; + } + + /** + * Stops inviting. Causes promises returned by invite() to be rejected. + */ + cancel() { + if (!this.busy) return; + + this._canceled = true; + this.deferred.reject(new Error('canceled')); + } + + getCompletionState(addr) { + return this.completionStates[addr]; + } + + getErrorText(addr) { + return this.errorTexts[addr]; + } + + _inviteMore(nextIndex) { + if (this._canceled) { + return; + } + + if (nextIndex == this.addrs.length) { + this.busy = false; + this.deferred.resolve(this.completionStates); + return; + } + + const addr = this.addrs[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(addr) === 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.completionStates[addr] == 'invited') { + this._inviteMore(nextIndex + 1); + return; + } + + inviteToRoom(this.roomId, addr).then(() => { + if (this._canceled) { return; } + + this.completionStates[addr] = 'invited'; + this.deferred.notify(this.completionStates); + + 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.completionStates[addr] = 'error'; + this.errorTexts[addr] = errorText; + this.busy = !fatal; + + if (!fatal) { + this.deferred.notify(this.completionStates); + this._inviteMore(nextIndex + 1); + } + }); + } +}