diff --git a/CHANGELOG.md b/CHANGELOG.md
index 852b6b75c..785462239 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
Changes in 0.1.2
================
+Breaking changes:
+ * `MatrixClient.setPowerLevel` now expects a `MatrixEvent` and not an `Object`
+ for the `event` parameter.
+
New features:
* Added `EventStatus.QUEUED` which is set on an event when it is waiting to be
sent by the scheduler and there are other events in front.
diff --git a/examples/voip/README.md b/examples/voip/README.md
new file mode 100644
index 000000000..c3f3d67dd
--- /dev/null
+++ b/examples/voip/README.md
@@ -0,0 +1,9 @@
+To try it out, **you must build the SDK first** and then host this folder:
+
+```
+ $ npm run build
+ $ cd examples/voip
+ $ python -m SimpleHTTPServer 8003
+```
+
+Then visit ``http://localhost:8003``.
diff --git a/examples/voip/browserTest.js b/examples/voip/browserTest.js
new file mode 100644
index 000000000..53b19920c
--- /dev/null
+++ b/examples/voip/browserTest.js
@@ -0,0 +1,89 @@
+"use strict";
+console.log("Loading browser sdk");
+var BASE_URL = "https://matrix.org";
+var TOKEN = "accesstokengoeshere";
+var USER_ID = "@username:localhost";
+var ROOM_ID = "!room:id";
+
+
+var client = matrixcs.createClient({
+ baseUrl: BASE_URL,
+ accessToken: TOKEN,
+ userId: USER_ID
+});
+var call;
+
+function disableButtons(place, answer, hangup) {
+ document.getElementById("hangup").disabled = hangup;
+ document.getElementById("answer").disabled = answer;
+ document.getElementById("call").disabled = place;
+}
+
+function addListeners(call) {
+ var lastError = "";
+ call.on("hangup", function() {
+ disableButtons(false, true, true);
+ document.getElementById("result").innerHTML = (
+ "
Call ended. Last error: "+lastError+"
"
+ );
+ });
+ call.on("error", function(err) {
+ lastError = err.message;
+ call.hangup();
+ disableButtons(false, true, true);
+ });
+}
+
+window.onload = function() {
+ document.getElementById("result").innerHTML = "Please wait. Syncing...
";
+ document.getElementById("config").innerHTML = "" +
+ "Homeserver: "+BASE_URL+" "+
+ "Room: "+ROOM_ID+" "+
+ "User: "+USER_ID+" "+
+ "
";
+ disableButtons(true, true, true);
+};
+
+client.on("syncComplete", function () {
+ document.getElementById("result").innerHTML = "Ready for calls.
";
+ disableButtons(false, true, true);
+
+ document.getElementById("call").onclick = function() {
+ console.log("Placing call...");
+ call = matrixcs.createNewMatrixCall(
+ client, ROOM_ID
+ );
+ console.log("Call => %s", call);
+ addListeners(call);
+ call.placeVideoCall(
+ document.getElementById("remote"),
+ document.getElementById("local")
+ );
+ document.getElementById("result").innerHTML = "Placed call.
";
+ disableButtons(true, true, false);
+ };
+
+ document.getElementById("hangup").onclick = function() {
+ console.log("Hanging up call...");
+ console.log("Call => %s", call);
+ call.hangup();
+ document.getElementById("result").innerHTML = "Hungup call.
";
+ };
+
+ document.getElementById("answer").onclick = function() {
+ console.log("Answering call...");
+ console.log("Call => %s", call);
+ call.answer();
+ disableButtons(true, true, false);
+ document.getElementById("result").innerHTML = "Answered call.
";
+ };
+
+ client.on("Call.incoming", function(c) {
+ console.log("Call ringing");
+ disableButtons(true, false, false);
+ document.getElementById("result").innerHTML = "Incoming call...
";
+ call = c;
+ addListeners(call);
+ });
+});
+client.startClient();
diff --git a/examples/voip/index.html b/examples/voip/index.html
new file mode 100644
index 000000000..a3259cfa1
--- /dev/null
+++ b/examples/voip/index.html
@@ -0,0 +1,26 @@
+
+
+VoIP Test
+
+
+
+
+ You can place and receive calls with this example. Make sure to edit the
+ constants in browserTest.js first.
+
+
+ Place Call
+ Answer Call
+ Hangup Call
+
+
+
+
diff --git a/examples/voip/lib/matrix.js b/examples/voip/lib/matrix.js
new file mode 120000
index 000000000..e4b1b6ecb
--- /dev/null
+++ b/examples/voip/lib/matrix.js
@@ -0,0 +1 @@
+../../../dist/browser-matrix-dev.js
\ No newline at end of file
diff --git a/lib/client.js b/lib/client.js
index 400cc8bf8..a2eff6fa1 100644
--- a/lib/client.js
+++ b/lib/client.js
@@ -15,6 +15,7 @@ var EventStatus = require("./models/event").EventStatus;
var StubStore = require("./store/stub");
var Room = require("./models/room");
var User = require("./models/user");
+var webRtcCall = require("./webrtc/call");
var utils = require("./utils");
// TODO: package this somewhere separate.
@@ -50,8 +51,8 @@ var OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
function MatrixClient(opts) {
utils.checkObjectHasKeys(opts, ["baseUrl", "request"]);
utils.checkObjectHasNoAdditionalKeys(opts, [
- "baseUrl", "request", "accessToken", "userId", "store", "scheduler",
- "sessionStore", "deviceId"
+ "baseUrl", "idBaseUrl", "request", "accessToken", "userId", "store",
+ "scheduler", "sessionStore", "deviceId"
]);
this.store = opts.store || new StubStore();
@@ -110,6 +111,7 @@ function MatrixClient(opts) {
var httpOpts = {
baseUrl: opts.baseUrl,
+ idBaseUrl: opts.idBaseUrl,
accessToken: opts.accessToken,
request: opts.request,
prefix: httpApi.PREFIX_V1,
@@ -122,6 +124,19 @@ function MatrixClient(opts) {
this._syncingRooms = {
// room_id: Promise
};
+ this.callList = {
+ // callId: MatrixCall
+ };
+
+ // try constructing a MatrixCall to see if we are running in an environment
+ // which has WebRTC. If we are, listen for and handle m.call.* events.
+ var call = webRtcCall.createNewMatrixCall(this);
+ this._supportsVoip = false;
+ if (call) {
+ setupCallEventHandler(this);
+ this._supportsVoip = true;
+ }
+
}
utils.inherits(MatrixClient, EventEmitter);
@@ -343,6 +358,16 @@ MatrixClient.prototype.getRooms = function() {
return this.store.getRooms();
};
+/**
+ * Retrieve a user.
+ * @param {string} userId The user ID to retrieve.
+ * @return {?User} A user or null if there is no data store or the user does
+ * not exist.
+ */
+MatrixClient.prototype.getUser = function(userId) {
+ return this.store.getUser(userId);
+};
+
// Room operations
// ===============
@@ -435,7 +460,7 @@ MatrixClient.prototype.setRoomTopic = function(roomId, topic, callback) {
* @param {string} roomId
* @param {string} userId
* @param {Number} powerLevel
- * @param {Object} event
+ * @param {MatrixEvent} event
* @param {module:client.callback} callback Optional.
* @return {module:client.Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response.
@@ -445,8 +470,10 @@ MatrixClient.prototype.setPowerLevel = function(roomId, userId, powerLevel,
var content = {
users: {}
};
- if (event && event.type === "m.room.power_levels") {
- content = event.content;
+ if (event && event.getType() === "m.room.power_levels") {
+ // take a copy of the content to ensure we don't corrupt
+ // existing client state with a failed power level change
+ content = utils.deepCopy(event.getContent());
}
content.users[userId] = powerLevel;
var path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", {
@@ -1134,18 +1161,47 @@ MatrixClient.prototype.setAvatarUrl = function(url, callback) {
*/
MatrixClient.prototype.getAvatarUrlForMember =
function(member, width, height, resizeMethod) {
- if (!member.events.member) {
+ if (!member || !member.events.member) {
return null;
}
var rawUrl = member.events.member.getContent().avatar_url;
if (rawUrl) {
return this._http.getHttpUriForMxc(rawUrl, width, height, resizeMethod);
+ } else {
+ return this._http.getIdenticonUri(member.userId, width, height);
}
return null;
};
/**
- * Turn an MXC URL into an HTP one
+ * Get the avatar URL for a room.
+ * @param {module:room.Room} room
+ * @param {Number} width The desired width of the thumbnail.
+ * @param {Number} height The desired height of the thumbnail.
+ * @param {string} resizeMethod The thumbnail resize method to use, either
+ * "crop" or "scale".
+ * @return {?string} the avatar URL or null.
+ */
+MatrixClient.prototype.getAvatarUrlForRoom =
+ function(room, width, height, resizeMethod) {
+
+ if (!room || !room.currentState || !room.currentState.members) {
+ return null;
+ }
+
+ var userId = this.credentials.userId;
+ var members = utils.filter(room.currentState.getMembers(), function(m) {
+ return (m.membership === "join" && m.userId !== userId);
+ });
+
+ if (members[0]) {
+ return this.getAvatarUrlForMember(members[0], width, height, resizeMethod);
+ }
+ return null;
+};
+
+/**
+ * Turn an MXC URL into an HTTP one
* @param {string} mxcUrl The MXC URL
* @param {Number} width The desired width of the thumbnail.
* @param {Number} height The desired height of the thumbnail.
@@ -1188,6 +1244,25 @@ MatrixClient.prototype.addThreePid = function(creds, bind, callback) {
);
};
+/**
+ * @param {Object} authDict
+ * @param {string} newPassword
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves: TODO
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype.setPassword = function(authDict, newPassword, callback) {
+ var path = "/account/password";
+ var data = {
+ 'auth': authDict,
+ 'new_password': newPassword
+ };
+
+ return this._http.authedRequestWithPrefix(
+ callback, "POST", path, null, data, httpApi.PREFIX_V2_ALPHA
+ );
+};
+
/**
* @param {string} presence
* @param {module:client.callback} callback Optional.
@@ -1223,15 +1298,6 @@ MatrixClient.prototype.publicRooms = function(callback) {
return this._http.request(callback, "GET", "/publicRooms");
};
-/**
- * @param {module:client.callback} callback Optional.
- * @return {module:client.Promise} Resolves: TODO
- * @return {module:http-api.MatrixError} Rejects: with an error response.
- */
-MatrixClient.prototype.registerFlows = function(callback) {
- return this._http.request(callback, "GET", "/register");
-};
-
/**
* @param {module:client.callback} callback Optional.
* @return {module:client.Promise} Resolves: TODO
@@ -1353,16 +1419,28 @@ MatrixClient.prototype.login = function(loginType, data, callback) {
};
/**
- * @param {string} loginType
- * @param {Object} data
+ * @param {string} username
+ * @param {string} password
+ * @param {string} sessionId
+ * @param {Object} auth
* @param {module:client.callback} callback Optional.
* @return {module:client.Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
-MatrixClient.prototype.register = function(loginType, data, callback) {
- data.type = loginType;
- return this._http.authedRequest(
- callback, "POST", "/register", undefined, data
+MatrixClient.prototype.register = function(username, password,
+ sessionId, auth, callback) {
+ if (auth === undefined) { auth = {}; }
+ if (sessionId) { auth.session = sessionId; }
+
+ var params = {
+ auth: auth
+ };
+ if (username !== undefined) { params.username = username; }
+ if (password !== undefined) { params.password = password; }
+
+ return this._http.requestWithPrefix(
+ callback, "POST", "/register", undefined,
+ params, httpApi.PREFIX_V2_ALPHA
);
};
@@ -1380,6 +1458,18 @@ MatrixClient.prototype.loginWithPassword = function(user, password, callback) {
}, callback);
};
+/**
+ * @param {string} relayState URL Callback after SAML2 Authentication
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves: TODO
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype.loginWithSAML2 = function(relayState, callback) {
+ return this.login("m.login.saml2", {
+ relay_state: relayState
+ }, callback);
+};
+
// Push operations
// ===============
@@ -1441,6 +1531,14 @@ MatrixClient.prototype.turnServer = function(callback) {
return this._http.authedRequest(callback, "GET", "/voip/turnServer");
};
+/**
+ * Get the TURN servers for this home server.
+ * @return {Array} The servers or an empty list.
+ */
+MatrixClient.prototype.getTurnServers = function() {
+ return this._turnServers || [];
+};
+
/**
* @return {boolean} true if there is a valid access_token for this client.
*/
@@ -1544,6 +1642,7 @@ function doInitialSync(client, historyLen) {
client.emit("syncComplete");
_pollForEvents(client);
}, function(err) {
+ console.error("/initialSync error: %s", err);
client.emit("syncError", err);
// TODO: Retries.
});
@@ -1568,6 +1667,9 @@ MatrixClient.prototype.startClient = function(historyLen) {
return;
}
+ // periodically poll for turn servers if we support voip
+ checkTurnServers(this);
+
var self = this;
this.pushRules().done(function(result) {
self.pushRules = result;
@@ -1589,6 +1691,7 @@ function _pollForEvents(client) {
var discardResult = false;
var timeoutObj = setTimeout(function() {
discardResult = true;
+ console.error("/events request timed out.");
_pollForEvents(client);
}, 40000);
@@ -1619,6 +1722,17 @@ function _pollForEvents(client) {
}
roomIdToEvents[roomId].push(events[i]);
}
+ else if (events[i].getType() === "m.presence") {
+ var usr = self.store.getUser(events[i].getContent().user_id);
+ if (usr) {
+ usr.setPresenceEvent(events[i]);
+ }
+ else {
+ usr = createNewUser(self, events[i].getContent().user_id);
+ usr.setPresenceEvent(events[i]);
+ self.store.storeUser(usr);
+ }
+ }
}
// add events to room
var roomIds = utils.keys(roomIdToEvents);
@@ -1663,6 +1777,7 @@ function _pollForEvents(client) {
}
_pollForEvents(self);
}, function(err) {
+ console.error("/events error: %s", JSON.stringify(err));
if (discardResult) {
return;
}
@@ -1755,6 +1870,187 @@ function reEmit(reEmitEntity, emittableEntity, eventNames) {
});
}
+function setupCallEventHandler(client) {
+ var candidatesByCall = {
+ // callId: [Candidate]
+ };
+ client.on("event", function(event) {
+ if (event.getType().indexOf("m.call.") !== 0) {
+ return; // not a call event
+ }
+ var content = event.getContent();
+ var call = content.call_id ? client.callList[content.call_id] : undefined;
+ var i;
+
+ if (event.getType() === "m.call.invite") {
+ if (event.getSender() === client.credentials.userId) {
+ return; // ignore invites you send
+ }
+
+ if (event.getAge() > content.lifetime) {
+ return; // expired call
+ }
+
+ if (call && call.state === "ended") {
+ return; // stale/old invite event
+ }
+ if (call) {
+ console.log(
+ "WARN: Already have a MatrixCall with id %s but got an " +
+ "invite. Clobbering.",
+ content.call_id
+ );
+ }
+
+ call = webRtcCall.createNewMatrixCall(client, event.getRoomId());
+ if (!call) {
+ console.log(
+ "Incoming call ID " + content.call_id + " but this client " +
+ "doesn't support WebRTC"
+ );
+ // don't hang up the call: there could be other clients
+ // connected that do support WebRTC and declining the
+ // the call on their behalf would be really annoying.
+ return;
+ }
+
+ call.callId = content.call_id;
+ call._initWithInvite(event);
+ client.callList[call.callId] = call;
+
+ // if we stashed candidate events for that call ID, play them back now
+ if (candidatesByCall[call.callId]) {
+ for (i = 0; i < candidatesByCall[call.callId].length; i++) {
+ call._gotRemoteIceCandidate(
+ candidatesByCall[call.callId][i]
+ );
+ }
+ }
+
+ // Were we trying to call that user (room)?
+ var existingCall;
+ var existingCalls = utils.values(client.callList);
+ for (i = 0; i < existingCalls.length; ++i) {
+ var thisCall = existingCalls[i];
+ if (call.room_id === thisCall.room_id &&
+ thisCall.direction === 'outbound' &&
+ (["wait_local_media", "create_offer", "invite_sent"].indexOf(
+ thisCall.state) !== -1)) {
+ existingCall = thisCall;
+ break;
+ }
+ }
+
+ if (existingCall) {
+ // If we've only got to wait_local_media or create_offer and
+ // we've got an invite, pick the incoming call because we know
+ // we haven't sent our invite yet otherwise, pick whichever
+ // call has the lowest call ID (by string comparison)
+ if (existingCall.state === 'wait_local_media' ||
+ existingCall.state === 'create_offer' ||
+ existingCall.callId > call.callId) {
+ console.log(
+ "Glare detected: answering incoming call " + call.callId +
+ " and canceling outgoing call " + existingCall.callId
+ );
+ existingCall._replacedBy(call);
+ call.answer();
+ }
+ else {
+ console.log(
+ "Glare detected: rejecting incoming call " + call.callId +
+ " and keeping outgoing call " + existingCall.callId
+ );
+ call.hangup();
+ }
+ }
+ else {
+ client.emit("Call.incoming", call);
+ }
+ }
+ else if (event.getType() === 'm.call.answer') {
+ if (!call) {
+ return;
+ }
+ if (event.getSender() === client.credentials.userId) {
+ if (call.state === 'ringing') {
+ call._onAnsweredElsewhere(content);
+ }
+ }
+ else {
+ call._receivedAnswer(content);
+ }
+ }
+ else if (event.getType() === 'm.call.candidates') {
+ if (event.getSender() === client.credentials.userId) {
+ return;
+ }
+ if (!call) {
+ // store the candidates; we may get a call eventually.
+ if (!candidatesByCall[content.call_id]) {
+ candidatesByCall[content.call_id] = [];
+ }
+ candidatesByCall[content.call_id] = candidatesByCall[
+ content.call_id
+ ].concat(content.candidates);
+ }
+ else {
+ for (i = 0; i < content.candidates.length; i++) {
+ call._gotRemoteIceCandidate(content.candidates[i]);
+ }
+ }
+ }
+ else if (event.getType() === 'm.call.hangup') {
+ // Note that we also observe our own hangups here so we can see
+ // if we've already rejected a call that would otherwise be valid
+ if (!call) {
+ // if not live, store the fact that the call has ended because
+ // we're probably getting events backwards so
+ // the hangup will come before the invite
+ call = webRtcCall.createNewMatrixCall(client, event.getRoomId());
+ if (call) {
+ call.callId = content.call_id;
+ call._initWithHangup(event);
+ client.callList[content.call_id] = call;
+ }
+ }
+ else {
+ if (call.state !== 'ended') {
+ call._onHangupReceived(content);
+ delete client.callList[content.call_id];
+ }
+ }
+ }
+ });
+}
+
+function checkTurnServers(client) {
+ if (!client._supportsVoip) {
+ return;
+ }
+ client.turnServer().done(function(res) {
+ if (res.uris) {
+ console.log("Got TURN URIs: " + res.uris + " refresh in " +
+ res.ttl + " secs");
+ // map the response to a format that can be fed to
+ // RTCPeerConnection
+ var servers = {
+ urls: res.uris,
+ username: res.username,
+ credential: res.password
+ };
+ client._turnServers = [servers];
+ // re-fetch when we're about to reach the TTL
+ setTimeout(function() { checkTurnServers(client); },
+ (res.ttl || (60 * 60)) * 1000 * 0.9
+ );
+ }
+ }, function(err) {
+ console.error("Failed to get TURN URIs");
+ setTimeout(function() { checkTurnServers(client); }, 60000);
+ });
+}
+
function createNewUser(client, userId) {
var user = new User(userId);
reEmit(client, user, ["User.avatarUrl", "User.displayName", "User.presence"]);
@@ -1809,6 +2105,47 @@ function _PojoToMatrixEventMapper(client) {
return mapper;
}
+// Identity Server Operations
+// ==========================
+
+/**
+ * @param {string} email
+ * @param {string} clientSecret
+ * @param {string} sendAttempt
+ * @param {string} nextLink Optional
+ * @param {module:client.callback} callback Optional.
+ * @return {module:client.Promise} Resolves: TODO
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixClient.prototype.requestEmailToken = function(email, clientSecret,
+ sendAttempt, nextLink, callback) {
+ var params = {
+ client_secret: clientSecret,
+ email: email,
+ send_attempt: sendAttempt,
+ next_link: nextLink
+ };
+ return this._http.idServerRequest(
+ callback, "POST", "/validate/email/requestToken",
+ params, httpApi.PREFIX_IDENTITY_V1
+ );
+};
+
+/**
+ * Generates a random string suitable for use as a client secret
+ * @return {string} A new client secret
+ */
+MatrixClient.prototype.generateClientSecret = function() {
+ var ret = "";
+ var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+
+ for (var i = 0; i < 32; i++) {
+ ret += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+
+ return ret;
+};
+
/** */
module.exports.MatrixClient = MatrixClient;
@@ -1857,6 +2194,16 @@ module.exports.MatrixClient = MatrixClient;
* });
*/
+/**
+ * Fires whenever an incoming call arrives.
+ * @event module:client~MatrixClient#"Call.incoming"
+ * @param {MatrixCall} call The incoming call.
+ * @example
+ * matrixClient.on("Call.incoming", function(call){
+ * call.answer(); // auto-answer
+ * });
+ */
+
// EventEmitter JSDocs
/**
diff --git a/lib/http-api.js b/lib/http-api.js
index 5c8b7b0d5..d1a90d7f2 100644
--- a/lib/http-api.js
+++ b/lib/http-api.js
@@ -23,6 +23,11 @@ module.exports.PREFIX_V1 = "/_matrix/client/api/v1";
*/
module.exports.PREFIX_V2_ALPHA = "/_matrix/client/v2_alpha";
+/**
+ * URI path for the identity API
+ */
+module.exports.PREFIX_IDENTITY_V1 = "/_matrix/identity/api/v1";
+
/**
* Construct a MatrixHttpApi.
* @constructor
@@ -140,7 +145,7 @@ module.exports.MatrixHttpApi.prototype = {
/**
* Upload content to the Home Server
- * @param {File object} file A File object (in a browser) or in Node,
+ * @param {File} file A File object (in a browser) or in Node,
an object with properties:
name: The file's name
stream: A read stream
@@ -216,6 +221,36 @@ module.exports.MatrixHttpApi.prototype = {
return defer.promise;
},
+ idServerRequest: function(callback, method, path, params, prefix) {
+ var fullUri = this.opts.idBaseUrl + prefix + path;
+
+ if (callback !== undefined && !utils.isFunction(callback)) {
+ throw Error(
+ "Expected callback to be a function but got " + typeof callback
+ );
+ }
+
+ var opts = {
+ uri: fullUri,
+ method: method,
+ withCredentials: false,
+ json: false,
+ _matrix_opts: this.opts
+ };
+ if (method == 'GET') {
+ opts.qs = params;
+ } else {
+ opts.form = params;
+ }
+
+ var defer = q.defer();
+ this.opts.request(
+ opts,
+ requestCallback(defer, callback, this.opts.onlyData)
+ );
+ return defer.promise;
+ },
+
/**
* Perform an authorised request to the homeserver.
* @param {Function} callback Optional. The callback to invoke on
@@ -352,6 +387,7 @@ var requestCallback = function(defer, userDefinedCallback, onlyData) {
return function(err, response, body) {
if (!err && response.statusCode >= 400) {
err = new module.exports.MatrixError(body);
+ err.httpStatus = response.statusCode;
}
if (err) {
@@ -378,6 +414,7 @@ var requestCallback = function(defer, userDefinedCallback, onlyData) {
* @prop {string} name The Matrix 'errcode' value, e.g. "M_FORBIDDEN".
* @prop {string} message The Matrix 'error' value, e.g. "Missing token."
* @prop {Object} data The raw Matrix error JSON used to construct this object.
+ * @prop {integer} httpStatus The numeric HTTP status code given
*/
module.exports.MatrixError = function MatrixError(errorJson) {
this.name = errorJson.errcode || "Unknown error code";
diff --git a/lib/matrix.js b/lib/matrix.js
index c805293e3..c2528cd6d 100644
--- a/lib/matrix.js
+++ b/lib/matrix.js
@@ -27,6 +27,16 @@ module.exports.MatrixScheduler = require("./scheduler");
/** The {@link module:store/session/webstorage.WebStorageSessionStore|WebStorageSessionStore} class */
module.exports.WebStorageSessionStore = require("./store/session/webstorage");
+/**
+ * Create a new Matrix Call.
+ * @function
+ * @param {module:client.MatrixClient} client The MatrixClient instance to use.
+ * @param {string} roomId The room the call is in.
+ * @return {module:webrtc/call~MatrixCall} The Matrix call or null if the browser
+ * does not support WebRTC.
+ */
+module.exports.createNewMatrixCall = require("./webrtc/call").createNewMatrixCall;
+
// expose the underlying request object so different environments can use
// different request libs (e.g. request or browser-request)
var request;
diff --git a/lib/models/room-member.js b/lib/models/room-member.js
index 56fe3680d..b5641de56 100644
--- a/lib/models/room-member.js
+++ b/lib/models/room-member.js
@@ -128,9 +128,26 @@ RoomMember.prototype.setTypingEvent = function(event) {
function calculateDisplayName(member, event, roomState) {
var displayName = event.getDirectionalContent().displayname;
var selfUserId = member.userId;
+
+ /*
+ // FIXME: this would be great but still needs to use the
+ // full userId to disambiguate if needed...
+
+ if (!displayName) {
+ var matches = selfUserId.match(/^@(.*?):/);
+ if (matches) {
+ return matches[1];
+ }
+ else {
+ return selfUserId;
+ }
+ }
+ */
+
if (!displayName) {
return selfUserId;
}
+
if (!roomState) {
return displayName;
}
diff --git a/lib/models/room.js b/lib/models/room.js
index 1a682a62a..74ddcad50 100644
--- a/lib/models/room.js
+++ b/lib/models/room.js
@@ -217,7 +217,7 @@ function calculateRoomName(room, userId) {
var mRoomName = room.currentState.getStateEvents('m.room.name', '');
if (mRoomName) {
- return mRoomName.getContent().name + (alias ? " (" + alias + ")" : "");
+ return mRoomName.getContent().name + (false && alias ? " (" + alias + ")" : "");
}
else if (alias) {
return alias;
diff --git a/lib/pushprocessor.js b/lib/pushprocessor.js
index 10533b148..f988f9a4b 100644
--- a/lib/pushprocessor.js
+++ b/lib/pushprocessor.js
@@ -136,6 +136,8 @@ module.exports = function(client) {
}
var room = client.getRoom(ev.room_id);
+ if (!room || !room.currentState || !room.currentState.members) { return false; }
+
var displayName = room.currentState.getMember(client.credentials.userId).name;
var pat = new RegExp("\\b" + escapeRegExp(displayName) + "\\b", 'i');
diff --git a/lib/webrtc/call.js b/lib/webrtc/call.js
new file mode 100644
index 000000000..8c3a7643b
--- /dev/null
+++ b/lib/webrtc/call.js
@@ -0,0 +1,931 @@
+"use strict";
+/**
+ * This is an internal module. See {@link createNewMatrixCall} for the public API.
+ * @module webrtc/call
+ */
+var utils = require("../utils");
+var EventEmitter = require("events").EventEmitter;
+var DEBUG = true; // set true to enable console logging.
+
+// events: hangup, error(err), replaced(call), state(state, oldState)
+
+/**
+ * Construct a new Matrix Call.
+ * @constructor
+ * @param {Object} opts Config options.
+ * @param {string} opts.roomId The room ID for this call.
+ * @param {Object} opts.webRtc The WebRTC globals from the browser.
+ * @param {Object} opts.URL The URL global.
+ * @param {Array} opts.turnServers Optional. A list of TURN servers.
+ * @param {MatrixClient} opts.client The Matrix Client instance to send events to.
+ */
+function MatrixCall(opts) {
+ this.roomId = opts.roomId;
+ this.client = opts.client;
+ this.webRtc = opts.webRtc;
+ this.URL = opts.URL;
+ // Array of Objects with urls, username, credential keys
+ this.turnServers = opts.turnServers || [];
+ if (this.turnServers.length === 0) {
+ this.turnServers.push({
+ urls: [MatrixCall.FALLBACK_STUN_SERVER]
+ });
+ }
+ utils.forEach(this.turnServers, function(server) {
+ utils.checkObjectHasKeys(server, ["urls"]);
+ });
+
+ this.callId = "c" + new Date().getTime();
+ this.state = 'fledgling';
+ this.didConnect = false;
+
+ // A queue for candidates waiting to go out.
+ // We try to amalgamate candidates into a single candidate message where
+ // possible
+ this.candidateSendQueue = [];
+ this.candidateSendTries = 0;
+}
+/** The length of time a call can be ringing for. */
+MatrixCall.CALL_TIMEOUT_MS = 60000;
+/** The fallback server to use for STUN. */
+MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302';
+/** An error code when the local client failed to create an offer. */
+MatrixCall.ERR_LOCAL_OFFER_FAILED = "local_offer_failed";
+/**
+ * An error code when there is no local mic/camera to use. This may be because
+ * the hardware isn't plugged in, or the user has explicitly denied access.
+ */
+MatrixCall.ERR_NO_USER_MEDIA = "no_user_media";
+
+utils.inherits(MatrixCall, EventEmitter);
+
+/**
+ * Place a voice call to this room.
+ * @throws If you have not specified a listener for 'error' events.
+ */
+MatrixCall.prototype.placeVoiceCall = function() {
+ checkForErrorListener(this);
+ _placeCallWithConstraints(this, _getUserMediaVideoContraints('voice'));
+ this.type = 'voice';
+};
+
+/**
+ * Place a video call to this room.
+ * @param {Element} remoteVideoElement a <video> DOM element
+ * to render video to.
+ * @param {Element} localVideoElement a <video> DOM element
+ * to render the local camera preview.
+ * @throws If you have not specified a listener for 'error' events.
+ */
+MatrixCall.prototype.placeVideoCall = function(remoteVideoElement, localVideoElement) {
+ checkForErrorListener(this);
+ this.localVideoElement = localVideoElement;
+ this.remoteVideoElement = remoteVideoElement;
+ _placeCallWithConstraints(this, _getUserMediaVideoContraints('video'));
+ this.type = 'video';
+ _tryPlayRemoteStream(this);
+};
+
+/**
+ * Retrieve the local <video> DOM element.
+ * @return {Element} The dom element
+ */
+MatrixCall.prototype.getLocalVideoElement = function() {
+ return this.localVideoElement;
+};
+
+/**
+ * Retrieve the remote <video> DOM element.
+ * @return {Element} The dom element
+ */
+MatrixCall.prototype.getRemoteVideoElement = function() {
+ return this.remoteVideoElement;
+};
+
+/**
+ * Set the local <video> DOM element. If this call is active,
+ * video will be rendered to it immediately.
+ * @param {Element} element The <video> DOM element.
+ */
+MatrixCall.prototype.setLocalVideoElement = function(element) {
+ this.localVideoElement = element;
+
+ if (element && this.localAVStream && this.type === 'video') {
+ element.autoplay = true;
+ element.src = this.URL.createObjectURL(this.localAVStream);
+ element.muted = true;
+ var self = this;
+ setTimeout(function() {
+ var vel = self.getLocalVideoElement();
+ if (vel.play) {
+ vel.play();
+ }
+ }, 0);
+ }
+};
+
+/**
+ * Set the remote <video> DOM element. If this call is active,
+ * video will be rendered to it immediately.
+ * @param {Element} element The <video> DOM element.
+ */
+MatrixCall.prototype.setRemoteVideoElement = function(element) {
+ this.remoteVideoElement = element;
+ _tryPlayRemoteStream(this);
+};
+
+/**
+ * Configure this call from an invite event. Used by MatrixClient.
+ * @protected
+ * @param {MatrixEvent} event The m.call.invite event
+ */
+MatrixCall.prototype._initWithInvite = function(event) {
+ this.msg = event.getContent();
+ this.peerConn = _createPeerConnection(this);
+ var self = this;
+ if (this.peerConn) {
+ this.peerConn.setRemoteDescription(
+ new this.webRtc.RtcSessionDescription(this.msg.offer),
+ hookCallback(self, self._onSetRemoteDescriptionSuccess),
+ hookCallback(self, self._onSetRemoteDescriptionError)
+ );
+ }
+ setState(this, 'ringing');
+ this.direction = 'inbound';
+
+ // firefox and OpenWebRTC's RTCPeerConnection doesn't add streams until it
+ // starts getting media on them so we need to figure out whether a video
+ // channel has been offered by ourselves.
+ if (this.msg.offer.sdp.indexOf('m=video') > -1) {
+ this.type = 'video';
+ }
+ else {
+ this.type = 'voice';
+ }
+
+ if (event.getAge()) {
+ setTimeout(function() {
+ if (self.state == 'ringing') {
+ self.hangupParty = 'remote'; // effectively
+ setState(self, 'ended');
+ stopAllMedia(self);
+ if (self.peerConn.signalingState != 'closed') {
+ self.peerConn.close();
+ }
+ self.emit("hangup", self);
+ }
+ }, this.msg.lifetime - event.getAge());
+ }
+};
+
+/**
+ * Configure this call from a hangup event. Used by MatrixClient.
+ * @protected
+ * @param {MatrixEvent} event The m.call.hangup event
+ */
+MatrixCall.prototype._initWithHangup = function(event) {
+ // perverse as it may seem, sometimes we want to instantiate a call with a
+ // hangup message (because when getting the state of the room on load, events
+ // come in reverse order and we want to remember that a call has been hung up)
+ this.msg = event.getContent();
+ setState(this, 'ended');
+};
+
+/**
+ * Answer a call.
+ */
+MatrixCall.prototype.answer = function() {
+ debuglog("Answering call %s of type %s", this.callId, this.type);
+ var self = this;
+
+ if (!this.localAVStream && !this.waitForLocalAVStream) {
+ this.webRtc.getUserMedia(
+ _getUserMediaVideoContraints(this.type),
+ hookCallback(self, self._gotUserMediaForAnswer),
+ hookCallback(self, self._getUserMediaFailed)
+ );
+ setState(this, 'wait_local_media');
+ } else if (this.localAVStream) {
+ this._gotUserMediaForAnswer(this.localAVStream);
+ } else if (this.waitForLocalAVStream) {
+ setState(this, 'wait_local_media');
+ }
+};
+
+/**
+ * Replace this call with a new call, e.g. for glare resolution. Used by
+ * MatrixClient.
+ * @protected
+ * @param {MatrixCall} newCall The new call.
+ */
+MatrixCall.prototype._replacedBy = function(newCall) {
+ debuglog(this.callId + " being replaced by " + newCall.callId);
+ if (this.state == 'wait_local_media') {
+ debuglog("Telling new call to wait for local media");
+ newCall.waitForLocalAVStream = true;
+ } else if (this.state == 'create_offer') {
+ debuglog("Handing local stream to new call");
+ newCall._gotUserMediaForAnswer(this.localAVStream);
+ delete(this.localAVStream);
+ } else if (this.state == 'invite_sent') {
+ debuglog("Handing local stream to new call");
+ newCall._gotUserMediaForAnswer(this.localAVStream);
+ delete(this.localAVStream);
+ }
+ newCall.localVideoElement = this.localVideoElement;
+ newCall.remoteVideoElement = this.remoteVideoElement;
+ this.successor = newCall;
+ this.emit("replaced", newCall);
+ this.hangup(true);
+};
+
+/**
+ * Hangup a call.
+ * @param {string} reason The reason why the call is being hung up.
+ * @param {boolean} suppressEvent True to suppress emitting an event.
+ */
+MatrixCall.prototype.hangup = function(reason, suppressEvent) {
+ debuglog("Ending call " + this.callId);
+ terminate(this, "local", reason, !suppressEvent);
+ var content = {
+ version: 0,
+ call_id: this.callId,
+ reason: reason
+ };
+ sendEvent(this, 'm.call.hangup', content);
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} stream
+ */
+MatrixCall.prototype._gotUserMediaForInvite = function(stream) {
+ if (this.successor) {
+ this.successor._gotUserMediaForAnswer(stream);
+ return;
+ }
+ if (this.state == 'ended') {
+ return;
+ }
+ var self = this;
+ var videoEl = this.getLocalVideoElement();
+
+ if (videoEl && this.type == 'video') {
+ videoEl.autoplay = true;
+ videoEl.src = this.URL.createObjectURL(stream);
+ videoEl.muted = true;
+ setTimeout(function() {
+ var vel = self.getLocalVideoElement();
+ if (vel.play) {
+ vel.play();
+ }
+ }, 0);
+ }
+
+ this.localAVStream = stream;
+ var audioTracks = stream.getAudioTracks();
+ for (var i = 0; i < audioTracks.length; i++) {
+ audioTracks[i].enabled = true;
+ }
+ this.peerConn = _createPeerConnection(this);
+ this.peerConn.addStream(stream);
+ this.peerConn.createOffer(
+ hookCallback(self, self._gotLocalOffer),
+ hookCallback(self, self._getLocalOfferFailed)
+ );
+ setState(self, 'create_offer');
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} stream
+ */
+MatrixCall.prototype._gotUserMediaForAnswer = function(stream) {
+ var self = this;
+ if (self.state == 'ended') {
+ return;
+ }
+ var localVidEl = self.getLocalVideoElement();
+
+ if (localVidEl && self.type == 'video') {
+ localVidEl.autoplay = true;
+ localVidEl.src = self.URL.createObjectURL(stream);
+ localVidEl.muted = true;
+ setTimeout(function() {
+ var vel = self.getLocalVideoElement();
+ if (vel.play) {
+ vel.play();
+ }
+ }, 0);
+ }
+
+ self.localAVStream = stream;
+ var audioTracks = stream.getAudioTracks();
+ for (var i = 0; i < audioTracks.length; i++) {
+ audioTracks[i].enabled = true;
+ }
+ self.peerConn.addStream(stream);
+
+ var constraints = {
+ 'mandatory': {
+ 'OfferToReceiveAudio': true,
+ 'OfferToReceiveVideo': self.type == 'video'
+ },
+ };
+ self.peerConn.createAnswer(function(description) {
+ debuglog("Created answer: " + description);
+ self.peerConn.setLocalDescription(description, function() {
+ var content = {
+ version: 0,
+ call_id: self.callId,
+ answer: {
+ sdp: self.peerConn.localDescription.sdp,
+ type: self.peerConn.localDescription.type
+ }
+ };
+ sendEvent(self, 'm.call.answer', content);
+ setState(self, 'connecting');
+ }, function() {
+ debuglog("Error setting local description!");
+ }, constraints);
+ }, function(err) {
+ debuglog("Failed to create answer: " + err);
+ });
+ setState(self, 'create_answer');
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} event
+ */
+MatrixCall.prototype._gotLocalIceCandidate = function(event) {
+ if (event.candidate) {
+ debuglog(
+ "Got local ICE " + event.candidate.sdpMid + " candidate: " +
+ event.candidate.candidate
+ );
+ // As with the offer, note we need to make a copy of this object, not
+ // pass the original: that broke in Chrome ~m43.
+ var c = {
+ candidate: event.candidate.candidate,
+ sdpMid: event.candidate.sdpMid,
+ sdpMLineIndex: event.candidate.sdpMLineIndex
+ };
+ sendCandidate(this, c);
+ }
+};
+
+/**
+ * Used by MatrixClient.
+ * @protected
+ * @param {Object} cand
+ */
+MatrixCall.prototype._gotRemoteIceCandidate = function(cand) {
+ if (this.state == 'ended') {
+ //debuglog("Ignoring remote ICE candidate because call has ended");
+ return;
+ }
+ debuglog("Got remote ICE " + cand.sdpMid + " candidate: " + cand.candidate);
+ this.peerConn.addIceCandidate(
+ new this.webRtc.RtcIceCandidate(cand),
+ function() {},
+ function(e) {}
+ );
+};
+
+/**
+ * Used by MatrixClient.
+ * @protected
+ * @param {Object} msg
+ */
+MatrixCall.prototype._receivedAnswer = function(msg) {
+ if (this.state == 'ended') {
+ return;
+ }
+
+ var self = this;
+ this.peerConn.setRemoteDescription(
+ new this.webRtc.RtcSessionDescription(msg.answer),
+ hookCallback(self, self._onSetRemoteDescriptionSuccess),
+ hookCallback(self, self._onSetRemoteDescriptionError)
+ );
+ setState(self, 'connecting');
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} description
+ */
+MatrixCall.prototype._gotLocalOffer = function(description) {
+ var self = this;
+ debuglog("Created offer: " + description);
+
+ if (self.state == 'ended') {
+ debuglog("Ignoring newly created offer on call ID " + self.callId +
+ " because the call has ended");
+ return;
+ }
+
+ self.peerConn.setLocalDescription(description, function() {
+ var content = {
+ version: 0,
+ call_id: self.callId,
+ // OpenWebRTC appears to add extra stuff (like the DTLS fingerprint)
+ // to the description when setting it on the peerconnection.
+ // According to the spec it should only add ICE
+ // candidates. Any ICE candidates that have already been generated
+ // at this point will probably be sent both in the offer and separately.
+ // Also, note that we have to make a new object here, copying the
+ // type and sdp properties.
+ // Passing the RTCSessionDescription object as-is doesn't work in
+ // Chrome (as of about m43).
+ offer: {
+ sdp: self.peerConn.localDescription.sdp,
+ type: self.peerConn.localDescription.type
+ },
+ lifetime: MatrixCall.CALL_TIMEOUT_MS
+ };
+ sendEvent(self, 'm.call.invite', content);
+
+ setTimeout(function() {
+ if (self.state == 'invite_sent') {
+ self.hangup('invite_timeout');
+ }
+ }, MatrixCall.CALL_TIMEOUT_MS);
+ setState(self, 'invite_sent');
+ }, function() {
+ debuglog("Error setting local description!");
+ });
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} error
+ */
+MatrixCall.prototype._getLocalOfferFailed = function(error) {
+ this.emit(
+ "error",
+ callError(MatrixCall.ERR_LOCAL_OFFER_FAILED, "Failed to start audio for call!")
+ );
+};
+
+/**
+ * Internal
+ * @private
+ */
+MatrixCall.prototype._getUserMediaFailed = function() {
+ this.emit(
+ "error",
+ callError(
+ MatrixCall.ERR_NO_USER_MEDIA,
+ "Couldn't start capturing media! Is your microphone set up and " +
+ "does this app have permission?"
+ )
+ );
+ this.hangup("user_media_failed");
+};
+
+/**
+ * Internal
+ * @private
+ */
+MatrixCall.prototype._onIceConnectionStateChanged = function() {
+ if (this.state == 'ended') {
+ return; // because ICE can still complete as we're ending the call
+ }
+ debuglog(
+ "Ice connection state changed to: " + this.peerConn.iceConnectionState
+ );
+ // ideally we'd consider the call to be connected when we get media but
+ // chrome doesn't implement any of the 'onstarted' events yet
+ if (this.peerConn.iceConnectionState == 'completed' ||
+ this.peerConn.iceConnectionState == 'connected') {
+ setState(this, 'connected');
+ this.didConnect = true;
+ } else if (this.peerConn.iceConnectionState == 'failed') {
+ this.hangup('ice_failed');
+ }
+};
+
+/**
+ * Internal
+ * @private
+ */
+MatrixCall.prototype._onSignallingStateChanged = function() {
+ debuglog(
+ "call " + this.callId + ": Signalling state changed to: " +
+ this.peerConn.signalingState
+ );
+};
+
+/**
+ * Internal
+ * @private
+ */
+MatrixCall.prototype._onSetRemoteDescriptionSuccess = function() {
+ debuglog("Set remote description");
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} e
+ */
+MatrixCall.prototype._onSetRemoteDescriptionError = function(e) {
+ debuglog("Failed to set remote description" + e);
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} event
+ */
+MatrixCall.prototype._onAddStream = function(event) {
+ debuglog("Stream added" + event);
+
+ var s = event.stream;
+
+ this.remoteAVStream = s;
+
+ if (this.direction == 'inbound') {
+ if (s.getVideoTracks().length > 0) {
+ this.type = 'video';
+ } else {
+ this.type = 'voice';
+ }
+ }
+
+ var self = this;
+ forAllTracksOnStream(s, function(t) {
+ // not currently implemented in chrome
+ t.onstarted = hookCallback(self, self._onRemoteStreamTrackStarted);
+ });
+
+ event.stream.onended = hookCallback(self, self._onRemoteStreamEnded);
+ // not currently implemented in chrome
+ event.stream.onstarted = hookCallback(self, self._onRemoteStreamStarted);
+
+ _tryPlayRemoteStream(this);
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} event
+ */
+MatrixCall.prototype._onRemoteStreamStarted = function(event) {
+ setState(this, 'connected');
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} event
+ */
+MatrixCall.prototype._onRemoteStreamEnded = function(event) {
+ debuglog("Remote stream ended");
+ this.hangupParty = 'remote';
+ setState(this, 'ended');
+ stopAllMedia(this);
+ if (this.peerConn.signalingState != 'closed') {
+ this.peerConn.close();
+ }
+ this.emit("hangup", this);
+};
+
+/**
+ * Internal
+ * @private
+ * @param {Object} event
+ */
+MatrixCall.prototype._onRemoteStreamTrackStarted = function(event) {
+ setState(this, 'connected');
+};
+
+/**
+ * Used by MatrixClient.
+ * @protected
+ * @param {Object} msg
+ */
+MatrixCall.prototype._onHangupReceived = function(msg) {
+ debuglog("Hangup received");
+ terminate(this, "remote", msg.reason, true);
+};
+
+/**
+ * Used by MatrixClient.
+ * @protected
+ * @param {Object} msg
+ */
+MatrixCall.prototype._onAnsweredElsewhere = function(msg) {
+ debuglog("Answered elsewhere");
+ terminate(this, "remote", "answered_elsewhere", true);
+};
+
+var setState = function(self, state) {
+ var oldState = self.state;
+ self.state = state;
+ self.emit("state", state, oldState);
+};
+
+/**
+ * Internal
+ * @param {MatrixCall} self
+ * @param {string} eventType
+ * @param {Object} content
+ * @return {Promise}
+ */
+var sendEvent = function(self, eventType, content) {
+ return self.client.sendEvent(self.roomId, eventType, content);
+};
+
+var sendCandidate = function(self, content) {
+ // Sends candidates with are sent in a special way because we try to amalgamate
+ // them into one message
+ self.candidateSendQueue.push(content);
+ if (self.candidateSendTries === 0) {
+ setTimeout(function() {
+ _sendCandidateQueue(self);
+ }, 100);
+ }
+};
+
+var terminate = function(self, hangupParty, hangupReason, shouldEmit) {
+ if (self.getRemoteVideoElement()) {
+ if (self.getRemoteVideoElement().pause) {
+ self.getRemoteVideoElement().pause();
+ }
+ self.getRemoteVideoElement().src = "";
+ }
+ if (self.getLocalVideoElement()) {
+ if (self.getLocalVideoElement().pause) {
+ self.getLocalVideoElement().pause();
+ }
+ self.getLocalVideoElement().src = "";
+ }
+ self.hangupParty = hangupParty;
+ self.hangupReason = hangupReason;
+ setState(self, 'ended');
+ stopAllMedia(self);
+ if (self.peerConn && self.peerConn.signalingState !== 'closed') {
+ self.peerConn.close();
+ }
+ if (shouldEmit) {
+ self.emit("hangup", self);
+ }
+};
+
+var stopAllMedia = function(self) {
+ if (self.localAVStream) {
+ forAllTracksOnStream(self.localAVStream, function(t) {
+ if (t.stop) {
+ t.stop();
+ }
+ });
+ // also call stop on the main stream so firefox will stop sharing
+ // the mic
+ if (self.localAVStream.stop) {
+ self.localAVStream.stop();
+ }
+ }
+ if (self.remoteAVStream) {
+ forAllTracksOnStream(self.remoteAVStream, function(t) {
+ if (t.stop) {
+ t.stop();
+ }
+ });
+ }
+};
+
+var _tryPlayRemoteStream = function(self) {
+ if (self.getRemoteVideoElement() && self.remoteAVStream) {
+ var player = self.getRemoteVideoElement();
+ player.autoplay = true;
+ player.src = self.URL.createObjectURL(self.remoteAVStream);
+ setTimeout(function() {
+ var vel = self.getRemoteVideoElement();
+ if (vel.play) {
+ vel.play();
+ }
+ // OpenWebRTC does not support oniceconnectionstatechange yet
+ if (self.webRtc.isOpenWebRTC()) {
+ setState(self, 'connected');
+ }
+ }, 0);
+ }
+};
+
+var checkForErrorListener = function(self) {
+ if (self.listeners("error").length === 0) {
+ throw new Error(
+ "You MUST attach an error listener using call.on('error', function() {})"
+ );
+ }
+};
+
+var callError = function(code, msg) {
+ var e = new Error(msg);
+ e.code = code;
+ return e;
+};
+
+var debuglog = function() {
+ if (DEBUG) {
+ console.log.apply(console, arguments);
+ }
+};
+
+var _sendCandidateQueue = function(self) {
+ if (self.candidateSendQueue.length === 0) {
+ return;
+ }
+
+ var cands = self.candidateSendQueue;
+ self.candidateSendQueue = [];
+ ++self.candidateSendTries;
+ var content = {
+ version: 0,
+ call_id: self.callId,
+ candidates: cands
+ };
+ debuglog("Attempting to send " + cands.length + " candidates");
+ sendEvent(self, 'm.call.candidates', content).then(function() {
+ self.candidateSendTries = 0;
+ _sendCandidateQueue(self);
+ }, function(error) {
+ for (var i = 0; i < cands.length; i++) {
+ self.candidateSendQueue.push(cands[i]);
+ }
+
+ if (self.candidateSendTries > 5) {
+ debuglog(
+ "Failed to send candidates on attempt %s. Giving up for now.",
+ self.candidateSendTries
+ );
+ self.candidateSendTries = 0;
+ return;
+ }
+
+ var delayMs = 500 * Math.pow(2, self.candidateSendTries);
+ ++self.candidateSendTries;
+ debuglog("Failed to send candidates. Retrying in " + delayMs + "ms");
+ setTimeout(function() {
+ _sendCandidateQueue(self);
+ }, delayMs);
+ });
+};
+
+var _placeCallWithConstraints = function(self, constraints) {
+ self.client.callList[self.callId] = self;
+ self.webRtc.getUserMedia(
+ constraints,
+ hookCallback(self, self._gotUserMediaForInvite),
+ hookCallback(self, self._getUserMediaFailed)
+ );
+ setState(self, 'wait_local_media');
+ self.direction = 'outbound';
+ self.config = constraints;
+};
+
+var _createPeerConnection = function(self) {
+ var servers = self.turnServers;
+ if (self.webRtc.vendor === "mozilla") {
+ // modify turnServers struct to match what mozilla expects.
+ servers = [];
+ for (var i = 0; i < self.turnServers.length; i++) {
+ for (var j = 0; j < self.turnServers[i].urls.length; j++) {
+ servers.push({
+ url: self.turnServers[i].urls[j],
+ username: self.turnServers[i].username,
+ credential: self.turnServers[i].credential
+ });
+ }
+ }
+ }
+
+ var pc = new self.webRtc.RtcPeerConnection({
+ iceServers: servers
+ });
+ pc.oniceconnectionstatechange = hookCallback(self, self._onIceConnectionStateChanged);
+ pc.onsignalingstatechange = hookCallback(self, self._onSignallingStateChanged);
+ pc.onicecandidate = hookCallback(self, self._gotLocalIceCandidate);
+ pc.onaddstream = hookCallback(self, self._onAddStream);
+ return pc;
+};
+
+var _getUserMediaVideoContraints = function(callType) {
+ switch (callType) {
+ case 'voice':
+ return ({audio: true, video: false});
+ case 'video':
+ return ({audio: true, video: {
+ mandatory: {
+ minWidth: 640,
+ maxWidth: 640,
+ minHeight: 360,
+ maxHeight: 360,
+ }
+ }});
+ }
+};
+
+var hookCallback = function(call, fn) {
+ return function() {
+ return fn.apply(call, arguments);
+ };
+};
+
+var forAllVideoTracksOnStream = function(s, f) {
+ var tracks = s.getVideoTracks();
+ for (var i = 0; i < tracks.length; i++) {
+ f(tracks[i]);
+ }
+};
+
+var forAllAudioTracksOnStream = function(s, f) {
+ var tracks = s.getAudioTracks();
+ for (var i = 0; i < tracks.length; i++) {
+ f(tracks[i]);
+ }
+};
+
+var forAllTracksOnStream = function(s, f) {
+ forAllVideoTracksOnStream(s, f);
+ forAllAudioTracksOnStream(s, f);
+};
+
+/** The MatrixCall class. */
+module.exports.MatrixCall = MatrixCall;
+
+/**
+ * Create a new Matrix call for the browser.
+ * @param {MatrixClient} client The client instance to use.
+ * @param {string} roomId The room the call is in.
+ * @return {MatrixCall} the call or null if the browser doesn't support calling.
+ */
+module.exports.createNewMatrixCall = function(client, roomId) {
+ var w = global.window;
+ var doc = global.document;
+ if (!w || !doc) {
+ return null;
+ }
+ var webRtc = {};
+ webRtc.isOpenWebRTC = function() {
+ var scripts = doc.getElementById("script");
+ if (!scripts || !scripts.length) {
+ return false;
+ }
+ for (var i = 0; i < scripts.length; i++) {
+ if (scripts[i].src.indexOf("owr.js") > -1) {
+ return true;
+ }
+ }
+ return false;
+ };
+ var getUserMedia = (
+ w.navigator.getUserMedia || w.navigator.webkitGetUserMedia ||
+ w.navigator.mozGetUserMedia
+ );
+ if (getUserMedia) {
+ webRtc.getUserMedia = function() {
+ return getUserMedia.apply(w.navigator, arguments);
+ };
+ }
+ webRtc.RtcPeerConnection = (
+ w.RTCPeerConnection || w.webkitRTCPeerConnection || w.mozRTCPeerConnection
+ );
+ webRtc.RtcSessionDescription = (
+ w.RTCSessionDescription || w.webkitRTCSessionDescription ||
+ w.mozRTCSessionDescription
+ );
+ webRtc.RtcIceCandidate = (
+ w.RTCIceCandidate || w.webkitRTCIceCandidate || w.mozRTCIceCandidate
+ );
+ webRtc.vendor = null;
+ if (w.mozRTCPeerConnection) {
+ webRtc.vendor = "mozilla";
+ }
+ else if (w.webkitRTCPeerConnection) {
+ webRtc.vendor = "webkit";
+ }
+ else if (w.RTCPeerConnection) {
+ webRtc.vendor = "generic";
+ }
+ if (!webRtc.RtcIceCandidate || !webRtc.RtcSessionDescription ||
+ !webRtc.RtcPeerConnection || !webRtc.getUserMedia) {
+ return null; // WebRTC is not supported.
+ }
+ var opts = {
+ webRtc: webRtc,
+ client: client,
+ URL: w.URL,
+ roomId: roomId,
+ turnServers: client.getTurnServers()
+ };
+ return new MatrixCall(opts);
+};
diff --git a/package.json b/package.json
index fe093075f..a3c998748 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"check": "jasmine-node spec --verbose --junitreport --forceexit --captureExceptions",
"build": "jshint -c .jshint lib/ && browserify browser-index.js -o dist/browser-matrix-dev.js",
"watch": "watchify browser-index.js -o dist/browser-matrix-dev.js -v",
- "lint": "jshint -c .jshint lib spec && gjslint --unix_mode --disable 0131,0211,0200 --max_line_length 90 -r spec/ -r lib/",
+ "lint": "jshint -c .jshint lib spec && gjslint --unix_mode --disable 0131,0211,0200,0222 --max_line_length 90 -r spec/ -r lib/",
"release": "npm run build && mkdir dist/$npm_package_version && uglifyjs -c -m -o dist/$npm_package_version/browser-matrix-$npm_package_version.min.js dist/browser-matrix-dev.js && cp dist/browser-matrix-dev.js dist/$npm_package_version/browser-matrix-$npm_package_version.js"
},
"repository": {
diff --git a/spec/integ/matrix-client-syncing.spec.js b/spec/integ/matrix-client-syncing.spec.js
index a97590a49..0382d4d86 100644
--- a/spec/integ/matrix-client-syncing.spec.js
+++ b/spec/integ/matrix-client-syncing.spec.js
@@ -64,6 +64,53 @@ describe("MatrixClient syncing", function() {
});
});
+ describe("users", function() {
+ var userA = "@alice:bar";
+ var userB = "@bob:bar";
+ var userC = "@claire:bar";
+ var initialSync = {
+ end: "s_5_3",
+ presence: [
+ utils.mkPresence({
+ user: userA, presence: "online"
+ }),
+ utils.mkPresence({
+ user: userB, presence: "unavailable"
+ })
+ ],
+ rooms: []
+ };
+ var eventData = {
+ start: "s_5_3",
+ end: "e_6_7",
+ chunk: [
+ // existing user change
+ utils.mkPresence({
+ user: userA, presence: "offline"
+ }),
+ // new user C
+ utils.mkPresence({
+ user: userC, presence: "online"
+ })
+ ]
+ };
+
+ it("should create users for presence events from /initialSync and /events",
+ function(done) {
+ httpBackend.when("GET", "/initialSync").respond(200, initialSync);
+ httpBackend.when("GET", "/events").respond(200, eventData);
+
+ client.startClient();
+
+ httpBackend.flush().done(function() {
+ expect(client.getUser(userA).presence).toEqual("offline");
+ expect(client.getUser(userB).presence).toEqual("unavailable");
+ expect(client.getUser(userC).presence).toEqual("online");
+ done();
+ });
+ });
+ });
+
describe("room state", function() {
var roomOne = "!foo:localhost";
var roomTwo = "!bar:localhost";
diff --git a/spec/test-utils.js b/spec/test-utils.js
index 92cdfac76..92a74107f 100644
--- a/spec/test-utils.js
+++ b/spec/test-utils.js
@@ -76,6 +76,29 @@ module.exports.mkEvent = function(opts) {
return opts.event ? new MatrixEvent(event) : event;
};
+/**
+ * Create an m.presence event.
+ * @param {Object} opts Values for the presence.
+ * @return {Object|MatrixEvent} The event
+ */
+module.exports.mkPresence = function(opts) {
+ if (!opts.user) {
+ throw new Error("Missing user");
+ }
+ var event = {
+ event_id: "$" + Math.random() + "-" + Math.random(),
+ type: "m.presence",
+ content: {
+ user_id: opts.user,
+ avatar_url: opts.url,
+ displayname: opts.name,
+ last_active_ago: opts.ago,
+ presence: opts.presence || "offline"
+ }
+ };
+ return opts.event ? new MatrixEvent(event) : event;
+};
+
/**
* Create an m.room.member event.
* @param {Object} opts Values for the membership.
diff --git a/spec/unit/pushprocessor.spec.js b/spec/unit/pushprocessor.spec.js
index f615b600e..1e0e5e758 100644
--- a/spec/unit/pushprocessor.spec.js
+++ b/spec/unit/pushprocessor.spec.js
@@ -21,7 +21,8 @@ describe('NotificationService', function() {
return {
name: testDisplayName
};
- }
+ },
+ members: {}
}
};
},