diff --git a/src/Invite.js b/src/Invite.js
index 3b52d6a1f4..6422812734 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 751332de1b..11c711d239 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 39e1d9b54c..346ceefc89 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 b26c22f9a7..7f04986b6b 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,
+ error: false,
});
},
- onDismissed: function() {
- this.setState({
- user: null,
- addressSelected: false,
- selected: 0,
- queryList: [],
- });
+ onDismissed: function(index) {
+ var self = this;
+ return function() {
+ var inviteList = self.state.inviteList.slice();
+ inviteList.splice(index, 1);
+ self.setState({
+ inviteList: inviteList,
+ queryList: [],
+ });
+ }
},
onClick: function(index) {
var self = this;
return function() {
- self.setState({
- user: self.state.queryList[index],
- addressSelected: true,
- queryList: [],
- hover: false,
- });
+ self.onSelected(index);
};
},
- onMouseEnter: function(index) {
- var self = this;
- return function() {
- self.setState({
- selected: index,
- hover: true,
- });
- };
- },
-
- 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 0000000000..204e08404e
--- /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 e0a5dbbc80..f81bb3dff0 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 0000000000..68a0800ed7
--- /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);
+ }
+ });
+ }
+}