diff --git a/lib/client.js b/lib/client.js index 69a50f374..afb4c340d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -146,6 +146,7 @@ function MatrixClient(opts) { this._ongoingScrollbacks = {}; this.timelineSupport = Boolean(opts.timelineSupport); this.urlPreviewCache = {}; + this._notifTimelineSet = null; this._crypto = null; if (CRYPTO_ENABLED && opts.sessionStore !== null && @@ -249,6 +250,24 @@ MatrixClient.prototype.retryImmediately = function() { return this._syncApi.retryImmediately(); }; +/** + * Return the global notification EventTimelineSet, if any + * + * @return {EventTimelineSet} the globl notification EventTimelineSet + */ +MatrixClient.prototype.getNotifTimelineSet = function() { + return this._notifTimelineSet; +}; + +/** + * Set the global notification EventTimelineSet + * + * @param {EventTimelineSet} notifTimelineSet + */ +MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) { + this._notifTimelineSet = notifTimelineSet; +}; + // Crypto bits // =========== @@ -1330,16 +1349,16 @@ function _membershipChange(client, roomId, userId, membership, reason, callback) /** * Obtain a dict of actions which should be performed for this event according - * to the push rules for this user. + * to the push rules for this user. Caches the dict on the event. * @param {MatrixEvent} event The event to get push actions for. * @return {module:pushprocessor~PushAction} A dict of actions to perform. */ MatrixClient.prototype.getPushActionsForEvent = function(event) { - if (event._pushActions === undefined) { + if (!event.getPushActions()) { var pushProcessor = new PushProcessor(this); - event._pushActions = pushProcessor.actionsForEvent(event); + event.setPushActions(pushProcessor.actionsForEvent(event)); } - return event._pushActions; + return event.getPushActions(); }; // Profile operations @@ -1584,18 +1603,18 @@ MatrixClient.prototype.paginateEventContext = function(eventContext, opts) { /** * Get an EventTimeline for the given event * - *
If the room object already has the given event in its store, the + *
If the EventTimelineSet object already has the given event in its store, the
* corresponding timeline will be returned. Otherwise, a /context request is
* made, and used to construct an EventTimeline.
*
- * @param {Room} room The room to look for the event in
+ * @param {EventTimelineSet} timelineSet The timelineSet to look for the event in
* @param {string} eventId The ID of the event to look for
*
* @return {module:client.Promise} Resolves:
* {@link module:models/event-timeline~EventTimeline} including the given
* event
*/
-MatrixClient.prototype.getEventTimeline = function(room, eventId) {
+MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) {
// don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) {
throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
@@ -1603,13 +1622,13 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) {
" it.");
}
- if (room.getTimelineForEvent(eventId)) {
- return q(room.getTimelineForEvent(eventId));
+ if (timelineSet.getTimelineForEvent(eventId)) {
+ return q(timelineSet.getTimelineForEvent(eventId));
}
var path = utils.encodeUri(
"/rooms/$roomId/context/$eventId", {
- $roomId: room.roomId,
+ $roomId: timelineSet.room.roomId,
$eventId: eventId,
}
);
@@ -1626,8 +1645,8 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) {
// by the time the request completes, the event might have ended up in
// the timeline.
- if (room.getTimelineForEvent(eventId)) {
- return room.getTimelineForEvent(eventId);
+ if (timelineSet.getTimelineForEvent(eventId)) {
+ return timelineSet.getTimelineForEvent(eventId);
}
// we start with the last event, since that's the point at which we
@@ -1639,21 +1658,22 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) {
.concat(res.events_before);
var matrixEvents = utils.map(events, self.getEventMapper());
- var timeline = room.getTimelineForEvent(matrixEvents[0].getId());
+ var timeline = timelineSet.getTimelineForEvent(matrixEvents[0].getId());
if (!timeline) {
- timeline = room.addTimeline();
+ timeline = timelineSet.addTimeline();
timeline.initialiseState(utils.map(res.state,
self.getEventMapper()));
timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end;
}
- room.addEventsToTimeline(matrixEvents, true, timeline, res.start);
+ timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start);
// there is no guarantee that the event ended up in "timeline" (we
// might have switched to a neighbouring timeline) - so check the
// room's index again. On the other hand, there's no guarantee the
// event ended up anywhere, if it was later redacted, so we just
// return the timeline we first thought of.
- return room.getTimelineForEvent(eventId) || timeline;
+ var tl = timelineSet.getTimelineForEvent(eventId) || timeline;
+ return tl;
});
return promise;
};
@@ -1665,7 +1685,7 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) {
* @param {module:models/event-timeline~EventTimeline} eventTimeline timeline
* object to be updated
* @param {Object} [opts]
- * @param {boolean} [opts.backwards = false] true to fill backwards,
+ * @param {bool} [opts.backwards = false] true to fill backwards,
* false to go forwards
* @param {number} [opts.limit = 30] number of events to request
*
@@ -1673,14 +1693,17 @@ MatrixClient.prototype.getEventTimeline = function(room, eventId) {
* events and we reached either end of the timeline; else true.
*/
MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) {
+ var isNotifTimeline = (eventTimeline.getTimelineSet() === this._notifTimelineSet);
+
// TODO: we should implement a backoff (as per scrollback()) to deal more
// nicely with HTTP errors.
opts = opts || {};
var backwards = opts.backwards || false;
- var room = this.getRoom(eventTimeline.getRoomId());
- if (!room) {
- throw new Error("Unknown room " + eventTimeline.getRoomId());
+ if (isNotifTimeline) {
+ if (!backwards) {
+ throw new Error("paginateNotifTimeline can only paginate backwards");
+ }
}
var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
@@ -1698,39 +1721,131 @@ MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) {
return pendingRequest;
}
- var path = utils.encodeUri(
- "/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()}
- );
- var params = {
- from: token,
- limit: ('limit' in opts) ? opts.limit : 30,
- dir: dir
- };
-
+ var path, params, promise;
var self = this;
- var promise =
- this._http.authedRequest(undefined, "GET", path, params
- ).then(function(res) {
- var token = res.end;
- var matrixEvents = utils.map(res.chunk, self.getEventMapper());
- room.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
+ if (isNotifTimeline) {
+ path = "/notifications";
+ params = {
+ limit: ('limit' in opts) ? opts.limit : 30,
+ only: 'highlight',
+ };
- // if we've hit the end of the timeline, we need to stop trying to
- // paginate. We need to keep the 'forwards' token though, to make sure
- // we can recover from gappy syncs.
- if (backwards && res.end == res.start) {
- eventTimeline.setPaginationToken(null, dir);
+ if (token && token !== "end") {
+ params.from = token;
}
- return res.end != res.start;
- }).finally(function() {
- eventTimeline._paginationRequests[dir] = null;
- });
- eventTimeline._paginationRequests[dir] = promise;
+
+ promise =
+ this._http.authedRequestWithPrefix(undefined, "GET", path, params,
+ undefined, httpApi.PREFIX_UNSTABLE
+ ).then(function(res) {
+ var token = res.next_token;
+ var matrixEvents = [];
+
+ for (var i = 0; i < res.notifications.length; i++) {
+ var notification = res.notifications[i];
+ var event = self.getEventMapper()(notification.event);
+ event.setPushActions(
+ PushProcessor.actionListToActionsObject(notification.actions)
+ );
+ event.event.room_id = notification.room_id; // XXX: gutwrenching
+ matrixEvents[i] = event;
+ }
+
+ eventTimeline.getTimelineSet()
+ .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && !res.next_token) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return res.next_token ? true : false;
+ }).finally(function() {
+ eventTimeline._paginationRequests[dir] = null;
+ });
+ eventTimeline._paginationRequests[dir] = promise;
+ }
+ else {
+ var room = this.getRoom(eventTimeline.getRoomId());
+ if (!room) {
+ throw new Error("Unknown room " + eventTimeline.getRoomId());
+ }
+
+ path = utils.encodeUri(
+ "/rooms/$roomId/messages", {$roomId: eventTimeline.getRoomId()}
+ );
+ params = {
+ from: token,
+ limit: ('limit' in opts) ? opts.limit : 30,
+ dir: dir
+ };
+
+ var filter = eventTimeline.getFilter();
+ if (filter) {
+ // XXX: it's horrific that /messages' filter parameter doesn't match
+ // /sync's one - see https://matrix.org/jira/browse/SPEC-451
+ params.filter = JSON.stringify(filter.getRoomTimelineFilterComponent());
+ }
+
+ promise =
+ this._http.authedRequest(undefined, "GET", path, params
+ ).then(function(res) {
+ var token = res.end;
+ var matrixEvents = utils.map(res.chunk, self.getEventMapper());
+ eventTimeline.getTimelineSet()
+ .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && res.end == res.start) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return res.end != res.start;
+ }).finally(function() {
+ eventTimeline._paginationRequests[dir] = null;
+ });
+ eventTimeline._paginationRequests[dir] = promise;
+ }
return promise;
};
+/**
+ * Reset the notifTimelineSet entirely, paginating in some historical notifs as
+ * a starting point for subsequent pagination.
+ */
+MatrixClient.prototype.resetNotifTimelineSet = function() {
+ if (!this._notifTimelineSet) {
+ return;
+ }
+
+ // FIXME: This thing is a total hack, and results in duplicate events being
+ // added to the timeline both from /sync and /notifications, and lots of
+ // slow and wasteful processing and pagination. The correct solution is to
+ // extend /messages or /search or something to filter on notifications.
+
+ // use the fictitious token 'end'. in practice we would ideally give it
+ // the oldest backwards pagination token from /sync, but /sync doesn't
+ // know about /notifications, so we have no choice but to start paginating
+ // from the current point in time. This may well overlap with historical
+ // notifs which are then inserted into the timeline by /sync responses.
+ this._notifTimelineSet.resetLiveTimeline('end', true);
+
+ // we could try to paginate a single event at this point in order to get
+ // a more valid pagination token, but it just ends up with an out of order
+ // timeline. given what a mess this is and given we're going to have duplicate
+ // events anyway, just leave it with the dummy token for now.
+ /*
+ this.paginateNotifTimeline(this._notifTimelineSet.getLiveTimeline(), {
+ backwards: true,
+ limit: 1
+ });
+ */
+};
+
/**
* Peek into a room and receive updates about the room. This only works if the
* history visibility for the room is world_readable.
@@ -2242,6 +2357,54 @@ MatrixClient.prototype.getFilter = function(userId, filterId, allowCached) {
});
};
+/**
+ * @param {string} filterName
+ * @param {Filter} filter
+ * @return {Promise This is an ordered sequence of timelines, which may or may not
+ * be continuous. Each timeline lists a series of events, as well as tracking
+ * the room state at the start and the end of the timeline (if appropriate).
+ * It also tracks forward and backward pagination tokens, as well as containing
+ * links to the next timeline in the sequence.
+ *
+ * There is one special timeline - the 'live' timeline, which represents the
+ * timeline to which events are being added in real-time as they are received
+ * from the /sync API. Note that you should not retain references to this
+ * timeline - even if it is the current timeline right now, it may not remain
+ * so if the server gives us a timeline gap in /sync.
+ *
+ * In order that we can find events from their ids later, we also maintain a
+ * map from event_id to timeline and index.
+ *
+ * @constructor
+ * @param {?Room} room the optional room for this timelineSet
+ * @param {Object} opts hash of options inherited from Room.
+ * opts.timelineSupport gives whether timeline support is enabled
+ * opts.filter is the filter object, if any, for this timelineSet.
+ */
+function EventTimelineSet(room, opts) {
+ this.room = room;
+
+ this._timelineSupport = Boolean(opts.timelineSupport);
+ this._liveTimeline = new EventTimeline(this);
+
+ // just a list - *not* ordered.
+ this._timelines = [this._liveTimeline];
+ this._eventIdToTimeline = {};
+
+ this._filter = opts.filter || null;
+}
+utils.inherits(EventTimelineSet, EventEmitter);
+
+/**
+ * Get the filter object this timeline set is filtered on, if any
+ * @return {?Filter} the optional filter for this timelineSet
+ */
+EventTimelineSet.prototype.getFilter = function() {
+ return this._filter;
+};
+
+/**
+ * Set the filter object this timeline set is filtered on
+ * (passed to the server when paginating via /messages).
+ * @param {Filter} filter the filter for this timelineSet
+ */
+EventTimelineSet.prototype.setFilter = function(filter) {
+ this._filter = filter;
+};
+
+/**
+ * Get the list of pending sent events for this timelineSet's room, filtered
+ * by the timelineSet's filter if appropriate.
+ *
+ * @return {module:models/event.MatrixEvent[]} A list of the sent events
+ * waiting for remote echo.
+ *
+ * @throws If This is used when /sync returns a 'limited' timeline.
+ *
+ * @param {string=} backPaginationToken token for back-paginating the new timeline
+ * @param {?bool} flush Whether to flush the non-live timelines too.
+ *
+ * @fires module:client~MatrixClient#event:"Room.timelineReset"
+ */
+EventTimelineSet.prototype.resetLiveTimeline = function(backPaginationToken, flush) {
+ var newTimeline;
+
+ if (!this._timelineSupport || flush) {
+ // if timeline support is disabled, forget about the old timelines
+ newTimeline = new EventTimeline(this);
+ this._timelines = [newTimeline];
+ this._eventIdToTimeline = {};
+ } else {
+ newTimeline = this.addTimeline();
+ }
+
+ // initialise the state in the new timeline from our last known state
+ var evMap = this._liveTimeline.getState(EventTimeline.FORWARDS).events;
+ var events = [];
+ for (var evtype in evMap) {
+ if (!evMap.hasOwnProperty(evtype)) { continue; }
+ for (var stateKey in evMap[evtype]) {
+ if (!evMap[evtype].hasOwnProperty(stateKey)) { continue; }
+ events.push(evMap[evtype][stateKey]);
+ }
+ }
+ newTimeline.initialiseState(events);
+
+ // make sure we set the pagination token before firing timelineReset,
+ // otherwise clients which start back-paginating will fail, and then get
+ // stuck without realising that they *can* back-paginate.
+ newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS);
+
+ this._liveTimeline = newTimeline;
+ this.emit("Room.timelineReset", this.room, this);
+};
+
+/**
+ * Get the timeline which contains the given event, if any
+ *
+ * @param {string} eventId event ID to look for
+ * @return {?module:models/event-timeline~EventTimeline} timeline containing
+ * the given event, or null if unknown
+ */
+EventTimelineSet.prototype.getTimelineForEvent = function(eventId) {
+ var res = this._eventIdToTimeline[eventId];
+ return (res === undefined) ? null : res;
+};
+
+/**
+ * Get an event which is stored in our timelines
+ *
+ * @param {string} eventId event ID to look for
+ * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown
+ */
+EventTimelineSet.prototype.findEventById = function(eventId) {
+ var tl = this.getTimelineForEvent(eventId);
+ if (!tl) {
+ return undefined;
+ }
+ return utils.findElement(tl.getEvents(),
+ function(ev) { return ev.getId() == eventId; });
+};
+
+/**
+ * Add a new timeline to this timeline list
+ *
+ * @return {module:models/event-timeline~EventTimeline} newly-created timeline
+ */
+EventTimelineSet.prototype.addTimeline = function() {
+ if (!this._timelineSupport) {
+ throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
+ " parameter to true when creating MatrixClient to enable" +
+ " it.");
+ }
+
+ var timeline = new EventTimeline(this);
+ this._timelines.push(timeline);
+ return timeline;
+};
+
+
+/**
+ * Add events to a timeline
+ *
+ * Will fire "Room.timeline" for each event added.
+ *
+ * @param {MatrixEvent[]} events A list of events to add.
+ *
+ * @param {boolean} toStartOfTimeline True to add these events to the start
+ * (oldest) instead of the end (newest) of the timeline. If true, the oldest
+ * event will be the last element of 'events'.
+ *
+ * @param {module:models/event-timeline~EventTimeline} timeline timeline to
+ * add events to.
+ *
+ * @param {string=} paginationToken token for the next batch of events
+ *
+ * @fires module:client~MatrixClient#event:"Room.timeline"
+ *
+ */
+EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
+ timeline, paginationToken) {
+ if (!timeline) {
+ throw new Error(
+ "'timeline' not specified for EventTimelineSet.addEventsToTimeline"
+ );
+ }
+
+ if (!toStartOfTimeline && timeline == this._liveTimeline) {
+ throw new Error(
+ "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " +
+ "the live timeline - use Room.addLiveEvents instead"
+ );
+ }
+
+ if (this._filter) {
+ events = this._filter.filterRoomTimeline(events);
+ if (!events.length) {
+ return;
+ }
+ }
+
+ var direction = toStartOfTimeline ? EventTimeline.BACKWARDS :
+ EventTimeline.FORWARDS;
+ var inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS :
+ EventTimeline.BACKWARDS;
+
+ // Adding events to timelines can be quite complicated. The following
+ // illustrates some of the corner-cases.
+ //
+ // Let's say we start by knowing about four timelines. timeline3 and
+ // timeline4 are neighbours:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M] [P] [S] <------> [T]
+ //
+ // Now we paginate timeline1, and get the following events from the server:
+ // [M, N, P, R, S, T, U].
+ //
+ // 1. First, we ignore event M, since we already know about it.
+ //
+ // 2. Next, we append N to timeline 1.
+ //
+ // 3. Next, we don't add event P, since we already know about it,
+ // but we do link together the timelines. We now have:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P] [S] <------> [T]
+ //
+ // 4. Now we add event R to timeline2:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P, R] [S] <------> [T]
+ //
+ // Note that we have switched the timeline we are working on from
+ // timeline1 to timeline2.
+ //
+ // 5. We ignore event S, but again join the timelines:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P, R] <---> [S] <------> [T]
+ //
+ // 6. We ignore event T, and the timelines are already joined, so there
+ // is nothing to do.
+ //
+ // 7. Finally, we add event U to timeline4:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P, R] <---> [S] <------> [T, U]
+ //
+ // The important thing to note in the above is what happened when we
+ // already knew about a given event:
+ //
+ // - if it was appropriate, we joined up the timelines (steps 3, 5).
+ // - in any case, we started adding further events to the timeline which
+ // contained the event we knew about (steps 3, 5, 6).
+ //
+ //
+ // So much for adding events to the timeline. But what do we want to do
+ // with the pagination token?
+ //
+ // In the case above, we will be given a pagination token which tells us how to
+ // get events beyond 'U' - in this case, it makes sense to store this
+ // against timeline4. But what if timeline4 already had 'U' and beyond? in
+ // that case, our best bet is to throw away the pagination token we were
+ // given and stick with whatever token timeline4 had previously. In short,
+ // we want to only store the pagination token if the last event we receive
+ // is one we didn't previously know about.
+ //
+ // We make an exception for this if it turns out that we already knew about
+ // *all* of the events, and we weren't able to join up any timelines. When
+ // that happens, it means our existing pagination token is faulty, since it
+ // is only telling us what we already know. Rather than repeatedly
+ // paginating with the same token, we might as well use the new pagination
+ // token in the hope that we eventually work our way out of the mess.
+
+ var didUpdate = false;
+ var lastEventWasNew = false;
+ for (var i = 0; i < events.length; i++) {
+ var event = events[i];
+ var eventId = event.getId();
+
+ var existingTimeline = this._eventIdToTimeline[eventId];
+
+ if (!existingTimeline) {
+ // we don't know about this event yet. Just add it to the timeline.
+ this.addEventToTimeline(event, timeline, toStartOfTimeline);
+ lastEventWasNew = true;
+ didUpdate = true;
+ continue;
+ }
+
+ lastEventWasNew = false;
+
+ if (existingTimeline == timeline) {
+ debuglog("Event " + eventId + " already in timeline " + timeline);
+ continue;
+ }
+
+ var neighbour = timeline.getNeighbouringTimeline(direction);
+ if (neighbour) {
+ // this timeline already has a neighbour in the relevant direction;
+ // let's assume the timelines are already correctly linked up, and
+ // skip over to it.
+ //
+ // there's probably some edge-case here where we end up with an
+ // event which is in a timeline a way down the chain, and there is
+ // a break in the chain somewhere. But I can't really imagine how
+ // that would happen, so I'm going to ignore it for now.
+ //
+ if (existingTimeline == neighbour) {
+ debuglog("Event " + eventId + " in neighbouring timeline - " +
+ "switching to " + existingTimeline);
+ } else {
+ debuglog("Event " + eventId + " already in a different " +
+ "timeline " + existingTimeline);
+ }
+ timeline = existingTimeline;
+ continue;
+ }
+
+ // time to join the timelines.
+ console.info("Already have timeline for " + eventId +
+ " - joining timeline " + timeline + " to " +
+ existingTimeline);
+ timeline.setNeighbouringTimeline(existingTimeline, direction);
+ existingTimeline.setNeighbouringTimeline(timeline, inverseDirection);
+ timeline = existingTimeline;
+ didUpdate = true;
+ }
+
+ // see above - if the last event was new to us, or if we didn't find any
+ // new information, we update the pagination token for whatever
+ // timeline we ended up on.
+ if (lastEventWasNew || !didUpdate) {
+ timeline.setPaginationToken(paginationToken, direction);
+ }
+};
+
+/**
+ * Add an event to the end of this live timeline.
+ *
+ * @param {MatrixEvent} event Event to be added
+ * @param {string?} duplicateStrategy 'ignore' or 'replace'
+ */
+EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) {
+ if (this._filter) {
+ var events = this._filter.filterRoomTimeline([event]);
+ if (!events.length) {
+ return;
+ }
+ }
+
+ var timeline = this._eventIdToTimeline[event.getId()];
+ if (timeline) {
+ if (duplicateStrategy === "replace") {
+ debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " +
+ event.getId());
+ var tlEvents = timeline.getEvents();
+ for (var j = 0; j < tlEvents.length; j++) {
+ if (tlEvents[j].getId() === event.getId()) {
+ // still need to set the right metadata on this event
+ EventTimeline.setEventMetadata(
+ event,
+ timeline.getState(EventTimeline.FORWARDS),
+ false
+ );
+
+ if (!tlEvents[j].encryptedType) {
+ tlEvents[j] = event;
+ }
+
+ // XXX: we need to fire an event when this happens.
+ break;
+ }
+ }
+ } else {
+ debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " +
+ event.getId());
+ }
+ return;
+ }
+
+ this.addEventToTimeline(event, this._liveTimeline, false);
+};
+
+/**
+ * Add event to the given timeline, and emit Room.timeline. Assumes
+ * we have already checked we don't know about this event.
+ *
+ * Will fire "Room.timeline" for each event added.
+ *
+ * @param {MatrixEvent} event
+ * @param {EventTimeline} timeline
+ * @param {boolean} toStartOfTimeline
+ *
+ * @fires module:client~MatrixClient#event:"Room.timeline"
+ */
+EventTimelineSet.prototype.addEventToTimeline = function(event, timeline,
+ toStartOfTimeline) {
+ var eventId = event.getId();
+ timeline.addEvent(event, toStartOfTimeline);
+ this._eventIdToTimeline[eventId] = timeline;
+
+ var data = {
+ timeline: timeline,
+ liveEvent: !toStartOfTimeline && timeline == this._liveTimeline,
+ };
+ this.emit("Room.timeline", event, this.room,
+ Boolean(toStartOfTimeline), false, data);
+};
+
+/**
+ * Replaces event with ID oldEventId with one with newEventId, if oldEventId is
+ * recognised. Otherwise, add to the live timeline. Used to handle remote echos.
+ *
+ * @param {MatrixEvent} localEvent the new event to be added to the timeline
+ * @param {String} oldEventId the ID of the original event
+ * @param {boolean} newEventId the ID of the replacement event
+ *
+ * @fires module:client~MatrixClient#event:"Room.timeline"
+ */
+EventTimelineSet.prototype.handleRemoteEcho = function(localEvent, oldEventId,
+ newEventId) {
+ // XXX: why don't we infer newEventId from localEvent?
+ var existingTimeline = this._eventIdToTimeline[oldEventId];
+ if (existingTimeline) {
+ delete this._eventIdToTimeline[oldEventId];
+ this._eventIdToTimeline[newEventId] = existingTimeline;
+ } else {
+ if (this._filter) {
+ if (this._filter.filterRoomTimeline([localEvent]).length) {
+ this.addEventToTimeline(localEvent, this._liveTimeline, false);
+ }
+ }
+ else {
+ this.addEventToTimeline(localEvent, this._liveTimeline, false);
+ }
+ }
+};
+
+/**
+ * Removes a single event from this room.
+ *
+ * @param {String} eventId The id of the event to remove
+ *
+ * @return {?MatrixEvent} the removed event, or null if the event was not found
+ * in this room.
+ */
+EventTimelineSet.prototype.removeEvent = function(eventId) {
+ var timeline = this._eventIdToTimeline[eventId];
+ if (!timeline) {
+ return null;
+ }
+
+ var removed = timeline.removeEvent(eventId);
+ if (removed) {
+ delete this._eventIdToTimeline[eventId];
+ var data = {
+ timeline: timeline,
+ };
+ this.emit("Room.timeline", removed, this.room, undefined, true, data);
+ }
+ return removed;
+};
+
+/**
+ * Determine where two events appear in the timeline relative to one another
+ *
+ * @param {string} eventId1 The id of the first event
+ * @param {string} eventId2 The id of the second event
+
+ * @return {?number} a number less than zero if eventId1 precedes eventId2, and
+ * greater than zero if eventId1 succeeds eventId2. zero if they are the
+ * same event; null if we can't tell (either because we don't know about one
+ * of the events, or because they are in separate timelines which don't join
+ * up).
+ */
+EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) {
+ if (eventId1 == eventId2) {
+ // optimise this case
+ return 0;
+ }
+
+ var timeline1 = this._eventIdToTimeline[eventId1];
+ var timeline2 = this._eventIdToTimeline[eventId2];
+
+ if (timeline1 === undefined) {
+ return null;
+ }
+ if (timeline2 === undefined) {
+ return null;
+ }
+
+ if (timeline1 === timeline2) {
+ // both events are in the same timeline - figure out their
+ // relative indices
+ var idx1, idx2;
+ var events = timeline1.getEvents();
+ for (var idx = 0; idx < events.length &&
+ (idx1 === undefined || idx2 === undefined); idx++) {
+ var evId = events[idx].getId();
+ if (evId == eventId1) {
+ idx1 = idx;
+ }
+ if (evId == eventId2) {
+ idx2 = idx;
+ }
+ }
+ return idx1 - idx2;
+ }
+
+ // the events are in different timelines. Iterate through the
+ // linkedlist to see which comes first.
+
+ // first work forwards from timeline1
+ var tl = timeline1;
+ while (tl) {
+ if (tl === timeline2) {
+ // timeline1 is before timeline2
+ return -1;
+ }
+ tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS);
+ }
+
+ // now try backwards from timeline1
+ tl = timeline1;
+ while (tl) {
+ if (tl === timeline2) {
+ // timeline2 is before timeline1
+ return 1;
+ }
+ tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS);
+ }
+
+ // the timelines are not contiguous.
+ return null;
+};
+
+/**
+ * The EventTimelineSet class.
+ */
+module.exports = EventTimelineSet;
+
+/**
+ * Fires whenever the timeline in a room is updated.
+ * @event module:client~MatrixClient#"Room.timeline"
+ * @param {MatrixEvent} event The matrix event which caused this event to fire.
+ * @param {?Room} room The room, if any, whose timeline was updated.
+ * @param {boolean} toStartOfTimeline True if this event was added to the start
+ * @param {boolean} removed True if this event has just been removed from the timeline
+ * (beginning; oldest) of the timeline e.g. due to pagination.
+ *
+ * @param {object} data more data about the event
+ *
+ * @param {module:event-timeline.EventTimeline} data.timeline the timeline the
+ * event was added to/removed from
+ *
+ * @param {boolean} data.liveEvent true if the event was a real-time event
+ * added to the end of the live timeline
+ *
+ * @example
+ * matrixClient.on("Room.timeline",
+ * function(event, room, toStartOfTimeline, removed, data) {
+ * if (!toStartOfTimeline && data.liveEvent) {
+ * var messageToAppend = room.timeline.[room.timeline.length - 1];
+ * }
+ * });
+ */
+
+/**
+ * Fires whenever the live timeline in a room is reset.
+ *
+ * When we get a 'limited' sync (for example, after a network outage), we reset
+ * the live timeline to be empty before adding the recent events to the new
+ * timeline. This event is fired after the timeline is reset, and before the
+ * new events are added.
+ *
+ * @event module:client~MatrixClient#"Room.timelineReset"
+ * @param {Room} room The room whose live timeline was reset, if any
+ * @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset
+ */
diff --git a/lib/models/event-timeline.js b/lib/models/event-timeline.js
index 4123849ac..5c1545286 100644
--- a/lib/models/event-timeline.js
+++ b/lib/models/event-timeline.js
@@ -25,16 +25,17 @@ var MatrixEvent = require("./event").MatrixEvent;
* Once a timeline joins up with its neighbour, they are linked together into a
* doubly-linked list.
*
- * @param {string} roomId the ID of the room where this timeline came from
+ * @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of
* @constructor
*/
-function EventTimeline(roomId) {
- this._roomId = roomId;
+function EventTimeline(eventTimelineSet) {
+ this._eventTimelineSet = eventTimelineSet;
+ this._roomId = eventTimelineSet.room ? eventTimelineSet.room.roomId : null;
this._events = [];
this._baseIndex = 0;
- this._startState = new RoomState(roomId);
+ this._startState = new RoomState(this._roomId);
this._startState.paginationToken = null;
- this._endState = new RoomState(roomId);
+ this._endState = new RoomState(this._roomId);
this._endState.paginationToken = null;
this._prevTimeline = null;
@@ -43,7 +44,7 @@ function EventTimeline(roomId) {
// this is used by client.js
this._paginationRequests = {'b': null, 'f': null};
- this._name = roomId + ":" + new Date().toISOString();
+ this._name = this._roomId + ":" + new Date().toISOString();
}
/**
@@ -91,6 +92,22 @@ EventTimeline.prototype.getRoomId = function() {
return this._roomId;
};
+/**
+ * Get the filter for this timeline's timelineSet (if any)
+ * @return {Filter} filter
+ */
+EventTimeline.prototype.getFilter = function() {
+ return this._eventTimelineSet.getFilter();
+};
+
+/**
+ * Get the timelineSet for this timeline
+ * @return {EventTimelineSet} timelineSet
+ */
+EventTimeline.prototype.getTimelineSet = function() {
+ return this._eventTimelineSet;
+};
+
/**
* Get the base index.
*
@@ -217,23 +234,29 @@ EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction)
EventTimeline.prototype.addEvent = function(event, atStart) {
var stateContext = atStart ? this._startState : this._endState;
- setEventMetadata(event, stateContext, atStart);
+ // only call setEventMetadata on the unfiltered timelineSets
+ var timelineSet = this.getTimelineSet();
+ if (timelineSet.room &&
+ timelineSet.room.getUnfilteredTimelineSet() === timelineSet)
+ {
+ EventTimeline.setEventMetadata(event, stateContext, atStart);
- // modify state
- if (event.isState()) {
- stateContext.setStateEvents([event]);
- // it is possible that the act of setting the state event means we
- // can set more metadata (specifically sender/target props), so try
- // it again if the prop wasn't previously set. It may also mean that
- // the sender/target is updated (if the event set was a room member event)
- // so we want to use the *updated* member (new avatar/name) instead.
- //
- // However, we do NOT want to do this on member events if we're going
- // back in time, else we'll set the .sender value for BEFORE the given
- // member event, whereas we want to set the .sender value for the ACTUAL
- // member event itself.
- if (!event.sender || (event.getType() === "m.room.member" && !atStart)) {
- setEventMetadata(event, stateContext, atStart);
+ // modify state
+ if (event.isState()) {
+ stateContext.setStateEvents([event]);
+ // it is possible that the act of setting the state event means we
+ // can set more metadata (specifically sender/target props), so try
+ // it again if the prop wasn't previously set. It may also mean that
+ // the sender/target is updated (if the event set was a room member event)
+ // so we want to use the *updated* member (new avatar/name) instead.
+ //
+ // However, we do NOT want to do this on member events if we're going
+ // back in time, else we'll set the .sender value for BEFORE the given
+ // member event, whereas we want to set the .sender value for the ACTUAL
+ // member event itself.
+ if (!event.sender || (event.getType() === "m.room.member" && !atStart)) {
+ EventTimeline.setEventMetadata(event, stateContext, atStart);
+ }
}
}
@@ -251,7 +274,14 @@ EventTimeline.prototype.addEvent = function(event, atStart) {
}
};
-function setEventMetadata(event, stateContext, toStartOfTimeline) {
+/**
+ * Static helper method to set sender and target properties
+ *
+ * @param {MatrixEvent} event the event whose metadata is to be set
+ * @param {RoomState} stateContext the room state to be queried
+ * @param {bool} toStartOfTimeline if true the event's forwardLooking flag is set false
+ */
+EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline) {
// set sender and target properties
event.sender = stateContext.getSentinelMember(
event.getSender()
@@ -270,7 +300,7 @@ function setEventMetadata(event, stateContext, toStartOfTimeline) {
event.forwardLooking = false;
}
}
-}
+};
/**
* Remove an event from the timeline
diff --git a/lib/models/event.js b/lib/models/event.js
index 92a9a5398..83bf207c3 100644
--- a/lib/models/event.js
+++ b/lib/models/event.js
@@ -76,6 +76,7 @@ module.exports.MatrixEvent = function MatrixEvent(event, clearEvent) {
this.forwardLooking = true;
this._clearEvent = clearEvent || {};
+ this._pushActions = null;
};
module.exports.MatrixEvent.prototype = {
@@ -232,12 +233,28 @@ module.exports.MatrixEvent.prototype = {
return Boolean(this._clearEvent.type);
},
+ /**
+ * The curve25519 key that sent this event
+ * @return {string}
+ */
getSenderKey: function() {
- if (!this.isEncrypted()) {
- return null;
- }
- var c = this.getWireContent();
- return c.sender_key;
+ return this.getKeysProved().curve25519 || null;
+ },
+
+ /**
+ * The keys that must have been owned by the sender of this encrypted event.
+ * @return {object}
+ */
+ getKeysProved: function() {
+ return this._clearEvent.keysProved || {};
+ },
+
+ /**
+ * The additional keys the sender of this encrypted event claims to possess
+ * @return {object}
+ */
+ getKeysClaimed: function() {
+ return this._clearEvent.keysClaimed || {};
},
getUnsigned: function() {
@@ -294,6 +311,24 @@ module.exports.MatrixEvent.prototype = {
isRedacted: function() {
return Boolean(this.getUnsigned().redacted_because);
},
+
+ /**
+ * Get the push actions, if known, for this event
+ *
+ * @return {?Object} push actions
+ */
+ getPushActions: function() {
+ return this._pushActions;
+ },
+
+ /**
+ * Set the push actions for this event.
+ *
+ * @param {Object} pushActions push actions
+ */
+ setPushActions: function(pushActions) {
+ this._pushActions = pushActions;
+ },
};
diff --git a/lib/models/room-state.js b/lib/models/room-state.js
index 1e22b0335..32b3a924e 100644
--- a/lib/models/room-state.js
+++ b/lib/models/room-state.js
@@ -25,7 +25,8 @@ var RoomMember = require("./room-member");
/**
* Construct room state.
* @constructor
- * @param {string} roomId Required. The ID of the room which has this state.
+ * @param {?string} roomId Optional. The ID of the room which has this state.
+ * If none is specified it just tracks paginationTokens, useful for notifTimelineSet
* @prop {Object. This is used when /sync returns a 'limited' timeline.
*
* @param {string=} backPaginationToken token for back-paginating the new timeline
- *
- * @fires module:client~MatrixClient#event:"Room.timelineReset"
*/
Room.prototype.resetLiveTimeline = function(backPaginationToken) {
- var newTimeline;
-
- if (!this._timelineSupport) {
- // if timeline support is disabled, forget about the old timelines
- newTimeline = new EventTimeline(this.roomId);
- this._timelines = [newTimeline];
- this._eventIdToTimeline = {};
- } else {
- newTimeline = this.addTimeline();
+ for (var i = 0; i < this._timelineSets.length; i++) {
+ this._timelineSets[i].resetLiveTimeline(backPaginationToken);
}
- // initialise the state in the new timeline from our last known state
- var evMap = this._liveTimeline.getState(EventTimeline.FORWARDS).events;
- var events = [];
- for (var evtype in evMap) {
- if (!evMap.hasOwnProperty(evtype)) { continue; }
- for (var stateKey in evMap[evtype]) {
- if (!evMap[evtype].hasOwnProperty(stateKey)) { continue; }
- events.push(evMap[evtype][stateKey]);
- }
- }
- newTimeline.initialiseState(events);
-
- // make sure we set the pagination token before firing timelineReset,
- // otherwise clients which start back-paginating will fail, and then get
- // stuck without realising that they *can* back-paginate.
- newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS);
-
- this._liveTimeline = newTimeline;
this._fixUpLegacyTimelineFields();
- this.emit("Room.timelineReset", this);
};
/**
@@ -254,39 +221,59 @@ Room.prototype._fixUpLegacyTimelineFields = function() {
// and this.oldState and this.currentState as references to the
// state at the start and end of that timeline. These are more
// for backwards-compatibility than anything else.
- this.timeline = this._liveTimeline.getEvents();
- this.oldState = this._liveTimeline.getState(EventTimeline.BACKWARDS);
- this.currentState = this._liveTimeline.getState(EventTimeline.FORWARDS);
+ this.timeline = this.getLiveTimeline().getEvents();
+ this.oldState = this.getLiveTimeline()
+ .getState(EventTimeline.BACKWARDS);
+ this.currentState = this.getLiveTimeline()
+ .getState(EventTimeline.FORWARDS);
};
/**
- * Get the timeline which contains the given event, if any
+ * Return the timeline sets for this room.
+ * @return {EventTimelineSet[]} array of timeline sets for this room
+ */
+Room.prototype.getTimelineSets = function() {
+ return this._timelineSets;
+};
+
+/**
+ * Helper to return the main unfiltered timeline set for this room
+ * @return {EventTimelineSet} room's unfiltered timeline set
+ */
+Room.prototype.getUnfilteredTimelineSet = function() {
+ return this._timelineSets[0];
+};
+
+/**
+ * Get the timeline which contains the given event from the unfiltered set, if any
*
* @param {string} eventId event ID to look for
* @return {?module:models/event-timeline~EventTimeline} timeline containing
* the given event, or null if unknown
*/
Room.prototype.getTimelineForEvent = function(eventId) {
- var res = this._eventIdToTimeline[eventId];
- return (res === undefined) ? null : res;
+ return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId);
};
/**
- * Get an event which is stored in our timelines
+ * Add a new timeline to this room's unfiltered timeline set
+ *
+ * @return {module:models/event-timeline~EventTimeline} newly-created timeline
+ */
+Room.prototype.addTimeline = function() {
+ return this.getUnfilteredTimelineSet().addTimeline();
+};
+
+/**
+ * Get an event which is stored in our unfiltered timeline set
*
* @param {string} eventId event ID to look for
* @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown
*/
Room.prototype.findEventById = function(eventId) {
- var tl = this.getTimelineForEvent(eventId);
- if (!tl) {
- return undefined;
- }
- return utils.findElement(tl.getEvents(),
- function(ev) { return ev.getId() == eventId; });
+ return this.getUnfilteredTimelineSet().findEventById(eventId);
};
-
/**
* Get one of the notification counts for this room
* @param {String} type The type of notification count to get. default: 'total'
@@ -379,6 +366,33 @@ Room.prototype.getCanonicalAlias = function() {
return null;
};
+/**
+ * Add events to a timeline
+ *
+ * Will fire "Room.timeline" for each event added.
+ *
+ * @param {MatrixEvent[]} events A list of events to add.
+ *
+ * @param {boolean} toStartOfTimeline True to add these events to the start
+ * (oldest) instead of the end (newest) of the timeline. If true, the oldest
+ * event will be the last element of 'events'.
+ *
+ * @param {module:models/event-timeline~EventTimeline} timeline timeline to
+ * add events to.
+ *
+ * @param {string=} paginationToken token for the next batch of events
+ *
+ * @fires module:client~MatrixClient#event:"Room.timeline"
+ *
+ */
+Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
+ timeline, paginationToken) {
+ timeline.getTimelineSet().addEventsToTimeline(
+ events, toStartOfTimeline,
+ timeline, paginationToken
+ );
+};
+
/**
* Get a member from the current room state.
* @param {string} userId The user ID of the member.
@@ -438,224 +452,72 @@ Room.prototype.getCanonicalAlias = function() {
};
/**
- * Add a new timeline to this room
- *
- * @return {module:models/event-timeline~EventTimeline} newly-created timeline
+ * Add a timelineSet for this room with the given filter
+ * @param {Filter} filter The filter to be applied to this timelineSet
+ * @return {EventTimelineSet} The timelineSet
*/
-Room.prototype.addTimeline = function() {
- if (!this._timelineSupport) {
- throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
- " parameter to true when creating MatrixClient to enable" +
- " it.");
+Room.prototype.getOrCreateFilteredTimelineSet = function(filter) {
+ if (this._filteredTimelineSets[filter.filterId]) {
+ return this._filteredTimelineSets[filter.filterId];
+ }
+ var opts = Object.assign({ filter: filter }, this._opts);
+ var timelineSet = new EventTimelineSet(this, opts);
+ reEmit(this, timelineSet, ["Room.timeline", "Room.timelineReset"]);
+ this._filteredTimelineSets[filter.filterId] = timelineSet;
+ this._timelineSets.push(timelineSet);
+
+ // populate up the new timelineSet with filtered events from our live
+ // unfiltered timeline.
+ //
+ // XXX: This is risky as our timeline
+ // may have grown huge and so take a long time to filter.
+ // see https://github.com/vector-im/vector-web/issues/2109
+
+ var unfilteredLiveTimeline = this.getLiveTimeline();
+
+ unfilteredLiveTimeline.getEvents().forEach(function(event) {
+ timelineSet.addLiveEvent(event);
+ });
+
+ // find the earliest unfiltered timeline
+ var timeline = unfilteredLiveTimeline;
+ while (timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) {
+ timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS);
}
- var timeline = new EventTimeline(this.roomId);
- this._timelines.push(timeline);
- return timeline;
+ timelineSet.getLiveTimeline().setPaginationToken(
+ timeline.getPaginationToken(EventTimeline.BACKWARDS),
+ EventTimeline.BACKWARDS
+ );
+
+ // alternatively, we could try to do something like this to try and re-paginate
+ // in the filtered events from nothing, but Mark says it's an abuse of the API
+ // to do so:
+ //
+ // timelineSet.resetLiveTimeline(
+ // unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS)
+ // );
+
+ return timelineSet;
};
-
/**
- * Add events to a timeline
- *
- * Will fire "Room.timeline" for each event added.
- *
- * @param {MatrixEvent[]} events A list of events to add.
- *
- * @param {boolean} toStartOfTimeline True to add these events to the start
- * (oldest) instead of the end (newest) of the timeline. If true, the oldest
- * event will be the last element of 'events'.
- *
- * @param {module:models/event-timeline~EventTimeline} timeline timeline to
- * add events to.
- *
- * @param {string=} paginationToken token for the next batch of events
- *
- * @fires module:client~MatrixClient#event:"Room.timeline"
+ * Forget the timelineSet for this room with the given filter
*
+ * @param {Filter} filter the filter whose timelineSet is to be forgotten
*/
-Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
- timeline, paginationToken) {
- if (!timeline) {
- throw new Error(
- "'timeline' not specified for Room.addEventsToTimeline"
- );
- }
-
- if (!toStartOfTimeline && timeline == this._liveTimeline) {
- throw new Error(
- "Room.addEventsToTimeline cannot be used for adding events to " +
- "the live timeline - use Room.addLiveEvents instead"
- );
- }
-
- var direction = toStartOfTimeline ? EventTimeline.BACKWARDS :
- EventTimeline.FORWARDS;
- var inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS :
- EventTimeline.BACKWARDS;
-
- // Adding events to timelines can be quite complicated. The following
- // illustrates some of the corner-cases.
- //
- // Let's say we start by knowing about four timelines. timeline3 and
- // timeline4 are neighbours:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M] [P] [S] <------> [T]
- //
- // Now we paginate timeline1, and get the following events from the server:
- // [M, N, P, R, S, T, U].
- //
- // 1. First, we ignore event M, since we already know about it.
- //
- // 2. Next, we append N to timeline 1.
- //
- // 3. Next, we don't add event P, since we already know about it,
- // but we do link together the timelines. We now have:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M, N] <---> [P] [S] <------> [T]
- //
- // 4. Now we add event R to timeline2:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M, N] <---> [P, R] [S] <------> [T]
- //
- // Note that we have switched the timeline we are working on from
- // timeline1 to timeline2.
- //
- // 5. We ignore event S, but again join the timelines:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M, N] <---> [P, R] <---> [S] <------> [T]
- //
- // 6. We ignore event T, and the timelines are already joined, so there
- // is nothing to do.
- //
- // 7. Finally, we add event U to timeline4:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M, N] <---> [P, R] <---> [S] <------> [T, U]
- //
- // The important thing to note in the above is what happened when we
- // already knew about a given event:
- //
- // - if it was appropriate, we joined up the timelines (steps 3, 5).
- // - in any case, we started adding further events to the timeline which
- // contained the event we knew about (steps 3, 5, 6).
- //
- //
- // So much for adding events to the timeline. But what do we want to do
- // with the pagination token?
- //
- // In the case above, we will be given a pagination token which tells us how to
- // get events beyond 'U' - in this case, it makes sense to store this
- // against timeline4. But what if timeline4 already had 'U' and beyond? in
- // that case, our best bet is to throw away the pagination token we were
- // given and stick with whatever token timeline4 had previously. In short,
- // we want to only store the pagination token if the last event we receive
- // is one we didn't previously know about.
- //
- // We make an exception for this if it turns out that we already knew about
- // *all* of the events, and we weren't able to join up any timelines. When
- // that happens, it means our existing pagination token is faulty, since it
- // is only telling us what we already know. Rather than repeatedly
- // paginating with the same token, we might as well use the new pagination
- // token in the hope that we eventually work our way out of the mess.
-
- var didUpdate = false;
- var lastEventWasNew = false;
- for (var i = 0; i < events.length; i++) {
- var event = events[i];
- var eventId = event.getId();
-
- var existingTimeline = this._eventIdToTimeline[eventId];
-
- if (!existingTimeline) {
- // we don't know about this event yet. Just add it to the timeline.
- this._addEventToTimeline(event, timeline, toStartOfTimeline);
- lastEventWasNew = true;
- didUpdate = true;
- continue;
- }
-
- lastEventWasNew = false;
-
- if (existingTimeline == timeline) {
- debuglog("Event " + eventId + " already in timeline " + timeline);
- continue;
- }
-
- var neighbour = timeline.getNeighbouringTimeline(direction);
- if (neighbour) {
- // this timeline already has a neighbour in the relevant direction;
- // let's assume the timelines are already correctly linked up, and
- // skip over to it.
- //
- // there's probably some edge-case here where we end up with an
- // event which is in a timeline a way down the chain, and there is
- // a break in the chain somewhere. But I can't really imagine how
- // that would happen, so I'm going to ignore it for now.
- //
- if (existingTimeline == neighbour) {
- debuglog("Event " + eventId + " in neighbouring timeline - " +
- "switching to " + existingTimeline);
- } else {
- debuglog("Event " + eventId + " already in a different " +
- "timeline " + existingTimeline);
- }
- timeline = existingTimeline;
- continue;
- }
-
- // time to join the timelines.
- console.info("Already have timeline for " + eventId +
- " - joining timeline " + timeline + " to " +
- existingTimeline);
- timeline.setNeighbouringTimeline(existingTimeline, direction);
- existingTimeline.setNeighbouringTimeline(timeline, inverseDirection);
- timeline = existingTimeline;
- didUpdate = true;
- }
-
- // see above - if the last event was new to us, or if we didn't find any
- // new information, we update the pagination token for whatever
- // timeline we ended up on.
- if (lastEventWasNew || !didUpdate) {
- timeline.setPaginationToken(paginationToken, direction);
+Room.prototype.removeFilteredTimelineSet = function(filter) {
+ var timelineSet = this._filteredTimelineSets[filter.filterId];
+ delete this._filteredTimelineSets[filter.filterId];
+ var i = this._timelineSets.indexOf(timelineSet);
+ if (i > -1) {
+ this._timelineSets.splice(i, 1);
}
};
/**
- * Add event to the given timeline, and emit Room.timeline. Assumes
- * we have already checked we don't know about this event.
- *
- * Will fire "Room.timeline" for each event added.
- *
- * @param {MatrixEvent} event
- * @param {EventTimeline} timeline
- * @param {boolean} toStartOfTimeline
- *
- * @fires module:client~MatrixClient#event:"Room.timeline"
- *
- * @private
- */
-Room.prototype._addEventToTimeline = function(event, timeline, toStartOfTimeline) {
- var eventId = event.getId();
- timeline.addEvent(event, toStartOfTimeline);
- this._eventIdToTimeline[eventId] = timeline;
-
- var data = {
- timeline: timeline,
- liveEvent: !toStartOfTimeline && timeline == this._liveTimeline,
- };
- this.emit("Room.timeline", event, this, Boolean(toStartOfTimeline), false, data);
-};
-
-
-/**
- * Add an event to the end of this room's live timeline. Will fire
- * "Room.timeline"..
+ * Add an event to the end of this room's live timelines. Will fire
+ * "Room.timeline".
*
* @param {MatrixEvent} event Event to be added
* @param {string?} duplicateStrategy 'ignore' or 'replace'
@@ -663,11 +525,12 @@ Room.prototype._addEventToTimeline = function(event, timeline, toStartOfTimeline
* @private
*/
Room.prototype._addLiveEvent = function(event, duplicateStrategy) {
+ var i;
if (event.getType() === "m.room.redaction") {
var redactId = event.event.redacts;
// if we know about this event, redact its contents now.
- var redactedEvent = this.findEventById(redactId);
+ var redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId);
if (redactedEvent) {
redactedEvent.makeRedacted(event);
this.emit("Room.redaction", event, this);
@@ -679,6 +542,8 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) {
// they are based on are changed.
}
+ // FIXME: apply redactions to notification list
+
// NB: We continue to add the redaction event to the timeline so
// clients can say "so and so redacted an event" if they wish to. Also
// this may be needed to trigger an update.
@@ -693,39 +558,11 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) {
}
}
- var timeline = this._eventIdToTimeline[event.getId()];
- if (timeline) {
- if (duplicateStrategy === "replace") {
- debuglog("Room._addLiveEvent: replacing duplicate event " +
- event.getId());
- var tlEvents = timeline.getEvents();
- for (var j = 0; j < tlEvents.length; j++) {
- if (tlEvents[j].getId() === event.getId()) {
- // still need to set the right metadata on this event
- setEventMetadata(
- event,
- timeline.getState(EventTimeline.FORWARDS),
- false
- );
-
- if (!tlEvents[j].encryptedType) {
- tlEvents[j] = event;
- }
-
- // XXX: we need to fire an event when this happens.
- break;
- }
- }
- } else {
- debuglog("Room._addLiveEvent: ignoring duplicate event " +
- event.getId());
- }
- return;
+ // add to our timeline sets
+ for (i = 0; i < this._timelineSets.length; i++) {
+ this._timelineSets[i].addLiveEvent(event, duplicateStrategy);
}
- // TODO: pass through filter to see if this should be added to the timeline.
- this._addEventToTimeline(event, this._liveTimeline, false);
-
// synthesize and inject implicit read receipts
// Done after adding the event because otherwise the app would get a read receipt
// pointing to an event that wasn't yet in the timeline
@@ -773,9 +610,11 @@ Room.prototype.addPendingEvent = function(event, txnId) {
}
// call setEventMetadata to set up event.sender etc
- setEventMetadata(
+ // as event is shared over all timelineSets, we set up its metadata based
+ // on the unfiltered timelineSet.
+ EventTimeline.setEventMetadata(
event,
- this._liveTimeline.getState(EventTimeline.FORWARDS),
+ this.getLiveTimeline().getState(EventTimeline.FORWARDS),
false
);
@@ -784,7 +623,19 @@ Room.prototype.addPendingEvent = function(event, txnId) {
if (this._opts.pendingEventOrdering == "detached") {
this._pendingEventList.push(event);
} else {
- this._addEventToTimeline(event, this._liveTimeline, false);
+ for (var i = 0; i < this._timelineSets.length; i++) {
+ var timelineSet = this._timelineSets[i];
+ if (timelineSet.getFilter()) {
+ if (this._filter.filterRoomTimeline([event]).length) {
+ timelineSet.addEventToTimeline(event,
+ timelineSet.getLiveTimeline(), false);
+ }
+ }
+ else {
+ timelineSet.addEventToTimeline(event,
+ timelineSet.getLiveTimeline(), false);
+ }
+ }
}
this.emit("Room.localEchoUpdated", event, this, null, null);
@@ -828,13 +679,11 @@ Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) {
// successfully sent.
localEvent.status = null;
- // if it's already in the timeline, update the timeline map. If it's not, add it.
- var existingTimeline = this._eventIdToTimeline[oldEventId];
- if (existingTimeline) {
- delete this._eventIdToTimeline[oldEventId];
- this._eventIdToTimeline[newEventId] = existingTimeline;
- } else {
- this._addEventToTimeline(localEvent, this._liveTimeline, false);
+ for (var i = 0; i < this._timelineSets.length; i++) {
+ var timelineSet = this._timelineSets[i];
+
+ // if it's already in the timeline, update the timeline map. If it's not, add it.
+ timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
}
this.emit("Room.localEchoUpdated", localEvent, this,
@@ -890,7 +739,7 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) {
// SENT races against /sync, so we have to special-case it.
if (newStatus == EventStatus.SENT) {
- var timeline = this._eventIdToTimeline[newEventId];
+ var timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId);
if (timeline) {
// we've already received the event via the event stream.
// nothing more to do here.
@@ -921,10 +770,8 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) {
// if the event was already in the timeline (which will be the case if
// opts.pendingEventOrdering==chronological), we need to update the
// timeline map.
- var existingTimeline = this._eventIdToTimeline[oldEventId];
- if (existingTimeline) {
- delete this._eventIdToTimeline[oldEventId];
- this._eventIdToTimeline[newEventId] = existingTimeline;
+ for (var i = 0; i < this._timelineSets.length; i++) {
+ this._timelineSets[i].replaceEventId(oldEventId, newEventId);
}
}
else if (newStatus == EventStatus.CANCELLED) {
@@ -960,24 +807,29 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) {
* @throws If opts.pendingEventOrdering was not 'detached'
+ */
+EventTimelineSet.prototype.getPendingEvents = function() {
+ if (!this.room) {
+ return [];
+ }
+
+ if (this._filter) {
+ return this._filter.filterRoomTimeline(this.room.getPendingEvents());
+ }
+ else {
+ return this.room.getPendingEvents();
+ }
+};
+
+/**
+ * Get the live timeline for this room.
+ *
+ * @return {module:models/event-timeline~EventTimeline} live timeline
+ */
+EventTimelineSet.prototype.getLiveTimeline = function() {
+ return this._liveTimeline;
+};
+
+/**
+ * Return the timeline (if any) this event is in.
+ * @param {String} eventId the eventId being sought
+ * @return {module:models/event-timeline~EventTimeline} timeline
+ */
+EventTimelineSet.prototype.eventIdToTimeline = function(eventId) {
+ return this._eventIdToTimeline[eventId];
+};
+
+/**
+ * Track a new event as if it were in the same timeline as an old event,
+ * replacing it.
+ * @param {String} oldEventId event ID of the original event
+ * @param {String} newEventId event ID of the replacement event
+ */
+EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) {
+ var existingTimeline = this._eventIdToTimeline[oldEventId];
+ if (existingTimeline) {
+ delete this._eventIdToTimeline[oldEventId];
+ this._eventIdToTimeline[newEventId] = existingTimeline;
+ }
+};
+
+/**
+ * Reset the live timeline, and start a new one.
+ *
+ * duplicateStrategy is not falsey, 'replace' or 'ignore'.
*/
Room.prototype.addLiveEvents = function(events, duplicateStrategy) {
+ var i;
if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
}
// sanity check that the live timeline is still live
- if (this._liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) {
- throw new Error(
- "live timeline is no longer live - it has a pagination token (" +
- this._liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")"
- );
- }
- if (this._liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) {
- throw new Error(
- "live timeline is no longer live - it has a neighbouring timeline"
- );
+ for (i = 0; i < this._timelineSets.length; i++) {
+ var liveTimeline = this._timelineSets[i].getLiveTimeline();
+ if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) {
+ throw new Error(
+ "live timeline " + i + " is no longer live - it has a pagination token " +
+ "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")"
+ );
+ }
+ if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) {
+ throw new Error(
+ "live timeline " + i + " is no longer live - " +
+ "it has a neighbouring timeline"
+ );
+ }
}
- for (var i = 0; i < events.length; i++) {
+ for (i = 0; i < events.length; i++) {
if (events[i].getType() === "m.typing") {
this.currentState.setTypingEvent(events[i]);
}
@@ -1009,98 +861,19 @@ Room.prototype.removeEvents = function(event_ids) {
*
* @param {String} eventId The id of the event to remove
*
- * @return {?MatrixEvent} the removed event, or null if the event was not found
- * in this room.
+ * @return {bool} true if the event was removed from any of the room's timeline sets
*/
Room.prototype.removeEvent = function(eventId) {
- var timeline = this._eventIdToTimeline[eventId];
- if (!timeline) {
- return null;
+ var removedAny = false;
+ for (var i = 0; i < this._timelineSets.length; i++) {
+ var removed = this._timelineSets[i].removeEvent(eventId);
+ if (removed) {
+ removedAny = true;
+ }
}
-
- var removed = timeline.removeEvent(eventId);
- if (removed) {
- delete this._eventIdToTimeline[eventId];
- var data = {
- timeline: timeline,
- };
- this.emit("Room.timeline", removed, this, undefined, true, data);
- }
- return removed;
+ return removedAny;
};
-/**
- * Determine where two events appear in the timeline relative to one another
- *
- * @param {string} eventId1 The id of the first event
- * @param {string} eventId2 The id of the second event
-
- * @return {?number} a number less than zero if eventId1 precedes eventId2, and
- * greater than zero if eventId1 succeeds eventId2. zero if they are the
- * same event; null if we can't tell (either because we don't know about one
- * of the events, or because they are in separate timelines which don't join
- * up).
- */
-Room.prototype.compareEventOrdering = function(eventId1, eventId2) {
- if (eventId1 == eventId2) {
- // optimise this case
- return 0;
- }
-
- var timeline1 = this._eventIdToTimeline[eventId1];
- var timeline2 = this._eventIdToTimeline[eventId2];
-
- if (timeline1 === undefined) {
- return null;
- }
- if (timeline2 === undefined) {
- return null;
- }
-
- if (timeline1 === timeline2) {
- // both events are in the same timeline - figure out their
- // relative indices
- var idx1, idx2;
- var events = timeline1.getEvents();
- for (var idx = 0; idx < events.length &&
- (idx1 === undefined || idx2 === undefined); idx++) {
- var evId = events[idx].getId();
- if (evId == eventId1) {
- idx1 = idx;
- }
- if (evId == eventId2) {
- idx2 = idx;
- }
- }
- return idx1 - idx2;
- }
-
- // the events are in different timelines. Iterate through the
- // linkedlist to see which comes first.
-
- // first work forwards from timeline1
- var tl = timeline1;
- while (tl) {
- if (tl === timeline2) {
- // timeline1 is before timeline2
- return -1;
- }
- tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS);
- }
-
- // now try backwards from timeline1
- tl = timeline1;
- while (tl) {
- if (tl === timeline2) {
- // timeline2 is before timeline1
- return 1;
- }
- tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS);
- }
-
- // the timelines are not contiguous.
- return null;
-};
/**
* Recalculate various aspects of the room, including the room name and
@@ -1221,7 +994,7 @@ Room.prototype.addReceipt = function(event, fake) {
// as there's nothing that would read it.
}
this._addReceiptsToStructure(event, this._receipts);
- this._receiptCacheByEventId = this._buildReciptCache(this._receipts);
+ this._receiptCacheByEventId = this._buildReceiptCache(this._receipts);
// send events after we've regenerated the cache, otherwise things that
// listened for the event would read from a stale cache
@@ -1254,7 +1027,7 @@ Room.prototype._addReceiptsToStructure = function(event, receipts) {
// than the one we already have. (This is managed
// server-side, but because we synthesize RRs locally we
// have to do it here too.)
- var ordering = self.compareEventOrdering(
+ var ordering = self.getUnfilteredTimelineSet().compareEventOrdering(
existingReceipt.eventId, eventId);
if (ordering !== null && ordering >= 0) {
return;
@@ -1275,7 +1048,7 @@ Room.prototype._addReceiptsToStructure = function(event, receipts) {
* @param {Object} receipts A map of receipts
* @return {Object} Map of receipts by event ID
*/
-Room.prototype._buildReciptCache = function(receipts) {
+Room.prototype._buildReceiptCache = function(receipts) {
var receiptCacheByEventId = {};
utils.keys(receipts).forEach(function(receiptType) {
utils.keys(receipts[receiptType]).forEach(function(userId) {
@@ -1350,27 +1123,6 @@ Room.prototype.getAccountData = function(type) {
return this.accountData[type];
};
-function setEventMetadata(event, stateContext, toStartOfTimeline) {
- // set sender and target properties
- event.sender = stateContext.getSentinelMember(
- event.getSender()
- );
- if (event.getType() === "m.room.member") {
- event.target = stateContext.getSentinelMember(
- event.getStateKey()
- );
- }
- if (event.isState()) {
- // room state has no concept of 'old' or 'current', but we want the
- // room state to regress back to previous values if toStartOfTimeline
- // is set, which means inspecting prev_content if it exists. This
- // is done by toggling the forwardLooking flag.
- if (toStartOfTimeline) {
- event.forwardLooking = false;
- }
- }
-}
-
/**
* This is an internal method. Calculates the name of the room from the current
* room state.
@@ -1486,49 +1238,30 @@ function calculateRoomName(room, userId, ignoreRoomNameEvent) {
}
}
+// FIXME: copypasted from sync.js
+function reEmit(reEmitEntity, emittableEntity, eventNames) {
+ utils.forEach(eventNames, function(eventName) {
+ // setup a listener on the entity (the Room, User, etc) for this event
+ emittableEntity.on(eventName, function() {
+ // take the args from the listener and reuse them, adding the
+ // event name to the arg list so it works with .emit()
+ // Transformation Example:
+ // listener on "foo" => function(a,b) { ... }
+ // Re-emit on "thing" => thing.emit("foo", a, b)
+ var newArgs = [eventName];
+ for (var i = 0; i < arguments.length; i++) {
+ newArgs.push(arguments[i]);
+ }
+ reEmitEntity.emit.apply(reEmitEntity, newArgs);
+ });
+ });
+}
+
/**
* The Room class.
*/
module.exports = Room;
-/**
- * Fires whenever the timeline in a room is updated.
- * @event module:client~MatrixClient#"Room.timeline"
- * @param {MatrixEvent} event The matrix event which caused this event to fire.
- * @param {Room} room The room whose Room.timeline was updated.
- * @param {boolean} toStartOfTimeline True if this event was added to the start
- * @param {boolean} removed True if this event has just been removed from the timeline
- * (beginning; oldest) of the timeline e.g. due to pagination.
- *
- * @param {object} data more data about the event
- *
- * @param {module:event-timeline.EventTimeline} data.timeline the timeline the
- * event was added to/removed from
- *
- * @param {boolean} data.liveEvent true if the event was a real-time event
- * added to the end of the live timeline
- *
- * @example
- * matrixClient.on("Room.timeline",
- * function(event, room, toStartOfTimeline, removed, data) {
- * if (!toStartOfTimeline && data.liveEvent) {
- * var messageToAppend = room.timeline.[room.timeline.length - 1];
- * }
- * });
- */
-
-/**
- * Fires whenever the live timeline in a room is reset.
- *
- * When we get a 'limited' sync (for example, after a network outage), we reset
- * the live timeline to be empty before adding the recent events to the new
- * timeline. This event is fired after the timeline is reset, and before the
- * new events are added.
- *
- * @event module:client~MatrixClient#"Room.timelineReset"
- * @param {Room} room The room whose live timeline was reset.
- */
-
/**
* Fires when an event we had previously received is redacted.
*
diff --git a/lib/sync.js b/lib/sync.js
index 83cde1d16..f1581cd32 100644
--- a/lib/sync.js
+++ b/lib/sync.js
@@ -72,6 +72,12 @@ function SyncApi(client, opts) {
this._running = false;
this._keepAliveTimer = null;
this._connectionReturnedDefer = null;
+ this._notifEvents = []; // accumulator of sync events in the current sync response
+
+ if (client.getNotifTimelineSet()) {
+ reEmit(client, client.getNotifTimelineSet(),
+ ["Room.timeline", "Room.timelineReset"]);
+ }
}
/**
@@ -148,7 +154,7 @@ SyncApi.prototype.syncLeftRooms = function() {
timeout: 0 // don't want to block since this is a single isolated req
};
- return this._getOrCreateFilter(
+ return client.getOrCreateFilter(
getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter
).then(function(filterId) {
qps.filter = filterId;
@@ -389,9 +395,15 @@ SyncApi.prototype.sync = function() {
var filter = new Filter(client.credentials.userId);
filter.setTimelineLimit(self.opts.initialSyncLimit);
- self._getOrCreateFilter(
+ client.getOrCreateFilter(
getFilterName(client.credentials.userId), filter
).done(function(filterId) {
+ // reset the notifications timeline to prepare it to paginate from
+ // the current point in time.
+ // The right solution would be to tie /sync pagination tokens into
+ // /notifications API somehow.
+ client.resetNotifTimelineSet();
+
self._sync({ filterId: filterId });
}, function(err) {
self._startKeepAlives().done(function() {
@@ -666,6 +678,8 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) {
}
}
+ this._notifEvents = [];
+
// Handle invites
inviteRooms.forEach(function(inviteObj) {
var room = inviteObj.room;
@@ -751,6 +765,12 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) {
room.currentState.paginationToken = syncToken;
self._deregisterStateListeners(room);
room.resetLiveTimeline(joinObj.timeline.prev_batch);
+
+ // We have to assume any gap in any timeline is
+ // reason to stop incrementally tracking notifications and
+ // reset the timeline.
+ client.resetNotifTimelineSet();
+
self._registerStateListeners(room);
}
}
@@ -799,6 +819,20 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) {
timelineEvents.forEach(function(e) { client.emit("event", e); });
accountDataEvents.forEach(function(e) { client.emit("event", e); });
});
+
+ // update the notification timeline, if appropriate.
+ // we only do this for live events, as otherwise we can't order them sanely
+ // in the timeline relative to ones paginated in by /notifications.
+ // XXX: we could fix this by making EventTimeline support chronological
+ // ordering... but it doesn't, right now.
+ if (syncToken && this._notifEvents.length) {
+ this._notifEvents.sort(function(a, b) {
+ return a.getTs() - b.getTs();
+ });
+ this._notifEvents.forEach(function(event) {
+ client.getNotifTimelineSet().addLiveEvent(event);
+ });
+ }
};
/**
@@ -862,53 +896,6 @@ SyncApi.prototype._pokeKeepAlive = function() {
});
};
-/**
- * @param {string} filterName
- * @param {Filter} filter
- * @return {Promise