diff --git a/lib/client.js b/lib/client.js index 31f80c7e6..3a45b695f 100644 --- a/lib/client.js +++ b/lib/client.js @@ -191,6 +191,17 @@ MatrixClient.prototype.getIdentityServerUrl = function() { return this.idBaseUrl; }; +/** + * Get the domain for this client's MXID + * @return {?string} Domain of this MXID + */ +MatrixClient.prototype.getDomain = function() { + if (this.credentials && this.credentials.userId) { + return this.credentials.userId.replace(/^.*?:/, ''); + } + return null; +}; + /** * Get the access token associated with this account. * @return {?String} The access_token or null @@ -201,7 +212,7 @@ MatrixClient.prototype.getAccessToken = function() { /** * Get the local part of the current user ID e.g. "foo" in "@foo:bar". - * @return {?String} The user ID localpart or null. + * @return {?string} The user ID localpart or null. */ MatrixClient.prototype.getUserIdLocalpart = function() { if (this.credentials && this.credentials.userId) { @@ -597,6 +608,14 @@ MatrixClient.prototype.getUser = function(userId) { return this.store.getUser(userId); }; +/** + * Retrieve all known users. + * @return {User[]} A list of users, or an empty list if there is no data store. + */ +MatrixClient.prototype.getUsers = function() { + return this.store.getUsers(); +}; + // Room operations // =============== @@ -742,6 +761,43 @@ MatrixClient.prototype.deleteRoomTag = function(roomId, tagName, callback) { ); }; +/** + * @param {string} eventType event type to be set + * @param {object} content event content + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setAccountData = function(eventType, content, callback) { + var path = utils.encodeUri("/user/$userId/account_data/$type", { + $userId: this.credentials.userId, + $type: eventType, + }); + return this._http.authedRequestWithPrefix( + callback, "PUT", path, undefined, content, httpApi.PREFIX_V2_ALPHA + ); +}; + +/** + * @param {string} roomId + * @param {string} eventType event type to be set + * @param {object} content event content + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.setRoomAccountData = function(roomId, eventType, + content, callback) { + var path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", { + $userId: this.credentials.userId, + $roomId: roomId, + $type: eventType, + }); + return this._http.authedRequestWithPrefix( + callback, "PUT", path, undefined, content, httpApi.PREFIX_V2_ALPHA + ); +}; + /** * Set a user's power level. * @param {string} roomId @@ -1410,6 +1466,23 @@ MatrixClient.prototype.createAlias = function(alias, roomId, callback) { ); }; +/** + * Delete an alias to room ID mapping. This alias must be on your local server + * and you must have sufficient access to do this operation. + * @param {string} alias The room alias to delete. + * @param {module:client.callback} callback Optional. + * @return {module:client.Promise} Resolves: TODO. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.deleteAlias = function(alias, callback) { + var path = utils.encodeUri("/directory/room/$alias", { + $alias: alias + }); + return this._http.authedRequest( + callback, "DELETE", path, undefined, undefined + ); +}; + /** * Get room info for the given alias. * @param {string} alias The room alias to resolve. @@ -1810,7 +1883,7 @@ MatrixClient.prototype.setPresence = function(presence, callback) { * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixClient.prototype.publicRooms = function(callback) { - return this._http.request(callback, "GET", "/publicRooms"); + return this._http.authedRequest(callback, "GET", "/publicRooms"); }; /** @@ -2973,7 +3046,12 @@ MatrixClient.prototype.requestEmailToken = function(email, clientSecret, return this._http.idServerRequest( callback, "POST", "/validate/email/requestToken", params, httpApi.PREFIX_IDENTITY_V1 - ); + ).then(function(res) { + if (typeof res === "string") { + return JSON.parse(res); + } + return res; + }); }; /** diff --git a/lib/models/event.js b/lib/models/event.js index 5c763a8c5..d333a6261 100644 --- a/lib/models/event.js +++ b/lib/models/event.js @@ -137,7 +137,8 @@ module.exports.MatrixEvent.prototype = { * @return {Object} The previous event content JSON, or an empty object. */ getPrevContent: function() { - return this.event.prev_content || {}; + // v2 then v1 then default + return this.getUnsigned().prev_content || this.event.prev_content || {}; }, /** diff --git a/lib/models/room-state.js b/lib/models/room-state.js index bc7216057..f787a9790 100644 --- a/lib/models/room-state.js +++ b/lib/models/room-state.js @@ -48,6 +48,7 @@ function RoomState(roomId) { this._updateModifiedTime(); this._displayNameToUserIds = {}; this._userIdsToDisplayNames = {}; + this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite } utils.inherits(RoomState, EventEmitter); @@ -129,6 +130,7 @@ RoomState.prototype.setStateEvents = function(stateEvents) { _updateDisplayNameCache( self, event.getStateKey(), event.getContent().displayname ); + _updateThirdPartyTokenCache(self, event); } self.emit("RoomState.events", event, self); }); @@ -187,6 +189,16 @@ RoomState.prototype.setTypingEvent = function(event) { }); }; +/** + * Get the m.room.member event which has the given third party invite token. + * + * @param {string} token The token + * @return {?MatrixEvent} The m.room.member event or null + */ +RoomState.prototype.getInviteForThreePidToken = function(token) { + return this._tokenToInvite[token] || null; +}; + /** * Update the last modified time to the current time. */ @@ -218,6 +230,23 @@ RoomState.prototype.getUserIdsWithDisplayName = function(displayName) { module.exports = RoomState; +function _updateThirdPartyTokenCache(roomState, memberEvent) { + if (!memberEvent.getContent().third_party_invite) { + return; + } + var token = (memberEvent.getContent().third_party_invite.signed || {}).token; + if (!token) { + return; + } + var threePidInvite = roomState.getStateEvents( + "m.room.third_party_invite", token + ); + if (!threePidInvite) { + return; + } + roomState._tokenToInvite[token] = memberEvent; +} + function _updateDisplayNameCache(roomState, userId, displayName) { var oldName = roomState._userIdsToDisplayNames[userId]; delete roomState._userIdsToDisplayNames[userId]; diff --git a/lib/models/room.js b/lib/models/room.js index 46c07fb90..59d94d556 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -62,6 +62,8 @@ function synthesizeReceipt(userId, event, receiptType) { * this room, with the oldest event at index 0. * @prop {object} tags Dict of room tags; the keys are the tag name and the values * are any metadata associated with the tag - e.g. { "fav" : { order: 1 } } + * @prop {object} accountData Dict of per-room account_data events; the keys are the + * event type and the values are the events. * @prop {RoomState} oldState The state of the room at the time of the oldest * event in the timeline. * @prop {RoomState} currentState The state of the room at the time of the @@ -88,6 +90,9 @@ function Room(roomId, opts) { // $tagName: { $metadata: $value }, // $tagName: { $metadata: $value }, }; + this.accountData = { + // $eventType: $event + }; this.oldState = new RoomState(roomId); this.currentState = new RoomState(roomId); this.summary = null; @@ -184,6 +189,18 @@ Room.prototype.getAvatarUrl = function(baseUrl, width, height, resizeMethod, }); }; + /** + * Get the default room name (i.e. what a given user would see if the + * room had no m.room.name) + * @param {string} userId The userId from whose perspective we want + * to calculate the default name + * @return {string} The default room name + */ + Room.prototype.getDefaultRoomName = function(userId) { + return calculateRoomName(this, userId, true); + }; + + /** * Check if the given user_id has the given membership state. * @param {string} userId The user ID to check. @@ -250,8 +267,10 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline) { stateContext.setStateEvents([events[i]]); // it is possible that the act of setting the state event means we // can set more metadata (specifically sender/target props), so try - // it again if the prop wasn't previously set. - if (!events[i].sender) { + // it again if the prop wasn't previously set. It may also mean that + // the sender/target is updated (if the event set was a room member event) + // so we want to use the *updated* member (new avatar/name) instead. + if (!events[i].sender || events[i].getType() === "m.room.member") { setEventMetadata(events[i], stateContext, toStartOfTimeline); } } @@ -333,9 +352,8 @@ Room.prototype.addEvents = function(events, duplicateStrategy) { else if (events[i].getType() === "m.receipt") { this.addReceipt(events[i]); } - else if (events[i].getType() === "m.tag") { - this.addTags(events[i]); - } + // N.B. account_data is added directly by /sync to avoid + // having to maintain an event.isAccountData() here else { if (duplicateStrategy) { // is there a duplicate? @@ -600,6 +618,30 @@ Room.prototype.addTags = function(event) { this.emit("Room.tags", event, this); }; +/** + * Update the account_data events for this room, overwriting events of the same type. + * @param {Array} events an array of account_data events to add + */ +Room.prototype.addAccountData = function(events) { + for (var i = 0; i < events.length; i++) { + var event = events[i]; + if (event.getType() === "m.tag") { + this.addTags(event); + } + this.accountData[event.getType()] = event; + this.emit("Room.accountData", event, this); + } +}; + +/** + * Access account_data event of given event type for this room + * @param {string} type the type of account_data event to be accessed + * @return {?MatrixEvent} the account_data event in question + */ +Room.prototype.getAccountData = function(type) { + return this.accountData[type]; +}; + function setEventMetadata(event, stateContext, toStartOfTimeline) { // set sender and target properties event.sender = stateContext.getSentinelMember( @@ -627,14 +669,18 @@ function setEventMetadata(event, stateContext, toStartOfTimeline) { * @param {Room} room The matrix room. * @param {string} userId The client's user ID. Used to filter room members * correctly. + * @param {bool} ignoreRoomNameEvent Return the implicit room name that we'd see if there + * was no m.room.name event. * @return {string} The calculated room name. */ -function calculateRoomName(room, userId) { - // check for an alias, if any. for now, assume first alias is the - // official one. - var mRoomName = room.currentState.getStateEvents("m.room.name", ""); - if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { - return mRoomName.getContent().name; +function calculateRoomName(room, userId, ignoreRoomNameEvent) { + if (!ignoreRoomNameEvent) { + // check for an alias, if any. for now, assume first alias is the + // official one. + var mRoomName = room.currentState.getStateEvents("m.room.name", ""); + if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { + return mRoomName.getContent().name; + } } var alias; @@ -674,7 +720,7 @@ function calculateRoomName(room, userId) { } } else { - return userId; + return userId; // XXX: why userId and not displayname or something? } } else { @@ -750,3 +796,16 @@ module.exports = Room; * if (newTags["favourite"]) showStar(room); * }); */ + +/** + * Fires whenever a room's account_data is updated. + * @event module:client~MatrixClient#"Room.accountData" + * @param {event} event The account_data event + * @param {Room} room The room whose account_data was updated. + * @example + * matrixClient.on("Room.accountData", function(event, room){ + * if (event.getType() === "m.room.colorscheme") { + * applyColorScheme(event.getContents()); + * } + * }); + */ diff --git a/lib/store/memory.js b/lib/store/memory.js index c4db09121..b3fd7ff6d 100644 --- a/lib/store/memory.js +++ b/lib/store/memory.js @@ -123,6 +123,14 @@ module.exports.MatrixInMemoryStore.prototype = { return this.users[userId] || null; }, + /** + * Retrieve all known users. + * @return {User[]} A list of users, which may be empty. + */ + getUsers: function() { + return utils.values(this.users); + }, + /** * Retrieve scrollback for this room. * @param {Room} room The matrix room diff --git a/lib/store/stub.js b/lib/store/stub.js index e40c3bca0..41fa7a25f 100644 --- a/lib/store/stub.js +++ b/lib/store/stub.js @@ -101,6 +101,14 @@ StubStore.prototype = { return null; }, + /** + * No-op. + * @return {User[]} + */ + getUsers: function() { + return []; + }, + /** * No-op. * @param {Room} room diff --git a/lib/sync.js b/lib/sync.js index 4381582ec..63e344164 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -30,6 +30,8 @@ var utils = require("./utils"); var httpApi = require("./http-api"); var Filter = require("./filter"); +var DEBUG = true; + // /sync requests allow you to set a timeout= but the request may continue // beyond that and wedge forever, so we need to track how long we are willing // to keep open the connection. This constant is *ADDED* to the timeout= value @@ -42,6 +44,10 @@ function getFilterName(userId, suffix) { return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : ""); } +function debuglog() { + if (!DEBUG) { return; } + console.log.apply(console, arguments); +} /** @@ -60,6 +66,7 @@ function SyncApi(client, opts) { opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; this.opts = opts; this._peekRoomId = null; + this._lowClientTimeouts = false; } /** @@ -107,7 +114,7 @@ SyncApi.prototype.syncLeftRooms = function() { var localTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS; var qps = { - timeout: 1 // don't want to block since this is a single isolated req + timeout: 0 // don't want to block since this is a single isolated req }; return this._getOrCreateFilter( @@ -139,7 +146,8 @@ SyncApi.prototype.syncLeftRooms = function() { return; } leaveObj.timeline = leaveObj.timeline || {}; - var timelineEvents = self._mapSyncEventsFormat(leaveObj.timeline, room); + var timelineEvents = + self._mapSyncEventsFormat(leaveObj.timeline, room); var stateEvents = self._mapSyncEventsFormat(leaveObj.state, room); var paginationToken = ( leaveObj.timeline.limited ? leaveObj.timeline.prev_batch : null @@ -223,7 +231,7 @@ SyncApi.prototype.stopPeeking = function() { */ SyncApi.prototype._peekPoll = function(roomId, token) { if (this._peekRoomId !== roomId) { - console.log("Stopped peeking in room %s", roomId); + debuglog("Stopped peeking in room %s", roomId); return; } @@ -253,7 +261,7 @@ SyncApi.prototype._peekPoll = function(roomId, token) { * Main entry point */ SyncApi.prototype.sync = function() { - console.log("SyncApi.sync"); + debuglog("SyncApi.sync"); var client = this.client; var self = this; @@ -284,7 +292,7 @@ SyncApi.prototype.sync = function() { self._getOrCreateFilter( getFilterName(client.credentials.userId), filter ).done(function(filterId) { - console.log("Using existing filter ID %s", filterId); + debuglog("Using existing filter ID %s", filterId); self._sync({ filterId: filterId }); }, retryHandler(attempt, getFilter)); } @@ -334,14 +342,32 @@ SyncApi.prototype._sync = function(syncOptions, attempt) { if (attempt > 1) { // we think the connection is dead. If it comes back up, we won't know // about it till /sync returns. If the timeout= is high, this could - // be a long time. Set it to 1 when doing retries. - qps.timeout = 1; + // be a long time. Set it to 0 when doing retries. + qps.timeout = 0; } + // normal timeout= plus buffer time + var clientSideTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS; + + if (self._lowClientTimeouts) { + debuglog("_lowClientTimeouts flag set."); + clientSideTimeoutMs = this.opts.pollTimeout; + } + + var dateStamp = new Date(); + dateStamp = dateStamp.getHours() + ":" + dateStamp.getMinutes() + ":" + + dateStamp.getSeconds() + "." + dateStamp.getMilliseconds(); + debuglog("DEBUG[%s]: NEW _sync attempt=%s qp_timeout=%s cli_timeout=%s", + dateStamp, attempt, qps.timeout, clientSideTimeoutMs); + + + client._http.authedRequestWithPrefix( undefined, "GET", "/sync", qps, undefined, httpApi.PREFIX_V2_ALPHA, - this.opts.pollTimeout + BUFFER_PERIOD_MS // normal timeout= plus buffer time + clientSideTimeoutMs ).done(function(data) { + debuglog("DEBUG[%s]: _sync RECV", dateStamp); + self._lowClientTimeouts = false; // data looks like: // { // next_batch: $token, @@ -420,7 +446,8 @@ SyncApi.prototype._sync = function(syncOptions, attempt) { // Handle invites inviteRooms.forEach(function(inviteObj) { var room = inviteObj.room; - var stateEvents = self._mapSyncEventsFormat(inviteObj.invite_state, room); + var stateEvents = + self._mapSyncEventsFormat(inviteObj.invite_state, room); self._processRoomEvents(room, stateEvents); if (inviteObj.isBrandNewRoom) { room.recalculate(client.credentials.userId); @@ -458,8 +485,15 @@ SyncApi.prototype._sync = function(syncOptions, attempt) { self._processRoomEvents( room, stateEvents, timelineEvents, paginationToken ); + + // XXX: should we be adding ephemeralEvents to the timeline? + // It feels like that for symmetry with room.addAccountData() + // there should be a room.addEphemeralEvents() or similar. room.addEvents(ephemeralEvents); - room.addEvents(accountDataEvents); + + // we deliberately don't add accountData to the timeline + room.addAccountData(accountDataEvents); + room.recalculate(client.credentials.userId); if (joinObj.isBrandNewRoom) { client.store.storeRoom(room); @@ -475,7 +509,8 @@ SyncApi.prototype._sync = function(syncOptions, attempt) { leaveRooms.forEach(function(leaveObj) { // Do the bear minimum to register rejected invites / you leaving rooms var room = leaveObj.room; - var timelineEvents = self._mapSyncEventsFormat(leaveObj.timeline, room); + var timelineEvents = + self._mapSyncEventsFormat(leaveObj.timeline, room); room.addEvents(timelineEvents); timelineEvents.forEach(function(e) { client.emit("event", e); }); }); @@ -496,10 +531,16 @@ SyncApi.prototype._sync = function(syncOptions, attempt) { self._sync(syncOptions); }, function(err) { + debuglog("DEBUG[%s]: RECV FAIL %s", dateStamp, require("util").inspect(err)); console.error("/sync error (%s attempts): %s", attempt, err); console.error(err); attempt += 1; - startSyncingRetryTimer(client, attempt, function(newAttempt) { + startSyncingRetryTimer(client, attempt, function(newAttempt, extendedWait) { + debuglog("DEBUG[%s]: Init new _sync new_attempt=%s extended_wait=%s", + dateStamp, newAttempt, extendedWait); + if (extendedWait) { + self._lowClientTimeouts = true; + } self._sync(syncOptions, newAttempt); }); updateSyncState(client, "ERROR", { error: err }); @@ -694,17 +735,19 @@ function startSyncingRetryTimer(client, attempt, fn) { client._syncingRetry.timeoutId = setTimeout(function() { var timeAfterWaitingMs = Date.now(); var timeDeltaMs = timeAfterWaitingMs - timeBeforeWaitingMs; + var extendedWait = false; if (timeDeltaMs > (2 * timeToWaitMs)) { // we've waited more than twice what we were supposed to. Reset the // attempt number back to 1. This can happen when the comp goes to // sleep whilst the timer is running. newAttempt = 1; + extendedWait = true; console.warn( "Sync retry timer: Tried to wait %s ms but actually waited %s ms", timeToWaitMs, timeDeltaMs ); } - fn(newAttempt); + fn(newAttempt, extendedWait); }, timeToWaitMs); }