From dd5878015a031b387aa4bd08f2ec55a1963c9af6 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 23 Aug 2016 14:31:47 +0100 Subject: [PATCH 01/56] WIP filtered timelines --- lib/filter-component.js | 119 +++++++++++++++++++++++++++++++++++ lib/filter.js | 66 +++++++++++++++++++ lib/models/event-timeline.js | 24 +++++++ 3 files changed, 209 insertions(+) create mode 100644 lib/filter-component.js diff --git a/lib/filter-component.js b/lib/filter-component.js new file mode 100644 index 000000000..3930806f6 --- /dev/null +++ b/lib/filter-component.js @@ -0,0 +1,119 @@ +/* +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 + */ + +function _matches_wildcard(actual_value, filter_value) { + if (filter_value.endsWith("*")) { + type_prefix = filter_value.slice(0, -1); + return actual_value.substr(0, type_prefix.length) === type_prefix; + } + else { + return actual_value === filter_value; + } +} + +/** + * A 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 from synapse's Filter object. + */ +FilterComponent = function(filter_json) { + this.filter_json = filter_json; + + this.types = filter_json.types || null; + this.not_types = filter_json.not_types || []; + + self.rooms = filter_json.rooms || null; + self.not_rooms = filter_json.not_rooms || []; + + self.senders = filter_json.senders || null; + self.not_senders = filter_json.not_senders || []; + + self.contains_url = filter_json.contains_url || null; +}; + +/** + * Checks with the filter component matches the given event + */ +FilterComponent.prototype.check = function(event) { + var sender = event.sender; + if (!sender) { + // Presence events have their 'sender' in content.user_id + if (event.content) { + sender = event.content.user_id; + } + } + + return this.checkFields( + event.room_id, + sender, + event.type, + event.content ? event.content.url !== undefined : false, + ); +}; + +/** + * Checks whether the filter matches the given event fields. + */ +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); }, + }; + + Object.keys(literal_keys).forEach(function(name) { + var match_func = literal_keys[name]; + var not_name = "not_" + name; + var disallowed_values = this[not_name]; + if (disallowed_values.map(match_func)) { + return false; + } + + var allowed_values = this[name]; + if (allowed_values) { + if (!allowed_values.map(match_func)) { + return false; + } + } + }); + + contains_url_filter = this.filter_json.contains_url; + if (contains_url_filter !== undefined) { + if (contains_url_filter !== contains_url) { + return false; + } + } + + return true; + } +}; + +FilterComponent.prototype.filter = function(events) { + return events.filter(this.check); +}; + +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..ff81d2fcd 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 @@ -63,6 +65,70 @@ 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"] + // }, + // "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.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 || {}); +}; + +/** + * Filter the list of events based on whether they are allowed in a timeline + * based on this filter + */ +Filter.prototype.filterRoomTimeline = function(events) { + return this._room_timeline_filter.filter(this._room_filter.filter(events)); }; /** diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index 4123849ac..f68bbe4d3 100644 --- a/lib/models/event-timeline.js +++ b/lib/models/event-timeline.js @@ -36,6 +36,7 @@ function EventTimeline(roomId) { this._startState.paginationToken = null; this._endState = new RoomState(roomId); this._endState.paginationToken = null; + this._filter = null; this._prevTimeline = null; this._nextTimeline = null; @@ -58,6 +59,21 @@ EventTimeline.BACKWARDS = "b"; */ EventTimeline.FORWARDS = "f"; +/** + * Get the filter object this timeline is filtered on + */ +EventTimeline.prototype.getFilter = function() { + return this._filter; +} + +/** + * Set the filter object this timeline is filtered on + * (passed to the server when paginating via /messages). + */ +EventTimeline.prototype.setFilter = function(filter) { + this._filter = filter; +} + /** * Initialise the start and end state with the given events * @@ -217,6 +233,14 @@ EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) EventTimeline.prototype.addEvent = function(event, atStart) { var stateContext = atStart ? this._startState : this._endState; + // manually filter the event if we have a filter, as currently we insert + // events incrementally only from the main /sync rather than a filtered + // /sync to avoid running multiple redundant /syncs. + if (this._filter) { + var events = this._filter.filterRoomTimeline([event]); + if (!events) return; + } + setEventMetadata(event, stateContext, atStart); // modify state From d46863e1998b9347c7ced15f025a15c38fac6e46 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sun, 28 Aug 2016 23:44:10 +0100 Subject: [PATCH 02/56] fix syntax --- lib/filter-component.js | 54 ++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/lib/filter-component.js b/lib/filter-component.js index 3930806f6..8534d420d 100644 --- a/lib/filter-component.js +++ b/lib/filter-component.js @@ -34,7 +34,7 @@ function _matches_wildcard(actual_value, filter_value) { * * This is all ported from synapse's Filter object. */ -FilterComponent = function(filter_json) { +function FilterComponent(filter_json) { this.filter_json = filter_json; this.types = filter_json.types || null; @@ -65,7 +65,7 @@ FilterComponent.prototype.check = function(event) { event.room_id, sender, event.type, - event.content ? event.content.url !== undefined : false, + event.content ? event.content.url !== undefined : false ); }; @@ -73,38 +73,38 @@ FilterComponent.prototype.check = function(event) { * Checks whether the filter matches the given event fields. */ 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); }, - }; + 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); }, + }; - Object.keys(literal_keys).forEach(function(name) { - var match_func = literal_keys[name]; - var not_name = "not_" + name; - var disallowed_values = this[not_name]; - if (disallowed_values.map(match_func)) { - return false; - } + Object.keys(literal_keys).forEach(function(name) { + var match_func = literal_keys[name]; + var not_name = "not_" + name; + var disallowed_values = this[not_name]; + if (disallowed_values.map(match_func)) { + return false; + } - var allowed_values = this[name]; - if (allowed_values) { - if (!allowed_values.map(match_func)) { - return false; - } - } - }); - - contains_url_filter = this.filter_json.contains_url; - if (contains_url_filter !== undefined) { - if (contains_url_filter !== contains_url) { + var allowed_values = this[name]; + if (allowed_values) { + if (!allowed_values.map(match_func)) { return false; } } + }); - return true; + contains_url_filter = this.filter_json.contains_url; + if (contains_url_filter !== undefined) { + if (contains_url_filter !== contains_url) { + return false; + } } + + return true; }; FilterComponent.prototype.filter = function(events) { From b42db46abd501f34fec4f5341aade60e435e8711 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 29 Aug 2016 21:06:53 +0100 Subject: [PATCH 03/56] WIP refactor --- lib/models/event-timeline.js | 24 -- lib/models/room.js | 524 +++++------------------------------ 2 files changed, 66 insertions(+), 482 deletions(-) diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index f68bbe4d3..4123849ac 100644 --- a/lib/models/event-timeline.js +++ b/lib/models/event-timeline.js @@ -36,7 +36,6 @@ function EventTimeline(roomId) { this._startState.paginationToken = null; this._endState = new RoomState(roomId); this._endState.paginationToken = null; - this._filter = null; this._prevTimeline = null; this._nextTimeline = null; @@ -59,21 +58,6 @@ EventTimeline.BACKWARDS = "b"; */ EventTimeline.FORWARDS = "f"; -/** - * Get the filter object this timeline is filtered on - */ -EventTimeline.prototype.getFilter = function() { - return this._filter; -} - -/** - * Set the filter object this timeline is filtered on - * (passed to the server when paginating via /messages). - */ -EventTimeline.prototype.setFilter = function(filter) { - this._filter = filter; -} - /** * Initialise the start and end state with the given events * @@ -233,14 +217,6 @@ EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) EventTimeline.prototype.addEvent = function(event, atStart) { var stateContext = atStart ? this._startState : this._endState; - // manually filter the event if we have a filter, as currently we insert - // events incrementally only from the main /sync rather than a filtered - // /sync to avoid running multiple redundant /syncs. - if (this._filter) { - var events = this._filter.filterRoomTimeline([event]); - if (!events) return; - } - setEventMetadata(event, stateContext, atStart); // modify state diff --git a/lib/models/room.js b/lib/models/room.js index 166a5762e..27e3aa5d3 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -25,6 +25,7 @@ var MatrixEvent = require("./event").MatrixEvent; var utils = require("../utils"); var ContentRepo = require("../content-repo"); var EventTimeline = require("./event-timeline"); +var EventTimelineList = require("./event-timeline-list"); // var DEBUG = false; @@ -159,13 +160,17 @@ function Room(roomId, opts) { this._notificationCounts = {}; - this._liveTimeline = new EventTimeline(this.roomId); - this._fixUpLegacyTimelineFields(); + // all our per-room timeline lists. the first one is the unfiltered ones; + // the subsequent ones are the filtered ones in no particular order. + this._timelineLists = [ new EventTimelineList(roomId, opts) ]; - // just a list - *not* ordered. - this._timelines = [this._liveTimeline]; - this._eventIdToTimeline = {}; - this._timelineSupport = Boolean(opts.timelineSupport); + // any filtered timeline lists we're maintaining for this room + this._filteredTimelineLists = { + // filter_id: timelineList + }; + + // a reference to our shared notification timeline list + this._notifTimelineList = opts.notifTimelineList; if (this._opts.pendingEventOrdering == "detached") { this._pendingEventList = []; @@ -192,101 +197,6 @@ Room.prototype.getPendingEvents = function() { }; -/** - * Get the live timeline for this room. - * - * @return {module:models/event-timeline~EventTimeline} live timeline - */ -Room.prototype.getLiveTimeline = function() { - return this._liveTimeline; -}; - -/** - * 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 - * - * @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(); - } - - // 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); -}; - -/** - * Fix up this.timeline, this.oldState and this.currentState - * - * @private - */ -Room.prototype._fixUpLegacyTimelineFields = function() { - // maintain this.timeline as a reference to the live timeline, - // 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); -}; - -/** - * 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 - */ -Room.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 - */ -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; }); -}; - - /** * Get one of the notification counts for this room * @param {String} type The type of notification count to get. default: 'total' @@ -438,224 +348,33 @@ Room.prototype.getCanonicalAlias = function() { }; /** - * Add a new timeline to this room - * - * @return {module:models/event-timeline~EventTimeline} newly-created timeline + * Add a timelineList for this room with the given filter */ -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."); - } - - var timeline = new EventTimeline(this.roomId); - this._timelines.push(timeline); - return timeline; +Room.prototype.addFilteredTimelineList(filter) { + var timelineList = new EventTimelineList( + this.roomId, { + filter: filter, + } + ); + this._filteredTimelineLists[filter.filterId] = timelineList; + this._timelineLists.push(timelineList); }; - /** - * 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 timelineList for this room with the given filter */ -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.removeFilteredTimelineList(filter) { + var timelineList = this._filteredTimelineLists[filter.filterId]; + delete this._filteredTimelineLists[filter.filterId]; + var i = this._timelineLists.indexOf(timelineList); + if (i > -1) { + this._timelineList.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' @@ -693,38 +412,17 @@ 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 lists + for (var i = 0; i < this._timelineLists.length; i++) { + this._timelineLists[i].addLiveEvent(event, duplicateStrategy); } - // TODO: pass through filter to see if this should be added to the timeline. - this._addEventToTimeline(event, this._liveTimeline, false); + // add to notification timeline list, if any + if (this._notifTimelineList) { + if (event.isNotification()) { + this._notifTimelineList.addLiveEvent(event, duplicateStrategy); + } + } // synthesize and inject implicit read receipts // Done after adding the event because otherwise the app would get a read receipt @@ -773,9 +471,11 @@ Room.prototype.addPendingEvent = function(event, txnId) { } // call setEventMetadata to set up event.sender etc - setEventMetadata( + // as event is shared over all timelinelists, we set up its metadata based + // on the unfiltered timelineList. + this._timelineLists[0].setEventMetadata( event, - this._liveTimeline.getState(EventTimeline.FORWARDS), + this._timelineLists[0].getLiveTimeline().getState(EventTimeline.FORWARDS), false ); @@ -784,7 +484,10 @@ 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._timelineLists.length; i++) { + this._timelineLists[i].addEventToTimeline(event, this._timelineLists[i].getLiveTimeline(), false); + } + // notifications are receive-only, so we don't need to worry about this._notifTimelineList. } this.emit("Room.localEchoUpdated", event, this, null, null); @@ -828,13 +531,17 @@ 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._timelineLists.length; i++) { + var timelineList = this._timelineLists[i]; + + // if it's already in the timeline, update the timeline map. If it's not, add it. + var existingTimeline = timelineList._eventIdToTimeline[oldEventId]; + if (existingTimeline) { + delete timelineList._eventIdToTimeline[oldEventId]; + timelineList._eventIdToTimeline[newEventId] = existingTimeline; + } else { + timelineList._addEventToTimeline(localEvent, timelineList._liveTimeline, false); + } } this.emit("Room.localEchoUpdated", localEvent, this, @@ -1013,94 +720,16 @@ Room.prototype.removeEvents = function(event_ids) { * in this room. */ Room.prototype.removeEvent = function(eventId) { - var timeline = this._eventIdToTimeline[eventId]; - if (!timeline) { - return null; + var removedAny; + for (var i = 0; i < this._timelineLists.length; i++) { + var removed = this._timelineLists[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 +850,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 @@ -1275,7 +904,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 +979,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. From b863a363da25ff5297538ba2d1066299418bd67b Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 29 Aug 2016 21:08:35 +0100 Subject: [PATCH 04/56] WIP refactor --- lib/models/event-timeline-list.js | 542 ++++++++++++++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 lib/models/event-timeline-list.js diff --git a/lib/models/event-timeline-list.js b/lib/models/event-timeline-list.js new file mode 100644 index 000000000..eeb131098 --- /dev/null +++ b/lib/models/event-timeline-list.js @@ -0,0 +1,542 @@ +/* +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 + */ + +/** + * 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. + */ +function EventTimelineList(roomId, opts) { + this._timelineSupport = Boolean(opts.timelineSupport); + this._liveTimeline = new EventTimeline(this.roomId); + this._fixUpLegacyTimelineFields(); + + // just a list - *not* ordered. + this._timelines = [this._liveTimeline]; + this._eventIdToTimeline = {}; + + this._filter = opts.filter; +} +utils.inherits(EventTimelineList, EventEmitter); + +/** + * Get the filter object this timeline list is filtered on + */ +EventTimeline.prototype.getFilter = function() { + return this._filter; +} + +/** + * Set the filter object this timeline list is filtered on + * (passed to the server when paginating via /messages). + */ +EventTimeline.prototype.setFilter = function(filter) { + this._filter = filter; +} + +/** + * Get the live timeline for this room. + * + * @return {module:models/event-timeline~EventTimeline} live timeline + */ +EventTimelineList.prototype.getLiveTimeline = function(filterId) { + return this._liveTimeline; +}; + +/** + * 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 + * + * @fires module:client~MatrixClient#event:"Room.timelineReset" + */ +EventTimelineList.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(); + } + + // 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); +}; + +/** + * Fix up this.timeline, this.oldState and this.currentState + * + * @private + */ +EventTimelineList.prototype._fixUpLegacyTimelineFields = function() { + // maintain this.timeline as a reference to the live timeline, + // 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); +}; + +/** + * 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 + */ +EventTimelineList.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 + */ +EventTimelineList.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 room + * + * @return {module:models/event-timeline~EventTimeline} newly-created timeline + */ +EventTimelineList.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.roomId); + 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" + * + */ +EventTimelineList.prototype.addEventsToTimeline = function(events, toStartOfTimeline, + timeline, paginationToken) { + if (!timeline) { + throw new Error( + "'timeline' not specified for EventTimelineList.addEventsToTimeline" + ); + } + + if (!toStartOfTimeline && timeline == this._liveTimeline) { + throw new Error( + "Room.addEventsToTimeline cannot be used for adding events to " + + "the live timeline - use EventTimelineList.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); + } +}; + +/** + * Add event to the live timeline + */ +EventTimelineList.prototype.addLiveEvent = function(event, duplicateStrategy) { + if (this._filter) { + var events = this._filter.filterRoomTimeline([event]); + if (!events) return; + } + + var timeline = this._eventIdToTimeline[event.getId()]; + if (timeline) { + if (duplicateStrategy === "replace") { + debuglog("EventTimelineList.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 + this.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("EventTimelineList.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" + * + * @private + */ +EventTimelineList.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, + filter: this._filter, + }; + this.emit("Room.timeline", event, this, Boolean(toStartOfTimeline), false, data); +}; + +/** + * Helper method to set sender and target properties, private to Room and EventTimelineList + */ +EventTimelineList.prototype.setEventMetadata = function(event, stateContext, toStartOfTimeline) { + 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; + } + } +} + +/** + * 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. + */ +EventTimelineList.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, 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). + */ +EventTimelineList.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; +}; \ No newline at end of file From c1c2ca3ec1f7a0f4ab27895e30063ad5c9e8882a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 29 Aug 2016 23:18:19 +0100 Subject: [PATCH 05/56] tweak doc; make it build --- lib/models/event-timeline-list.js | 47 ++++++++++++++++++++++++++++++- lib/models/room.js | 41 ++------------------------- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/lib/models/event-timeline-list.js b/lib/models/event-timeline-list.js index eeb131098..44970ee42 100644 --- a/lib/models/event-timeline-list.js +++ b/lib/models/event-timeline-list.js @@ -17,6 +17,9 @@ limitations under the License. /** * @module models/event-timeline-set */ +var EventEmitter = require("events").EventEmitter; +var utils = require("../utils"); +var EventTimeline = require("./event-timeline"); /** * Construct a set of EventTimeline objects, typically on behalf of a given @@ -539,4 +542,46 @@ EventTimelineList.prototype.compareEventOrdering = function(eventId1, eventId2) // the timelines are not contiguous. return null; -}; \ No newline at end of file +}; + +/** + * The EventTimelineList class. + */ +module.exports = EventTimelineList; + +/** + * 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, 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. + */ diff --git a/lib/models/room.js b/lib/models/room.js index 27e3aa5d3..bae465f23 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -350,7 +350,7 @@ Room.prototype.getCanonicalAlias = function() { /** * Add a timelineList for this room with the given filter */ -Room.prototype.addFilteredTimelineList(filter) { +Room.prototype.addFilteredTimelineList = function(filter) { var timelineList = new EventTimelineList( this.roomId, { filter: filter, @@ -363,7 +363,7 @@ Room.prototype.addFilteredTimelineList(filter) { /** * Forget the timelineList for this room with the given filter */ -Room.prototype.removeFilteredTimelineList(filter) { +Room.prototype.removeFilteredTimelineList = function(filter) { var timelineList = this._filteredTimelineLists[filter.filterId]; delete this._filteredTimelineLists[filter.filterId]; var i = this._timelineLists.indexOf(timelineList); @@ -1099,43 +1099,6 @@ function calculateRoomName(room, userId, ignoreRoomNameEvent) { */ 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, 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. * From 58031ab21d58d84acb0aaca87b2a0a0548d2c80c Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 30 Aug 2016 00:36:52 +0100 Subject: [PATCH 06/56] fix things until they almost work again... --- lib/models/event-timeline-list.js | 37 ++++----- lib/models/room.js | 132 +++++++++++++++++++++++------- 2 files changed, 120 insertions(+), 49 deletions(-) diff --git a/lib/models/event-timeline-list.js b/lib/models/event-timeline-list.js index 44970ee42..3db20c82e 100644 --- a/lib/models/event-timeline-list.js +++ b/lib/models/event-timeline-list.js @@ -44,9 +44,10 @@ var EventTimeline = require("./event-timeline"); * map from event_id to timeline and index. */ function EventTimelineList(roomId, opts) { + this.roomId = roomId; + this._timelineSupport = Boolean(opts.timelineSupport); this._liveTimeline = new EventTimeline(this.roomId); - this._fixUpLegacyTimelineFields(); // just a list - *not* ordered. this._timelines = [this._liveTimeline]; @@ -86,8 +87,6 @@ EventTimelineList.prototype.getLiveTimeline = function(filterId) { *

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" */ EventTimelineList.prototype.resetLiveTimeline = function(backPaginationToken) { var newTimeline; @@ -119,23 +118,6 @@ EventTimelineList.prototype.resetLiveTimeline = function(backPaginationToken) { newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); this._liveTimeline = newTimeline; - this._fixUpLegacyTimelineFields(); - this.emit("Room.timelineReset", this); -}; - -/** - * Fix up this.timeline, this.oldState and this.currentState - * - * @private - */ -EventTimelineList.prototype._fixUpLegacyTimelineFields = function() { - // maintain this.timeline as a reference to the live timeline, - // 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); }; /** @@ -217,6 +199,11 @@ EventTimelineList.prototype.addEventsToTimeline = function(events, toStartOfTime ); } + if (this._filter) { + var events = this._filter.filterRoomTimeline(events); + if (!events) return; + } + var direction = toStartOfTimeline ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; var inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : @@ -423,6 +410,16 @@ EventTimelineList.prototype.addEventToTimeline = function(event, timeline, toSta this.emit("Room.timeline", event, this, Boolean(toStartOfTimeline), false, data); }; +EventTimelineList.prototype.replaceOrAddEvent = function(localEvent, oldEventId, newEventId) { + var existingTimeline = this._eventIdToTimeline[oldEventId]; + if (existingTimeline) { + delete this._eventIdToTimeline[oldEventId]; + this._eventIdToTimeline[newEventId] = existingTimeline; + } else { + this.addEventToTimeline(localEvent, this._liveTimeline, false); + } +}; + /** * Helper method to set sender and target properties, private to Room and EventTimelineList */ diff --git a/lib/models/room.js b/lib/models/room.js index bae465f23..9edbfd317 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -163,6 +163,7 @@ function Room(roomId, opts) { // all our per-room timeline lists. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. this._timelineLists = [ new EventTimelineList(roomId, opts) ]; + this._fixUpLegacyTimelineFields(); // any filtered timeline lists we're maintaining for this room this._filteredTimelineLists = { @@ -196,6 +197,50 @@ Room.prototype.getPendingEvents = function() { return this._pendingEventList; }; +/** + * Get the live unfiltered timeline for this room. + * + * @return {module:models/event-timeline~EventTimeline} live timeline + */ +Room.prototype.getLiveTimeline = function(filterId) { + return this._timelineLists[0].getLiveTimeline(); +}; + + +/** + * 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 + * + * @fires module:client~MatrixClient#event:"Room.timelineReset" + */ +Room.prototype.resetLiveTimeline = function(backPaginationToken) { + var newTimeline; + + for (var i = 0; i < this._timelineLists.length; i++) { + this._timelineLists[i].resetLiveTimeline(backPaginationToken); + } + + this._fixUpLegacyTimelineFields(); + this.emit("Room.timelineReset", this); +}; + +/** + * Fix up this.timeline, this.oldState and this.currentState + * + * @private + */ +Room.prototype._fixUpLegacyTimelineFields = function() { + // maintain this.timeline as a reference to the live timeline, + // 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._timelineLists[0].getLiveTimeline().getEvents(); + this.oldState = this._timelineLists[0].getLiveTimeline().getState(EventTimeline.BACKWARDS); + this.currentState = this._timelineLists[0].getLiveTimeline().getState(EventTimeline.FORWARDS); +}; /** * Get one of the notification counts for this room @@ -289,6 +334,32 @@ 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) { + for (var i = 0; i < this._timelineLists.length; i++) { + this._timelineLists[0] + } +}; + /** * Get a member from the current room state. * @param {string} userId The user ID of the member. @@ -368,7 +439,7 @@ Room.prototype.removeFilteredTimelineList = function(filter) { delete this._filteredTimelineLists[filter.filterId]; var i = this._timelineLists.indexOf(timelineList); if (i > -1) { - this._timelineList.splice(i, 1); + this._timelineLists.splice(i, 1); } }; @@ -385,17 +456,23 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { 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); - if (redactedEvent) { - redactedEvent.makeRedacted(event); - this.emit("Room.redaction", event, this); + for (var i = 0; i < this._timelineLists.length; i++) { + var timelineList = this._timelineLists[i]; + // if we know about this event, redact its contents now. + var redactedEvent = timelineList.findEventById(redactId); + if (redactedEvent) { + redactedEvent.makeRedacted(event); + // FIXME: these should be emitted from EventTimelineList probably + this.emit("Room.redaction", event, this, timelineList); - // TODO: we stash user displaynames (among other things) in - // RoomMember objects which are then attached to other events - // (in the sender and target fields). We should get those - // RoomMember objects to update themselves when the events that - // they are based on are changed. + // TODO: we stash user displaynames (among other things) in + // RoomMember objects which are then attached to other events + // (in the sender and target fields). We should get those + // RoomMember objects to update themselves when the events that + // they are based on are changed. + } + + // FIXME: apply redactions to notification list } // NB: We continue to add the redaction event to the timeline so @@ -535,13 +612,7 @@ Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) { var timelineList = this._timelineLists[i]; // if it's already in the timeline, update the timeline map. If it's not, add it. - var existingTimeline = timelineList._eventIdToTimeline[oldEventId]; - if (existingTimeline) { - delete timelineList._eventIdToTimeline[oldEventId]; - timelineList._eventIdToTimeline[newEventId] = existingTimeline; - } else { - timelineList._addEventToTimeline(localEvent, timelineList._liveTimeline, false); - } + timelineList.replaceOrAddEvent(localEvent, oldEventId, newEventId); } this.emit("Room.localEchoUpdated", localEvent, this, @@ -672,16 +743,19 @@ Room.prototype.addLiveEvents = function(events, duplicateStrategy) { } // 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 (var i = 0; i < this._timelineLists.length; i++) { + var liveTimeline = this._timelineLists[i].getLiveTimeline(); + if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { + throw new Error( + "live timeline "+i+" is no longer live - it has a pagination token (" + + timelineList.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++) { @@ -883,7 +957,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._timelineLists[0].compareEventOrdering( existingReceipt.eventId, eventId); if (ordering !== null && ordering >= 0) { return; From 7514aea813dc7de1e2be8812d07762d0fb7ad61d Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 30 Aug 2016 01:11:47 +0100 Subject: [PATCH 07/56] make most things work other than Room.timeline firing --- lib/models/event-timeline-list.js | 8 +++++--- lib/models/room.js | 32 ++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/lib/models/event-timeline-list.js b/lib/models/event-timeline-list.js index 3db20c82e..8e59114cb 100644 --- a/lib/models/event-timeline-list.js +++ b/lib/models/event-timeline-list.js @@ -43,8 +43,9 @@ var EventTimeline = require("./event-timeline"); *

In order that we can find events from their ids later, we also maintain a * map from event_id to timeline and index. */ -function EventTimelineList(roomId, opts) { +function EventTimelineList(roomId, room, opts) { this.roomId = roomId; + this.room = room; this._timelineSupport = Boolean(opts.timelineSupport); this._liveTimeline = new EventTimeline(this.roomId); @@ -407,7 +408,7 @@ EventTimelineList.prototype.addEventToTimeline = function(event, timeline, toSta liveEvent: !toStartOfTimeline && timeline == this._liveTimeline, filter: this._filter, }; - this.emit("Room.timeline", event, this, Boolean(toStartOfTimeline), false, data); + this.emit("Room.timeline", event, this.room, Boolean(toStartOfTimeline), false, data); }; EventTimelineList.prototype.replaceOrAddEvent = function(localEvent, oldEventId, newEventId) { @@ -462,8 +463,9 @@ EventTimelineList.prototype.removeEvent = function(eventId) { delete this._eventIdToTimeline[eventId]; var data = { timeline: timeline, + filter: this._filter, }; - this.emit("Room.timeline", removed, this, undefined, true, data); + this.emit("Room.timeline", removed, this.room, undefined, true, data); } return removed; }; diff --git a/lib/models/room.js b/lib/models/room.js index 9edbfd317..cbcce5afb 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -162,7 +162,8 @@ function Room(roomId, opts) { // all our per-room timeline lists. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. - this._timelineLists = [ new EventTimelineList(roomId, opts) ]; + this._timelineLists = [ new EventTimelineList(roomId, this, opts) ]; + this._fixUpLegacyTimelineFields(); // any filtered timeline lists we're maintaining for this room @@ -242,6 +243,28 @@ Room.prototype._fixUpLegacyTimelineFields = function() { this.currentState = this._timelineLists[0].getLiveTimeline().getState(EventTimeline.FORWARDS); }; +/** + * 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) { + return this._timelineLists[0].getTimelineForEvent(eventId); +}; + +/** + * 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) { + return this._timelineLists[0].findEventById(eventId); +} + /** * Get one of the notification counts for this room * @param {String} type The type of notification count to get. default: 'total' @@ -356,7 +379,10 @@ Room.prototype.getCanonicalAlias = function() { Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, timeline, paginationToken) { for (var i = 0; i < this._timelineLists.length; i++) { - this._timelineLists[0] + this._timelineLists[0].addEventsToTimeline( + events, toStartOfTimeline, + timeline, paginationToken + ); } }; @@ -423,7 +449,7 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, */ Room.prototype.addFilteredTimelineList = function(filter) { var timelineList = new EventTimelineList( - this.roomId, { + this.roomId, this, { filter: filter, } ); From 0848d4ed104beb960755b9044a689dea2eed3668 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 30 Aug 2016 01:13:32 +0100 Subject: [PATCH 08/56] reemit Room.timeline events correctly --- lib/models/room.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/models/room.js b/lib/models/room.js index cbcce5afb..80ca543c3 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -163,6 +163,7 @@ function Room(roomId, opts) { // all our per-room timeline lists. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. this._timelineLists = [ new EventTimelineList(roomId, this, opts) ]; + reEmit(this, this._timelineLists[0], [ "Room.timeline" ]); this._fixUpLegacyTimelineFields(); @@ -453,6 +454,7 @@ Room.prototype.addFilteredTimelineList = function(filter) { filter: filter, } ); + reEmit(this, timelineList, [ "Room.timeline" ]); this._filteredTimelineLists[filter.filterId] = timelineList; this._timelineLists.push(timelineList); }; @@ -1194,6 +1196,25 @@ 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. */ From e18b446190c65e2fa26d58f93deb7c3c1b44333b Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 30 Aug 2016 01:30:23 +0100 Subject: [PATCH 09/56] unbreak filter text --- lib/filter-component.js | 10 +++++----- lib/filter.js | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/filter-component.js b/lib/filter-component.js index 8534d420d..35d21f380 100644 --- a/lib/filter-component.js +++ b/lib/filter-component.js @@ -40,13 +40,13 @@ function FilterComponent(filter_json) { this.types = filter_json.types || null; this.not_types = filter_json.not_types || []; - self.rooms = filter_json.rooms || null; - self.not_rooms = filter_json.not_rooms || []; + this.rooms = filter_json.rooms || null; + this.not_rooms = filter_json.not_rooms || []; - self.senders = filter_json.senders || null; - self.not_senders = filter_json.not_senders || []; + this.senders = filter_json.senders || null; + this.not_senders = filter_json.not_senders || []; - self.contains_url = filter_json.contains_url || null; + this.contains_url = filter_json.contains_url || null; }; /** diff --git a/lib/filter.js b/lib/filter.js index ff81d2fcd..2a4f0fb4f 100644 --- a/lib/filter.js +++ b/lib/filter.js @@ -113,7 +113,9 @@ Filter.prototype.setDefinition = function(definition) { } this._room_filter = new FilterComponent(room_filter_fields); - this._room_timeline_filter = new FilterComponent(room_filter_json.timeline || {}); + 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 || {}); From d25d60f0f0d1d256934ebda18fc09c35193953f7 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 30 Aug 2016 23:34:11 +0100 Subject: [PATCH 10/56] make the tests pass again --- lib/models/event-timeline-list.js | 49 +++++++++++++++---------------- lib/models/event-timeline.js | 9 ++++-- lib/models/room.js | 21 ++++++++----- spec/unit/room.spec.js | 34 ++++++++++----------- 4 files changed, 61 insertions(+), 52 deletions(-) diff --git a/lib/models/event-timeline-list.js b/lib/models/event-timeline-list.js index 8e59114cb..41fde4fc6 100644 --- a/lib/models/event-timeline-list.js +++ b/lib/models/event-timeline-list.js @@ -21,6 +21,16 @@ 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 @@ -82,6 +92,18 @@ EventTimelineList.prototype.getLiveTimeline = function(filterId) { return this._liveTimeline; }; +EventTimelineList.prototype.eventIdToTimeline = function(eventId) { + return this._eventIdToTimeline[eventId]; +}; + +EventTimelineList.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. * @@ -149,7 +171,7 @@ EventTimelineList.prototype.findEventById = function(eventId) { }; /** - * Add a new timeline to this room + * Add a new timeline to this timeline list * * @return {module:models/event-timeline~EventTimeline} newly-created timeline */ @@ -360,7 +382,7 @@ EventTimelineList.prototype.addLiveEvent = function(event, duplicateStrategy) { for (var j = 0; j < tlEvents.length; j++) { if (tlEvents[j].getId() === event.getId()) { // still need to set the right metadata on this event - this.setEventMetadata( + EventTimeline.setEventMetadata( event, timeline.getState(EventTimeline.FORWARDS), false @@ -421,29 +443,6 @@ EventTimelineList.prototype.replaceOrAddEvent = function(localEvent, oldEventId, } }; -/** - * Helper method to set sender and target properties, private to Room and EventTimelineList - */ -EventTimelineList.prototype.setEventMetadata = function(event, stateContext, toStartOfTimeline) { - 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; - } - } -} - /** * Removes a single event from this room. * diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index 4123849ac..6c083b7c8 100644 --- a/lib/models/event-timeline.js +++ b/lib/models/event-timeline.js @@ -217,7 +217,7 @@ EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) EventTimeline.prototype.addEvent = function(event, atStart) { var stateContext = atStart ? this._startState : this._endState; - setEventMetadata(event, stateContext, atStart); + EventTimeline.setEventMetadata(event, stateContext, atStart); // modify state if (event.isState()) { @@ -233,7 +233,7 @@ EventTimeline.prototype.addEvent = function(event, atStart) { // 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); + EventTimeline.setEventMetadata(event, stateContext, atStart); } } @@ -251,7 +251,10 @@ EventTimeline.prototype.addEvent = function(event, atStart) { } }; -function setEventMetadata(event, stateContext, toStartOfTimeline) { +/** + * Static helper method to set sender and target properties + */ +EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline) { // set sender and target properties event.sender = stateContext.getSentinelMember( event.getSender() diff --git a/lib/models/room.js b/lib/models/room.js index 80ca543c3..501de8174 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -256,6 +256,15 @@ Room.prototype.getTimelineForEvent = function(eventId) { return this._timelineLists[0].getTimelineForEvent(eventId); }; +/** + * Add a new timeline to this room's unfiltered timeline list + * + * @return {module:models/event-timeline~EventTimeline} newly-created timeline + */ +Room.prototype.addTimeline = function() { + return this._timelineLists[0].addTimeline(); +}; + /** * Get an event which is stored in our unfiltered timeline set * @@ -264,7 +273,7 @@ Room.prototype.getTimelineForEvent = function(eventId) { */ Room.prototype.findEventById = function(eventId) { return this._timelineLists[0].findEventById(eventId); -} +}; /** * Get one of the notification counts for this room @@ -578,7 +587,7 @@ Room.prototype.addPendingEvent = function(event, txnId) { // call setEventMetadata to set up event.sender etc // as event is shared over all timelinelists, we set up its metadata based // on the unfiltered timelineList. - this._timelineLists[0].setEventMetadata( + EventTimeline.setEventMetadata( event, this._timelineLists[0].getLiveTimeline().getState(EventTimeline.FORWARDS), false @@ -696,7 +705,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._timelineLists[0].eventIdToTimeline(newEventId); if (timeline) { // we've already received the event via the event stream. // nothing more to do here. @@ -727,10 +736,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._timelineLists.length; i++) { + this._timelineLists[i].replaceEventId(oldEventId, newEventId); } } else if (newStatus == EventStatus.CANCELLED) { diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 83d170109..176e9f45b 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._timelineLists[0].compareEventOrdering(events[0].getId(), + events[1].getId())) .toBeLessThan(0); - expect(room.compareEventOrdering(events[2].getId(), - events[1].getId())) + expect(room._timelineLists[0].compareEventOrdering(events[2].getId(), + events[1].getId())) .toBeGreaterThan(0); - expect(room.compareEventOrdering(events[1].getId(), - events[1].getId())) + expect(room._timelineLists[0].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._timelineLists[0].compareEventOrdering(events[0].getId(), + events[1].getId())) .toBeLessThan(0); - expect(room.compareEventOrdering(events[1].getId(), - events[0].getId())) + expect(room._timelineLists[0].compareEventOrdering(events[1].getId(), + events[0].getId())) .toBeGreaterThan(0); }); @@ -512,22 +512,22 @@ describe("Room", function() { room.addEventsToTimeline([events[0]], false, oldTimeline); room.addLiveEvents([events[1]]); - expect(room.compareEventOrdering(events[0].getId(), - events[1].getId())) + expect(room._timelineLists[0].compareEventOrdering(events[0].getId(), + events[1].getId())) .toBe(null); - expect(room.compareEventOrdering(events[1].getId(), - events[0].getId())) + expect(room._timelineLists[0].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")) + expect(room._timelineLists[0].compareEventOrdering(events[0].getId(), "xxx")) .toBe(null); - expect(room.compareEventOrdering("xxx", events[0].getId())) + expect(room._timelineLists[0].compareEventOrdering("xxx", events[0].getId())) .toBe(null); - expect(room.compareEventOrdering(events[0].getId(), + expect(room._timelineLists[0].compareEventOrdering(events[0].getId(), events[0].getId())) .toBe(0); }); From 4ff2ad9fac9f961e8e3f21b3232b94bf8d3f5044 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 3 Sep 2016 22:27:29 +0100 Subject: [PATCH 11/56] s/EventTimelineList/EventTimelineSet/g at vdh's req --- ...timeline-list.js => event-timeline-set.js} | 46 +++---- lib/models/room.js | 112 +++++++++--------- spec/unit/room.spec.js | 20 ++-- 3 files changed, 90 insertions(+), 88 deletions(-) rename lib/models/{event-timeline-list.js => event-timeline-set.js} (92%) diff --git a/lib/models/event-timeline-list.js b/lib/models/event-timeline-set.js similarity index 92% rename from lib/models/event-timeline-list.js rename to lib/models/event-timeline-set.js index 41fde4fc6..80ce94fe8 100644 --- a/lib/models/event-timeline-list.js +++ b/lib/models/event-timeline-set.js @@ -53,7 +53,7 @@ if (DEBUG) { *

In order that we can find events from their ids later, we also maintain a * map from event_id to timeline and index. */ -function EventTimelineList(roomId, room, opts) { +function EventTimelineSet(roomId, room, opts) { this.roomId = roomId; this.room = room; @@ -66,7 +66,7 @@ function EventTimelineList(roomId, room, opts) { this._filter = opts.filter; } -utils.inherits(EventTimelineList, EventEmitter); +utils.inherits(EventTimelineSet, EventEmitter); /** * Get the filter object this timeline list is filtered on @@ -88,15 +88,15 @@ EventTimeline.prototype.setFilter = function(filter) { * * @return {module:models/event-timeline~EventTimeline} live timeline */ -EventTimelineList.prototype.getLiveTimeline = function(filterId) { +EventTimelineSet.prototype.getLiveTimeline = function(filterId) { return this._liveTimeline; }; -EventTimelineList.prototype.eventIdToTimeline = function(eventId) { +EventTimelineSet.prototype.eventIdToTimeline = function(eventId) { return this._eventIdToTimeline[eventId]; }; -EventTimelineList.prototype.replaceEventId = function(oldEventId, newEventId) { +EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) { var existingTimeline = this._eventIdToTimeline[oldEventId]; if (existingTimeline) { delete this._eventIdToTimeline[oldEventId]; @@ -111,7 +111,7 @@ EventTimelineList.prototype.replaceEventId = function(oldEventId, newEventId) { * * @param {string=} backPaginationToken token for back-paginating the new timeline */ -EventTimelineList.prototype.resetLiveTimeline = function(backPaginationToken) { +EventTimelineSet.prototype.resetLiveTimeline = function(backPaginationToken) { var newTimeline; if (!this._timelineSupport) { @@ -150,7 +150,7 @@ EventTimelineList.prototype.resetLiveTimeline = function(backPaginationToken) { * @return {?module:models/event-timeline~EventTimeline} timeline containing * the given event, or null if unknown */ -EventTimelineList.prototype.getTimelineForEvent = function(eventId) { +EventTimelineSet.prototype.getTimelineForEvent = function(eventId) { var res = this._eventIdToTimeline[eventId]; return (res === undefined) ? null : res; }; @@ -161,7 +161,7 @@ EventTimelineList.prototype.getTimelineForEvent = function(eventId) { * @param {string} eventId event ID to look for * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown */ -EventTimelineList.prototype.findEventById = function(eventId) { +EventTimelineSet.prototype.findEventById = function(eventId) { var tl = this.getTimelineForEvent(eventId); if (!tl) { return undefined; @@ -175,7 +175,7 @@ EventTimelineList.prototype.findEventById = function(eventId) { * * @return {module:models/event-timeline~EventTimeline} newly-created timeline */ -EventTimelineList.prototype.addTimeline = function() { +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" + @@ -207,18 +207,18 @@ EventTimelineList.prototype.addTimeline = function() { * @fires module:client~MatrixClient#event:"Room.timeline" * */ -EventTimelineList.prototype.addEventsToTimeline = function(events, toStartOfTimeline, +EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimeline, timeline, paginationToken) { if (!timeline) { throw new Error( - "'timeline' not specified for EventTimelineList.addEventsToTimeline" + "'timeline' not specified for EventTimelineSet.addEventsToTimeline" ); } if (!toStartOfTimeline && timeline == this._liveTimeline) { throw new Error( "Room.addEventsToTimeline cannot be used for adding events to " + - "the live timeline - use EventTimelineList.addLiveEvents instead" + "the live timeline - use EventTimelineSet.addLiveEvents instead" ); } @@ -367,7 +367,7 @@ EventTimelineList.prototype.addEventsToTimeline = function(events, toStartOfTime /** * Add event to the live timeline */ -EventTimelineList.prototype.addLiveEvent = function(event, duplicateStrategy) { +EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) { if (this._filter) { var events = this._filter.filterRoomTimeline([event]); if (!events) return; @@ -376,7 +376,7 @@ EventTimelineList.prototype.addLiveEvent = function(event, duplicateStrategy) { var timeline = this._eventIdToTimeline[event.getId()]; if (timeline) { if (duplicateStrategy === "replace") { - debuglog("EventTimelineList.addLiveEvent: replacing duplicate event " + + debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + event.getId()); var tlEvents = timeline.getEvents(); for (var j = 0; j < tlEvents.length; j++) { @@ -397,7 +397,7 @@ EventTimelineList.prototype.addLiveEvent = function(event, duplicateStrategy) { } } } else { - debuglog("EventTimelineList.addLiveEvent: ignoring duplicate event " + + debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + event.getId()); } return; @@ -420,7 +420,7 @@ EventTimelineList.prototype.addLiveEvent = function(event, duplicateStrategy) { * * @private */ -EventTimelineList.prototype.addEventToTimeline = function(event, timeline, toStartOfTimeline) { +EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, toStartOfTimeline) { var eventId = event.getId(); timeline.addEvent(event, toStartOfTimeline); this._eventIdToTimeline[eventId] = timeline; @@ -433,7 +433,7 @@ EventTimelineList.prototype.addEventToTimeline = function(event, timeline, toSta this.emit("Room.timeline", event, this.room, Boolean(toStartOfTimeline), false, data); }; -EventTimelineList.prototype.replaceOrAddEvent = function(localEvent, oldEventId, newEventId) { +EventTimelineSet.prototype.replaceOrAddEvent = function(localEvent, oldEventId, newEventId) { var existingTimeline = this._eventIdToTimeline[oldEventId]; if (existingTimeline) { delete this._eventIdToTimeline[oldEventId]; @@ -451,7 +451,7 @@ EventTimelineList.prototype.replaceOrAddEvent = function(localEvent, oldEventId, * @return {?MatrixEvent} the removed event, or null if the event was not found * in this room. */ -EventTimelineList.prototype.removeEvent = function(eventId) { +EventTimelineSet.prototype.removeEvent = function(eventId) { var timeline = this._eventIdToTimeline[eventId]; if (!timeline) { return null; @@ -481,7 +481,7 @@ EventTimelineList.prototype.removeEvent = function(eventId) { * of the events, or because they are in separate timelines which don't join * up). */ -EventTimelineList.prototype.compareEventOrdering = function(eventId1, eventId2) { +EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) { if (eventId1 == eventId2) { // optimise this case return 0; @@ -543,9 +543,9 @@ EventTimelineList.prototype.compareEventOrdering = function(eventId1, eventId2) }; /** - * The EventTimelineList class. + * The EventTimelineSet class. */ -module.exports = EventTimelineList; +module.exports = EventTimelineSet; /** * Fires whenever the timeline in a room is updated. @@ -565,7 +565,8 @@ module.exports = EventTimelineList; * added to the end of the live timeline * * @example - * matrixClient.on("Room.timeline", function(event, room, toStartOfTimeline, data){ + * matrixClient.on("Room.timeline", + * function(event, room, toStartOfTimeline, removed, data) { * if (!toStartOfTimeline && data.liveEvent) { * var messageToAppend = room.timeline.[room.timeline.length - 1]; * } @@ -583,3 +584,4 @@ module.exports = EventTimelineList; * @event module:client~MatrixClient#"Room.timelineReset" * @param {Room} room The room whose live timeline was reset. */ + diff --git a/lib/models/room.js b/lib/models/room.js index 096226bff..60c5a608c 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -25,7 +25,7 @@ var MatrixEvent = require("./event").MatrixEvent; var utils = require("../utils"); var ContentRepo = require("../content-repo"); var EventTimeline = require("./event-timeline"); -var EventTimelineList = require("./event-timeline-list"); +var EventTimelineSet = require("./event-timeline-set"); // var DEBUG = false; @@ -162,18 +162,18 @@ function Room(roomId, opts) { // all our per-room timeline lists. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. - this._timelineLists = [ new EventTimelineList(roomId, this, opts) ]; - reEmit(this, this._timelineLists[0], [ "Room.timeline" ]); + this._timelineSets = [ new EventTimelineSet(roomId, this, opts) ]; + reEmit(this, this._timelineSets[0], [ "Room.timeline" ]); this._fixUpLegacyTimelineFields(); // any filtered timeline lists we're maintaining for this room - this._filteredTimelineLists = { - // filter_id: timelineList + this._filteredTimelineSets = { + // filter_id: timelineSet }; // a reference to our shared notification timeline list - this._notifTimelineList = opts.notifTimelineList; + this._notifTimelineSet = opts.notifTimelineSet; if (this._opts.pendingEventOrdering == "detached") { this._pendingEventList = []; @@ -205,7 +205,7 @@ Room.prototype.getPendingEvents = function() { * @return {module:models/event-timeline~EventTimeline} live timeline */ Room.prototype.getLiveTimeline = function(filterId) { - return this._timelineLists[0].getLiveTimeline(); + return this._timelineSets[0].getLiveTimeline(); }; @@ -221,8 +221,8 @@ Room.prototype.getLiveTimeline = function(filterId) { Room.prototype.resetLiveTimeline = function(backPaginationToken) { var newTimeline; - for (var i = 0; i < this._timelineLists.length; i++) { - this._timelineLists[i].resetLiveTimeline(backPaginationToken); + for (var i = 0; i < this._timelineSets.length; i++) { + this._timelineSets[i].resetLiveTimeline(backPaginationToken); } this._fixUpLegacyTimelineFields(); @@ -239,9 +239,9 @@ 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._timelineLists[0].getLiveTimeline().getEvents(); - this.oldState = this._timelineLists[0].getLiveTimeline().getState(EventTimeline.BACKWARDS); - this.currentState = this._timelineLists[0].getLiveTimeline().getState(EventTimeline.FORWARDS); + this.timeline = this._timelineSets[0].getLiveTimeline().getEvents(); + this.oldState = this._timelineSets[0].getLiveTimeline().getState(EventTimeline.BACKWARDS); + this.currentState = this._timelineSets[0].getLiveTimeline().getState(EventTimeline.FORWARDS); }; /** @@ -253,7 +253,7 @@ Room.prototype._fixUpLegacyTimelineFields = function() { */ Room.prototype.getTimelineForEvent = function(eventId) { - return this._timelineLists[0].getTimelineForEvent(eventId); + return this._timelineSets[0].getTimelineForEvent(eventId); }; /** @@ -262,7 +262,7 @@ Room.prototype.getTimelineForEvent = function(eventId) { * @return {module:models/event-timeline~EventTimeline} newly-created timeline */ Room.prototype.addTimeline = function() { - return this._timelineLists[0].addTimeline(); + return this._timelineSets[0].addTimeline(); }; /** @@ -272,7 +272,7 @@ Room.prototype.addTimeline = function() { * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown */ Room.prototype.findEventById = function(eventId) { - return this._timelineLists[0].findEventById(eventId); + return this._timelineSets[0].findEventById(eventId); }; /** @@ -388,8 +388,8 @@ Room.prototype.getCanonicalAlias = function() { */ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, timeline, paginationToken) { - for (var i = 0; i < this._timelineLists.length; i++) { - this._timelineLists[0].addEventsToTimeline( + for (var i = 0; i < this._timelineSets.length; i++) { + this._timelineSets[0].addEventsToTimeline( events, toStartOfTimeline, timeline, paginationToken ); @@ -455,28 +455,28 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, }; /** - * Add a timelineList for this room with the given filter + * Add a timelineSet for this room with the given filter */ -Room.prototype.addFilteredTimelineList = function(filter) { - var timelineList = new EventTimelineList( +Room.prototype.addFilteredTimelineSet = function(filter) { + var timelineSet = new EventTimelineSet( this.roomId, this, { filter: filter, } ); - reEmit(this, timelineList, [ "Room.timeline" ]); - this._filteredTimelineLists[filter.filterId] = timelineList; - this._timelineLists.push(timelineList); + reEmit(this, timelineSet, [ "Room.timeline" ]); + this._filteredTimelineSets[filter.filterId] = timelineSet; + this._timelineSets.push(timelineSet); }; /** - * Forget the timelineList for this room with the given filter + * Forget the timelineSet for this room with the given filter */ -Room.prototype.removeFilteredTimelineList = function(filter) { - var timelineList = this._filteredTimelineLists[filter.filterId]; - delete this._filteredTimelineLists[filter.filterId]; - var i = this._timelineLists.indexOf(timelineList); +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._timelineLists.splice(i, 1); + this._timelineSets.splice(i, 1); } }; @@ -493,14 +493,14 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { if (event.getType() === "m.room.redaction") { var redactId = event.event.redacts; - for (var i = 0; i < this._timelineLists.length; i++) { - var timelineList = this._timelineLists[i]; + for (var i = 0; i < this._timelineSets.length; i++) { + var timelineSet = this._timelineSets[i]; // if we know about this event, redact its contents now. - var redactedEvent = timelineList.findEventById(redactId); + var redactedEvent = timelineSet.findEventById(redactId); if (redactedEvent) { redactedEvent.makeRedacted(event); - // FIXME: these should be emitted from EventTimelineList probably - this.emit("Room.redaction", event, this, timelineList); + // FIXME: these should be emitted from EventTimelineSet probably + this.emit("Room.redaction", event, this, timelineSet); // TODO: we stash user displaynames (among other things) in // RoomMember objects which are then attached to other events @@ -527,14 +527,14 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { } // add to our timeline lists - for (var i = 0; i < this._timelineLists.length; i++) { - this._timelineLists[i].addLiveEvent(event, duplicateStrategy); + for (var i = 0; i < this._timelineSets.length; i++) { + this._timelineSets[i].addLiveEvent(event, duplicateStrategy); } // add to notification timeline list, if any - if (this._notifTimelineList) { + if (this._notifTimelineSet) { if (event.isNotification()) { - this._notifTimelineList.addLiveEvent(event, duplicateStrategy); + this._notifTimelineSet.addLiveEvent(event, duplicateStrategy); } } @@ -585,11 +585,11 @@ Room.prototype.addPendingEvent = function(event, txnId) { } // call setEventMetadata to set up event.sender etc - // as event is shared over all timelinelists, we set up its metadata based - // on the unfiltered timelineList. + // as event is shared over all timelineSets, we set up its metadata based + // on the unfiltered timelineSet. EventTimeline.setEventMetadata( event, - this._timelineLists[0].getLiveTimeline().getState(EventTimeline.FORWARDS), + this._timelineSets[0].getLiveTimeline().getState(EventTimeline.FORWARDS), false ); @@ -598,10 +598,10 @@ Room.prototype.addPendingEvent = function(event, txnId) { if (this._opts.pendingEventOrdering == "detached") { this._pendingEventList.push(event); } else { - for (var i = 0; i < this._timelineLists.length; i++) { - this._timelineLists[i].addEventToTimeline(event, this._timelineLists[i].getLiveTimeline(), false); + for (var i = 0; i < this._timelineSets.length; i++) { + this._timelineSets[i].addEventToTimeline(event, this._timelineSets[i].getLiveTimeline(), false); } - // notifications are receive-only, so we don't need to worry about this._notifTimelineList. + // notifications are receive-only, so we don't need to worry about this._notifTimelineSet. } this.emit("Room.localEchoUpdated", event, this, null, null); @@ -645,11 +645,11 @@ Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) { // successfully sent. localEvent.status = null; - for (var i = 0; i < this._timelineLists.length; i++) { - var timelineList = this._timelineLists[i]; + 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. - timelineList.replaceOrAddEvent(localEvent, oldEventId, newEventId); + timelineSet.replaceOrAddEvent(localEvent, oldEventId, newEventId); } this.emit("Room.localEchoUpdated", localEvent, this, @@ -705,7 +705,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._timelineLists[0].eventIdToTimeline(newEventId); + var timeline = this._timelineSets[0].eventIdToTimeline(newEventId); if (timeline) { // we've already received the event via the event stream. // nothing more to do here. @@ -736,8 +736,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. - for (var i = 0; i < this._timelineLists.length; i++) { - this._timelineLists[i].replaceEventId(oldEventId, newEventId); + for (var i = 0; i < this._timelineSets.length; i++) { + this._timelineSets[i].replaceEventId(oldEventId, newEventId); } } else if (newStatus == EventStatus.CANCELLED) { @@ -778,12 +778,12 @@ Room.prototype.addLiveEvents = function(events, duplicateStrategy) { } // sanity check that the live timeline is still live - for (var i = 0; i < this._timelineLists.length; i++) { - var liveTimeline = this._timelineLists[i].getLiveTimeline(); + for (var 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 (" + - timelineList.getPaginationToken(EventTimeline.FORWARDS) + ")" + timelineSet.getPaginationToken(EventTimeline.FORWARDS) + ")" ); } if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { @@ -830,8 +830,8 @@ Room.prototype.removeEvents = function(event_ids) { */ Room.prototype.removeEvent = function(eventId) { var removedAny; - for (var i = 0; i < this._timelineLists.length; i++) { - var removed = this._timelineLists[i].removeEvent(eventId); + for (var i = 0; i < this._timelineSets.length; i++) { + var removed = this._timelineSets[i].removeEvent(eventId); if (removed) { removedAny = true; } @@ -992,7 +992,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._timelineLists[0].compareEventOrdering( + var ordering = self._timelineSets[0].compareEventOrdering( existingReceipt.eventId, eventId); if (ordering !== null && ordering >= 0) { return; diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 176e9f45b..5784fa0f3 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -479,13 +479,13 @@ describe("Room", function() { it("should handle events in the same timeline", function() { room.addLiveEvents(events); - expect(room._timelineLists[0].compareEventOrdering(events[0].getId(), + expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), events[1].getId())) .toBeLessThan(0); - expect(room._timelineLists[0].compareEventOrdering(events[2].getId(), + expect(room._timelineSets[0].compareEventOrdering(events[2].getId(), events[1].getId())) .toBeGreaterThan(0); - expect(room._timelineLists[0].compareEventOrdering(events[1].getId(), + expect(room._timelineSets[0].compareEventOrdering(events[1].getId(), events[1].getId())) .toEqual(0); }); @@ -498,10 +498,10 @@ describe("Room", function() { room.addEventsToTimeline([events[0]], false, oldTimeline); room.addLiveEvents([events[1]]); - expect(room._timelineLists[0].compareEventOrdering(events[0].getId(), + expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), events[1].getId())) .toBeLessThan(0); - expect(room._timelineLists[0].compareEventOrdering(events[1].getId(), + expect(room._timelineSets[0].compareEventOrdering(events[1].getId(), events[0].getId())) .toBeGreaterThan(0); }); @@ -512,10 +512,10 @@ describe("Room", function() { room.addEventsToTimeline([events[0]], false, oldTimeline); room.addLiveEvents([events[1]]); - expect(room._timelineLists[0].compareEventOrdering(events[0].getId(), + expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), events[1].getId())) .toBe(null); - expect(room._timelineLists[0].compareEventOrdering(events[1].getId(), + expect(room._timelineSets[0].compareEventOrdering(events[1].getId(), events[0].getId())) .toBe(null); }); @@ -523,11 +523,11 @@ describe("Room", function() { it("should return null for unknown events", function() { room.addLiveEvents(events); - expect(room._timelineLists[0].compareEventOrdering(events[0].getId(), "xxx")) + expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), "xxx")) .toBe(null); - expect(room._timelineLists[0].compareEventOrdering("xxx", events[0].getId())) + expect(room._timelineSets[0].compareEventOrdering("xxx", events[0].getId())) .toBe(null); - expect(room._timelineLists[0].compareEventOrdering(events[0].getId(), + expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), events[0].getId())) .toBe(0); }); From 2daa1b6007508e2ea9e39c79dbf5565d63ae555f Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sun, 4 Sep 2016 13:57:56 +0100 Subject: [PATCH 12/56] change TimelineWindow to take a timelineSet rather than a Room --- lib/client.js | 25 +++++------ lib/models/room.js | 14 ++++++ lib/timeline-window.js | 11 ++--- .../matrix-client-event-timeline.spec.js | 43 ++++++++++++------- spec/unit/timeline-window.spec.js | 26 +++++------ 5 files changed, 73 insertions(+), 46 deletions(-) diff --git a/lib/client.js b/lib/client.js index a9c2ac740..84c527201 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1618,18 +1618,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'" + @@ -1637,13 +1637,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, } ); @@ -1660,8 +1660,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 @@ -1673,21 +1673,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; }; diff --git a/lib/models/room.js b/lib/models/room.js index 60c5a608c..b337a05b1 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -244,6 +244,20 @@ Room.prototype._fixUpLegacyTimelineFields = function() { this.currentState = this._timelineSets[0].getLiveTimeline().getState(EventTimeline.FORWARDS); }; +/** + * Return the timeline sets for this room + */ +Room.prototype.getTimelineSets = function() { + return this._timelineSets; +}; + +/** + * Return the notification timeline set for this room + */ +Room.prototype.getNotifTimelineSet = function() { + return this._notifTimelineSet; +}; + /** * Get the timeline which contains the given event from the unfiltered set, if any * diff --git a/lib/timeline-window.js b/lib/timeline-window.js index 89acec9dc..bbe16fb31 100644 --- a/lib/timeline-window.js +++ b/lib/timeline-window.js @@ -19,6 +19,7 @@ limitations under the License. var q = require("q"); var EventTimeline = require("./models/event-timeline"); +var EventTimelineSet = require("./models/event-timeline-set"); /** * @private @@ -56,7 +57,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 +67,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 +114,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 +127,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(); } 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/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index b6f91d111..9c706d75f 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -133,19 +133,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 +169,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 +192,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)) From ed5c061566f81be697012cfce8cbeae9360c68ef Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 5 Sep 2016 02:44:24 +0100 Subject: [PATCH 13/56] move getOrCreateClient from sync.js to client.js --- lib/client.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/sync.js | 51 ++------------------------------------------------- 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/lib/client.js b/lib/client.js index 84c527201..544fbf9d5 100644 --- a/lib/client.js +++ b/lib/client.js @@ -2277,6 +2277,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/sync.js b/lib/sync.js index 91bb0cc51..870c83597 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -148,7 +148,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,7 +389,7 @@ 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) { self._sync({ filterId: filterId }); @@ -837,53 +837,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[]} From 888fbe35491337d47cb5ae7502aad92e1e73dd9a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 5 Sep 2016 02:44:46 +0100 Subject: [PATCH 14/56] fix some lint --- lib/filter-component.js | 6 +++--- lib/filter.js | 3 ++- lib/models/event-timeline-set.js | 18 ++++++++++------- lib/models/event-timeline.js | 2 +- lib/models/room.js | 33 ++++++++++++++------------------ lib/timeline-window.js | 1 - 6 files changed, 31 insertions(+), 32 deletions(-) diff --git a/lib/filter-component.js b/lib/filter-component.js index 35d21f380..236de7a77 100644 --- a/lib/filter-component.js +++ b/lib/filter-component.js @@ -20,7 +20,7 @@ limitations under the License. function _matches_wildcard(actual_value, filter_value) { if (filter_value.endsWith("*")) { - type_prefix = filter_value.slice(0, -1); + var type_prefix = filter_value.slice(0, -1); return actual_value.substr(0, type_prefix.length) === type_prefix; } else { @@ -47,7 +47,7 @@ function FilterComponent(filter_json) { this.not_senders = filter_json.not_senders || []; this.contains_url = filter_json.contains_url || null; -}; +} /** * Checks with the filter component matches the given event @@ -97,7 +97,7 @@ FilterComponent.prototype.checkFields = } }); - contains_url_filter = this.filter_json.contains_url; + var contains_url_filter = this.filter_json.contains_url; if (contains_url_filter !== undefined) { if (contains_url_filter !== contains_url) { return false; diff --git a/lib/filter.js b/lib/filter.js index 2a4f0fb4f..6247592de 100644 --- a/lib/filter.js +++ b/lib/filter.js @@ -75,13 +75,14 @@ Filter.prototype.setDefinition = function(definition) { // "not_rooms": ["!123456:example.com"], // "state": { // "types": ["m.room.*"], - // "not_rooms": ["!726s6s6q:example.com"] + // "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"], diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index 80ce94fe8..1f5823926 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -73,7 +73,7 @@ utils.inherits(EventTimelineSet, EventEmitter); */ EventTimeline.prototype.getFilter = function() { return this._filter; -} +}; /** * Set the filter object this timeline list is filtered on @@ -81,7 +81,7 @@ EventTimeline.prototype.getFilter = function() { */ EventTimeline.prototype.setFilter = function(filter) { this._filter = filter; -} +}; /** * Get the live timeline for this room. @@ -223,8 +223,10 @@ EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimel } if (this._filter) { - var events = this._filter.filterRoomTimeline(events); - if (!events) return; + events = this._filter.filterRoomTimeline(events); + if (!events) { + return; + } } var direction = toStartOfTimeline ? EventTimeline.BACKWARDS : @@ -370,7 +372,9 @@ EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimel EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) { if (this._filter) { var events = this._filter.filterRoomTimeline([event]); - if (!events) return; + if (!events) { + return; + } } var timeline = this._eventIdToTimeline[event.getId()]; @@ -428,7 +432,7 @@ EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, toStar var data = { timeline: timeline, liveEvent: !toStartOfTimeline && timeline == this._liveTimeline, - filter: this._filter, + timelineSet: this, }; this.emit("Room.timeline", event, this.room, Boolean(toStartOfTimeline), false, data); }; @@ -462,7 +466,7 @@ EventTimelineSet.prototype.removeEvent = function(eventId) { delete this._eventIdToTimeline[eventId]; var data = { timeline: timeline, - filter: this._filter, + timelineSet: this, }; this.emit("Room.timeline", removed, this.room, undefined, true, data); } diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index 6c083b7c8..6d70b0687 100644 --- a/lib/models/event-timeline.js +++ b/lib/models/event-timeline.js @@ -273,7 +273,7 @@ EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline event.forwardLooking = false; } } -} +}; /** * Remove an event from the timeline diff --git a/lib/models/room.js b/lib/models/room.js index b337a05b1..cca017c48 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -28,17 +28,6 @@ var EventTimeline = require("./event-timeline"); var EventTimelineSet = require("./event-timeline-set"); -// 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() {}; -} - - function synthesizeReceipt(userId, event, receiptType) { // console.log("synthesizing receipt for "+event.getId()); // This is really ugly because JS has no way to express an object literal @@ -219,8 +208,6 @@ Room.prototype.getLiveTimeline = function(filterId) { * @fires module:client~MatrixClient#event:"Room.timelineReset" */ Room.prototype.resetLiveTimeline = function(backPaginationToken) { - var newTimeline; - for (var i = 0; i < this._timelineSets.length; i++) { this._timelineSets[i].resetLiveTimeline(backPaginationToken); } @@ -470,8 +457,13 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, /** * 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.addFilteredTimelineSet = function(filter) { +Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { + if (this._filteredTimelineSets[filter.filterId]) { + return this._filteredTimelineSets[filter.filterId]; + } var timelineSet = new EventTimelineSet( this.roomId, this, { filter: filter, @@ -480,6 +472,7 @@ Room.prototype.addFilteredTimelineSet = function(filter) { reEmit(this, timelineSet, [ "Room.timeline" ]); this._filteredTimelineSets[filter.filterId] = timelineSet; this._timelineSets.push(timelineSet); + return timelineSet; }; /** @@ -504,10 +497,11 @@ Room.prototype.removeFilteredTimelineSet = function(filter) { * @private */ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { + var i; if (event.getType() === "m.room.redaction") { var redactId = event.event.redacts; - for (var i = 0; i < this._timelineSets.length; i++) { + for (i = 0; i < this._timelineSets.length; i++) { var timelineSet = this._timelineSets[i]; // if we know about this event, redact its contents now. var redactedEvent = timelineSet.findEventById(redactId); @@ -541,7 +535,7 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { } // add to our timeline lists - for (var i = 0; i < this._timelineSets.length; i++) { + for (i = 0; i < this._timelineSets.length; i++) { this._timelineSets[i].addLiveEvent(event, duplicateStrategy); } @@ -787,17 +781,18 @@ 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 - for (var i = 0; i < this._timelineSets.length; i++) { + 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 (" + - timelineSet.getPaginationToken(EventTimeline.FORWARDS) + ")" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")" ); } if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { @@ -807,7 +802,7 @@ Room.prototype.addLiveEvents = function(events, duplicateStrategy) { } } - 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]); } diff --git a/lib/timeline-window.js b/lib/timeline-window.js index bbe16fb31..b022419aa 100644 --- a/lib/timeline-window.js +++ b/lib/timeline-window.js @@ -19,7 +19,6 @@ limitations under the License. var q = require("q"); var EventTimeline = require("./models/event-timeline"); -var EventTimelineSet = require("./models/event-timeline-set"); /** * @private From 1bda527e3d669cf07a1645a0b99e7165eb46837e Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 6 Sep 2016 01:04:23 +0100 Subject: [PATCH 15/56] export EventTimelineSet --- lib/matrix.js | 2 ++ 1 file changed, 2 insertions(+) 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. */ From c4995bd153d3d3e34a4919a0505e6d63d8532c1a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 7 Sep 2016 02:17:03 +0100 Subject: [PATCH 16/56] fix filtering --- lib/filter-component.js | 25 +++++++++------------ lib/models/event-timeline-set.js | 37 +++++++++++++++++++++++++++----- lib/models/room.js | 17 +++++++++------ 3 files changed, 53 insertions(+), 26 deletions(-) diff --git a/lib/filter-component.js b/lib/filter-component.js index 236de7a77..6c1e289f6 100644 --- a/lib/filter-component.js +++ b/lib/filter-component.js @@ -51,21 +51,15 @@ function FilterComponent(filter_json) { /** * Checks with the filter component matches the given event + * + * Takes a MatrixEvent object */ FilterComponent.prototype.check = function(event) { - var sender = event.sender; - if (!sender) { - // Presence events have their 'sender' in content.user_id - if (event.content) { - sender = event.content.user_id; - } - } - return this.checkFields( - event.room_id, - sender, - event.type, - event.content ? event.content.url !== undefined : false + event.getRoomId(), + event.getSender(), + event.getType(), + event.getContent() ? event.getContent().url !== undefined : false ); }; @@ -81,15 +75,16 @@ FilterComponent.prototype.checkFields = "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 = this[not_name]; + var disallowed_values = self[not_name]; if (disallowed_values.map(match_func)) { return false; } - var allowed_values = this[name]; + var allowed_values = self[name]; if (allowed_values) { if (!allowed_values.map(match_func)) { return false; @@ -108,7 +103,7 @@ FilterComponent.prototype.checkFields = }; FilterComponent.prototype.filter = function(events) { - return events.filter(this.check); + return events.filter(this.check, this); }; FilterComponent.prototype.limit = function() { diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index 1f5823926..26ea4a1ec 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -71,7 +71,7 @@ utils.inherits(EventTimelineSet, EventEmitter); /** * Get the filter object this timeline list is filtered on */ -EventTimeline.prototype.getFilter = function() { +EventTimelineSet.prototype.getFilter = function() { return this._filter; }; @@ -79,10 +79,30 @@ EventTimeline.prototype.getFilter = function() { * Set the filter object this timeline list is filtered on * (passed to the server when paginating via /messages). */ -EventTimeline.prototype.setFilter = function(filter) { +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. * @@ -224,7 +244,7 @@ EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimel if (this._filter) { events = this._filter.filterRoomTimeline(events); - if (!events) { + if (!events.length) { return; } } @@ -372,7 +392,7 @@ EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimel EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) { if (this._filter) { var events = this._filter.filterRoomTimeline([event]); - if (!events) { + if (!events.length) { return; } } @@ -443,7 +463,14 @@ EventTimelineSet.prototype.replaceOrAddEvent = function(localEvent, oldEventId, delete this._eventIdToTimeline[oldEventId]; this._eventIdToTimeline[newEventId] = existingTimeline; } else { - this.addEventToTimeline(localEvent, this._liveTimeline, false); + if (this._filter) { + if (this._filter.filterRoomTimeline([localEvent]).length) { + this.addEventToTimeline(localEvent, this._liveTimeline, false); + } + } + else { + this.addEventToTimeline(localEvent, this._liveTimeline, false); + } } }; diff --git a/lib/models/room.js b/lib/models/room.js index cca017c48..c5fdd21b4 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -464,11 +464,8 @@ Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { if (this._filteredTimelineSets[filter.filterId]) { return this._filteredTimelineSets[filter.filterId]; } - var timelineSet = new EventTimelineSet( - this.roomId, this, { - filter: filter, - } - ); + var opts = Object.assign({ filter: filter }, this._opts); + var timelineSet = new EventTimelineSet(this.roomId, this, opts); reEmit(this, timelineSet, [ "Room.timeline" ]); this._filteredTimelineSets[filter.filterId] = timelineSet; this._timelineSets.push(timelineSet); @@ -607,7 +604,15 @@ Room.prototype.addPendingEvent = function(event, txnId) { this._pendingEventList.push(event); } else { for (var i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].addEventToTimeline(event, this._timelineSets[i].getLiveTimeline(), false); + 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); + } } // notifications are receive-only, so we don't need to worry about this._notifTimelineSet. } From 5e583d3c50a6c8e60bfe99fc6ab3345a485df125 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 7 Sep 2016 19:45:30 +0100 Subject: [PATCH 17/56] populate up filtered timelineSets vaguely correctly --- lib/models/event-timeline-set.js | 4 ++-- lib/models/room.js | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index 26ea4a1ec..d9435348e 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -237,8 +237,8 @@ EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimel if (!toStartOfTimeline && timeline == this._liveTimeline) { throw new Error( - "Room.addEventsToTimeline cannot be used for adding events to " + - "the live timeline - use EventTimelineSet.addLiveEvents instead" + "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " + + "the live timeline - use Room.addLiveEvents instead" ); } diff --git a/lib/models/room.js b/lib/models/room.js index c5fdd21b4..ee6b07433 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -469,6 +469,33 @@ Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { reEmit(this, timelineSet, [ "Room.timeline" ]); 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._timelineSets[0].getLiveTimeline(); + + unfilteredLiveTimeline.getEvents().forEach(function(event) { + timelineSet.addLiveEvent(event); + }); + + timelineSet.getLiveTimeline().setPaginationToken( + unfilteredLiveTimeline.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; }; From 91f8df8d191ed6ea4bbc7e3680db28c40b4891b7 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 7 Sep 2016 21:17:06 +0100 Subject: [PATCH 18/56] make EventTimeline take an EventTimelineSet --- lib/models/event-timeline-set.js | 6 +++--- lib/models/event-timeline.js | 21 +++++++++++++++------ spec/unit/event-timeline.spec.js | 2 +- spec/unit/timeline-window.spec.js | 2 +- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index d9435348e..5cdec4c21 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -58,7 +58,7 @@ function EventTimelineSet(roomId, room, opts) { this.room = room; this._timelineSupport = Boolean(opts.timelineSupport); - this._liveTimeline = new EventTimeline(this.roomId); + this._liveTimeline = new EventTimeline(this); // just a list - *not* ordered. this._timelines = [this._liveTimeline]; @@ -136,7 +136,7 @@ EventTimelineSet.prototype.resetLiveTimeline = function(backPaginationToken) { if (!this._timelineSupport) { // if timeline support is disabled, forget about the old timelines - newTimeline = new EventTimeline(this.roomId); + newTimeline = new EventTimeline(this); this._timelines = [newTimeline]; this._eventIdToTimeline = {}; } else { @@ -202,7 +202,7 @@ EventTimelineSet.prototype.addTimeline = function() { " it."); } - var timeline = new EventTimeline(this.roomId); + var timeline = new EventTimeline(this); this._timelines.push(timeline); return timeline; }; diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index 6d70b0687..3a12f26db 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.roomId; 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,14 @@ 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 base index. * diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index 86a4b785b..9a7aba2ae 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -16,7 +16,7 @@ describe("EventTimeline", function() { beforeEach(function() { utils.beforeEach(this); - timeline = new EventTimeline(roomId); + timeline = new EventTimeline({ roomId : roomId }); }); describe("construction", function() { diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index 9c706d75f..f33e9451c 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -18,7 +18,7 @@ function createTimeline(numEvents, baseIndex) { if (numEvents === undefined) { numEvents = 3; } if (baseIndex === undefined) { baseIndex = 1; } - var timeline = new EventTimeline(ROOM_ID); + var timeline = new EventTimeline({ roomId : ROOM_ID }); // add the events after the baseIndex first addEventsToTimeline(timeline, numEvents - baseIndex, false); From dac820f957ed12b5684fa81685d358387be1dfbc Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 7 Sep 2016 22:04:12 +0100 Subject: [PATCH 19/56] actually filter /messages --- lib/client.js | 7 +++++++ lib/filter.js | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/client.js b/lib/client.js index 1a514ad08..d4a51938d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1743,6 +1743,13 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { 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()); + } + var self = this; var promise = diff --git a/lib/filter.js b/lib/filter.js index 6247592de..d1d31a0bc 100644 --- a/lib/filter.js +++ b/lib/filter.js @@ -51,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 @@ -126,6 +134,14 @@ Filter.prototype.setDefinition = function(definition) { // 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 From fc495a5f1ec294a19d6179ec41d5f750bf0bb300 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 8 Sep 2016 00:18:17 +0100 Subject: [PATCH 20/56] fix lint --- lib/filter-component.js | 41 +++++++++++++++++---- lib/filter.js | 17 ++++++--- lib/models/event-timeline-set.js | 60 ++++++++++++++++++++++++------- lib/models/event-timeline.js | 6 +++- lib/models/room.js | 39 ++++++++++++-------- spec/unit/event-timeline.spec.js | 2 +- spec/unit/timeline-window.spec.js | 2 +- 7 files changed, 125 insertions(+), 42 deletions(-) diff --git a/lib/filter-component.js b/lib/filter-component.js index 6c1e289f6..98f8fd7d7 100644 --- a/lib/filter-component.js +++ b/lib/filter-component.js @@ -18,6 +18,13 @@ limitations under the License. * @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); @@ -29,10 +36,15 @@ function _matches_wildcard(actual_value, filter_value) { } /** - * A FilterComponent is a section of a Filter definition which defines the + * 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. * - * This is all ported 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; @@ -51,11 +63,11 @@ function FilterComponent(filter_json) { /** * Checks with the filter component matches the given event - * - * Takes a MatrixEvent object + * @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( + return this._checkFields( event.getRoomId(), event.getSender(), event.getType(), @@ -64,9 +76,14 @@ FilterComponent.prototype.check = function(event) { }; /** - * Checks whether the filter matches the given event fields. + * 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 = +FilterComponent.prototype._checkFields = function(room_id, sender, event_type, contains_url) { var literal_keys = { @@ -102,10 +119,20 @@ FilterComponent.prototype.checkFields = 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; }; diff --git a/lib/filter.js b/lib/filter.js index d1d31a0bc..1aa892960 100644 --- a/lib/filter.js +++ b/lib/filter.js @@ -127,11 +127,16 @@ Filter.prototype.setDefinition = function(definition) { ); // 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 || {}); + // 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 || {}); }; /** @@ -145,6 +150,8 @@ Filter.prototype.getRoomTimelineFilterComponent = function() { /** * 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/models/event-timeline-set.js b/lib/models/event-timeline-set.js index 5cdec4c21..abc1d9386 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -41,8 +41,7 @@ if (DEBUG) { * 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. + * 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 @@ -52,6 +51,13 @@ if (DEBUG) { * *

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 {?String} roomId the roomId of this timelineSet's room, if any + * @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(roomId, room, opts) { this.roomId = roomId; @@ -69,15 +75,17 @@ function EventTimelineSet(roomId, room, opts) { utils.inherits(EventTimelineSet, EventEmitter); /** - * Get the filter object this timeline list is filtered on + * 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 list is filtered on + * 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; @@ -93,7 +101,9 @@ EventTimelineSet.prototype.setFilter = function(filter) { * @throws If opts.pendingEventOrdering was not 'detached' */ EventTimelineSet.prototype.getPendingEvents = function() { - if (!this.room) return []; + if (!this.room) { + return []; + } if (this._filter) { return this._filter.filterRoomTimeline(this.room.getPendingEvents()); @@ -108,14 +118,25 @@ EventTimelineSet.prototype.getPendingEvents = function() { * * @return {module:models/event-timeline~EventTimeline} live timeline */ -EventTimelineSet.prototype.getLiveTimeline = function(filterId) { +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) { @@ -387,7 +408,10 @@ EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimel }; /** - * Add event to the live timeline + * 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) { @@ -441,10 +465,9 @@ EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) { * @param {boolean} toStartOfTimeline * * @fires module:client~MatrixClient#event:"Room.timeline" - * - * @private */ -EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, toStartOfTimeline) { +EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, + toStartOfTimeline) { var eventId = event.getId(); timeline.addEvent(event, toStartOfTimeline); this._eventIdToTimeline[eventId] = timeline; @@ -454,10 +477,23 @@ EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, toStar liveEvent: !toStartOfTimeline && timeline == this._liveTimeline, timelineSet: this, }; - this.emit("Room.timeline", event, this.room, Boolean(toStartOfTimeline), false, data); + this.emit("Room.timeline", event, this.room, + Boolean(toStartOfTimeline), false, data); }; -EventTimelineSet.prototype.replaceOrAddEvent = function(localEvent, oldEventId, newEventId) { +/** + * Replaces event with ID oldEventId with one with newEventId, if oldEventId is + * recognised. Otherwise, add to the live timeline. + * + * @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.replaceOrAddEvent = function(localEvent, oldEventId, + newEventId) { + // XXX: why don't we infer newEventId from localEvent? var existingTimeline = this._eventIdToTimeline[oldEventId]; if (existingTimeline) { delete this._eventIdToTimeline[oldEventId]; diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index 3a12f26db..b885e0469 100644 --- a/lib/models/event-timeline.js +++ b/lib/models/event-timeline.js @@ -94,7 +94,7 @@ EventTimeline.prototype.getRoomId = function() { /** * Get the filter for this timeline's timelineSet (if any) - * @return {Filter}} filter + * @return {Filter} filter */ EventTimeline.prototype.getFilter = function() { return this._eventTimelineSet.getFilter(); @@ -262,6 +262,10 @@ EventTimeline.prototype.addEvent = function(event, atStart) { /** * 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 diff --git a/lib/models/room.js b/lib/models/room.js index ee6b07433..467e26d83 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -151,8 +151,8 @@ function Room(roomId, opts) { // all our per-room timeline lists. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. - this._timelineSets = [ new EventTimelineSet(roomId, this, opts) ]; - reEmit(this, this._timelineSets[0], [ "Room.timeline" ]); + this._timelineSets = [new EventTimelineSet(roomId, this, opts)]; + reEmit(this, this._timelineSets[0], ["Room.timeline"]); this._fixUpLegacyTimelineFields(); @@ -193,7 +193,7 @@ Room.prototype.getPendingEvents = function() { * * @return {module:models/event-timeline~EventTimeline} live timeline */ -Room.prototype.getLiveTimeline = function(filterId) { +Room.prototype.getLiveTimeline = function() { return this._timelineSets[0].getLiveTimeline(); }; @@ -227,19 +227,23 @@ Room.prototype._fixUpLegacyTimelineFields = function() { // state at the start and end of that timeline. These are more // for backwards-compatibility than anything else. this.timeline = this._timelineSets[0].getLiveTimeline().getEvents(); - this.oldState = this._timelineSets[0].getLiveTimeline().getState(EventTimeline.BACKWARDS); - this.currentState = this._timelineSets[0].getLiveTimeline().getState(EventTimeline.FORWARDS); + this.oldState = this._timelineSets[0].getLiveTimeline() + .getState(EventTimeline.BACKWARDS); + this.currentState = this._timelineSets[0].getLiveTimeline() + .getState(EventTimeline.FORWARDS); }; /** - * Return the timeline sets for this room + * Return the timeline sets for this room. + * @return {EventTimelineSet[]} array of timeline sets for this room */ Room.prototype.getTimelineSets = function() { return this._timelineSets; }; /** - * Return the notification timeline set for this room + * Return the shared notification timeline set + * @return {EventTimelineSet} notification timeline set */ Room.prototype.getNotifTimelineSet = function() { return this._notifTimelineSet; @@ -252,7 +256,6 @@ Room.prototype.getNotifTimelineSet = function() { * @return {?module:models/event-timeline~EventTimeline} timeline containing * the given event, or null if unknown */ - Room.prototype.getTimelineForEvent = function(eventId) { return this._timelineSets[0].getTimelineForEvent(eventId); }; @@ -466,7 +469,7 @@ Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { } var opts = Object.assign({ filter: filter }, this._opts); var timelineSet = new EventTimelineSet(this.roomId, this, opts); - reEmit(this, timelineSet, [ "Room.timeline" ]); + reEmit(this, timelineSet, ["Room.timeline"]); this._filteredTimelineSets[filter.filterId] = timelineSet; this._timelineSets.push(timelineSet); @@ -501,6 +504,8 @@ Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { /** * Forget the timelineSet for this room with the given filter + * + * @param {Filter} filter the filter whose timelineSet is to be forgotten */ Room.prototype.removeFilteredTimelineSet = function(filter) { var timelineSet = this._filteredTimelineSets[filter.filterId]; @@ -634,14 +639,17 @@ Room.prototype.addPendingEvent = function(event, txnId) { var timelineSet = this._timelineSets[i]; if (timelineSet.getFilter()) { if (this._filter.filterRoomTimeline([event]).length) { - timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), false); + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); } } else { - timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), false); + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); } } - // notifications are receive-only, so we don't need to worry about this._notifTimelineSet. + // notifications are receive-only, so we don't need to worry + // about this._notifTimelineSet. } this.emit("Room.localEchoUpdated", event, this, null, null); @@ -823,13 +831,14 @@ Room.prototype.addLiveEvents = function(events, duplicateStrategy) { 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) + ")" + "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" + "live timeline " + i + " is no longer live - " + + "it has a neighbouring timeline" ); } } diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index 9a7aba2ae..25b748648 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -16,7 +16,7 @@ describe("EventTimeline", function() { beforeEach(function() { utils.beforeEach(this); - timeline = new EventTimeline({ roomId : roomId }); + timeline = new EventTimeline({ roomId: roomId }); }); describe("construction", function() { diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index f33e9451c..ce87f13c0 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -18,7 +18,7 @@ function createTimeline(numEvents, baseIndex) { if (numEvents === undefined) { numEvents = 3; } if (baseIndex === undefined) { baseIndex = 1; } - var timeline = new EventTimeline({ roomId : ROOM_ID }); + var timeline = new EventTimeline({ roomId: ROOM_ID }); // add the events after the baseIndex first addEventsToTimeline(timeline, numEvents - baseIndex, false); From e4ec2aa55fe3c703c85d74089917d27ad055ceb4 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 8 Sep 2016 02:57:49 +0100 Subject: [PATCH 21/56] maintain the global notification timeline set. * track notifTimelineSet on MatrixClient * stop Rooms from tracking notifTimelineSet as they don't need to * Implement client.paginateNotifTimelineSet * make Events store their pushActions properly * insert live notifs directly into the notifTimelineSet in /sync, ordering by origin_server_ts. --- lib/client.js | 109 +++++++++++++++++++++++++++++++++-- lib/models/event-timeline.js | 8 +++ lib/models/event.js | 19 ++++++ lib/models/room.js | 28 ++------- lib/sync.js | 22 +++++++ lib/timeline-window.js | 7 ++- 6 files changed, 164 insertions(+), 29 deletions(-) diff --git a/lib/client.js b/lib/client.js index d4a51938d..b7028a0e0 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 // =========== @@ -1365,16 +1384,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 @@ -1774,6 +1793,88 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { return promise; }; + +/** + * Take an EventTimeline, and backfill results from the notifications API. + * In future, the notifications API should probably be replaced by /messages + * with a custom filter or something - so we don't feel too bad about this being + * cargoculted from paginateEventTimeLine. + * + * @param {module:models/event-timeline~EventTimeline} eventTimeline timeline + * object to be updated + * @param {Object} [opts] + * @param {boolean} [opts.backwards = false] true to fill backwards, + * false to go forwards. Forwards is not implemented yet! + * @param {number} [opts.limit = 30] number of events to request + * + * @return {module:client.Promise} Resolves to a boolean: false if there are no + * events and we reached either end of the timeline; else true. + */ +MatrixClient.prototype.paginateNotifTimeline = function(eventTimeline, opts) { + // TODO: we should implement a backoff (as per scrollback()) to deal more + // nicely with HTTP errors. + opts = opts || {}; + var backwards = opts.backwards || false; + + if (!backwards) { + throw new Error("paginateNotifTimeline can only paginate backwards"); + } + + if (eventTimeline.getRoomId()) { + throw new Error("paginateNotifTimeline should never be called on a " + + "timeline associated with a room"); + } + + var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; + + var token = eventTimeline.getPaginationToken(dir); + + var pendingRequest = eventTimeline._paginationRequests[dir]; + if (pendingRequest) { + // already a request in progress - return the existing promise + return pendingRequest; + } + + var path = "/notifications"; + var params = { + from: token, + limit: ('limit' in opts) ? opts.limit : 30, + only: 'highlight', + }; + + var self = this; + var 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(notification.actions); + matrixEvents[i] = event; + } + + eventTimeline.getEventTimelineSet() + .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; + + return promise; +}; + /** * Peek into a room and receive updates about the room. This only works if the * history visibility for the room is world_readable. diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index b885e0469..40d52580d 100644 --- a/lib/models/event-timeline.js +++ b/lib/models/event-timeline.js @@ -100,6 +100,14 @@ 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. * diff --git a/lib/models/event.js b/lib/models/event.js index 92a9a5398..90e8a39b4 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 = { @@ -294,6 +295,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.js b/lib/models/room.js index 467e26d83..35ed3f1cd 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -149,21 +149,18 @@ function Room(roomId, opts) { this._notificationCounts = {}; - // all our per-room timeline lists. the first one is the unfiltered ones; + // 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(roomId, this, opts)]; reEmit(this, this._timelineSets[0], ["Room.timeline"]); this._fixUpLegacyTimelineFields(); - // any filtered timeline lists we're maintaining for this room + // any filtered timeline sets we're maintaining for this room this._filteredTimelineSets = { // filter_id: timelineSet }; - // a reference to our shared notification timeline list - this._notifTimelineSet = opts.notifTimelineSet; - if (this._opts.pendingEventOrdering == "detached") { this._pendingEventList = []; } @@ -241,14 +238,6 @@ Room.prototype.getTimelineSets = function() { return this._timelineSets; }; -/** - * Return the shared notification timeline set - * @return {EventTimelineSet} notification timeline set - */ -Room.prototype.getNotifTimelineSet = function() { - return this._notifTimelineSet; -}; - /** * Get the timeline which contains the given event from the unfiltered set, if any * @@ -261,7 +250,7 @@ Room.prototype.getTimelineForEvent = function(eventId) { }; /** - * Add a new timeline to this room's unfiltered timeline list + * Add a new timeline to this room's unfiltered timeline set * * @return {module:models/event-timeline~EventTimeline} newly-created timeline */ @@ -563,18 +552,11 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { } } - // add to our timeline lists + // add to our timeline sets for (i = 0; i < this._timelineSets.length; i++) { this._timelineSets[i].addLiveEvent(event, duplicateStrategy); } - // add to notification timeline list, if any - if (this._notifTimelineSet) { - if (event.isNotification()) { - this._notifTimelineSet.addLiveEvent(event, duplicateStrategy); - } - } - // 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 @@ -648,8 +630,6 @@ Room.prototype.addPendingEvent = function(event, txnId) { timelineSet.getLiveTimeline(), false); } } - // notifications are receive-only, so we don't need to worry - // about this._notifTimelineSet. } this.emit("Room.localEchoUpdated", event, this, null, null); diff --git a/lib/sync.js b/lib/sync.js index 2b6385608..623680b53 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -655,6 +655,8 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { } } + this.notifEvents = []; + // Handle invites inviteRooms.forEach(function(inviteObj) { var room = inviteObj.room; @@ -788,6 +790,16 @@ 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 + if (this.notifEvents.length) { + this.notifEvents.sort(function(a, b) { + return a.getTs() - b.getTs(); + }); + this.notifEvents.forEach(function(event) { + client.getNotifTimelineSet().addLiveEvent(event); + }); + } }; /** @@ -976,6 +988,16 @@ 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) { + 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 b022419aa..9c6b28345 100644 --- a/lib/timeline-window.js +++ b/lib/timeline-window.js @@ -254,7 +254,12 @@ TimelineWindow.prototype.paginate = function(direction, size, makeRequest, debuglog("TimelineWindow: starting request"); var self = this; - var prom = this._client.paginateEventTimeline(tl.timeline, { + + var paginateTimeline = tl.timeline.getRoomId() ? + this._client.paginateEventTimeline : + this._client.paginateNotifTimeline; + + var prom = paginateTimeline.call(this._client, tl.timeline, { backwards: direction == EventTimeline.BACKWARDS, limit: size }).finally(function() { From 4d88736d137459be067bd1b3098eb6f8b4ad5ecb Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 8 Sep 2016 14:37:26 +0100 Subject: [PATCH 22/56] add much-needed room.getUnfilteredTimelineSet() helper --- lib/models/room.js | 40 +++++++++++++++++++++++++--------------- spec/unit/room.spec.js | 20 ++++++++++---------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/lib/models/room.js b/lib/models/room.js index 35ed3f1cd..f7379414a 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -152,7 +152,7 @@ function Room(roomId, opts) { // 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(roomId, this, opts)]; - reEmit(this, this._timelineSets[0], ["Room.timeline"]); + reEmit(this, this.getUnfilteredTimelineSet(), ["Room.timeline"]); this._fixUpLegacyTimelineFields(); @@ -191,7 +191,7 @@ Room.prototype.getPendingEvents = function() { * @return {module:models/event-timeline~EventTimeline} live timeline */ Room.prototype.getLiveTimeline = function() { - return this._timelineSets[0].getLiveTimeline(); + return this.getUnfilteredTimelineSet().getLiveTimeline(); }; @@ -223,11 +223,11 @@ 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._timelineSets[0].getLiveTimeline().getEvents(); - this.oldState = this._timelineSets[0].getLiveTimeline() - .getState(EventTimeline.BACKWARDS); - this.currentState = this._timelineSets[0].getLiveTimeline() - .getState(EventTimeline.FORWARDS); + this.timeline = this.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + this.oldState = this.getUnfilteredTimelineSet().getLiveTimeline() + .getState(EventTimeline.BACKWARDS); + this.currentState = this.getUnfilteredTimelineSet().getLiveTimeline() + .getState(EventTimeline.FORWARDS); }; /** @@ -238,6 +238,14 @@ 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 * @@ -246,7 +254,7 @@ Room.prototype.getTimelineSets = function() { * the given event, or null if unknown */ Room.prototype.getTimelineForEvent = function(eventId) { - return this._timelineSets[0].getTimelineForEvent(eventId); + return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId); }; /** @@ -255,7 +263,7 @@ Room.prototype.getTimelineForEvent = function(eventId) { * @return {module:models/event-timeline~EventTimeline} newly-created timeline */ Room.prototype.addTimeline = function() { - return this._timelineSets[0].addTimeline(); + return this.getUnfilteredTimelineSet().addTimeline(); }; /** @@ -265,7 +273,7 @@ Room.prototype.addTimeline = function() { * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown */ Room.prototype.findEventById = function(eventId) { - return this._timelineSets[0].findEventById(eventId); + return this.getUnfilteredTimelineSet().findEventById(eventId); }; /** @@ -382,7 +390,7 @@ Room.prototype.getCanonicalAlias = function() { Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, timeline, paginationToken) { for (var i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[0].addEventsToTimeline( + this._timelineSets[i].addEventsToTimeline( events, toStartOfTimeline, timeline, paginationToken ); @@ -469,7 +477,7 @@ Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { // 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._timelineSets[0].getLiveTimeline(); + var unfilteredLiveTimeline = this.getUnfilteredTimelineSet().getLiveTimeline(); unfilteredLiveTimeline.getEvents().forEach(function(event) { timelineSet.addLiveEvent(event); @@ -608,7 +616,9 @@ Room.prototype.addPendingEvent = function(event, txnId) { // on the unfiltered timelineSet. EventTimeline.setEventMetadata( event, - this._timelineSets[0].getLiveTimeline().getState(EventTimeline.FORWARDS), + this.getUnfilteredTimelineSet() + .getLiveTimeline() + .getState(EventTimeline.FORWARDS), false ); @@ -733,7 +743,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._timelineSets[0].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. @@ -1022,7 +1032,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._timelineSets[0].compareEventOrdering( + var ordering = self.getUnfilteredTimelineSet().compareEventOrdering( existingReceipt.eventId, eventId); if (ordering !== null && ordering >= 0) { return; diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 5784fa0f3..b90afaede 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -479,13 +479,13 @@ describe("Room", function() { it("should handle events in the same timeline", function() { room.addLiveEvents(events); - expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), events[1].getId())) .toBeLessThan(0); - expect(room._timelineSets[0].compareEventOrdering(events[2].getId(), + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[2].getId(), events[1].getId())) .toBeGreaterThan(0); - expect(room._timelineSets[0].compareEventOrdering(events[1].getId(), + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), events[1].getId())) .toEqual(0); }); @@ -498,10 +498,10 @@ describe("Room", function() { room.addEventsToTimeline([events[0]], false, oldTimeline); room.addLiveEvents([events[1]]); - expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), events[1].getId())) .toBeLessThan(0); - expect(room._timelineSets[0].compareEventOrdering(events[1].getId(), + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), events[0].getId())) .toBeGreaterThan(0); }); @@ -512,10 +512,10 @@ describe("Room", function() { room.addEventsToTimeline([events[0]], false, oldTimeline); room.addLiveEvents([events[1]]); - expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), events[1].getId())) .toBe(null); - expect(room._timelineSets[0].compareEventOrdering(events[1].getId(), + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId(), events[0].getId())) .toBe(null); }); @@ -523,11 +523,11 @@ describe("Room", function() { it("should return null for unknown events", function() { room.addLiveEvents(events); - expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), "xxx")) + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), "xxx")) .toBe(null); - expect(room._timelineSets[0].compareEventOrdering("xxx", events[0].getId())) + expect(room.getUnfilteredTimelineSet().compareEventOrdering("xxx", events[0].getId())) .toBe(null); - expect(room._timelineSets[0].compareEventOrdering(events[0].getId(), + expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId(), events[0].getId())) .toBe(0); }); From 13c186dfbe48dab0bfc0fe17932127e8371288dc Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 8 Sep 2016 15:29:53 +0100 Subject: [PATCH 23/56] fix lint --- spec/unit/room.spec.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index b90afaede..0a878b41e 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -523,13 +523,15 @@ describe("Room", function() { it("should return null for unknown events", function() { room.addLiveEvents(events); - 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); + 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); }); }); From 7dfc4a404cc34b32705642045b18a8cf3e5b76cd Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 8 Sep 2016 17:51:14 +0100 Subject: [PATCH 24/56] initial PR fixes --- lib/models/event-timeline-set.js | 15 +-------------- lib/models/room.js | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index abc1d9386..4b9a80e4d 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -70,7 +70,7 @@ function EventTimelineSet(roomId, room, opts) { this._timelines = [this._liveTimeline]; this._eventIdToTimeline = {}; - this._filter = opts.filter; + this._filter = opts.filter || null; } utils.inherits(EventTimelineSet, EventEmitter); @@ -639,16 +639,3 @@ module.exports = EventTimelineSet; * } * }); */ - -/** - * 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. - */ - diff --git a/lib/models/room.js b/lib/models/room.js index f7379414a..621ac8493 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -223,10 +223,10 @@ 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.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); - this.oldState = this.getUnfilteredTimelineSet().getLiveTimeline() + this.timeline = this.getLiveTimeline().getEvents(); + this.oldState = this.getLiveTimeline() .getState(EventTimeline.BACKWARDS); - this.currentState = this.getUnfilteredTimelineSet().getLiveTimeline() + this.currentState = this.getLiveTimeline() .getState(EventTimeline.FORWARDS); }; @@ -477,7 +477,7 @@ Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { // 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.getUnfilteredTimelineSet().getLiveTimeline(); + var unfilteredLiveTimeline = this.getLiveTimeline(); unfilteredLiveTimeline.getEvents().forEach(function(event) { timelineSet.addLiveEvent(event); @@ -1358,3 +1358,15 @@ module.exports = Room; * * @param {EventStatus} oldStatus The previous event status. */ + +/** + * 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. + */ From f959e1a1349273ea04e807134aab9fee8e6a037e Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 8 Sep 2016 22:38:39 +0100 Subject: [PATCH 25/56] incorporate PR feedback --- lib/models/event-timeline-set.js | 10 ++----- lib/models/event-timeline.js | 2 +- lib/models/room-state.js | 3 +- lib/models/room.js | 47 ++++++++++++++++--------------- spec/unit/event-timeline.spec.js | 4 ++- spec/unit/timeline-window.spec.js | 4 ++- 6 files changed, 36 insertions(+), 34 deletions(-) diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index 4b9a80e4d..83339aa22 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -53,14 +53,12 @@ if (DEBUG) { * map from event_id to timeline and index. * * @constructor - * @param {?String} roomId the roomId of this timelineSet's room, if any * @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(roomId, room, opts) { - this.roomId = roomId; +function EventTimelineSet(room, opts) { this.room = room; this._timelineSupport = Boolean(opts.timelineSupport); @@ -475,7 +473,6 @@ EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, var data = { timeline: timeline, liveEvent: !toStartOfTimeline && timeline == this._liveTimeline, - timelineSet: this, }; this.emit("Room.timeline", event, this.room, Boolean(toStartOfTimeline), false, data); @@ -483,7 +480,7 @@ EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, /** * Replaces event with ID oldEventId with one with newEventId, if oldEventId is - * recognised. Otherwise, add to the live timeline. + * 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 @@ -491,7 +488,7 @@ EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, * * @fires module:client~MatrixClient#event:"Room.timeline" */ -EventTimelineSet.prototype.replaceOrAddEvent = function(localEvent, oldEventId, +EventTimelineSet.prototype.handleRemoteEcho = function(localEvent, oldEventId, newEventId) { // XXX: why don't we infer newEventId from localEvent? var existingTimeline = this._eventIdToTimeline[oldEventId]; @@ -529,7 +526,6 @@ EventTimelineSet.prototype.removeEvent = function(eventId) { delete this._eventIdToTimeline[eventId]; var data = { timeline: timeline, - timelineSet: this, }; this.emit("Room.timeline", removed, this.room, undefined, true, data); } diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index 40d52580d..343b3fbed 100644 --- a/lib/models/event-timeline.js +++ b/lib/models/event-timeline.js @@ -30,7 +30,7 @@ var MatrixEvent = require("./event").MatrixEvent; */ function EventTimeline(eventTimelineSet) { this._eventTimelineSet = eventTimelineSet; - this._roomId = eventTimelineSet.roomId; + this._roomId = eventTimelineSet.room ? eventTimelineSet.room.roomId : null; this._events = []; this._baseIndex = 0; this._startState = new RoomState(this._roomId); 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 621ac8493..0a38df71b 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -151,7 +151,7 @@ function Room(roomId, opts) { // 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(roomId, this, opts)]; + this._timelineSets = [new EventTimelineSet(this, opts)]; reEmit(this, this.getUnfilteredTimelineSet(), ["Room.timeline"]); this._fixUpLegacyTimelineFields(); @@ -465,7 +465,7 @@ Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { return this._filteredTimelineSets[filter.filterId]; } var opts = Object.assign({ filter: filter }, this._opts); - var timelineSet = new EventTimelineSet(this.roomId, this, opts); + var timelineSet = new EventTimelineSet(this, opts); reEmit(this, timelineSet, ["Room.timeline"]); this._filteredTimelineSets[filter.filterId] = timelineSet; this._timelineSets.push(timelineSet); @@ -483,8 +483,14 @@ Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { timelineSet.addLiveEvent(event); }); + // find the earliest unfiltered timeline + var timeline = unfilteredLiveTimeline; + while (timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) { + timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); + } + timelineSet.getLiveTimeline().setPaginationToken( - unfilteredLiveTimeline.getPaginationToken(EventTimeline.BACKWARDS), + timeline.getPaginationToken(EventTimeline.BACKWARDS), EventTimeline.BACKWARDS ); @@ -527,25 +533,21 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { if (event.getType() === "m.room.redaction") { var redactId = event.event.redacts; - for (i = 0; i < this._timelineSets.length; i++) { - var timelineSet = this._timelineSets[i]; - // if we know about this event, redact its contents now. - var redactedEvent = timelineSet.findEventById(redactId); - if (redactedEvent) { - redactedEvent.makeRedacted(event); - // FIXME: these should be emitted from EventTimelineSet probably - this.emit("Room.redaction", event, this, timelineSet); + // if we know about this event, redact its contents now. + var redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); + if (redactedEvent) { + redactedEvent.makeRedacted(event); + this.emit("Room.redaction", event, this); - // TODO: we stash user displaynames (among other things) in - // RoomMember objects which are then attached to other events - // (in the sender and target fields). We should get those - // RoomMember objects to update themselves when the events that - // they are based on are changed. - } - - // FIXME: apply redactions to notification list + // TODO: we stash user displaynames (among other things) in + // RoomMember objects which are then attached to other events + // (in the sender and target fields). We should get those + // RoomMember objects to update themselves when the events that + // 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. @@ -687,7 +689,7 @@ Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) { var timelineSet = this._timelineSets[i]; // if it's already in the timeline, update the timeline map. If it's not, add it. - timelineSet.replaceOrAddEvent(localEvent, oldEventId, newEventId); + timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); } this.emit("Room.localEchoUpdated", localEvent, this, @@ -865,11 +867,10 @@ 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 removedAny; + var removedAny = false; for (var i = 0; i < this._timelineSets.length; i++) { var removed = this._timelineSets[i].removeEvent(eventId); if (removed) { diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index 25b748648..f1a8f0109 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -16,7 +16,9 @@ describe("EventTimeline", function() { beforeEach(function() { utils.beforeEach(this); - timeline = new EventTimeline({ roomId: roomId }); + // XXX: this is a horrid hack; should use sinon or something instead + var timelineSet = { room: { roomId: roomId }}; + timeline = new EventTimeline(timelineSet); }); describe("construction", function() { diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index ce87f13c0..f0f101ede 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -18,7 +18,9 @@ function createTimeline(numEvents, baseIndex) { if (numEvents === undefined) { numEvents = 3; } if (baseIndex === undefined) { baseIndex = 1; } - var timeline = new EventTimeline({ roomId: ROOM_ID }); + // XXX: this is a horrid hack + var timelineSet = { room: { roomId: ROOM_ID }}; + var timeline = new EventTimeline(timelineSet); // add the events after the baseIndex first addEventsToTimeline(timeline, numEvents - baseIndex, false); From 2e4c362ccd0349a121091dd65ca6fee3f94125a1 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 02:08:39 +0100 Subject: [PATCH 26/56] make /notification pagination actually work --- lib/client.js | 39 ++++++++++++++++++++++++++++++-- lib/models/event-timeline-set.js | 5 ++-- lib/sync.js | 33 +++++++++++++++++++++------ 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/lib/client.js b/lib/client.js index b7028a0e0..8515c4c77 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1793,6 +1793,38 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { 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 + }); + */ +}; /** * Take an EventTimeline, and backfill results from the notifications API. @@ -1853,11 +1885,14 @@ MatrixClient.prototype.paginateNotifTimeline = function(eventTimeline, opts) { for (var i = 0; i < res.notifications.length; i++) { var notification = res.notifications[i]; var event = self.getEventMapper()(notification.event); - event.setPushActions(notification.actions); + event.setPushActions( + PushProcessor.actionListToActionsObject(notification.actions) + ); + event.event.room_id = notification.room_id; // XXX: gutwrenching matrixEvents[i] = event; } - eventTimeline.getEventTimelineSet() + eventTimeline.getTimelineSet() .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); // if we've hit the end of the timeline, we need to stop trying to diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index 83339aa22..23ce4c715 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -149,11 +149,12 @@ EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) { *

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. */ -EventTimelineSet.prototype.resetLiveTimeline = function(backPaginationToken) { +EventTimelineSet.prototype.resetLiveTimeline = function(backPaginationToken, flush) { var newTimeline; - if (!this._timelineSupport) { + if (!this._timelineSupport || flush) { // if timeline support is disabled, forget about the old timelines newTimeline = new EventTimeline(this); this._timelines = [newTimeline]; diff --git a/lib/sync.js b/lib/sync.js index 623680b53..f66a1133a 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -72,6 +72,7 @@ function SyncApi(client, opts) { this._running = false; this._keepAliveTimer = null; this._connectionReturnedDefer = null; + this._notifEvents = []; // accumulator of sync events in the current sync response } /** @@ -392,6 +393,16 @@ SyncApi.prototype.sync = function() { client.getOrCreateFilter( getFilterName(client.credentials.userId), filter ).done(function(filterId) { + // prepare the notif timeline for pagination before we receive + // sync responses. Technically there are two small races here: + // 1: the reset will execute async and may race with the /sync. + // 2: the reset may return before the sync, and the room may go + // so fast that in the gap before the sync returns we may miss some + // notifs. This is incredibly unlikely however. + // The right solution would be to tie /sync pagination tokens into + // notifications. + client.resetNotifTimelineSet(); + self._sync({ filterId: filterId }); }, function(err) { self._startKeepAlives().done(function() { @@ -655,7 +666,7 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { } } - this.notifEvents = []; + this._notifEvents = []; // Handle invites inviteRooms.forEach(function(inviteObj) { @@ -742,6 +753,12 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { room.currentState.paginationToken = syncToken; self._deregisterStateListeners(room); room.resetLiveTimeline(joinObj.timeline.prev_batch); + + // XXX: for now 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); } } @@ -792,11 +809,11 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { }); // update the notification timeline, if appropriate - if (this.notifEvents.length) { - this.notifEvents.sort(function(a, b) { + if (this._notifEvents.length) { + this._notifEvents.sort(function(a, b) { return a.getTs() - b.getTs(); }); - this.notifEvents.forEach(function(event) { + this._notifEvents.forEach(function(event) { client.getNotifTimelineSet().addLiveEvent(event); }); } @@ -988,12 +1005,14 @@ 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 + // 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) { - this.notifEvents.push(timelineEventList[i]); + if (pushActions && pushActions.notify && + pushActions.tweaks && pushActions.tweaks.highlight) + { + this._notifEvents.push(timelineEventList[i]); } } } From c6d358a6f3faf7a13f3c875eac81fc4e5f6d737d Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 02:27:51 +0100 Subject: [PATCH 27/56] doc Room.timeline event correctly --- lib/models/event-timeline-set.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index 23ce4c715..6b4b5be00 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -615,7 +615,7 @@ 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 whose Room.timeline was updated. + * @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. From 93f45c0a94ec6054c5511185a9d52b3a2f392c5f Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 02:28:01 +0100 Subject: [PATCH 28/56] reemit notif timeline events correctly --- lib/sync.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/sync.js b/lib/sync.js index f66a1133a..df7df05a5 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -73,6 +73,8 @@ function SyncApi(client, opts) { this._keepAliveTimer = null; this._connectionReturnedDefer = null; this._notifEvents = []; // accumulator of sync events in the current sync response + + reEmit(client, client.getNotifTimelineSet(), ["Room.timeline"]); } /** From d480b6cf3efac91f1e4ddbf83b3d456fc31d1b28 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 16:06:10 +0100 Subject: [PATCH 29/56] remove unnecessary getUnfilteredTimelineSet() --- lib/models/room.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/models/room.js b/lib/models/room.js index 0a38df71b..f11cae1aa 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -618,9 +618,7 @@ Room.prototype.addPendingEvent = function(event, txnId) { // on the unfiltered timelineSet. EventTimeline.setEventMetadata( event, - this.getUnfilteredTimelineSet() - .getLiveTimeline() - .getState(EventTimeline.FORWARDS), + this.getLiveTimeline().getState(EventTimeline.FORWARDS), false ); From a9d3ae4ef88df33a3571842b4e68e04bf3cd6df5 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 16:34:02 +0100 Subject: [PATCH 30/56] fix tests --- lib/sync.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/sync.js b/lib/sync.js index df7df05a5..4ee175928 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -74,7 +74,9 @@ function SyncApi(client, opts) { this._connectionReturnedDefer = null; this._notifEvents = []; // accumulator of sync events in the current sync response - reEmit(client, client.getNotifTimelineSet(), ["Room.timeline"]); + if (client.getNotifTimelineSet()) { + reEmit(client, client.getNotifTimelineSet(), ["Room.timeline"]); + } } /** From 75b6ebf2878f11d93abca0040c3fc14a5f7ece8a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 16:35:38 +0100 Subject: [PATCH 31/56] revert comment position --- lib/models/room.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/models/room.js b/lib/models/room.js index f11cae1aa..61c809366 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -1323,6 +1323,18 @@ module.exports = Room; * }); */ +/** + * 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 the status of a transmitted event is updated. * @@ -1357,15 +1369,3 @@ module.exports = Room; * * @param {EventStatus} oldStatus The previous event status. */ - -/** - * 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. - */ From 5a5257a5987e9a9ec981c6f795340396e575f2b5 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 16:41:29 +0100 Subject: [PATCH 32/56] fix comment --- lib/sync.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/sync.js b/lib/sync.js index 4ee175928..f4823c81c 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -397,14 +397,10 @@ SyncApi.prototype.sync = function() { client.getOrCreateFilter( getFilterName(client.credentials.userId), filter ).done(function(filterId) { - // prepare the notif timeline for pagination before we receive - // sync responses. Technically there are two small races here: - // 1: the reset will execute async and may race with the /sync. - // 2: the reset may return before the sync, and the room may go - // so fast that in the gap before the sync returns we may miss some - // notifs. This is incredibly unlikely however. + // 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. + // /notifications API somehow. client.resetNotifTimelineSet(); self._sync({ filterId: filterId }); @@ -758,7 +754,7 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { self._deregisterStateListeners(room); room.resetLiveTimeline(joinObj.timeline.prev_batch); - // XXX: for now we have to assume any gap in any timeline is + // We have to assume any gap in any timeline is // reason to stop incrementally tracking notifications and // reset the timeline. client.resetNotifTimelineSet(); From bd32ed5598bc0e183bbf2065be0a162766ba233b Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 16:49:39 +0100 Subject: [PATCH 33/56] refactr paginateNotifTimeline out of existence --- lib/client.js | 201 +++++++++++++++++------------------------ lib/timeline-window.js | 6 +- 2 files changed, 85 insertions(+), 122 deletions(-) diff --git a/lib/client.js b/lib/client.js index 7baf529e1..a34a6f861 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1683,7 +1683,7 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, 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 * @@ -1691,11 +1691,19 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, 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; + if (isNotifTimeline) { + if (!backwards) { + throw new Error("paginateNotifTimeline can only paginate backwards"); + } + } + var room = this.getRoom(eventTimeline.getRoomId()); if (!room) { throw new Error("Unknown room " + eventTimeline.getRoomId()); @@ -1716,42 +1724,85 @@ 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 - }; + if (isNotifTimeline) { + var path = "/notifications"; + var params = { + from: token, + limit: ('limit' in opts) ? opts.limit : 30, + only: 'highlight', + }; - 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()); + var self = this; + var 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 path = utils.encodeUri( + "/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()} + ); + var params = { + from: token, + limit: ('limit' in opts) ? opts.limit : 30, + dir: dir + }; - 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 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); + 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()); } - return res.end != res.start; - }).finally(function() { - eventTimeline._paginationRequests[dir] = null; - }); - eventTimeline._paginationRequests[dir] = 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 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; }; @@ -1789,90 +1840,6 @@ MatrixClient.prototype.resetNotifTimelineSet = function() { */ }; -/** - * Take an EventTimeline, and backfill results from the notifications API. - * In future, the notifications API should probably be replaced by /messages - * with a custom filter or something - so we don't feel too bad about this being - * cargoculted from paginateEventTimeLine. - * - * @param {module:models/event-timeline~EventTimeline} eventTimeline timeline - * object to be updated - * @param {Object} [opts] - * @param {boolean} [opts.backwards = false] true to fill backwards, - * false to go forwards. Forwards is not implemented yet! - * @param {number} [opts.limit = 30] number of events to request - * - * @return {module:client.Promise} Resolves to a boolean: false if there are no - * events and we reached either end of the timeline; else true. - */ -MatrixClient.prototype.paginateNotifTimeline = function(eventTimeline, opts) { - // TODO: we should implement a backoff (as per scrollback()) to deal more - // nicely with HTTP errors. - opts = opts || {}; - var backwards = opts.backwards || false; - - if (!backwards) { - throw new Error("paginateNotifTimeline can only paginate backwards"); - } - - if (eventTimeline.getRoomId()) { - throw new Error("paginateNotifTimeline should never be called on a " + - "timeline associated with a room"); - } - - var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; - - var token = eventTimeline.getPaginationToken(dir); - - var pendingRequest = eventTimeline._paginationRequests[dir]; - if (pendingRequest) { - // already a request in progress - return the existing promise - return pendingRequest; - } - - var path = "/notifications"; - var params = { - from: token, - limit: ('limit' in opts) ? opts.limit : 30, - only: 'highlight', - }; - - var self = this; - var 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; - - return promise; -}; - /** * Peek into a room and receive updates about the room. This only works if the * history visibility for the room is world_readable. diff --git a/lib/timeline-window.js b/lib/timeline-window.js index 9c6b28345..1889631ae 100644 --- a/lib/timeline-window.js +++ b/lib/timeline-window.js @@ -255,11 +255,7 @@ TimelineWindow.prototype.paginate = function(direction, size, makeRequest, debuglog("TimelineWindow: starting request"); var self = this; - var paginateTimeline = tl.timeline.getRoomId() ? - this._client.paginateEventTimeline : - this._client.paginateNotifTimeline; - - var prom = paginateTimeline.call(this._client, tl.timeline, { + var prom = this._client.paginateEventTimeline(tl.timeline, { backwards: direction == EventTimeline.BACKWARDS, limit: size }).finally(function() { From bd9e3e579442d5fe0b0a15de2f556f48b459603f Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 17:42:24 +0100 Subject: [PATCH 34/56] only call setEventMetadata on unfiltered timelineSets --- lib/models/event-timeline.js | 38 +++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js index 343b3fbed..5c1545286 100644 --- a/lib/models/event-timeline.js +++ b/lib/models/event-timeline.js @@ -234,23 +234,29 @@ EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) EventTimeline.prototype.addEvent = function(event, atStart) { var stateContext = atStart ? this._startState : this._endState; - EventTimeline.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)) { - 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)) { + EventTimeline.setEventMetadata(event, stateContext, atStart); + } } } From ad7db78829747b3ef1771fbd292940c16d3b2b6b Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 18:05:43 +0100 Subject: [PATCH 35/56] only consider rooms when paginating EventTimelines with rooms --- lib/client.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/client.js b/lib/client.js index a34a6f861..80d489229 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1704,11 +1704,6 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { } } - var room = this.getRoom(eventTimeline.getRoomId()); - if (!room) { - throw new Error("Unknown room " + eventTimeline.getRoomId()); - } - var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; var token = eventTimeline.getPaginationToken(dir); @@ -1766,6 +1761,11 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { eventTimeline._paginationRequests[dir] = promise; } else { + var room = this.getRoom(eventTimeline.getRoomId()); + if (!room) { + throw new Error("Unknown room " + eventTimeline.getRoomId()); + } + var path = utils.encodeUri( "/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()} ); From 2c6409a67ac8f419e6d788d84654f3df77ef05e9 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 9 Sep 2016 18:45:15 +0100 Subject: [PATCH 36/56] special case 'end' token --- lib/client.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/client.js b/lib/client.js index 80d489229..c4aee6fe2 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1722,11 +1722,14 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { if (isNotifTimeline) { var path = "/notifications"; var params = { - from: token, limit: ('limit' in opts) ? opts.limit : 30, only: 'highlight', }; + if (token && token !== "end") { + params.token = token; + } + var self = this; var promise = this._http.authedRequestWithPrefix(undefined, "GET", path, params, From b69f6cf70a0c890b6c2a8aa43a60e9b64da64e6d Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 10 Sep 2016 00:52:39 +0100 Subject: [PATCH 37/56] don't double-add events in Room.addEventsToTimeline also, ignore notif events from initialSync as their time ordering is wrong --- lib/client.js | 3 ++- lib/models/room.js | 10 ++++------ lib/sync.js | 8 ++++++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/client.js b/lib/client.js index c4aee6fe2..9fc843730 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1792,7 +1792,8 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { ).then(function(res) { var token = res.end; var matrixEvents = utils.map(res.chunk, self.getEventMapper()); - room.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token); + 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 diff --git a/lib/models/room.js b/lib/models/room.js index 61c809366..1af9cd18a 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -389,12 +389,10 @@ Room.prototype.getCanonicalAlias = function() { */ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, timeline, paginationToken) { - for (var i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].addEventsToTimeline( - events, toStartOfTimeline, - timeline, paginationToken - ); - } + timeline.getTimelineSet().addEventsToTimeline( + events, toStartOfTimeline, + timeline, paginationToken + ); }; /** diff --git a/lib/sync.js b/lib/sync.js index f4823c81c..27b17dc98 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -808,8 +808,12 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { accountDataEvents.forEach(function(e) { client.emit("event", e); }); }); - // update the notification timeline, if appropriate - if (this._notifEvents.length) { + // 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(); }); From 0713e65fc5068cda7a315a5a02b285cdae5a5b7e Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 10 Sep 2016 00:58:16 +0100 Subject: [PATCH 38/56] fix lint --- lib/client.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/client.js b/lib/client.js index 9fc843730..9c992e642 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1719,9 +1719,12 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { return pendingRequest; } + var path, params, promise; + var self = this; + if (isNotifTimeline) { - var path = "/notifications"; - var params = { + path = "/notifications"; + params = { limit: ('limit' in opts) ? opts.limit : 30, only: 'highlight', }; @@ -1730,8 +1733,7 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { params.token = token; } - var self = this; - var promise = + promise = this._http.authedRequestWithPrefix(undefined, "GET", path, params, undefined, httpApi.PREFIX_UNSTABLE ).then(function(res) { @@ -1769,10 +1771,10 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { throw new Error("Unknown room " + eventTimeline.getRoomId()); } - var path = utils.encodeUri( + path = utils.encodeUri( "/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()} ); - var params = { + params = { from: token, limit: ('limit' in opts) ? opts.limit : 30, dir: dir @@ -1785,9 +1787,7 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { params.filter = JSON.stringify(filter.getRoomTimelineFilterComponent()); } - var self = this; - - var promise = + promise = this._http.authedRequest(undefined, "GET", path, params ).then(function(res) { var token = res.end; From b4dc5e620b61c274608d8b85759b98579f3a6903 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 10 Sep 2016 01:36:12 +0100 Subject: [PATCH 39/56] oops, unbreak notif pagination --- lib/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/client.js b/lib/client.js index 9c992e642..287f2bc9d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1730,7 +1730,7 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) { }; if (token && token !== "end") { - params.token = token; + params.from = token; } promise = From e614e17a717a78a1eb4066613ba8a43e305f2ac0 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sat, 10 Sep 2016 10:44:48 +0100 Subject: [PATCH 40/56] correctly notify when timelineSets get reset --- lib/models/event-timeline-set.js | 17 +++++++++++++++++ lib/models/room.js | 19 ++----------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index 6b4b5be00..498b2cab7 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -150,6 +150,8 @@ EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) { * * @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; @@ -180,6 +182,8 @@ EventTimelineSet.prototype.resetLiveTimeline = function(backPaginationToken, flu // stuck without realising that they *can* back-paginate. newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); + this.emit("Room.timelineReset", this.room, this); + this._liveTimeline = newTimeline; }; @@ -636,3 +640,16 @@ module.exports = EventTimelineSet; * } * }); */ + +/** + * 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/room.js b/lib/models/room.js index 1af9cd18a..1c50ad6b7 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -196,13 +196,11 @@ Room.prototype.getLiveTimeline = function() { /** - * 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) { for (var i = 0; i < this._timelineSets.length; i++) { @@ -210,7 +208,6 @@ Room.prototype.resetLiveTimeline = function(backPaginationToken) { } this._fixUpLegacyTimelineFields(); - this.emit("Room.timelineReset", this); }; /** @@ -464,7 +461,7 @@ Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { } var opts = Object.assign({ filter: filter }, this._opts); var timelineSet = new EventTimelineSet(this, opts); - reEmit(this, timelineSet, ["Room.timeline"]); + reEmit(this, timelineSet, ["Room.timeline", "Room.timelineReset"]); this._filteredTimelineSets[filter.filterId] = timelineSet; this._timelineSets.push(timelineSet); @@ -1321,18 +1318,6 @@ module.exports = Room; * }); */ -/** - * 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 the status of a transmitted event is updated. * From 87c6a40b3f7fbaece4b0f665c9b5e6d39b3bea47 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sun, 11 Sep 2016 02:15:29 +0100 Subject: [PATCH 41/56] reemit timelineReset correctly from Sync --- lib/sync.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/sync.js b/lib/sync.js index 27b17dc98..0c2c69d66 100644 --- a/lib/sync.js +++ b/lib/sync.js @@ -75,7 +75,8 @@ function SyncApi(client, opts) { this._notifEvents = []; // accumulator of sync events in the current sync response if (client.getNotifTimelineSet()) { - reEmit(client, client.getNotifTimelineSet(), ["Room.timeline"]); + reEmit(client, client.getNotifTimelineSet(), + ["Room.timeline", "Room.timelineReset"]); } } From eef03882ad074709e075334324292dbf56253e77 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sun, 11 Sep 2016 03:23:15 +0100 Subject: [PATCH 42/56] don't forget to emit timelineResets for normal room resets --- lib/models/room.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/models/room.js b/lib/models/room.js index 1c50ad6b7..61b536d35 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -152,7 +152,8 @@ function Room(roomId, opts) { // 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"]); + reEmit(this, this.getUnfilteredTimelineSet(), + ["Room.timeline", "Room.timelineReset"]); this._fixUpLegacyTimelineFields(); From 85b2e5d758f2888c02bcc17c3d71be08d0d75659 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sun, 11 Sep 2016 03:23:43 +0100 Subject: [PATCH 43/56] fix refactoring bug; emit timelineReset after updating _liveTimeline --- lib/models/event-timeline-set.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/models/event-timeline-set.js b/lib/models/event-timeline-set.js index 498b2cab7..1f4bc4739 100644 --- a/lib/models/event-timeline-set.js +++ b/lib/models/event-timeline-set.js @@ -182,9 +182,8 @@ EventTimelineSet.prototype.resetLiveTimeline = function(backPaginationToken, flu // stuck without realising that they *can* back-paginate. newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); - this.emit("Room.timelineReset", this.room, this); - this._liveTimeline = newTimeline; + this.emit("Room.timelineReset", this.room, this); }; /** From f0274f3f266241a5bb1d73d2ff4c022fdd462d89 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Mon, 12 Sep 2016 11:44:31 +0100 Subject: [PATCH 44/56] Wrap the crypto event handlers in try/catch blocks --- lib/crypto/index.js | 47 ++++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/lib/crypto/index.js b/lib/crypto/index.js index 2b67596fc..f5184c40b 100644 --- a/lib/crypto/index.js +++ b/lib/crypto/index.js @@ -89,32 +89,47 @@ function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId) { 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); }); } From 8a848deddcfb446fe8ac2bb9d3b042eb657a1c1e Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 12 Sep 2016 15:52:10 +0100 Subject: [PATCH 45/56] unbreak mocks in tests --- spec/unit/event-timeline.spec.js | 5 ++++- spec/unit/timeline-window.spec.js | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index f1a8f0109..8cb2f2e48 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -16,8 +16,11 @@ describe("EventTimeline", function() { beforeEach(function() { utils.beforeEach(this); - // XXX: this is a horrid hack; should use sinon or something instead + + // 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); }); diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index f0f101ede..d04f6b585 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -20,6 +20,8 @@ function createTimeline(numEvents, baseIndex) { // 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 From 72a4b92022a1cebbdbd4e912e0fb799c77551fae Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Wed, 14 Sep 2016 19:16:24 +0100 Subject: [PATCH 46/56] Send a 'm.new_device' when we get a message for an unknown group session This should reduce the risk of a device getting permenantly stuck unable to receive encrypted group messages. --- lib/crypto/OlmDevice.js | 10 +++++---- lib/crypto/algorithms/megolm.js | 9 +++++++-- lib/crypto/algorithms/olm.js | 6 ++++-- lib/crypto/index.js | 36 ++++++++++++++++++++++++++++++++- 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/lib/crypto/OlmDevice.js b/lib/crypto/OlmDevice.js index ca9565fe3..5d2fe6620 100644 --- a/lib/crypto/OlmDevice.js +++ b/lib/crypto/OlmDevice.js @@ -539,7 +539,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( @@ -550,7 +552,7 @@ OlmDevice.prototype._getInboundGroupSession = function( ); if (r === null) { - throw new Error("Unknown inbound group session id"); + return {sessionExists: false} } r = JSON.parse(r); @@ -567,7 +569,7 @@ 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)}; } finally { session.free(); } @@ -603,7 +605,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 b10794524..56d7bf226 100644 --- a/lib/crypto/algorithms/megolm.js +++ b/lib/crypto/algorithms/megolm.js @@ -356,7 +356,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 'sessionKey' key. * * @throws {module:crypto/algorithms/base.DecryptionError} if there is a * problem decrypting the event @@ -377,7 +378,11 @@ 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) { + return {result: JSON.parse(res.result), sessionExists: true}; + } else { + return {sessionExists: false}; + } } catch (e) { throw new base.DecryptionError(e); } diff --git a/lib/crypto/algorithms/olm.js b/lib/crypto/algorithms/olm.js index d5fd4fd4e..e75877cdf 100644 --- a/lib/crypto/algorithms/olm.js +++ b/lib/crypto/algorithms/olm.js @@ -142,7 +142,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 +200,7 @@ 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); + return {result: JSON.parse(payloadString), sessionExists: true}; } else { throw new base.DecryptionError("Bad Encrypted Message"); } diff --git a/lib/crypto/index.js b/lib/crypto/index.js index f5184c40b..00284064d 100644 --- a/lib/crypto/index.js +++ b/lib/crypto/index.js @@ -820,7 +820,41 @@ Crypto.prototype.decryptEvent = function(event) { var alg = new AlgClass({ olmDevice: this._olmDevice, }); - return alg.decryptEvent(event); + var r = alg.decryptEvent(event); + if (r.sessionExists) { + return r.result; + } 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 exists so that they might + // tell us about the sender. + if (event.getRoomId !== undefined && event.getSender !== undefined) { + var senderUserId = event.getSender(); + var roomId = event.getRoomId(); + var content = {}; + var senderDeviceId = event.content.device_id; + if (senderDeviceId !== undefined) { + content[senderUserId][senderDeviceId] = { + device_id: this._deviceId, + rooms: [roomId], + }; + } else { + content[senderUserId]["*"] = { + device_id: this._deviceId, + rooms: [roomId], + }; + } + // TODO: Ratelimit the "m.new_device" messages to make sure we don't + // flood the target device with messages if we get lots of encrypted + // messages from them at once. + this._baseApis.sendToDevice( + "m.new_device", // OH HAI! + content + ).done(function() {}); + } + + throw new algorithms.DecryptionError("Unknown inbound session id"); + } }; /** From 5ec8688cf6e8ef831dc6bfec154b50d9331c0420 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Wed, 14 Sep 2016 19:26:44 +0100 Subject: [PATCH 47/56] Semicolon --- lib/crypto/OlmDevice.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crypto/OlmDevice.js b/lib/crypto/OlmDevice.js index 5d2fe6620..01a710580 100644 --- a/lib/crypto/OlmDevice.js +++ b/lib/crypto/OlmDevice.js @@ -552,7 +552,7 @@ OlmDevice.prototype._getInboundGroupSession = function( ); if (r === null) { - return {sessionExists: false} + return {sessionExists: false}; } r = JSON.parse(r); From d02c205910d933698eeb92ced7961002ce430b0d Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 15 Sep 2016 11:46:49 +0100 Subject: [PATCH 48/56] Rename the "content" variable to avoid shadowing --- lib/crypto/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/crypto/index.js b/lib/crypto/index.js index 00284064d..533176157 100644 --- a/lib/crypto/index.js +++ b/lib/crypto/index.js @@ -831,15 +831,15 @@ Crypto.prototype.decryptEvent = function(event) { if (event.getRoomId !== undefined && event.getSender !== undefined) { var senderUserId = event.getSender(); var roomId = event.getRoomId(); - var content = {}; + var pingContent = {}; var senderDeviceId = event.content.device_id; if (senderDeviceId !== undefined) { - content[senderUserId][senderDeviceId] = { + pingContent[senderUserId][senderDeviceId] = { device_id: this._deviceId, rooms: [roomId], }; } else { - content[senderUserId]["*"] = { + pingContent[senderUserId]["*"] = { device_id: this._deviceId, rooms: [roomId], }; @@ -849,7 +849,7 @@ Crypto.prototype.decryptEvent = function(event) { // messages from them at once. this._baseApis.sendToDevice( "m.new_device", // OH HAI! - content + pingContent ).done(function() {}); } From 6f9bb38232bda585a97003b3c864fc138eb19ea0 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 15 Sep 2016 11:56:56 +0100 Subject: [PATCH 49/56] Include our device key in megolm messages --- lib/crypto/algorithms/megolm.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/crypto/algorithms/megolm.js b/lib/crypto/algorithms/megolm.js index 56d7bf226..78bafdffd 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; From 35d99564c195a6a777688b9fa3cbfb505a34413e Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 15 Sep 2016 14:07:40 +0100 Subject: [PATCH 50/56] Rate limit the oh hai pings --- lib/crypto/index.js | 68 ++++++++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/lib/crypto/index.js b/lib/crypto/index.js index 533176157..86c724eb8 100644 --- a/lib/crypto/index.js +++ b/lib/crypto/index.js @@ -85,6 +85,8 @@ function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId) { ); _registerEventHandlers(this, eventEmitter); + + this._lastNewDeviceMessageTsByUserDeviceRoom = {}; } function _registerEventHandlers(crypto, eventEmitter) { @@ -829,34 +831,56 @@ Crypto.prototype.decryptEvent = function(event) { // Remind the sender that we exists so that they might // tell us about the sender. if (event.getRoomId !== undefined && event.getSender !== undefined) { - var senderUserId = event.getSender(); - var roomId = event.getRoomId(); - var pingContent = {}; - var senderDeviceId = event.content.device_id; - if (senderDeviceId !== undefined) { - pingContent[senderUserId][senderDeviceId] = { - device_id: this._deviceId, - rooms: [roomId], - }; - } else { - pingContent[senderUserId]["*"] = { - device_id: this._deviceId, - rooms: [roomId], - }; - } - // TODO: Ratelimit the "m.new_device" messages to make sure we don't - // flood the target device with messages if we get lots of encrypted - // messages from them at once. - this._baseApis.sendToDevice( - "m.new_device", // OH HAI! - pingContent - ).done(function() {}); + 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() {}); + }; +}; + /** * handle an m.room.encryption event * From 355b728a5782b55e6d2f9ad6fc3d0a9c45570398 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 15 Sep 2016 14:23:30 +0100 Subject: [PATCH 51/56] Remove unnecessary semicolon; --- lib/crypto/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crypto/index.js b/lib/crypto/index.js index 86c724eb8..70189d7a6 100644 --- a/lib/crypto/index.js +++ b/lib/crypto/index.js @@ -878,7 +878,7 @@ Crypto.prototype._sendPingToDevice = function(userId, deviceId, roomId) { "m.new_device", // OH HAI! content ).done(function() {}); - }; + } }; /** From 2fbef8638fb2160e75162a77a39aebed6cbaf1fa Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 15 Sep 2016 14:43:23 +0100 Subject: [PATCH 52/56] Fix grammar --- lib/crypto/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crypto/index.js b/lib/crypto/index.js index 70189d7a6..b0a519ff6 100644 --- a/lib/crypto/index.js +++ b/lib/crypto/index.js @@ -828,7 +828,7 @@ Crypto.prototype.decryptEvent = function(event) { } 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 exists so that they might + // Remind the sender that we exist so that they might // tell us about the sender. if (event.getRoomId !== undefined && event.getSender !== undefined) { this._sendPingToDevice( From bde6a171f6425bf71a4315a4ff63b79a8bfd370d Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 15 Sep 2016 16:26:43 +0100 Subject: [PATCH 53/56] Add getKeysProved and getKeysClaimed methods to MatrixEvent. These list the keys that sender of the event must have ownership of and the keys of that the sender claims ownership of. All olm and megolm messages prove ownership of a curve25519 key. All new olm and megolm message will now claim ownership of a ed25519 key. This allows us to detect if an attacker claims ownership of a curve25519 key they don't own when advertising their device keys, because when we receive an event from the original user it will have a different ed25519 key to the attackers. --- lib/crypto/OlmDevice.js | 15 +++++++++++---- lib/crypto/algorithms/megolm.js | 7 ++++--- lib/crypto/algorithms/olm.js | 19 ++++++++++++++++++- lib/crypto/index.js | 5 ++++- lib/models/event.js | 25 ++++++++++++++++++++----- 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/lib/crypto/OlmDevice.js b/lib/crypto/OlmDevice.js index 01a710580..a4d564362 100644 --- a/lib/crypto/OlmDevice.js +++ b/lib/crypto/OlmDevice.js @@ -514,21 +514,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) + senderKey, senderCurve25519Key, sessionId, JSON.stringify(r) ); }; @@ -569,7 +571,12 @@ OlmDevice.prototype._getInboundGroupSession = function( var session = new Olm.InboundGroupSession(); try { session.unpickle(this._pickleKey, r.session); - return {sessionExists: true, result: func(session)}; + return { + sessionExists: true, + result: func(session), + keysProved: {curve25519: senderKey}, + keysClaimed: r.keysClaimed || {}, + }; } finally { session.free(); } diff --git a/lib/crypto/algorithms/megolm.js b/lib/crypto/algorithms/megolm.js index 78bafdffd..d082ecdb3 100644 --- a/lib/crypto/algorithms/megolm.js +++ b/lib/crypto/algorithms/megolm.js @@ -360,7 +360,7 @@ utils.inherits(MegolmDecryption, base.DecryptionAlgorithm); * @param {object} event raw event * * @return {object} object with 'result' key with decrypted payload (with - * properties 'type', 'content') and a 'sessionKey' key. + * properties 'type', 'content') and a 'sessionExists' key. * * @throws {module:crypto/algorithms/base.DecryptionError} if there is a * problem decrypting the event @@ -382,7 +382,8 @@ MegolmDecryption.prototype.decryptEvent = function(event) { event.room_id, content.sender_key, content.session_id, content.ciphertext ); if (res.sessionExists) { - return {result: JSON.parse(res.result), sessionExists: true}; + res.result = JSON.parse(res.result); + return res; } else { return {sessionExists: false}; } @@ -411,7 +412,7 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { this._olmDevice.addInboundGroupSession( content.room_id, event.getSenderKey(), content.session_id, - content.session_key, content.chain_index + content.session_key, content.chain_index, event.getKeysClaimed() ); }; diff --git a/lib/crypto/algorithms/olm.js b/lib/crypto/algorithms/olm.js index e75877cdf..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 + }, } ); }); @@ -200,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 {result: JSON.parse(payloadString), sessionExists: true}; + 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 b0a519ff6..e650308f8 100644 --- a/lib/crypto/index.js +++ b/lib/crypto/index.js @@ -823,8 +823,11 @@ Crypto.prototype.decryptEvent = function(event) { olmDevice: this._olmDevice, }); var r = alg.decryptEvent(event); + var payload = r.result; + payload.keysClaimed = r.keysClaimed; + payload.keysProved = r.keysProved; if (r.sessionExists) { - return r.result; + 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. diff --git a/lib/models/event.js b/lib/models/event.js index 90e8a39b4..859c2b488 100644 --- a/lib/models/event.js +++ b/lib/models/event.js @@ -233,12 +233,27 @@ module.exports.MatrixEvent.prototype = { return Boolean(this._clearEvent.type); }, + /** + * Returns the curve25519 key that sent this event + */ 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() { From 45e9f59fdcdbc0eaacaec9fe98a7b34c018306dd Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 15 Sep 2016 16:40:02 +0100 Subject: [PATCH 54/56] Poke jenkins From 45ed0884df3e44ee5090db694ecc2f5a77297a38 Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 15 Sep 2016 16:42:40 +0100 Subject: [PATCH 55/56] Document return type --- lib/models/event.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/models/event.js b/lib/models/event.js index 859c2b488..83bf207c3 100644 --- a/lib/models/event.js +++ b/lib/models/event.js @@ -234,7 +234,8 @@ module.exports.MatrixEvent.prototype = { }, /** - * Returns the curve25519 key that sent this event + * The curve25519 key that sent this event + * @return {string} */ getSenderKey: function() { return this.getKeysProved().curve25519 || null; From 0d5d74674ef4f41abf66ac410092fbba23d7f49b Mon Sep 17 00:00:00 2001 From: Mark Haines Date: Thu, 15 Sep 2016 16:46:28 +0100 Subject: [PATCH 56/56] Remove spurious senderKey argument --- lib/crypto/OlmDevice.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/crypto/OlmDevice.js b/lib/crypto/OlmDevice.js index a4d564362..55e6c4e07 100644 --- a/lib/crypto/OlmDevice.js +++ b/lib/crypto/OlmDevice.js @@ -530,7 +530,7 @@ OlmDevice.prototype._saveInboundGroupSession = function( }; this._sessionStore.storeEndToEndInboundGroupSession( - senderKey, senderCurve25519Key, sessionId, JSON.stringify(r) + senderCurve25519Key, sessionId, JSON.stringify(r) ); };