diff --git a/CHANGELOG.md b/CHANGELOG.md index 9443e03ac..695bc8253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,3 +8,8 @@ Breaking changes: New properties: * `User.events` * `RoomMember.events` + +New features: + * Local echo. When you send an event using the SDK it will immediately be + added to the timeline with the event.status of `'sending'`. When the event is + finally sent, this status will be removed. diff --git a/examples/node/app.js b/examples/node/app.js index 2137521c8..f777c6380 100644 --- a/examples/node/app.js +++ b/examples/node/app.js @@ -15,6 +15,7 @@ var viewingRoom = null; var numMessagesToShow = 20; // Reading from stdin +var CLEAR_CONSOLE = '\x1B[2J'; var readline = require("readline"); var rl = readline.createInterface({ input: process.stdin, @@ -49,11 +50,14 @@ rl.on('line', function(line) { } else if (viewingRoom) { matrixClient.sendTextMessage(viewingRoom.roomId, line).done(function() { - console.log('\x1B[2J'); // clear console + console.log(CLEAR_CONSOLE); printMessages(); }, function(err) { console.log("Error: %s", err); }); + // print local echo immediately + console.log(CLEAR_CONSOLE); + printMessages(); } }); // ==== END User input @@ -101,7 +105,7 @@ function printMessages() { printRoomList(); return; } - console.log('\x1B[2J'); // clear console + console.log(CLEAR_CONSOLE); var mostRecentMessages = viewingRoom.timeline.slice(numMessagesToShow * -1); for (var i = 0; i < mostRecentMessages.length; i++) { printLine(mostRecentMessages[i]); @@ -149,6 +153,9 @@ function printLine(event) { if (event.getSender() === myUserId) { name = "Me"; separator = ">>>"; + if (event.status === "sending") { + separator = "..."; + } } var body = ""; diff --git a/lib/client.js b/lib/client.js index 494c0860b..1e2294c21 100644 --- a/lib/client.js +++ b/lib/client.js @@ -7,6 +7,7 @@ var EventEmitter = require("events").EventEmitter; var httpApi = require("./http-api"); var MatrixEvent = require("./models/event").MatrixEvent; +var EventStatus = require("./models/event").EventStatus; var Room = require("./models/room"); var User = require("./models/user"); var MatrixInMemoryStore = require("./store/memory").MatrixInMemoryStore; @@ -233,9 +234,47 @@ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId, $eventType: eventType, $txnId: txnId }); + + // add this event immediately to the local store as 'sending'. + // NB: Don't need to check for this.store since getRoom does. + var room = this.getRoom(roomId); + var localEvent = null; + if (room) { + localEvent = new MatrixEvent({ + event_id: "~" + roomId + ":" + txnId, + user_id: this.credentials.userId, + room_id: roomId, + type: "m.room.message", + origin_server_ts: new Date().getTime(), + content: content + }); + localEvent.status = EventStatus.SENDING; + room.addEventsToTimeline([localEvent]); + } return this._http.authedRequest( callback, "PUT", path, undefined, content - ); + ).then(function (res) { + if (room && localEvent) { + var eventId = res.event_id; + // try to find an event with this event_id. If we find it, this is + // the echo of this event *from the event stream* so we can remove + // the fake event we made above. If we don't find it, we're still + // waiting on the fake event and so should assign the fake event + // with the real event_id for matching later. + var matchingEvent = utils.findElement(room.timeline, function(ev) { + return ev.getId() === eventId; + }, true); + if (matchingEvent) { + utils.removeElement(room.timeline, function(ev) { + return ev.getId() === localEvent.getId(); + }, true); + } + else { + localEvent.event.event_id = res.event_id; + localEvent.status = null; + } + } + }); }; /** @@ -982,7 +1021,7 @@ function _pollForEvents(client) { // roomInitialSync at this point to pull in state). room = createNewRoom(self, roomIds[i]); } - room.addEvents(roomIdToEvents[roomIds[i]]); + room.addEvents(roomIdToEvents[roomIds[i]], "replace"); room.recalculate(self.credentials.userId); } } diff --git a/lib/models/event.js b/lib/models/event.js index dad5f2de7..7e783e499 100644 --- a/lib/models/event.js +++ b/lib/models/event.js @@ -44,6 +44,7 @@ module.exports.MatrixEvent = function MatrixEvent(event) { this.forwardLooking = true; }; module.exports.MatrixEvent.prototype = { + /** * Get the event_id for this event. * @return {string} The event ID, e.g. $143350589368169JsLZx:localhost diff --git a/lib/models/room.js b/lib/models/room.js index db3e20c7e..24550ad76 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -104,13 +104,45 @@ Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline) { * events and typing notifications. These events are treated as "live" so * they will go to the end of the timeline. * @param {MatrixEvent[]} events A list of events to add. + * @param {string} duplicateStrategy Optional. Applies to events in the + * timeline only. If this is not specified, no duplicate suppression is + * performed (this improves performance). If this is 'replace' then if a + * duplicate is encountered, the event passed to this function will replace the + * existing event in the timeline. If this is 'ignore', then the event passed to + * this function will be ignored entirely, preserving the existing event in the + * timeline. Events are identical based on their event ID only. + * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. */ -Room.prototype.addEvents = function(events) { +Room.prototype.addEvents = function(events, duplicateStrategy) { + if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { + throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); + } for (var i = 0; i < events.length; i++) { if (events[i].getType() === "m.typing") { this.currentState.setTypingEvent(events[i]); } else { + if (duplicateStrategy) { + // is there a duplicate? + var shouldIgnore = false; + for (var j = 0; j < this.timeline.length; j++) { + if (this.timeline[j].getId() === events[i].getId()) { + if (duplicateStrategy === "replace") { + this.timeline[j] = events[i]; + // skip the insert so we don't add this event twice. + // Don't break in case we replace multiple events. + shouldIgnore = true; + } + else if (duplicateStrategy === "ignore") { + shouldIgnore = true; + break; // stop searching, we're skipping the insert + } + } + } + if (shouldIgnore) { + continue; // skip the insertion of this event. + } + } // TODO: We should have a filter to say "only add state event // types X Y Z to the timeline". this.addEventsToTimeline([events[i]]); diff --git a/lib/utils.js b/lib/utils.js index 2f35a8398..9005f3df4 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -111,6 +111,61 @@ module.exports.forEach = function(array, fn) { } }; +/** + * The findElement() method returns a value in the array, if an element in the array + * satisfies (returns true) the provided testing function. Otherwise undefined + * is returned. + * @param {Array} array The array. + * @param {Function} fn Function to execute on each value in the array, with the + * function signature fn(element, index, array) + * @param {boolean} reverse True to search in reverse order. + * @return {*} The first value in the array which returns true for + * the given function. + */ +module.exports.findElement = function(array, fn, reverse) { + if (reverse) { + for (var i = array.length-1; i >= 0; i--) { + if (fn(array[i], i, array)) { + return array[i]; + } + } + } + else { + for (var i = 0; i < array.length; i++) { + if (fn(array[i], i, array)) { + return array[i]; + } + } + } +}; + +/** + * The removeElement() method removes the first element in the array that + * satisfies (returns true) the provided testing function. + * @param {Array} array The array. + * @param {Function} fn Function to execute on each value in the array, with the + * function signature fn(element, index, array). Return true to + * remove this element and break. + * @param {boolean} reverse True to search in reverse order. + */ +module.exports.removeElement = function(array, fn, reverse) { + if (reverse) { + for (var i = array.length-1; i >= 0; i--) { + if (fn(array[i], i, array)) { + array.splice(i, 1); + return; } + } + } + else { + for (var i = 0; i < array.length; i++) { + if (fn(array[i], i, array)) { + array.splice(i, 1); + return; + } + } + } +}; + /** * Checks if the given thing is a function. * @param {*} value The thing to check.