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. +
+
+ + + +
+
+ +
+
+
+
+ +
+
+ + 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: {} } }; },