diff --git a/lib/client.js b/lib/client.js index 69a50f374..afb4c340d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -146,6 +146,7 @@ function MatrixClient(opts) { this._ongoingScrollbacks = {}; this.timelineSupport = Boolean(opts.timelineSupport); this.urlPreviewCache = {}; + this._notifTimelineSet = null; this._crypto = null; if (CRYPTO_ENABLED && opts.sessionStore !== null && @@ -249,6 +250,24 @@ MatrixClient.prototype.retryImmediately = function() { return this._syncApi.retryImmediately(); }; +/** + * Return the global notification EventTimelineSet, if any + * + * @return {EventTimelineSet} the globl notification EventTimelineSet + */ +MatrixClient.prototype.getNotifTimelineSet = function() { + return this._notifTimelineSet; +}; + +/** + * Set the global notification EventTimelineSet + * + * @param {EventTimelineSet} notifTimelineSet + */ +MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) { + this._notifTimelineSet = notifTimelineSet; +}; + // Crypto bits // =========== @@ -1330,16 +1349,16 @@ function _membershipChange(client, roomId, userId, membership, reason, callback) /** * Obtain a dict of actions which should be performed for this event according - * to the push rules for this user. + * to the push rules for this user. Caches the dict on the event. * @param {MatrixEvent} event The event to get push actions for. * @return {module:pushprocessor~PushAction} A dict of actions to perform. */ MatrixClient.prototype.getPushActionsForEvent = function(event) { - if (event._pushActions === undefined) { + if (!event.getPushActions()) { var pushProcessor = new PushProcessor(this); - event._pushActions = pushProcessor.actionsForEvent(event); + event.setPushActions(pushProcessor.actionsForEvent(event)); } - return event._pushActions; + return event.getPushActions(); }; // Profile operations @@ -1584,18 +1603,18 @@ MatrixClient.prototype.paginateEventContext = function(eventContext, opts) { /** * Get an EventTimeline for the given event * - *

If the room object already has the given event in its store, the + *

If the EventTimelineSet object already has the given event in its store, the * corresponding timeline will be returned. Otherwise, a /context request is * made, and used to construct an EventTimeline. * - * @param {Room} room The room to look for the event in + * @param {EventTimelineSet} timelineSet The timelineSet to look for the event in * @param {string} eventId The ID of the event to look for * * @return {module:client.Promise} Resolves: * {@link module:models/event-timeline~EventTimeline} including the given * event */ -MatrixClient.prototype.getEventTimeline = function(room, eventId) { +MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + @@ -1603,13 +1622,13 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) { " it."); } - if (room.getTimelineForEvent(eventId)) { - return q(room.getTimelineForEvent(eventId)); + if (timelineSet.getTimelineForEvent(eventId)) { + return q(timelineSet.getTimelineForEvent(eventId)); } var path = utils.encodeUri( "/rooms/$roomId/context/$eventId", { - $roomId: room.roomId, + $roomId: timelineSet.room.roomId, $eventId: eventId, } ); @@ -1626,8 +1645,8 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) { // by the time the request completes, the event might have ended up in // the timeline. - if (room.getTimelineForEvent(eventId)) { - return room.getTimelineForEvent(eventId); + if (timelineSet.getTimelineForEvent(eventId)) { + return timelineSet.getTimelineForEvent(eventId); } // we start with the last event, since that's the point at which we @@ -1639,21 +1658,22 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) { .concat(res.events_before); var matrixEvents = utils.map(events, self.getEventMapper()); - var timeline = room.getTimelineForEvent(matrixEvents[0].getId()); + var timeline = timelineSet.getTimelineForEvent(matrixEvents[0].getId()); if (!timeline) { - timeline = room.addTimeline(); + timeline = timelineSet.addTimeline(); timeline.initialiseState(utils.map(res.state, self.getEventMapper())); timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end; } - room.addEventsToTimeline(matrixEvents, true, timeline, res.start); + timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start); // there is no guarantee that the event ended up in "timeline" (we // might have switched to a neighbouring timeline) - so check the // room's index again. On the other hand, there's no guarantee the // event ended up anywhere, if it was later redacted, so we just // return the timeline we first thought of. - return room.getTimelineForEvent(eventId) || timeline; + var tl = timelineSet.getTimelineForEvent(eventId) || timeline; + return tl; }); return promise; }; @@ -1665,7 +1685,7 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) { * @param {module:models/event-timeline~EventTimeline} eventTimeline timeline * object to be updated * @param {Object} [opts] - * @param {boolean} [opts.backwards = false] true to fill backwards, + * @param {bool} [opts.backwards = false] true to fill backwards, * false to go forwards * @param {number} [opts.limit = 30] number of events to request * @@ -1673,14 +1693,17 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) { * events and we reached either end of the timeline; else true. */ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { + var isNotifTimeline = (eventTimeline.getTimelineSet() === this._notifTimelineSet); + // TODO: we should implement a backoff (as per scrollback()) to deal more // nicely with HTTP errors. opts = opts || {}; var backwards = opts.backwards || false; - var room = this.getRoom(eventTimeline.getRoomId()); - if (!room) { - throw new Error("Unknown room " + eventTimeline.getRoomId()); + if (isNotifTimeline) { + if (!backwards) { + throw new Error("paginateNotifTimeline can only paginate backwards"); + } } var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; @@ -1698,39 +1721,131 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { return pendingRequest; } - var path = utils.encodeUri( - "/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()} - ); - var params = { - from: token, - limit: ('limit' in opts) ? opts.limit : 30, - dir: dir - }; - + var path, params, promise; var self = this; - var promise = - this._http.authedRequest(undefined, "GET", path, params - ).then(function(res) { - var token = res.end; - var matrixEvents = utils.map(res.chunk, self.getEventMapper()); - room.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + if (isNotifTimeline) { + path = "/notifications"; + params = { + limit: ('limit' in opts) ? opts.limit : 30, + only: 'highlight', + }; - // if we've hit the end of the timeline, we need to stop trying to - // paginate. We need to keep the 'forwards' token though, to make sure - // we can recover from gappy syncs. - if (backwards && res.end == res.start) { - eventTimeline.setPaginationToken(null, dir); + if (token && token !== "end") { + params.from = token; } - return res.end != res.start; - }).finally(function() { - eventTimeline._paginationRequests[dir] = null; - }); - eventTimeline._paginationRequests[dir] = promise; + + promise = + this._http.authedRequestWithPrefix(undefined, "GET", path, params, + undefined, httpApi.PREFIX_UNSTABLE + ).then(function(res) { + var token = res.next_token; + var matrixEvents = []; + + for (var i = 0; i < res.notifications.length; i++) { + var notification = res.notifications[i]; + var event = self.getEventMapper()(notification.event); + event.setPushActions( + PushProcessor.actionListToActionsObject(notification.actions) + ); + event.event.room_id = notification.room_id; // XXX: gutwrenching + matrixEvents[i] = event; + } + + eventTimeline.getTimelineSet() + .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && !res.next_token) { + eventTimeline.setPaginationToken(null, dir); + } + return res.next_token ? true : false; + }).finally(function() { + eventTimeline._paginationRequests[dir] = null; + }); + eventTimeline._paginationRequests[dir] = promise; + } + else { + var room = this.getRoom(eventTimeline.getRoomId()); + if (!room) { + throw new Error("Unknown room " + eventTimeline.getRoomId()); + } + + path = utils.encodeUri( + "/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()} + ); + params = { + from: token, + limit: ('limit' in opts) ? opts.limit : 30, + dir: dir + }; + + var filter = eventTimeline.getFilter(); + if (filter) { + // XXX: it's horrific that /messages' filter parameter doesn't match + // /sync's one - see https://matrix.org/jira/browse/SPEC-451 + params.filter = JSON.stringify(filter.getRoomTimelineFilterComponent()); + } + + promise = + this._http.authedRequest(undefined, "GET", path, params + ).then(function(res) { + var token = res.end; + var matrixEvents = utils.map(res.chunk, self.getEventMapper()); + eventTimeline.getTimelineSet() + .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && res.end == res.start) { + eventTimeline.setPaginationToken(null, dir); + } + return res.end != res.start; + }).finally(function() { + eventTimeline._paginationRequests[dir] = null; + }); + eventTimeline._paginationRequests[dir] = promise; + } return promise; }; +/** + * Reset the notifTimelineSet entirely, paginating in some historical notifs as + * a starting point for subsequent pagination. + */ +MatrixClient.prototype.resetNotifTimelineSet = function() { + if (!this._notifTimelineSet) { + return; + } + + // FIXME: This thing is a total hack, and results in duplicate events being + // added to the timeline both from /sync and /notifications, and lots of + // slow and wasteful processing and pagination. The correct solution is to + // extend /messages or /search or something to filter on notifications. + + // use the fictitious token 'end'. in practice we would ideally give it + // the oldest backwards pagination token from /sync, but /sync doesn't + // know about /notifications, so we have no choice but to start paginating + // from the current point in time. This may well overlap with historical + // notifs which are then inserted into the timeline by /sync responses. + this._notifTimelineSet.resetLiveTimeline('end', true); + + // we could try to paginate a single event at this point in order to get + // a more valid pagination token, but it just ends up with an out of order + // timeline. given what a mess this is and given we're going to have duplicate + // events anyway, just leave it with the dummy token for now. + /* + this.paginateNotifTimeline(this._notifTimelineSet.getLiveTimeline(), { + backwards: true, + limit: 1 + }); + */ +}; + /** * Peek into a room and receive updates about the room. This only works if the * history visibility for the room is world_readable. @@ -2242,6 +2357,54 @@ MatrixClient.prototype.getFilter = function(userId, filterId, allowCached) { }); }; +/** + * @param {string} filterName + * @param {Filter} filter + * @return {Promise} Filter ID + */ +MatrixClient.prototype.getOrCreateFilter = function(filterName, filter) { + + var filterId = this.store.getFilterIdByName(filterName); + var promise = q(); + var self = this; + + if (filterId) { + // check that the existing filter matches our expectations + promise = self.getFilter(self.credentials.userId, + filterId, true + ).then(function(existingFilter) { + var oldDef = existingFilter.getDefinition(); + var newDef = filter.getDefinition(); + + if (utils.deepCompare(oldDef, newDef)) { + // super, just use that. + // debuglog("Using existing filter ID %s: %s", filterId, + // JSON.stringify(oldDef)); + return q(filterId); + } + // debuglog("Existing filter ID %s: %s; new filter: %s", + // filterId, JSON.stringify(oldDef), JSON.stringify(newDef)); + return; + }); + } + + return promise.then(function(existingId) { + if (existingId) { + return existingId; + } + + // create a new filter + return self.createFilter(filter.getDefinition() + ).then(function(createdFilter) { + // debuglog("Created new filter ID %s: %s", createdFilter.filterId, + // JSON.stringify(createdFilter.getDefinition())); + self.store.setFilterIdByName(filterName, createdFilter.filterId); + return createdFilter.filterId; + }); + }); +}; + + /** * Gets a bearer token from the Home Server that the user can * present to a third party in order to prove their ownership diff --git a/lib/crypto/OlmDevice.js b/lib/crypto/OlmDevice.js index b52298cf8..39832a9ee 100644 --- a/lib/crypto/OlmDevice.js +++ b/lib/crypto/OlmDevice.js @@ -522,21 +522,23 @@ OlmDevice.prototype.getOutboundGroupSessionKey = function(sessionId) { * store an InboundGroupSession in the session store * * @param {string} roomId - * @param {string} senderKey + * @param {string} senderCurve25519Key * @param {string} sessionId * @param {Olm.InboundGroupSession} session + * @param {object} keysClaimed Other keys the sender claims. * @private */ OlmDevice.prototype._saveInboundGroupSession = function( - roomId, senderKey, sessionId, session + roomId, senderCurve25519Key, sessionId, session, keysClaimed ) { var r = { room_id: roomId, session: session.pickle(this._pickleKey), + keysClaimed: keysClaimed, }; this._sessionStore.storeEndToEndInboundGroupSession( - senderKey, sessionId, JSON.stringify(r) + senderCurve25519Key, sessionId, JSON.stringify(r) ); }; @@ -547,7 +549,9 @@ OlmDevice.prototype._saveInboundGroupSession = function( * @param {string} senderKey * @param {string} sessionId * @param {function} func - * @return {object} result of func + * @return {object} Object with two keys "result": result of func, "exists" + * whether the session exists. if the session doesn't exist then the function + * isn't called and the "result" is undefined. * @private */ OlmDevice.prototype._getInboundGroupSession = function( @@ -558,7 +562,7 @@ OlmDevice.prototype._getInboundGroupSession = function( ); if (r === null) { - throw new Error("Unknown inbound group session id"); + return {sessionExists: false}; } r = JSON.parse(r); @@ -575,7 +579,12 @@ OlmDevice.prototype._getInboundGroupSession = function( var session = new Olm.InboundGroupSession(); try { session.unpickle(this._pickleKey, r.session); - return func(session); + return { + sessionExists: true, + result: func(session), + keysProved: {curve25519: senderKey}, + keysClaimed: r.keysClaimed || {}, + }; } finally { session.free(); } @@ -616,7 +625,7 @@ OlmDevice.prototype.addInboundGroupSession = function( * @param {string} sessionId session identifier * @param {string} body base64-encoded body of the encrypted message * - * @return {string} plaintext + * @return {object} {result: "plaintext"|undefined, sessionExists: Boolean} */ OlmDevice.prototype.decryptGroupMessage = function( roomId, senderKey, sessionId, body diff --git a/lib/crypto/algorithms/megolm.js b/lib/crypto/algorithms/megolm.js index 0a809780a..58321978d 100644 --- a/lib/crypto/algorithms/megolm.js +++ b/lib/crypto/algorithms/megolm.js @@ -272,6 +272,9 @@ MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) { sender_key: self._olmDevice.deviceCurve25519Key, ciphertext: ciphertext, session_id: session_id, + // Include our device ID so that recipients can send us a + // m.new_device message if they don't have our session key. + device_id: self._deviceId, }; return encryptedContent; @@ -356,7 +359,8 @@ utils.inherits(MegolmDecryption, base.DecryptionAlgorithm); * * @param {object} event raw event * - * @return {object} decrypted payload (with properties 'type', 'content') + * @return {object} object with 'result' key with decrypted payload (with + * properties 'type', 'content') and a 'sessionExists' key. * * @throws {module:crypto/algorithms/base.DecryptionError} if there is a * problem decrypting the event @@ -377,7 +381,12 @@ MegolmDecryption.prototype.decryptEvent = function(event) { var res = this._olmDevice.decryptGroupMessage( event.room_id, content.sender_key, content.session_id, content.ciphertext ); - return JSON.parse(res); + if (res.sessionExists) { + res.result = JSON.parse(res.result); + return res; + } else { + return {sessionExists: false}; + } } catch (e) { throw new base.DecryptionError(e); } @@ -402,7 +411,7 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { this._olmDevice.addInboundGroupSession( content.room_id, event.getSenderKey(), content.session_id, - content.session_key + content.session_key, event.getKeysClaimed() ); }; diff --git a/lib/crypto/algorithms/olm.js b/lib/crypto/algorithms/olm.js index d5fd4fd4e..df889766b 100644 --- a/lib/crypto/algorithms/olm.js +++ b/lib/crypto/algorithms/olm.js @@ -119,6 +119,17 @@ OlmEncryption.prototype.encryptMessage = function(room, eventType, content) { room_id: room.roomId, type: eventType, content: content, + // Include the ED25519 key so that the recipient knows what + // device this message came from. + // We don't need to include the curve25519 key since the + // recipient will already know this from the olm headers. + // When combined with the device keys retrieved from the + // homeserver signed by the ed25519 key this proves that + // the curve25519 key and the ed25519 key are owned by + // the same device. + keys: { + "ed25519": self._olmDevice.deviceEd25519Key + }, } ); }); @@ -142,7 +153,9 @@ utils.inherits(OlmDecryption, base.DecryptionAlgorithm); * * @param {object} event raw event * - * @return {object} decrypted payload (with properties 'type', 'content') + * @return {object} result object with result property with the decrypted + * payload (with properties 'type', 'content'), and a "sessionExists" key + * always set to true. * * @throws {module:crypto/algorithms/base.DecryptionError} if there is a * problem decrypting the event @@ -198,7 +211,13 @@ OlmDecryption.prototype.decryptEvent = function(event) { // TODO: Check the sender user id matches the sender key. // TODO: check the room_id and fingerprint if (payloadString !== null) { - return JSON.parse(payloadString); + var payload = JSON.parse(payloadString); + return { + result: payload, + sessionExists: true, + keysProved: {curve25519: deviceKey}, + keysClaimed: payload.keys || {} + }; } else { throw new base.DecryptionError("Bad Encrypted Message"); } diff --git a/lib/crypto/index.js b/lib/crypto/index.js index b312034dc..24a480d43 100644 --- a/lib/crypto/index.js +++ b/lib/crypto/index.js @@ -85,36 +85,53 @@ function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId) { ); _registerEventHandlers(this, eventEmitter); + + this._lastNewDeviceMessageTsByUserDeviceRoom = {}; } function _registerEventHandlers(crypto, eventEmitter) { eventEmitter.on("sync", function(syncState, oldState, data) { - if (syncState == "PREPARED") { - // XXX ugh. we're assuming the eventEmitter is a MatrixClient. - // how can we avoid doing so? - var rooms = eventEmitter.getRooms(); - crypto._onInitialSyncCompleted(rooms); + try { + if (syncState == "PREPARED") { + // XXX ugh. we're assuming the eventEmitter is a MatrixClient. + // how can we avoid doing so? + var rooms = eventEmitter.getRooms(); + crypto._onInitialSyncCompleted(rooms); + } + } catch (e) { + console.error("Error handling sync", e); } }); - eventEmitter.on( - "RoomMember.membership", - crypto._onRoomMembership.bind(crypto) - ); + eventEmitter.on("RoomMember.membership", function(event, member, oldMembership) { + try { + crypto._onRoomMembership(event, member, oldMembership); + } catch (e) { + console.error("Error handling membership change:", e); + } + }); eventEmitter.on("toDeviceEvent", function(event) { - if (event.getType() == "m.room_key") { - crypto._onRoomKeyEvent(event); - } else if (event.getType() == "m.new_device") { - crypto._onNewDeviceEvent(event); + try { + if (event.getType() == "m.room_key") { + crypto._onRoomKeyEvent(event); + } else if (event.getType() == "m.new_device") { + crypto._onNewDeviceEvent(event); + } + } catch (e) { + console.error("Error handling toDeviceEvent:", e); } }); eventEmitter.on("event", function(event) { - if (!event.isState() || event.getType() != "m.room.encryption") { - return; + try { + if (!event.isState() || event.getType() != "m.room.encryption") { + return; + } + crypto._onCryptoEvent(event); + } catch (e) { + console.error("Error handling crypto event:", e); } - crypto._onCryptoEvent(event); }); } @@ -812,7 +829,66 @@ Crypto.prototype.decryptEvent = function(event) { var alg = new AlgClass({ olmDevice: this._olmDevice, }); - return alg.decryptEvent(event); + var r = alg.decryptEvent(event); + var payload = r.result; + payload.keysClaimed = r.keysClaimed; + payload.keysProved = r.keysProved; + if (r.sessionExists) { + return payload; + } else { + // We've got a message for a session we don't have. + // Maybe the sender forgot to tell us about the session. + // Remind the sender that we exist so that they might + // tell us about the sender. + if (event.getRoomId !== undefined && event.getSender !== undefined) { + this._sendPingToDevice( + event.getSender(), event.content.device, event.getRoomId + ); + } + + throw new algorithms.DecryptionError("Unknown inbound session id"); + } +}; + +/** + * Send a "m.new_device" message to remind it that we exist and are a member + * of a room. + * + * This is rate limited to send a message at most once an hour per desination. + * + * @param {string} userId The ID of the user to ping. + * @param {string} deviceId The ID of the device to ping. + * @param {string} roomId The ID of the room we want to remind them about. + */ +Crypto.prototype._sendPingToDevice = function(userId, deviceId, roomId) { + if (deviceId === undefined) { + deviceId = "*"; + } + + var lastMessageTsMap = this._lastNewDeviceMessageTsByUserDeviceRoom; + var lastTsByDevice = lastMessageTsMap[userId] || {}; + var lastTsByRoom = lastTsByDevice[deviceId] || {}; + var lastTs = lastTsByRoom[roomId]; + var timeNowMs = Date.now(); + var oneHourMs = 1000 * 60 * 60; + + if (lastTs === undefined || lastTs + oneHourMs < timeNowMs) { + var content = { + userId: { + deviceId: { + device_id: this._deviceId, + rooms: [roomId], + } + } + }; + + lastTsByRoom[roomId] = timeNowMs; + + this._baseApis.sendToDevice( + "m.new_device", // OH HAI! + content + ).done(function() {}); + } }; /** diff --git a/lib/filter-component.js b/lib/filter-component.js new file mode 100644 index 000000000..98f8fd7d7 --- /dev/null +++ b/lib/filter-component.js @@ -0,0 +1,141 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +"use strict"; +/** + * @module filter-component + */ + +/** + * Checks if a value matches a given field value, which may be a * terminated + * wildcard pattern. + * @param {String} actual_value The value to be compared + * @param {String} filter_value The filter pattern to be compared + * @return {bool} true if the actual_value matches the filter_value + */ +function _matches_wildcard(actual_value, filter_value) { + if (filter_value.endsWith("*")) { + var type_prefix = filter_value.slice(0, -1); + return actual_value.substr(0, type_prefix.length) === type_prefix; + } + else { + return actual_value === filter_value; + } +} + +/** + * FilterComponent is a section of a Filter definition which defines the + * types, rooms, senders filters etc to be applied to a particular type of resource. + * This is all ported over from synapse's Filter object. + * + * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as + * 'Filters' are referred to as 'FilterCollections'. + * + * @constructor + * @param {Object} the definition of this filter JSON, e.g. { 'contains_url': true } + */ +function FilterComponent(filter_json) { + this.filter_json = filter_json; + + this.types = filter_json.types || null; + this.not_types = filter_json.not_types || []; + + this.rooms = filter_json.rooms || null; + this.not_rooms = filter_json.not_rooms || []; + + this.senders = filter_json.senders || null; + this.not_senders = filter_json.not_senders || []; + + this.contains_url = filter_json.contains_url || null; +} + +/** + * Checks with the filter component matches the given event + * @param {MatrixEvent} event event to be checked against the filter + * @return {bool} true if the event matches the filter + */ +FilterComponent.prototype.check = function(event) { + return this._checkFields( + event.getRoomId(), + event.getSender(), + event.getType(), + event.getContent() ? event.getContent().url !== undefined : false + ); +}; + +/** + * Checks whether the filter component matches the given event fields. + * @param {String} room_id the room_id for the event being checked + * @param {String} sender the sender of the event being checked + * @param {String} event_type the type of the event being checked + * @param {String} contains_url whether the event contains a content.url field + * @return {bool} true if the event fields match the filter + */ +FilterComponent.prototype._checkFields = + function(room_id, sender, event_type, contains_url) +{ + var literal_keys = { + "rooms": function(v) { return room_id === v; }, + "senders": function(v) { return sender === v; }, + "types": function(v) { return _matches_wildcard(event_type, v); }, + }; + + var self = this; + Object.keys(literal_keys).forEach(function(name) { + var match_func = literal_keys[name]; + var not_name = "not_" + name; + var disallowed_values = self[not_name]; + if (disallowed_values.map(match_func)) { + return false; + } + + var allowed_values = self[name]; + if (allowed_values) { + if (!allowed_values.map(match_func)) { + return false; + } + } + }); + + var contains_url_filter = this.filter_json.contains_url; + if (contains_url_filter !== undefined) { + if (contains_url_filter !== contains_url) { + return false; + } + } + + return true; +}; + +/** + * Filters a list of events down to those which match this filter component + * @param {MatrixEvent[]} events Events to be checked againt the filter component + * @return {MatrixEvent[]} events which matched the filter component + */ +FilterComponent.prototype.filter = function(events) { + return events.filter(this.check, this); +}; + +/** + * Returns the limit field for a given filter component, providing a default of + * 10 if none is otherwise specified. Cargo-culted from Synapse. + * @return {Number} the limit for this filter component. + */ +FilterComponent.prototype.limit = function() { + return this.filter_json.limit !== undefined ? this.filter_json.limit : 10; +}; + +/** The FilterComponent class */ +module.exports = FilterComponent; diff --git a/lib/filter.js b/lib/filter.js index e533ae937..1aa892960 100644 --- a/lib/filter.js +++ b/lib/filter.js @@ -18,6 +18,8 @@ limitations under the License. * @module filter */ +var FilterComponent = require("./filter-component"); + /** * @param {Object} obj * @param {string} keyNesting @@ -49,6 +51,14 @@ function Filter(userId, filterId) { this.definition = {}; } +/** + * Get the ID of this filter on your homeserver (if known) + * @return {?Number} The filter ID + */ +Filter.prototype.getFilterId = function() { + return this.filterId; +}; + /** * Get the JSON body of the filter. * @return {Object} The filter definition @@ -63,6 +73,88 @@ Filter.prototype.getDefinition = function() { */ Filter.prototype.setDefinition = function(definition) { this.definition = definition; + + // This is all ported from synapse's FilterCollection() + + // definitions look something like: + // { + // "room": { + // "rooms": ["!abcde:example.com"], + // "not_rooms": ["!123456:example.com"], + // "state": { + // "types": ["m.room.*"], + // "not_rooms": ["!726s6s6q:example.com"], + // }, + // "timeline": { + // "limit": 10, + // "types": ["m.room.message"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // "contains_url": true + // }, + // "ephemeral": { + // "types": ["m.receipt", "m.typing"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // } + // }, + // "presence": { + // "types": ["m.presence"], + // "not_senders": ["@alice:example.com"] + // }, + // "event_format": "client", + // "event_fields": ["type", "content", "sender"] + // } + + var room_filter_json = definition.room; + + // consider the top level rooms/not_rooms filter + var room_filter_fields = {}; + if (room_filter_json) { + if (room_filter_json.rooms) { + room_filter_fields.rooms = room_filter_json.rooms; + } + if (room_filter_json.rooms) { + room_filter_fields.not_rooms = room_filter_json.not_rooms; + } + + this._include_leave = room_filter_json.include_leave || false; + } + + this._room_filter = new FilterComponent(room_filter_fields); + this._room_timeline_filter = new FilterComponent( + room_filter_json ? (room_filter_json.timeline || {}) : {} + ); + + // don't bother porting this from synapse yet: + // this._room_state_filter = + // new FilterComponent(room_filter_json.state || {}); + // this._room_ephemeral_filter = + // new FilterComponent(room_filter_json.ephemeral || {}); + // this._room_account_data_filter = + // new FilterComponent(room_filter_json.account_data || {}); + // this._presence_filter = + // new FilterComponent(definition.presence || {}); + // this._account_data_filter = + // new FilterComponent(definition.account_data || {}); +}; + +/** + * Get the room.timeline filter component of the filter + * @return {FilterComponent} room timeline filter component + */ +Filter.prototype.getRoomTimelineFilterComponent = function() { + return this._room_timeline_filter; +}; + +/** + * Filter the list of events based on whether they are allowed in a timeline + * based on this filter + * @param {MatrixEvent[]} events the list of events being filtered + * @return {MatrixEvent[]} the list of events which match the filter + */ +Filter.prototype.filterRoomTimeline = function(events) { + return this._room_timeline_filter.filter(this._room_filter.filter(events)); }; /** diff --git a/lib/matrix.js b/lib/matrix.js index 34758555f..0dfcedb4a 100644 --- a/lib/matrix.js +++ b/lib/matrix.js @@ -34,6 +34,8 @@ module.exports.MatrixClient = require("./client").MatrixClient; module.exports.Room = require("./models/room"); /** The {@link module:models/event-timeline~EventTimeline} class. */ module.exports.EventTimeline = require("./models/event-timeline"); +/** The {@link module:models/event-timeline-set~EventTimelineSet} class. */ +module.exports.EventTimelineSet = require("./models/event-timeline-set"); /** The {@link module:models/room-member|RoomMember} class. */ module.exports.RoomMember = require("./models/room-member"); /** The {@link module:models/room-state~RoomState|RoomState} class. */ diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js new file mode 100644 index 000000000..1f4bc4739 --- /dev/null +++ b/lib/models/event-timeline-set.js @@ -0,0 +1,654 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +"use strict"; +/** + * @module models/event-timeline-set + */ +var EventEmitter = require("events").EventEmitter; +var utils = require("../utils"); +var EventTimeline = require("./event-timeline"); + +// var DEBUG = false; +var DEBUG = true; + +if (DEBUG) { + // using bind means that we get to keep useful line numbers in the console + var debuglog = console.log.bind(console); +} else { + var debuglog = function() {}; +} + +/** + * Construct a set of EventTimeline objects, typically on behalf of a given + * room. A room may have multiple EventTimelineSets for different levels + * of filtering. The global notification list is also an EventTimelineSet, but + * lacks a room. + * + *

This is an ordered sequence of timelines, which may or may not + * be continuous. Each timeline lists a series of events, as well as tracking + * the room state at the start and the end of the timeline (if appropriate). + * It also tracks forward and backward pagination tokens, as well as containing + * links to the next timeline in the sequence. + * + *

There is one special timeline - the 'live' timeline, which represents the + * timeline to which events are being added in real-time as they are received + * from the /sync API. Note that you should not retain references to this + * timeline - even if it is the current timeline right now, it may not remain + * so if the server gives us a timeline gap in /sync. + * + *

In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @constructor + * @param {?Room} room the optional room for this timelineSet + * @param {Object} opts hash of options inherited from Room. + * opts.timelineSupport gives whether timeline support is enabled + * opts.filter is the filter object, if any, for this timelineSet. + */ +function EventTimelineSet(room, opts) { + this.room = room; + + this._timelineSupport = Boolean(opts.timelineSupport); + this._liveTimeline = new EventTimeline(this); + + // just a list - *not* ordered. + this._timelines = [this._liveTimeline]; + this._eventIdToTimeline = {}; + + this._filter = opts.filter || null; +} +utils.inherits(EventTimelineSet, EventEmitter); + +/** + * Get the filter object this timeline set is filtered on, if any + * @return {?Filter} the optional filter for this timelineSet + */ +EventTimelineSet.prototype.getFilter = function() { + return this._filter; +}; + +/** + * Set the filter object this timeline set is filtered on + * (passed to the server when paginating via /messages). + * @param {Filter} filter the filter for this timelineSet + */ +EventTimelineSet.prototype.setFilter = function(filter) { + this._filter = filter; +}; + +/** + * Get the list of pending sent events for this timelineSet's room, filtered + * by the timelineSet's filter if appropriate. + * + * @return {module:models/event.MatrixEvent[]} A list of the sent events + * waiting for remote echo. + * + * @throws If opts.pendingEventOrdering was not 'detached' + */ +EventTimelineSet.prototype.getPendingEvents = function() { + if (!this.room) { + return []; + } + + if (this._filter) { + return this._filter.filterRoomTimeline(this.room.getPendingEvents()); + } + else { + return this.room.getPendingEvents(); + } +}; + +/** + * Get the live timeline for this room. + * + * @return {module:models/event-timeline~EventTimeline} live timeline + */ +EventTimelineSet.prototype.getLiveTimeline = function() { + return this._liveTimeline; +}; + +/** + * Return the timeline (if any) this event is in. + * @param {String} eventId the eventId being sought + * @return {module:models/event-timeline~EventTimeline} timeline + */ +EventTimelineSet.prototype.eventIdToTimeline = function(eventId) { + return this._eventIdToTimeline[eventId]; +}; + +/** + * Track a new event as if it were in the same timeline as an old event, + * replacing it. + * @param {String} oldEventId event ID of the original event + * @param {String} newEventId event ID of the replacement event + */ +EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) { + var existingTimeline = this._eventIdToTimeline[oldEventId]; + if (existingTimeline) { + delete this._eventIdToTimeline[oldEventId]; + this._eventIdToTimeline[newEventId] = existingTimeline; + } +}; + +/** + * Reset the live timeline, and start a new one. + * + *

This is used when /sync returns a 'limited' timeline. + * + * @param {string=} backPaginationToken token for back-paginating the new timeline + * @param {?bool} flush Whether to flush the non-live timelines too. + * + * @fires module:client~MatrixClient#event:"Room.timelineReset" + */ +EventTimelineSet.prototype.resetLiveTimeline = function(backPaginationToken, flush) { + var newTimeline; + + if (!this._timelineSupport || flush) { + // if timeline support is disabled, forget about the old timelines + newTimeline = new EventTimeline(this); + this._timelines = [newTimeline]; + this._eventIdToTimeline = {}; + } else { + newTimeline = this.addTimeline(); + } + + // initialise the state in the new timeline from our last known state + var evMap = this._liveTimeline.getState(EventTimeline.FORWARDS).events; + var events = []; + for (var evtype in evMap) { + if (!evMap.hasOwnProperty(evtype)) { continue; } + for (var stateKey in evMap[evtype]) { + if (!evMap[evtype].hasOwnProperty(stateKey)) { continue; } + events.push(evMap[evtype][stateKey]); + } + } + newTimeline.initialiseState(events); + + // make sure we set the pagination token before firing timelineReset, + // otherwise clients which start back-paginating will fail, and then get + // stuck without realising that they *can* back-paginate. + newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); + + this._liveTimeline = newTimeline; + this.emit("Room.timelineReset", this.room, this); +}; + +/** + * Get the timeline which contains the given event, if any + * + * @param {string} eventId event ID to look for + * @return {?module:models/event-timeline~EventTimeline} timeline containing + * the given event, or null if unknown + */ +EventTimelineSet.prototype.getTimelineForEvent = function(eventId) { + var res = this._eventIdToTimeline[eventId]; + return (res === undefined) ? null : res; +}; + +/** + * Get an event which is stored in our timelines + * + * @param {string} eventId event ID to look for + * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown + */ +EventTimelineSet.prototype.findEventById = function(eventId) { + var tl = this.getTimelineForEvent(eventId); + if (!tl) { + return undefined; + } + return utils.findElement(tl.getEvents(), + function(ev) { return ev.getId() == eventId; }); +}; + +/** + * Add a new timeline to this timeline list + * + * @return {module:models/event-timeline~EventTimeline} newly-created timeline + */ +EventTimelineSet.prototype.addTimeline = function() { + if (!this._timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable" + + " it."); + } + + var timeline = new EventTimeline(this); + this._timelines.push(timeline); + return timeline; +}; + + +/** + * Add events to a timeline + * + *

Will fire "Room.timeline" for each event added. + * + * @param {MatrixEvent[]} events A list of events to add. + * + * @param {boolean} toStartOfTimeline True to add these events to the start + * (oldest) instead of the end (newest) of the timeline. If true, the oldest + * event will be the last element of 'events'. + * + * @param {module:models/event-timeline~EventTimeline} timeline timeline to + * add events to. + * + * @param {string=} paginationToken token for the next batch of events + * + * @fires module:client~MatrixClient#event:"Room.timeline" + * + */ +EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimeline, + timeline, paginationToken) { + if (!timeline) { + throw new Error( + "'timeline' not specified for EventTimelineSet.addEventsToTimeline" + ); + } + + if (!toStartOfTimeline && timeline == this._liveTimeline) { + throw new Error( + "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " + + "the live timeline - use Room.addLiveEvents instead" + ); + } + + if (this._filter) { + events = this._filter.filterRoomTimeline(events); + if (!events.length) { + return; + } + } + + var direction = toStartOfTimeline ? EventTimeline.BACKWARDS : + EventTimeline.FORWARDS; + var inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : + EventTimeline.BACKWARDS; + + // Adding events to timelines can be quite complicated. The following + // illustrates some of the corner-cases. + // + // Let's say we start by knowing about four timelines. timeline3 and + // timeline4 are neighbours: + // + // timeline1 timeline2 timeline3 timeline4 + // [M] [P] [S] <------> [T] + // + // Now we paginate timeline1, and get the following events from the server: + // [M, N, P, R, S, T, U]. + // + // 1. First, we ignore event M, since we already know about it. + // + // 2. Next, we append N to timeline 1. + // + // 3. Next, we don't add event P, since we already know about it, + // but we do link together the timelines. We now have: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P] [S] <------> [T] + // + // 4. Now we add event R to timeline2: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] [S] <------> [T] + // + // Note that we have switched the timeline we are working on from + // timeline1 to timeline2. + // + // 5. We ignore event S, but again join the timelines: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] <---> [S] <------> [T] + // + // 6. We ignore event T, and the timelines are already joined, so there + // is nothing to do. + // + // 7. Finally, we add event U to timeline4: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] <---> [S] <------> [T, U] + // + // The important thing to note in the above is what happened when we + // already knew about a given event: + // + // - if it was appropriate, we joined up the timelines (steps 3, 5). + // - in any case, we started adding further events to the timeline which + // contained the event we knew about (steps 3, 5, 6). + // + // + // So much for adding events to the timeline. But what do we want to do + // with the pagination token? + // + // In the case above, we will be given a pagination token which tells us how to + // get events beyond 'U' - in this case, it makes sense to store this + // against timeline4. But what if timeline4 already had 'U' and beyond? in + // that case, our best bet is to throw away the pagination token we were + // given and stick with whatever token timeline4 had previously. In short, + // we want to only store the pagination token if the last event we receive + // is one we didn't previously know about. + // + // We make an exception for this if it turns out that we already knew about + // *all* of the events, and we weren't able to join up any timelines. When + // that happens, it means our existing pagination token is faulty, since it + // is only telling us what we already know. Rather than repeatedly + // paginating with the same token, we might as well use the new pagination + // token in the hope that we eventually work our way out of the mess. + + var didUpdate = false; + var lastEventWasNew = false; + for (var i = 0; i < events.length; i++) { + var event = events[i]; + var eventId = event.getId(); + + var existingTimeline = this._eventIdToTimeline[eventId]; + + if (!existingTimeline) { + // we don't know about this event yet. Just add it to the timeline. + this.addEventToTimeline(event, timeline, toStartOfTimeline); + lastEventWasNew = true; + didUpdate = true; + continue; + } + + lastEventWasNew = false; + + if (existingTimeline == timeline) { + debuglog("Event " + eventId + " already in timeline " + timeline); + continue; + } + + var neighbour = timeline.getNeighbouringTimeline(direction); + if (neighbour) { + // this timeline already has a neighbour in the relevant direction; + // let's assume the timelines are already correctly linked up, and + // skip over to it. + // + // there's probably some edge-case here where we end up with an + // event which is in a timeline a way down the chain, and there is + // a break in the chain somewhere. But I can't really imagine how + // that would happen, so I'm going to ignore it for now. + // + if (existingTimeline == neighbour) { + debuglog("Event " + eventId + " in neighbouring timeline - " + + "switching to " + existingTimeline); + } else { + debuglog("Event " + eventId + " already in a different " + + "timeline " + existingTimeline); + } + timeline = existingTimeline; + continue; + } + + // time to join the timelines. + console.info("Already have timeline for " + eventId + + " - joining timeline " + timeline + " to " + + existingTimeline); + timeline.setNeighbouringTimeline(existingTimeline, direction); + existingTimeline.setNeighbouringTimeline(timeline, inverseDirection); + timeline = existingTimeline; + didUpdate = true; + } + + // see above - if the last event was new to us, or if we didn't find any + // new information, we update the pagination token for whatever + // timeline we ended up on. + if (lastEventWasNew || !didUpdate) { + timeline.setPaginationToken(paginationToken, direction); + } +}; + +/** + * Add an event to the end of this live timeline. + * + * @param {MatrixEvent} event Event to be added + * @param {string?} duplicateStrategy 'ignore' or 'replace' + */ +EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) { + if (this._filter) { + var events = this._filter.filterRoomTimeline([event]); + if (!events.length) { + return; + } + } + + var timeline = this._eventIdToTimeline[event.getId()]; + if (timeline) { + if (duplicateStrategy === "replace") { + debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + + event.getId()); + var tlEvents = timeline.getEvents(); + for (var j = 0; j < tlEvents.length; j++) { + if (tlEvents[j].getId() === event.getId()) { + // still need to set the right metadata on this event + EventTimeline.setEventMetadata( + event, + timeline.getState(EventTimeline.FORWARDS), + false + ); + + if (!tlEvents[j].encryptedType) { + tlEvents[j] = event; + } + + // XXX: we need to fire an event when this happens. + break; + } + } + } else { + debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + + event.getId()); + } + return; + } + + this.addEventToTimeline(event, this._liveTimeline, false); +}; + +/** + * Add event to the given timeline, and emit Room.timeline. Assumes + * we have already checked we don't know about this event. + * + * Will fire "Room.timeline" for each event added. + * + * @param {MatrixEvent} event + * @param {EventTimeline} timeline + * @param {boolean} toStartOfTimeline + * + * @fires module:client~MatrixClient#event:"Room.timeline" + */ +EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, + toStartOfTimeline) { + var eventId = event.getId(); + timeline.addEvent(event, toStartOfTimeline); + this._eventIdToTimeline[eventId] = timeline; + + var data = { + timeline: timeline, + liveEvent: !toStartOfTimeline && timeline == this._liveTimeline, + }; + this.emit("Room.timeline", event, this.room, + Boolean(toStartOfTimeline), false, data); +}; + +/** + * Replaces event with ID oldEventId with one with newEventId, if oldEventId is + * recognised. Otherwise, add to the live timeline. Used to handle remote echos. + * + * @param {MatrixEvent} localEvent the new event to be added to the timeline + * @param {String} oldEventId the ID of the original event + * @param {boolean} newEventId the ID of the replacement event + * + * @fires module:client~MatrixClient#event:"Room.timeline" + */ +EventTimelineSet.prototype.handleRemoteEcho = function(localEvent, oldEventId, + newEventId) { + // XXX: why don't we infer newEventId from localEvent? + var existingTimeline = this._eventIdToTimeline[oldEventId]; + if (existingTimeline) { + delete this._eventIdToTimeline[oldEventId]; + this._eventIdToTimeline[newEventId] = existingTimeline; + } else { + if (this._filter) { + if (this._filter.filterRoomTimeline([localEvent]).length) { + this.addEventToTimeline(localEvent, this._liveTimeline, false); + } + } + else { + this.addEventToTimeline(localEvent, this._liveTimeline, false); + } + } +}; + +/** + * Removes a single event from this room. + * + * @param {String} eventId The id of the event to remove + * + * @return {?MatrixEvent} the removed event, or null if the event was not found + * in this room. + */ +EventTimelineSet.prototype.removeEvent = function(eventId) { + var timeline = this._eventIdToTimeline[eventId]; + if (!timeline) { + return null; + } + + var removed = timeline.removeEvent(eventId); + if (removed) { + delete this._eventIdToTimeline[eventId]; + var data = { + timeline: timeline, + }; + this.emit("Room.timeline", removed, this.room, undefined, true, data); + } + return removed; +}; + +/** + * Determine where two events appear in the timeline relative to one another + * + * @param {string} eventId1 The id of the first event + * @param {string} eventId2 The id of the second event + + * @return {?number} a number less than zero if eventId1 precedes eventId2, and + * greater than zero if eventId1 succeeds eventId2. zero if they are the + * same event; null if we can't tell (either because we don't know about one + * of the events, or because they are in separate timelines which don't join + * up). + */ +EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) { + if (eventId1 == eventId2) { + // optimise this case + return 0; + } + + var timeline1 = this._eventIdToTimeline[eventId1]; + var timeline2 = this._eventIdToTimeline[eventId2]; + + if (timeline1 === undefined) { + return null; + } + if (timeline2 === undefined) { + return null; + } + + if (timeline1 === timeline2) { + // both events are in the same timeline - figure out their + // relative indices + var idx1, idx2; + var events = timeline1.getEvents(); + for (var idx = 0; idx < events.length && + (idx1 === undefined || idx2 === undefined); idx++) { + var evId = events[idx].getId(); + if (evId == eventId1) { + idx1 = idx; + } + if (evId == eventId2) { + idx2 = idx; + } + } + return idx1 - idx2; + } + + // the events are in different timelines. Iterate through the + // linkedlist to see which comes first. + + // first work forwards from timeline1 + var tl = timeline1; + while (tl) { + if (tl === timeline2) { + // timeline1 is before timeline2 + return -1; + } + tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS); + } + + // now try backwards from timeline1 + tl = timeline1; + while (tl) { + if (tl === timeline2) { + // timeline2 is before timeline1 + return 1; + } + tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS); + } + + // the timelines are not contiguous. + return null; +}; + +/** + * The EventTimelineSet class. + */ +module.exports = EventTimelineSet; + +/** + * Fires whenever the timeline in a room is updated. + * @event module:client~MatrixClient#"Room.timeline" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {?Room} room The room, if any, whose timeline was updated. + * @param {boolean} toStartOfTimeline True if this event was added to the start + * @param {boolean} removed True if this event has just been removed from the timeline + * (beginning; oldest) of the timeline e.g. due to pagination. + * + * @param {object} data more data about the event + * + * @param {module:event-timeline.EventTimeline} data.timeline the timeline the + * event was added to/removed from + * + * @param {boolean} data.liveEvent true if the event was a real-time event + * added to the end of the live timeline + * + * @example + * matrixClient.on("Room.timeline", + * function(event, room, toStartOfTimeline, removed, data) { + * if (!toStartOfTimeline && data.liveEvent) { + * var messageToAppend = room.timeline.[room.timeline.length - 1]; + * } + * }); + */ + +/** + * Fires whenever the live timeline in a room is reset. + * + * When we get a 'limited' sync (for example, after a network outage), we reset + * the live timeline to be empty before adding the recent events to the new + * timeline. This event is fired after the timeline is reset, and before the + * new events are added. + * + * @event module:client~MatrixClient#"Room.timelineReset" + * @param {Room} room The room whose live timeline was reset, if any + * @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset + */ diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index 4123849ac..5c1545286 100644 --- a/lib/models/event-timeline.js +++ b/lib/models/event-timeline.js @@ -25,16 +25,17 @@ var MatrixEvent = require("./event").MatrixEvent; *

Once a timeline joins up with its neighbour, they are linked together into a * doubly-linked list. * - * @param {string} roomId the ID of the room where this timeline came from + * @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of * @constructor */ -function EventTimeline(roomId) { - this._roomId = roomId; +function EventTimeline(eventTimelineSet) { + this._eventTimelineSet = eventTimelineSet; + this._roomId = eventTimelineSet.room ? eventTimelineSet.room.roomId : null; this._events = []; this._baseIndex = 0; - this._startState = new RoomState(roomId); + this._startState = new RoomState(this._roomId); this._startState.paginationToken = null; - this._endState = new RoomState(roomId); + this._endState = new RoomState(this._roomId); this._endState.paginationToken = null; this._prevTimeline = null; @@ -43,7 +44,7 @@ function EventTimeline(roomId) { // this is used by client.js this._paginationRequests = {'b': null, 'f': null}; - this._name = roomId + ":" + new Date().toISOString(); + this._name = this._roomId + ":" + new Date().toISOString(); } /** @@ -91,6 +92,22 @@ EventTimeline.prototype.getRoomId = function() { return this._roomId; }; +/** + * Get the filter for this timeline's timelineSet (if any) + * @return {Filter} filter + */ +EventTimeline.prototype.getFilter = function() { + return this._eventTimelineSet.getFilter(); +}; + +/** + * Get the timelineSet for this timeline + * @return {EventTimelineSet} timelineSet + */ +EventTimeline.prototype.getTimelineSet = function() { + return this._eventTimelineSet; +}; + /** * Get the base index. * @@ -217,23 +234,29 @@ EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) EventTimeline.prototype.addEvent = function(event, atStart) { var stateContext = atStart ? this._startState : this._endState; - setEventMetadata(event, stateContext, atStart); + // only call setEventMetadata on the unfiltered timelineSets + var timelineSet = this.getTimelineSet(); + if (timelineSet.room && + timelineSet.room.getUnfilteredTimelineSet() === timelineSet) + { + EventTimeline.setEventMetadata(event, stateContext, atStart); - // modify state - if (event.isState()) { - stateContext.setStateEvents([event]); - // 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. 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. - // - // However, we do NOT want to do this on member events if we're going - // back in time, else we'll set the .sender value for BEFORE the given - // member event, whereas we want to set the .sender value for the ACTUAL - // member event itself. - if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { - setEventMetadata(event, stateContext, atStart); + // modify state + if (event.isState()) { + stateContext.setStateEvents([event]); + // 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. 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. + // + // However, we do NOT want to do this on member events if we're going + // back in time, else we'll set the .sender value for BEFORE the given + // member event, whereas we want to set the .sender value for the ACTUAL + // member event itself. + if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { + EventTimeline.setEventMetadata(event, stateContext, atStart); + } } } @@ -251,7 +274,14 @@ EventTimeline.prototype.addEvent = function(event, atStart) { } }; -function setEventMetadata(event, stateContext, toStartOfTimeline) { +/** + * Static helper method to set sender and target properties + * + * @param {MatrixEvent} event the event whose metadata is to be set + * @param {RoomState} stateContext the room state to be queried + * @param {bool} toStartOfTimeline if true the event's forwardLooking flag is set false + */ +EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline) { // set sender and target properties event.sender = stateContext.getSentinelMember( event.getSender() @@ -270,7 +300,7 @@ function setEventMetadata(event, stateContext, toStartOfTimeline) { event.forwardLooking = false; } } -} +}; /** * Remove an event from the timeline diff --git a/lib/models/event.js b/lib/models/event.js index 92a9a5398..83bf207c3 100644 --- a/lib/models/event.js +++ b/lib/models/event.js @@ -76,6 +76,7 @@ module.exports.MatrixEvent = function MatrixEvent(event, clearEvent) { this.forwardLooking = true; this._clearEvent = clearEvent || {}; + this._pushActions = null; }; module.exports.MatrixEvent.prototype = { @@ -232,12 +233,28 @@ module.exports.MatrixEvent.prototype = { return Boolean(this._clearEvent.type); }, + /** + * The curve25519 key that sent this event + * @return {string} + */ getSenderKey: function() { - if (!this.isEncrypted()) { - return null; - } - var c = this.getWireContent(); - return c.sender_key; + return this.getKeysProved().curve25519 || null; + }, + + /** + * The keys that must have been owned by the sender of this encrypted event. + * @return {object} + */ + getKeysProved: function() { + return this._clearEvent.keysProved || {}; + }, + + /** + * The additional keys the sender of this encrypted event claims to possess + * @return {object} + */ + getKeysClaimed: function() { + return this._clearEvent.keysClaimed || {}; }, getUnsigned: function() { @@ -294,6 +311,24 @@ module.exports.MatrixEvent.prototype = { isRedacted: function() { return Boolean(this.getUnsigned().redacted_because); }, + + /** + * Get the push actions, if known, for this event + * + * @return {?Object} push actions + */ + getPushActions: function() { + return this._pushActions; + }, + + /** + * Set the push actions for this event. + * + * @param {Object} pushActions push actions + */ + setPushActions: function(pushActions) { + this._pushActions = pushActions; + }, }; diff --git a/lib/models/room-state.js b/lib/models/room-state.js index 1e22b0335..32b3a924e 100644 --- a/lib/models/room-state.js +++ b/lib/models/room-state.js @@ -25,7 +25,8 @@ var RoomMember = require("./room-member"); /** * Construct room state. * @constructor - * @param {string} roomId Required. The ID of the room which has this state. + * @param {?string} roomId Optional. The ID of the room which has this state. + * If none is specified it just tracks paginationTokens, useful for notifTimelineSet * @prop {Object.} members The room member dictionary, keyed * on the user's ID. * @prop {Object.>} events The state diff --git a/lib/models/room.js b/lib/models/room.js index 972f484d4..61b536d35 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -25,17 +25,7 @@ var MatrixEvent = require("./event").MatrixEvent; var utils = require("../utils"); var ContentRepo = require("../content-repo"); var EventTimeline = require("./event-timeline"); - - -// var DEBUG = false; -var DEBUG = true; - -if (DEBUG) { - // using bind means that we get to keep useful line numbers in the console - var debuglog = console.log.bind(console); -} else { - var debuglog = function() {}; -} +var EventTimelineSet = require("./event-timeline-set"); function synthesizeReceipt(userId, event, receiptType) { @@ -159,13 +149,18 @@ function Room(roomId, opts) { this._notificationCounts = {}; - this._liveTimeline = new EventTimeline(this.roomId); + // all our per-room timeline sets. the first one is the unfiltered ones; + // the subsequent ones are the filtered ones in no particular order. + this._timelineSets = [new EventTimelineSet(this, opts)]; + reEmit(this, this.getUnfilteredTimelineSet(), + ["Room.timeline", "Room.timelineReset"]); + this._fixUpLegacyTimelineFields(); - // just a list - *not* ordered. - this._timelines = [this._liveTimeline]; - this._eventIdToTimeline = {}; - this._timelineSupport = Boolean(opts.timelineSupport); + // any filtered timeline sets we're maintaining for this room + this._filteredTimelineSets = { + // filter_id: timelineSet + }; if (this._opts.pendingEventOrdering == "detached") { this._pendingEventList = []; @@ -191,57 +186,29 @@ Room.prototype.getPendingEvents = function() { return this._pendingEventList; }; - /** - * Get the live timeline for this room. + * Get the live unfiltered timeline for this room. * * @return {module:models/event-timeline~EventTimeline} live timeline */ Room.prototype.getLiveTimeline = function() { - return this._liveTimeline; + return this.getUnfilteredTimelineSet().getLiveTimeline(); }; + /** - * Reset the live timeline, and start a new one. + * Reset the live timeline of all timelineSets, and start new ones. * *

This is used when /sync returns a 'limited' timeline. * * @param {string=} backPaginationToken token for back-paginating the new timeline - * - * @fires module:client~MatrixClient#event:"Room.timelineReset" */ Room.prototype.resetLiveTimeline = function(backPaginationToken) { - var newTimeline; - - if (!this._timelineSupport) { - // if timeline support is disabled, forget about the old timelines - newTimeline = new EventTimeline(this.roomId); - this._timelines = [newTimeline]; - this._eventIdToTimeline = {}; - } else { - newTimeline = this.addTimeline(); + for (var i = 0; i < this._timelineSets.length; i++) { + this._timelineSets[i].resetLiveTimeline(backPaginationToken); } - // initialise the state in the new timeline from our last known state - var evMap = this._liveTimeline.getState(EventTimeline.FORWARDS).events; - var events = []; - for (var evtype in evMap) { - if (!evMap.hasOwnProperty(evtype)) { continue; } - for (var stateKey in evMap[evtype]) { - if (!evMap[evtype].hasOwnProperty(stateKey)) { continue; } - events.push(evMap[evtype][stateKey]); - } - } - newTimeline.initialiseState(events); - - // make sure we set the pagination token before firing timelineReset, - // otherwise clients which start back-paginating will fail, and then get - // stuck without realising that they *can* back-paginate. - newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); - - this._liveTimeline = newTimeline; this._fixUpLegacyTimelineFields(); - this.emit("Room.timelineReset", this); }; /** @@ -254,39 +221,59 @@ Room.prototype._fixUpLegacyTimelineFields = function() { // and this.oldState and this.currentState as references to the // state at the start and end of that timeline. These are more // for backwards-compatibility than anything else. - this.timeline = this._liveTimeline.getEvents(); - this.oldState = this._liveTimeline.getState(EventTimeline.BACKWARDS); - this.currentState = this._liveTimeline.getState(EventTimeline.FORWARDS); + this.timeline = this.getLiveTimeline().getEvents(); + this.oldState = this.getLiveTimeline() + .getState(EventTimeline.BACKWARDS); + this.currentState = this.getLiveTimeline() + .getState(EventTimeline.FORWARDS); }; /** - * Get the timeline which contains the given event, if any + * Return the timeline sets for this room. + * @return {EventTimelineSet[]} array of timeline sets for this room + */ +Room.prototype.getTimelineSets = function() { + return this._timelineSets; +}; + +/** + * Helper to return the main unfiltered timeline set for this room + * @return {EventTimelineSet} room's unfiltered timeline set + */ +Room.prototype.getUnfilteredTimelineSet = function() { + return this._timelineSets[0]; +}; + +/** + * Get the timeline which contains the given event from the unfiltered set, if any * * @param {string} eventId event ID to look for * @return {?module:models/event-timeline~EventTimeline} timeline containing * the given event, or null if unknown */ Room.prototype.getTimelineForEvent = function(eventId) { - var res = this._eventIdToTimeline[eventId]; - return (res === undefined) ? null : res; + return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId); }; /** - * Get an event which is stored in our timelines + * Add a new timeline to this room's unfiltered timeline set + * + * @return {module:models/event-timeline~EventTimeline} newly-created timeline + */ +Room.prototype.addTimeline = function() { + return this.getUnfilteredTimelineSet().addTimeline(); +}; + +/** + * Get an event which is stored in our unfiltered timeline set * * @param {string} eventId event ID to look for * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown */ Room.prototype.findEventById = function(eventId) { - var tl = this.getTimelineForEvent(eventId); - if (!tl) { - return undefined; - } - return utils.findElement(tl.getEvents(), - function(ev) { return ev.getId() == eventId; }); + return this.getUnfilteredTimelineSet().findEventById(eventId); }; - /** * Get one of the notification counts for this room * @param {String} type The type of notification count to get. default: 'total' @@ -379,6 +366,33 @@ Room.prototype.getCanonicalAlias = function() { return null; }; +/** + * Add events to a timeline + * + *

Will fire "Room.timeline" for each event added. + * + * @param {MatrixEvent[]} events A list of events to add. + * + * @param {boolean} toStartOfTimeline True to add these events to the start + * (oldest) instead of the end (newest) of the timeline. If true, the oldest + * event will be the last element of 'events'. + * + * @param {module:models/event-timeline~EventTimeline} timeline timeline to + * add events to. + * + * @param {string=} paginationToken token for the next batch of events + * + * @fires module:client~MatrixClient#event:"Room.timeline" + * + */ +Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, + timeline, paginationToken) { + timeline.getTimelineSet().addEventsToTimeline( + events, toStartOfTimeline, + timeline, paginationToken + ); +}; + /** * Get a member from the current room state. * @param {string} userId The user ID of the member. @@ -438,224 +452,72 @@ Room.prototype.getCanonicalAlias = function() { }; /** - * Add a new timeline to this room - * - * @return {module:models/event-timeline~EventTimeline} newly-created timeline + * Add a timelineSet for this room with the given filter + * @param {Filter} filter The filter to be applied to this timelineSet + * @return {EventTimelineSet} The timelineSet */ -Room.prototype.addTimeline = function() { - if (!this._timelineSupport) { - throw new Error("timeline support is disabled. Set the 'timelineSupport'" + - " parameter to true when creating MatrixClient to enable" + - " it."); +Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { + if (this._filteredTimelineSets[filter.filterId]) { + return this._filteredTimelineSets[filter.filterId]; + } + var opts = Object.assign({ filter: filter }, this._opts); + var timelineSet = new EventTimelineSet(this, opts); + reEmit(this, timelineSet, ["Room.timeline", "Room.timelineReset"]); + this._filteredTimelineSets[filter.filterId] = timelineSet; + this._timelineSets.push(timelineSet); + + // populate up the new timelineSet with filtered events from our live + // unfiltered timeline. + // + // XXX: This is risky as our timeline + // may have grown huge and so take a long time to filter. + // see https://github.com/vector-im/vector-web/issues/2109 + + var unfilteredLiveTimeline = this.getLiveTimeline(); + + unfilteredLiveTimeline.getEvents().forEach(function(event) { + timelineSet.addLiveEvent(event); + }); + + // find the earliest unfiltered timeline + var timeline = unfilteredLiveTimeline; + while (timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) { + timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); } - var timeline = new EventTimeline(this.roomId); - this._timelines.push(timeline); - return timeline; + timelineSet.getLiveTimeline().setPaginationToken( + timeline.getPaginationToken(EventTimeline.BACKWARDS), + EventTimeline.BACKWARDS + ); + + // alternatively, we could try to do something like this to try and re-paginate + // in the filtered events from nothing, but Mark says it's an abuse of the API + // to do so: + // + // timelineSet.resetLiveTimeline( + // unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS) + // ); + + return timelineSet; }; - /** - * Add events to a timeline - * - *

Will fire "Room.timeline" for each event added. - * - * @param {MatrixEvent[]} events A list of events to add. - * - * @param {boolean} toStartOfTimeline True to add these events to the start - * (oldest) instead of the end (newest) of the timeline. If true, the oldest - * event will be the last element of 'events'. - * - * @param {module:models/event-timeline~EventTimeline} timeline timeline to - * add events to. - * - * @param {string=} paginationToken token for the next batch of events - * - * @fires module:client~MatrixClient#event:"Room.timeline" + * Forget the timelineSet for this room with the given filter * + * @param {Filter} filter the filter whose timelineSet is to be forgotten */ -Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, - timeline, paginationToken) { - if (!timeline) { - throw new Error( - "'timeline' not specified for Room.addEventsToTimeline" - ); - } - - if (!toStartOfTimeline && timeline == this._liveTimeline) { - throw new Error( - "Room.addEventsToTimeline cannot be used for adding events to " + - "the live timeline - use Room.addLiveEvents instead" - ); - } - - var direction = toStartOfTimeline ? EventTimeline.BACKWARDS : - EventTimeline.FORWARDS; - var inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : - EventTimeline.BACKWARDS; - - // Adding events to timelines can be quite complicated. The following - // illustrates some of the corner-cases. - // - // Let's say we start by knowing about four timelines. timeline3 and - // timeline4 are neighbours: - // - // timeline1 timeline2 timeline3 timeline4 - // [M] [P] [S] <------> [T] - // - // Now we paginate timeline1, and get the following events from the server: - // [M, N, P, R, S, T, U]. - // - // 1. First, we ignore event M, since we already know about it. - // - // 2. Next, we append N to timeline 1. - // - // 3. Next, we don't add event P, since we already know about it, - // but we do link together the timelines. We now have: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P] [S] <------> [T] - // - // 4. Now we add event R to timeline2: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] [S] <------> [T] - // - // Note that we have switched the timeline we are working on from - // timeline1 to timeline2. - // - // 5. We ignore event S, but again join the timelines: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] <---> [S] <------> [T] - // - // 6. We ignore event T, and the timelines are already joined, so there - // is nothing to do. - // - // 7. Finally, we add event U to timeline4: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] <---> [S] <------> [T, U] - // - // The important thing to note in the above is what happened when we - // already knew about a given event: - // - // - if it was appropriate, we joined up the timelines (steps 3, 5). - // - in any case, we started adding further events to the timeline which - // contained the event we knew about (steps 3, 5, 6). - // - // - // So much for adding events to the timeline. But what do we want to do - // with the pagination token? - // - // In the case above, we will be given a pagination token which tells us how to - // get events beyond 'U' - in this case, it makes sense to store this - // against timeline4. But what if timeline4 already had 'U' and beyond? in - // that case, our best bet is to throw away the pagination token we were - // given and stick with whatever token timeline4 had previously. In short, - // we want to only store the pagination token if the last event we receive - // is one we didn't previously know about. - // - // We make an exception for this if it turns out that we already knew about - // *all* of the events, and we weren't able to join up any timelines. When - // that happens, it means our existing pagination token is faulty, since it - // is only telling us what we already know. Rather than repeatedly - // paginating with the same token, we might as well use the new pagination - // token in the hope that we eventually work our way out of the mess. - - var didUpdate = false; - var lastEventWasNew = false; - for (var i = 0; i < events.length; i++) { - var event = events[i]; - var eventId = event.getId(); - - var existingTimeline = this._eventIdToTimeline[eventId]; - - if (!existingTimeline) { - // we don't know about this event yet. Just add it to the timeline. - this._addEventToTimeline(event, timeline, toStartOfTimeline); - lastEventWasNew = true; - didUpdate = true; - continue; - } - - lastEventWasNew = false; - - if (existingTimeline == timeline) { - debuglog("Event " + eventId + " already in timeline " + timeline); - continue; - } - - var neighbour = timeline.getNeighbouringTimeline(direction); - if (neighbour) { - // this timeline already has a neighbour in the relevant direction; - // let's assume the timelines are already correctly linked up, and - // skip over to it. - // - // there's probably some edge-case here where we end up with an - // event which is in a timeline a way down the chain, and there is - // a break in the chain somewhere. But I can't really imagine how - // that would happen, so I'm going to ignore it for now. - // - if (existingTimeline == neighbour) { - debuglog("Event " + eventId + " in neighbouring timeline - " + - "switching to " + existingTimeline); - } else { - debuglog("Event " + eventId + " already in a different " + - "timeline " + existingTimeline); - } - timeline = existingTimeline; - continue; - } - - // time to join the timelines. - console.info("Already have timeline for " + eventId + - " - joining timeline " + timeline + " to " + - existingTimeline); - timeline.setNeighbouringTimeline(existingTimeline, direction); - existingTimeline.setNeighbouringTimeline(timeline, inverseDirection); - timeline = existingTimeline; - didUpdate = true; - } - - // see above - if the last event was new to us, or if we didn't find any - // new information, we update the pagination token for whatever - // timeline we ended up on. - if (lastEventWasNew || !didUpdate) { - timeline.setPaginationToken(paginationToken, direction); +Room.prototype.removeFilteredTimelineSet = function(filter) { + var timelineSet = this._filteredTimelineSets[filter.filterId]; + delete this._filteredTimelineSets[filter.filterId]; + var i = this._timelineSets.indexOf(timelineSet); + if (i > -1) { + this._timelineSets.splice(i, 1); } }; /** - * Add event to the given timeline, and emit Room.timeline. Assumes - * we have already checked we don't know about this event. - * - * Will fire "Room.timeline" for each event added. - * - * @param {MatrixEvent} event - * @param {EventTimeline} timeline - * @param {boolean} toStartOfTimeline - * - * @fires module:client~MatrixClient#event:"Room.timeline" - * - * @private - */ -Room.prototype._addEventToTimeline = function(event, timeline, toStartOfTimeline) { - var eventId = event.getId(); - timeline.addEvent(event, toStartOfTimeline); - this._eventIdToTimeline[eventId] = timeline; - - var data = { - timeline: timeline, - liveEvent: !toStartOfTimeline && timeline == this._liveTimeline, - }; - this.emit("Room.timeline", event, this, Boolean(toStartOfTimeline), false, data); -}; - - -/** - * Add an event to the end of this room's live timeline. Will fire - * "Room.timeline".. + * Add an event to the end of this room's live timelines. Will fire + * "Room.timeline". * * @param {MatrixEvent} event Event to be added * @param {string?} duplicateStrategy 'ignore' or 'replace' @@ -663,11 +525,12 @@ Room.prototype._addEventToTimeline = function(event, timeline, toStartOfTimeline * @private */ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { + var i; if (event.getType() === "m.room.redaction") { var redactId = event.event.redacts; // if we know about this event, redact its contents now. - var redactedEvent = this.findEventById(redactId); + var redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); if (redactedEvent) { redactedEvent.makeRedacted(event); this.emit("Room.redaction", event, this); @@ -679,6 +542,8 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { // they are based on are changed. } + // FIXME: apply redactions to notification list + // NB: We continue to add the redaction event to the timeline so // clients can say "so and so redacted an event" if they wish to. Also // this may be needed to trigger an update. @@ -693,39 +558,11 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { } } - var timeline = this._eventIdToTimeline[event.getId()]; - if (timeline) { - if (duplicateStrategy === "replace") { - debuglog("Room._addLiveEvent: replacing duplicate event " + - event.getId()); - var tlEvents = timeline.getEvents(); - for (var j = 0; j < tlEvents.length; j++) { - if (tlEvents[j].getId() === event.getId()) { - // still need to set the right metadata on this event - setEventMetadata( - event, - timeline.getState(EventTimeline.FORWARDS), - false - ); - - if (!tlEvents[j].encryptedType) { - tlEvents[j] = event; - } - - // XXX: we need to fire an event when this happens. - break; - } - } - } else { - debuglog("Room._addLiveEvent: ignoring duplicate event " + - event.getId()); - } - return; + // add to our timeline sets + for (i = 0; i < this._timelineSets.length; i++) { + this._timelineSets[i].addLiveEvent(event, duplicateStrategy); } - // TODO: pass through filter to see if this should be added to the timeline. - this._addEventToTimeline(event, this._liveTimeline, false); - // synthesize and inject implicit read receipts // Done after adding the event because otherwise the app would get a read receipt // pointing to an event that wasn't yet in the timeline @@ -773,9 +610,11 @@ Room.prototype.addPendingEvent = function(event, txnId) { } // call setEventMetadata to set up event.sender etc - setEventMetadata( + // as event is shared over all timelineSets, we set up its metadata based + // on the unfiltered timelineSet. + EventTimeline.setEventMetadata( event, - this._liveTimeline.getState(EventTimeline.FORWARDS), + this.getLiveTimeline().getState(EventTimeline.FORWARDS), false ); @@ -784,7 +623,19 @@ Room.prototype.addPendingEvent = function(event, txnId) { if (this._opts.pendingEventOrdering == "detached") { this._pendingEventList.push(event); } else { - this._addEventToTimeline(event, this._liveTimeline, false); + for (var i = 0; i < this._timelineSets.length; i++) { + var timelineSet = this._timelineSets[i]; + if (timelineSet.getFilter()) { + if (this._filter.filterRoomTimeline([event]).length) { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } + else { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } } this.emit("Room.localEchoUpdated", event, this, null, null); @@ -828,13 +679,11 @@ Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) { // successfully sent. localEvent.status = null; - // if it's already in the timeline, update the timeline map. If it's not, add it. - var existingTimeline = this._eventIdToTimeline[oldEventId]; - if (existingTimeline) { - delete this._eventIdToTimeline[oldEventId]; - this._eventIdToTimeline[newEventId] = existingTimeline; - } else { - this._addEventToTimeline(localEvent, this._liveTimeline, false); + for (var i = 0; i < this._timelineSets.length; i++) { + var timelineSet = this._timelineSets[i]; + + // if it's already in the timeline, update the timeline map. If it's not, add it. + timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); } this.emit("Room.localEchoUpdated", localEvent, this, @@ -890,7 +739,7 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { // SENT races against /sync, so we have to special-case it. if (newStatus == EventStatus.SENT) { - var timeline = this._eventIdToTimeline[newEventId]; + var timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId); if (timeline) { // we've already received the event via the event stream. // nothing more to do here. @@ -921,10 +770,8 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { // if the event was already in the timeline (which will be the case if // opts.pendingEventOrdering==chronological), we need to update the // timeline map. - var existingTimeline = this._eventIdToTimeline[oldEventId]; - if (existingTimeline) { - delete this._eventIdToTimeline[oldEventId]; - this._eventIdToTimeline[newEventId] = existingTimeline; + for (var i = 0; i < this._timelineSets.length; i++) { + this._timelineSets[i].replaceEventId(oldEventId, newEventId); } } else if (newStatus == EventStatus.CANCELLED) { @@ -960,24 +807,29 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. */ Room.prototype.addLiveEvents = function(events, duplicateStrategy) { + var i; if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); } // sanity check that the live timeline is still live - if (this._liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline is no longer live - it has a pagination token (" + - this._liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")" - ); - } - if (this._liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline is no longer live - it has a neighbouring timeline" - ); + for (i = 0; i < this._timelineSets.length; i++) { + var liveTimeline = this._timelineSets[i].getLiveTimeline(); + if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { + throw new Error( + "live timeline " + i + " is no longer live - it has a pagination token " + + "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")" + ); + } + if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { + throw new Error( + "live timeline " + i + " is no longer live - " + + "it has a neighbouring timeline" + ); + } } - for (var i = 0; i < events.length; i++) { + for (i = 0; i < events.length; i++) { if (events[i].getType() === "m.typing") { this.currentState.setTypingEvent(events[i]); } @@ -1009,98 +861,19 @@ Room.prototype.removeEvents = function(event_ids) { * * @param {String} eventId The id of the event to remove * - * @return {?MatrixEvent} the removed event, or null if the event was not found - * in this room. + * @return {bool} true if the event was removed from any of the room's timeline sets */ Room.prototype.removeEvent = function(eventId) { - var timeline = this._eventIdToTimeline[eventId]; - if (!timeline) { - return null; + var removedAny = false; + for (var i = 0; i < this._timelineSets.length; i++) { + var removed = this._timelineSets[i].removeEvent(eventId); + if (removed) { + removedAny = true; + } } - - var removed = timeline.removeEvent(eventId); - if (removed) { - delete this._eventIdToTimeline[eventId]; - var data = { - timeline: timeline, - }; - this.emit("Room.timeline", removed, this, undefined, true, data); - } - return removed; + return removedAny; }; -/** - * Determine where two events appear in the timeline relative to one another - * - * @param {string} eventId1 The id of the first event - * @param {string} eventId2 The id of the second event - - * @return {?number} a number less than zero if eventId1 precedes eventId2, and - * greater than zero if eventId1 succeeds eventId2. zero if they are the - * same event; null if we can't tell (either because we don't know about one - * of the events, or because they are in separate timelines which don't join - * up). - */ -Room.prototype.compareEventOrdering = function(eventId1, eventId2) { - if (eventId1 == eventId2) { - // optimise this case - return 0; - } - - var timeline1 = this._eventIdToTimeline[eventId1]; - var timeline2 = this._eventIdToTimeline[eventId2]; - - if (timeline1 === undefined) { - return null; - } - if (timeline2 === undefined) { - return null; - } - - if (timeline1 === timeline2) { - // both events are in the same timeline - figure out their - // relative indices - var idx1, idx2; - var events = timeline1.getEvents(); - for (var idx = 0; idx < events.length && - (idx1 === undefined || idx2 === undefined); idx++) { - var evId = events[idx].getId(); - if (evId == eventId1) { - idx1 = idx; - } - if (evId == eventId2) { - idx2 = idx; - } - } - return idx1 - idx2; - } - - // the events are in different timelines. Iterate through the - // linkedlist to see which comes first. - - // first work forwards from timeline1 - var tl = timeline1; - while (tl) { - if (tl === timeline2) { - // timeline1 is before timeline2 - return -1; - } - tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS); - } - - // now try backwards from timeline1 - tl = timeline1; - while (tl) { - if (tl === timeline2) { - // timeline2 is before timeline1 - return 1; - } - tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS); - } - - // the timelines are not contiguous. - return null; -}; /** * Recalculate various aspects of the room, including the room name and @@ -1221,7 +994,7 @@ Room.prototype.addReceipt = function(event, fake) { // as there's nothing that would read it. } this._addReceiptsToStructure(event, this._receipts); - this._receiptCacheByEventId = this._buildReciptCache(this._receipts); + this._receiptCacheByEventId = this._buildReceiptCache(this._receipts); // send events after we've regenerated the cache, otherwise things that // listened for the event would read from a stale cache @@ -1254,7 +1027,7 @@ Room.prototype._addReceiptsToStructure = function(event, receipts) { // than the one we already have. (This is managed // server-side, but because we synthesize RRs locally we // have to do it here too.) - var ordering = self.compareEventOrdering( + var ordering = self.getUnfilteredTimelineSet().compareEventOrdering( existingReceipt.eventId, eventId); if (ordering !== null && ordering >= 0) { return; @@ -1275,7 +1048,7 @@ Room.prototype._addReceiptsToStructure = function(event, receipts) { * @param {Object} receipts A map of receipts * @return {Object} Map of receipts by event ID */ -Room.prototype._buildReciptCache = function(receipts) { +Room.prototype._buildReceiptCache = function(receipts) { var receiptCacheByEventId = {}; utils.keys(receipts).forEach(function(receiptType) { utils.keys(receipts[receiptType]).forEach(function(userId) { @@ -1350,27 +1123,6 @@ Room.prototype.getAccountData = function(type) { return this.accountData[type]; }; -function setEventMetadata(event, stateContext, toStartOfTimeline) { - // set sender and target properties - event.sender = stateContext.getSentinelMember( - event.getSender() - ); - if (event.getType() === "m.room.member") { - event.target = stateContext.getSentinelMember( - event.getStateKey() - ); - } - if (event.isState()) { - // room state has no concept of 'old' or 'current', but we want the - // room state to regress back to previous values if toStartOfTimeline - // is set, which means inspecting prev_content if it exists. This - // is done by toggling the forwardLooking flag. - if (toStartOfTimeline) { - event.forwardLooking = false; - } - } -} - /** * This is an internal method. Calculates the name of the room from the current * room state. @@ -1486,49 +1238,30 @@ function calculateRoomName(room, userId, ignoreRoomNameEvent) { } } +// FIXME: copypasted from sync.js +function reEmit(reEmitEntity, emittableEntity, eventNames) { + utils.forEach(eventNames, function(eventName) { + // setup a listener on the entity (the Room, User, etc) for this event + emittableEntity.on(eventName, function() { + // take the args from the listener and reuse them, adding the + // event name to the arg list so it works with .emit() + // Transformation Example: + // listener on "foo" => function(a,b) { ... } + // Re-emit on "thing" => thing.emit("foo", a, b) + var newArgs = [eventName]; + for (var i = 0; i < arguments.length; i++) { + newArgs.push(arguments[i]); + } + reEmitEntity.emit.apply(reEmitEntity, newArgs); + }); + }); +} + /** * The Room class. */ module.exports = Room; -/** - * Fires whenever the timeline in a room is updated. - * @event module:client~MatrixClient#"Room.timeline" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {Room} room The room whose Room.timeline was updated. - * @param {boolean} toStartOfTimeline True if this event was added to the start - * @param {boolean} removed True if this event has just been removed from the timeline - * (beginning; oldest) of the timeline e.g. due to pagination. - * - * @param {object} data more data about the event - * - * @param {module:event-timeline.EventTimeline} data.timeline the timeline the - * event was added to/removed from - * - * @param {boolean} data.liveEvent true if the event was a real-time event - * added to the end of the live timeline - * - * @example - * matrixClient.on("Room.timeline", - * function(event, room, toStartOfTimeline, removed, data) { - * if (!toStartOfTimeline && data.liveEvent) { - * var messageToAppend = room.timeline.[room.timeline.length - 1]; - * } - * }); - */ - -/** - * Fires whenever the live timeline in a room is reset. - * - * When we get a 'limited' sync (for example, after a network outage), we reset - * the live timeline to be empty before adding the recent events to the new - * timeline. This event is fired after the timeline is reset, and before the - * new events are added. - * - * @event module:client~MatrixClient#"Room.timelineReset" - * @param {Room} room The room whose live timeline was reset. - */ - /** * Fires when an event we had previously received is redacted. * diff --git a/lib/sync.js b/lib/sync.js index 83cde1d16..f1581cd32 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -72,6 +72,12 @@ function SyncApi(client, opts) { this._running = false; this._keepAliveTimer = null; this._connectionReturnedDefer = null; + this._notifEvents = []; // accumulator of sync events in the current sync response + + if (client.getNotifTimelineSet()) { + reEmit(client, client.getNotifTimelineSet(), + ["Room.timeline", "Room.timelineReset"]); + } } /** @@ -148,7 +154,7 @@ SyncApi.prototype.syncLeftRooms = function() { timeout: 0 // don't want to block since this is a single isolated req }; - return this._getOrCreateFilter( + return client.getOrCreateFilter( getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter ).then(function(filterId) { qps.filter = filterId; @@ -389,9 +395,15 @@ SyncApi.prototype.sync = function() { var filter = new Filter(client.credentials.userId); filter.setTimelineLimit(self.opts.initialSyncLimit); - self._getOrCreateFilter( + client.getOrCreateFilter( getFilterName(client.credentials.userId), filter ).done(function(filterId) { + // reset the notifications timeline to prepare it to paginate from + // the current point in time. + // The right solution would be to tie /sync pagination tokens into + // /notifications API somehow. + client.resetNotifTimelineSet(); + self._sync({ filterId: filterId }); }, function(err) { self._startKeepAlives().done(function() { @@ -666,6 +678,8 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { } } + this._notifEvents = []; + // Handle invites inviteRooms.forEach(function(inviteObj) { var room = inviteObj.room; @@ -751,6 +765,12 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { room.currentState.paginationToken = syncToken; self._deregisterStateListeners(room); room.resetLiveTimeline(joinObj.timeline.prev_batch); + + // We have to assume any gap in any timeline is + // reason to stop incrementally tracking notifications and + // reset the timeline. + client.resetNotifTimelineSet(); + self._registerStateListeners(room); } } @@ -799,6 +819,20 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { timelineEvents.forEach(function(e) { client.emit("event", e); }); accountDataEvents.forEach(function(e) { client.emit("event", e); }); }); + + // update the notification timeline, if appropriate. + // we only do this for live events, as otherwise we can't order them sanely + // in the timeline relative to ones paginated in by /notifications. + // XXX: we could fix this by making EventTimeline support chronological + // ordering... but it doesn't, right now. + if (syncToken && this._notifEvents.length) { + this._notifEvents.sort(function(a, b) { + return a.getTs() - b.getTs(); + }); + this._notifEvents.forEach(function(event) { + client.getNotifTimelineSet().addLiveEvent(event); + }); + } }; /** @@ -862,53 +896,6 @@ SyncApi.prototype._pokeKeepAlive = function() { }); }; -/** - * @param {string} filterName - * @param {Filter} filter - * @return {Promise} Filter ID - */ -SyncApi.prototype._getOrCreateFilter = function(filterName, filter) { - var client = this.client; - - var filterId = client.store.getFilterIdByName(filterName); - var promise = q(); - - if (filterId) { - // check that the existing filter matches our expectations - promise = client.getFilter(client.credentials.userId, - filterId, true - ).then(function(existingFilter) { - var oldDef = existingFilter.getDefinition(); - var newDef = filter.getDefinition(); - - if (utils.deepCompare(oldDef, newDef)) { - // super, just use that. - debuglog("Using existing filter ID %s: %s", filterId, - JSON.stringify(oldDef)); - return q(filterId); - } - debuglog("Existing filter ID %s: %s; new filter: %s", - filterId, JSON.stringify(oldDef), JSON.stringify(newDef)); - return; - }); - } - - return promise.then(function(existingId) { - if (existingId) { - return existingId; - } - - // create a new filter - return client.createFilter(filter.getDefinition() - ).then(function(createdFilter) { - debuglog("Created new filter ID %s: %s", createdFilter.filterId, - JSON.stringify(createdFilter.getDefinition())); - client.store.setFilterIdByName(filterName, createdFilter.filterId); - return createdFilter.filterId; - }); - }); -}; - /** * @param {Object} obj * @return {Object[]} @@ -1034,6 +1021,18 @@ SyncApi.prototype._processRoomEvents = function(room, stateEventList, // may make notifications appear which should have the right name. room.recalculate(this.client.credentials.userId); + // gather our notifications into this._notifEvents + if (client.getNotifTimelineSet()) { + for (var i = 0; i < timelineEventList.length; i++) { + var pushActions = client.getPushActionsForEvent(timelineEventList[i]); + if (pushActions && pushActions.notify && + pushActions.tweaks && pushActions.tweaks.highlight) + { + this._notifEvents.push(timelineEventList[i]); + } + } + } + // execute the timeline events, this will begin to diverge the current state // if the timeline has any state events in it. room.addLiveEvents(timelineEventList); diff --git a/lib/timeline-window.js b/lib/timeline-window.js index 89acec9dc..1889631ae 100644 --- a/lib/timeline-window.js +++ b/lib/timeline-window.js @@ -56,7 +56,7 @@ var DEFAULT_PAGINATE_LOOP_LIMIT = 5; * @param {MatrixClient} client MatrixClient to be used for context/pagination * requests. * - * @param {Room} room The room to track + * @param {EventTimelineSet} timelineSet The timelineSet to track * * @param {Object} [opts] Configuration options for this window * @@ -66,10 +66,10 @@ var DEFAULT_PAGINATE_LOOP_LIMIT = 5; * * @constructor */ -function TimelineWindow(client, room, opts) { +function TimelineWindow(client, timelineSet, opts) { opts = opts || {}; this._client = client; - this._room = room; + this._timelineSet = timelineSet; // these will be TimelineIndex objects; they delineate the 'start' and // 'end' of the window. @@ -113,7 +113,7 @@ TimelineWindow.prototype.load = function(initialEventId, initialWindowSize) { // TODO: ideally we'd spot getEventTimeline returning a resolved promise and // skip straight to the find-event loop. if (initialEventId) { - return this._client.getEventTimeline(this._room, initialEventId) + return this._client.getEventTimeline(this._timelineSet, initialEventId) .then(function(tl) { // make sure that our window includes the event for (var i = 0; i < tl.getEvents().length; i++) { @@ -126,7 +126,7 @@ TimelineWindow.prototype.load = function(initialEventId, initialWindowSize) { }); } else { // start with the most recent events - var tl = this._room.getLiveTimeline(); + var tl = this._timelineSet.getLiveTimeline(); initFields(tl, tl.getEvents().length); return q(); } @@ -254,6 +254,7 @@ TimelineWindow.prototype.paginate = function(direction, size, makeRequest, debuglog("TimelineWindow: starting request"); var self = this; + var prom = this._client.paginateEventTimeline(tl.timeline, { backwards: direction == EventTimeline.BACKWARDS, limit: size diff --git a/spec/integ/matrix-client-event-timeline.spec.js b/spec/integ/matrix-client-event-timeline.spec.js index 6d77cd4d8..b5cac3959 100644 --- a/spec/integ/matrix-client-event-timeline.spec.js +++ b/spec/integ/matrix-client-event-timeline.spec.js @@ -121,7 +121,8 @@ describe("getEventTimeline support", function() { startClient(httpBackend, client ).then(function() { var room = client.getRoom(roomId); - expect(function() { client.getEventTimeline(room, "event"); }) + var timelineSet = room.getTimelineSets()[0]; + expect(function() { client.getEventTimeline(timelineSet, "event"); }) .toThrow(); }).catch(utils.failTest).done(done); }); @@ -137,7 +138,8 @@ describe("getEventTimeline support", function() { startClient(httpBackend, client ).then(function() { var room = client.getRoom(roomId); - expect(function() { client.getEventTimeline(room, "event"); }) + var timelineSet = room.getTimelineSets()[0]; + expect(function() { client.getEventTimeline(timelineSet, "event"); }) .not.toThrow(); }).catch(utils.failTest).done(done); @@ -242,6 +244,7 @@ describe("MatrixClient event timelines", function() { describe("getEventTimeline", function() { it("should create a new timeline for new events", function(done) { var room = client.getRoom(roomId); + var timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1%3Abar") .respond(200, function() { return { @@ -257,7 +260,7 @@ describe("MatrixClient event timelines", function() { }; }); - client.getEventTimeline(room, "event1:bar").then(function(tl) { + client.getEventTimeline(timelineSet, "event1:bar").then(function(tl) { expect(tl.getEvents().length).toEqual(4); for (var i = 0; i < 4; i++) { expect(tl.getEvents()[i].event).toEqual(EVENTS[i]); @@ -274,6 +277,7 @@ describe("MatrixClient event timelines", function() { it("should return existing timeline for known events", function(done) { var room = client.getRoom(roomId); + var timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", rooms: { @@ -291,7 +295,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.flush("/sync").then(function() { - return client.getEventTimeline(room, EVENTS[0].event_id); + return client.getEventTimeline(timelineSet, EVENTS[0].event_id); }).then(function(tl) { expect(tl.getEvents().length).toEqual(2); expect(tl.getEvents()[1].event).toEqual(EVENTS[0]); @@ -305,6 +309,7 @@ describe("MatrixClient event timelines", function() { it("should update timelines where they overlap a previous /sync", function(done) { var room = client.getRoom(roomId); + var timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", rooms: { @@ -335,7 +340,7 @@ describe("MatrixClient event timelines", function() { }); client.on("sync", function() { - client.getEventTimeline(room, EVENTS[2].event_id + client.getEventTimeline(timelineSet, EVENTS[2].event_id ).then(function(tl) { expect(tl.getEvents().length).toEqual(4); expect(tl.getEvents()[0].event).toEqual(EVENTS[1]); @@ -354,6 +359,7 @@ describe("MatrixClient event timelines", function() { it("should join timelines where they overlap a previous /context", function(done) { var room = client.getRoom(roomId); + var timelineSet = room.getTimelineSets()[0]; // we fetch event 0, then 2, then 3, and finally 1. 1 is returned // with context which joins them all up. @@ -410,19 +416,19 @@ describe("MatrixClient event timelines", function() { }); var tl0, tl2, tl3; - client.getEventTimeline(room, EVENTS[0].event_id + client.getEventTimeline(timelineSet, EVENTS[0].event_id ).then(function(tl) { expect(tl.getEvents().length).toEqual(1); tl0 = tl; - return client.getEventTimeline(room, EVENTS[2].event_id); + return client.getEventTimeline(timelineSet, EVENTS[2].event_id); }).then(function(tl) { expect(tl.getEvents().length).toEqual(1); tl2 = tl; - return client.getEventTimeline(room, EVENTS[3].event_id); + return client.getEventTimeline(timelineSet, EVENTS[3].event_id); }).then(function(tl) { expect(tl.getEvents().length).toEqual(1); tl3 = tl; - return client.getEventTimeline(room, EVENTS[1].event_id); + return client.getEventTimeline(timelineSet, EVENTS[1].event_id); }).then(function(tl) { // we expect it to get merged in with event 2 expect(tl.getEvents().length).toEqual(2); @@ -447,6 +453,7 @@ describe("MatrixClient event timelines", function() { it("should fail gracefully if there is no event field", function(done) { var room = client.getRoom(roomId); + var timelineSet = room.getTimelineSets()[0]; // we fetch event 0, then 2, then 3, and finally 1. 1 is returned // with context which joins them all up. httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1") @@ -460,7 +467,7 @@ describe("MatrixClient event timelines", function() { }; }); - client.getEventTimeline(room, "event1" + client.getEventTimeline(timelineSet, "event1" ).then(function(tl) { // could do with a fail() expect(true).toBeFalsy(); @@ -475,6 +482,7 @@ describe("MatrixClient event timelines", function() { describe("paginateEventTimeline", function() { it("should allow you to paginate backwards", function(done) { var room = client.getRoom(roomId); + var timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)) @@ -503,7 +511,7 @@ describe("MatrixClient event timelines", function() { }); var tl; - client.getEventTimeline(room, EVENTS[0].event_id + client.getEventTimeline(timelineSet, EVENTS[0].event_id ).then(function(tl0) { tl = tl0; return client.paginateEventTimeline(tl, {backwards: true}); @@ -525,6 +533,7 @@ describe("MatrixClient event timelines", function() { it("should allow you to paginate forwards", function(done) { var room = client.getRoom(roomId); + var timelineSet = room.getTimelineSets()[0]; httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)) @@ -553,7 +562,7 @@ describe("MatrixClient event timelines", function() { }); var tl; - client.getEventTimeline(room, EVENTS[0].event_id + client.getEventTimeline(timelineSet, EVENTS[0].event_id ).then(function(tl0) { tl = tl0; return client.paginateEventTimeline( @@ -607,10 +616,11 @@ describe("MatrixClient event timelines", function() { it("should work when /send returns before /sync", function(done) { var room = client.getRoom(roomId); + var timelineSet = room.getTimelineSets()[0]; client.sendTextMessage(roomId, "a body", TXN_ID).then(function(res) { expect(res.event_id).toEqual(event.event_id); - return client.getEventTimeline(room, event.event_id); + return client.getEventTimeline(timelineSet, event.event_id); }).then(function(tl) { // 2 because the initial sync contained an event expect(tl.getEvents().length).toEqual(2); @@ -619,7 +629,7 @@ describe("MatrixClient event timelines", function() { // now let the sync complete, and check it again return httpBackend.flush("/sync", 1); }).then(function() { - return client.getEventTimeline(room, event.event_id); + return client.getEventTimeline(timelineSet, event.event_id); }).then(function(tl) { expect(tl.getEvents().length).toEqual(2); expect(tl.getEvents()[1].event).toEqual(event); @@ -630,13 +640,14 @@ describe("MatrixClient event timelines", function() { it("should work when /send returns after /sync", function(done) { var room = client.getRoom(roomId); + var timelineSet = room.getTimelineSets()[0]; // initiate the send, and set up checks to be done when it completes // - but note that it won't complete until after the /sync does, below. client.sendTextMessage(roomId, "a body", TXN_ID).then(function(res) { console.log("sendTextMessage completed"); expect(res.event_id).toEqual(event.event_id); - return client.getEventTimeline(room, event.event_id); + return client.getEventTimeline(timelineSet, event.event_id); }).then(function(tl) { console.log("getEventTimeline completed (2)"); expect(tl.getEvents().length).toEqual(2); @@ -644,7 +655,7 @@ describe("MatrixClient event timelines", function() { }).catch(utils.failTest).done(done); httpBackend.flush("/sync", 1).then(function() { - return client.getEventTimeline(room, event.event_id); + return client.getEventTimeline(timelineSet, event.event_id); }).then(function(tl) { console.log("getEventTimeline completed (1)"); expect(tl.getEvents().length).toEqual(2); diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index 86a4b785b..8cb2f2e48 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -16,7 +16,12 @@ describe("EventTimeline", function() { beforeEach(function() { utils.beforeEach(this); - timeline = new EventTimeline(roomId); + + // XXX: this is a horrid hack; should use sinon or something instead to mock + var timelineSet = { room: { roomId: roomId }}; + timelineSet.room.getUnfilteredTimelineSet = function() { return timelineSet; }; + + timeline = new EventTimeline(timelineSet); }); describe("construction", function() { diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 83d170109..0a878b41e 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -479,14 +479,14 @@ describe("Room", function() { it("should handle events in the same timeline", function() { room.addLiveEvents(events); - expect(room.compareEventOrdering(events[0].getId(), - events[1].getId())) + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), + events[1].getId())) .toBeLessThan(0); - expect(room.compareEventOrdering(events[2].getId(), - events[1].getId())) + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[2].getId(), + events[1].getId())) .toBeGreaterThan(0); - expect(room.compareEventOrdering(events[1].getId(), - events[1].getId())) + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), + events[1].getId())) .toEqual(0); }); @@ -498,11 +498,11 @@ describe("Room", function() { room.addEventsToTimeline([events[0]], false, oldTimeline); room.addLiveEvents([events[1]]); - expect(room.compareEventOrdering(events[0].getId(), - events[1].getId())) + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), + events[1].getId())) .toBeLessThan(0); - expect(room.compareEventOrdering(events[1].getId(), - events[0].getId())) + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), + events[0].getId())) .toBeGreaterThan(0); }); @@ -512,24 +512,26 @@ describe("Room", function() { room.addEventsToTimeline([events[0]], false, oldTimeline); room.addLiveEvents([events[1]]); - expect(room.compareEventOrdering(events[0].getId(), - events[1].getId())) + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), + events[1].getId())) .toBe(null); - expect(room.compareEventOrdering(events[1].getId(), - events[0].getId())) + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), + events[0].getId())) .toBe(null); }); it("should return null for unknown events", function() { room.addLiveEvents(events); - expect(room.compareEventOrdering(events[0].getId(), "xxx")) - .toBe(null); - expect(room.compareEventOrdering("xxx", events[0].getId())) - .toBe(null); - expect(room.compareEventOrdering(events[0].getId(), - events[0].getId())) - .toBe(0); + expect(room.getUnfilteredTimelineSet() + .compareEventOrdering(events[0].getId(), "xxx")) + .toBe(null); + expect(room.getUnfilteredTimelineSet() + .compareEventOrdering("xxx", events[0].getId())) + .toBe(null); + expect(room.getUnfilteredTimelineSet() + .compareEventOrdering(events[0].getId(), events[0].getId())) + .toBe(0); }); }); diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index b6f91d111..d04f6b585 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -18,7 +18,11 @@ function createTimeline(numEvents, baseIndex) { if (numEvents === undefined) { numEvents = 3; } if (baseIndex === undefined) { baseIndex = 1; } - var timeline = new EventTimeline(ROOM_ID); + // XXX: this is a horrid hack + var timelineSet = { room: { roomId: ROOM_ID }}; + timelineSet.room.getUnfilteredTimelineSet = function() { return timelineSet; }; + + var timeline = new EventTimeline(timelineSet); // add the events after the baseIndex first addEventsToTimeline(timeline, numEvents - baseIndex, false); @@ -133,19 +137,19 @@ describe("TimelineIndex", function() { describe("TimelineWindow", function() { /** - * create a dummy room and client, and a TimelineWindow + * create a dummy eventTimelineSet and client, and a TimelineWindow * attached to them. */ - var room, client; + var timelineSet, client; function createWindow(timeline, opts) { - room = {}; + timelineSet = {}; client = {}; - client.getEventTimeline = function(room0, eventId0) { - expect(room0).toBe(room); + client.getEventTimeline = function(timelineSet0, eventId0) { + expect(timelineSet0).toBe(timelineSet); return q(timeline); }; - return new TimelineWindow(client, room, opts); + return new TimelineWindow(client, timelineSet, opts); } beforeEach(function() { @@ -169,15 +173,15 @@ describe("TimelineWindow", function() { var timeline = createTimeline(); var eventId = timeline.getEvents()[1].getId(); - var room = {}; + var timelineSet = {}; var client = {}; - client.getEventTimeline = function(room0, eventId0) { - expect(room0).toBe(room); + client.getEventTimeline = function(timelineSet0, eventId0) { + expect(timelineSet0).toBe(timelineSet); expect(eventId0).toEqual(eventId); return q(timeline); }; - var timelineWindow = new TimelineWindow(client, room); + var timelineWindow = new TimelineWindow(client, timelineSet); timelineWindow.load(eventId, 3).then(function() { var expectedEvents = timeline.getEvents(); expect(timelineWindow.getEvents()).toEqual(expectedEvents); @@ -192,12 +196,12 @@ describe("TimelineWindow", function() { var eventId = timeline.getEvents()[1].getId(); - var room = {}; + var timelineSet = {}; var client = {}; - var timelineWindow = new TimelineWindow(client, room); + var timelineWindow = new TimelineWindow(client, timelineSet); - client.getEventTimeline = function(room0, eventId0) { + client.getEventTimeline = function(timelineSet0, eventId0) { expect(timelineWindow.canPaginate(EventTimeline.BACKWARDS)) .toBe(false); expect(timelineWindow.canPaginate(EventTimeline.FORWARDS))