From 9048efeb658e96f1613e083e43d3dc5834f946ef Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 16 Oct 2015 11:32:27 +0100 Subject: [PATCH] Implement receipt handling and expose new Room functions Add polyfills for Array.map/filter according to MDN because it looks much better than the utils format. Add stub tests for edge cases and implement test for the common case. --- index.js | 3 + lib/models/room.js | 98 +++++++++++++++++++++++++++++ lib/utils.js | 137 +++++++++++++++++++++++++++++++++++++++++ spec/unit/room.spec.js | 75 ++++++++++++++++++++++ 4 files changed, 313 insertions(+) diff --git a/index.js b/index.js index f05f83f04..35845b1f7 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,6 @@ var matrixcs = require("./lib/matrix"); matrixcs.request(require("request")); module.exports = matrixcs; + +var utils = require("./lib/utils"); +utils.runPolyfills(); diff --git a/lib/models/room.js b/lib/models/room.js index 3e398c50f..c9c8b4794 100644 --- a/lib/models/room.js +++ b/lib/models/room.js @@ -36,6 +36,25 @@ function Room(roomId, storageToken) { this.summary = null; this.storageToken = storageToken; this._redactions = []; + // receipts should clobber based on receipt_type and user_id pairs hence + // the form of this structure. This is sub-optimal for the exposed APIs + // which pass in an event ID and get back some receipts, so we also store + // a pre-cached list for this purpose. + this._receipts = { + // receipt_type: { + // user_id: { + // eventId: , + // data: + // } + // } + }; + this._receiptCacheByEventId = { + // $event_id: [{ + // type: $type, + // userId: $user_id, + // data: + // }] + }; } utils.inherits(Room, EventEmitter); @@ -164,6 +183,9 @@ Room.prototype.addEvents = function(events, duplicateStrategy) { if (events[i].getType() === "m.typing") { this.currentState.setTypingEvent(events[i]); } + else if (events[i].getType() === "m.receipt") { + addReceipt(this, events[i]); + } else { if (duplicateStrategy) { // is there a duplicate? @@ -220,6 +242,82 @@ Room.prototype.recalculate = function(userId) { } }; + +/** + * Get a list of user IDs who have read up to the given event. + * @param {MatrixEvent} event the event to get read receipts for. + * @return {String[]} A list of user IDs. + */ +Room.prototype.getUsersReadUpTo = function(event) { + return this.getReceiptsForEvent(event).filter(function(receipt) { + return receipt.type === "m.read"; + }).map(function(receipt) { + return receipt.userId; + }); +}; + +/** + * Get a list of receipts for the given event. + * @param {MatrixEvent} event the event to get receipts for + * @return {Object[]} A list of receipts with a userId, type and data keys or + * an empty list. + */ +Room.prototype.getReceiptsForEvent = function(event) { + return this._receiptCacheByEventId[event.getId()] || []; +}; + +/** + * Add a receipt event to the room. + * @param {MatrixEvent} event The m.receipt event. + */ +Room.prototype.addReceipt = function(event) { + // event content looks like: + // content: { + // $event_id: { + // $receipt_type: { + // $user_id: { + // ts: $timestamp + // } + // } + // } + // } + var self = this; + utils.keys(event.getContent()).forEach(function(eventId) { + utils.keys(event.getContent()[eventId]).forEach(function(receiptType) { + utils.keys(event.getContent()[eventId][receiptType]).forEach(function(userId) { + var receipt = event.getContent()[eventId][receiptType][userId]; + if (!self._receipts[receiptType]) { + self._receipts[receiptType] = {}; + } + if (!self._receipts[receiptType][userId]) { + self._receipts[receiptType][userId] = {}; + } + var oldEventId = self._receipts[receiptType][userId].eventId; + self._receipts[receiptType][userId] = { + eventId: eventId, + data: receipt + }; + }); + }); + }); + + // pre-cache receipts by event + self._receiptCacheByEventId = {}; + utils.keys(self._receipts).forEach(function(receiptType) { + utils.keys(self._receipts[receiptType]).forEach(function(userId) { + var receipt = self._receipts[receiptType][userId]; + if (!self._receiptCacheByEventId[receipt.eventId]) { + self._receiptCacheByEventId[receipt.eventId] = []; + } + self._receiptCacheByEventId[receipt.eventId].push({ + userId: userId, + type: receiptType, + data: receipt.data + }); + }); + }); +}; + function setEventMetadata(event, stateContext, toStartOfTimeline) { // set sender and target properties event.sender = stateContext.getSentinelMember( diff --git a/lib/utils.js b/lib/utils.js index 913d5815a..464bebc7d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -228,6 +228,143 @@ module.exports.deepCopy = function(obj) { return JSON.parse(JSON.stringify(obj)); }; + +/** + * Run polyfills to add Array.map and Array.filter if they are missing. + */ +module.exports.runPolyfills = function() { + // Array.prototype.filter + // ======================================================== + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter + if (!Array.prototype.filter) { + Array.prototype.filter = function(fun/*, thisArg*/) { + 'use strict'; + + if (this === void 0 || this === null) { + throw new TypeError(); + } + + var t = Object(this); + var len = t.length >>> 0; + if (typeof fun !== 'function') { + throw new TypeError(); + } + + var res = []; + var thisArg = arguments.length >= 2 ? arguments[1] : void 0; + for (var i = 0; i < len; i++) { + if (i in t) { + var val = t[i]; + + // NOTE: Technically this should Object.defineProperty at + // the next index, as push can be affected by + // properties on Object.prototype and Array.prototype. + // But that method's new, and collisions should be + // rare, so use the more-compatible alternative. + if (fun.call(thisArg, val, i, t)) { + res.push(val); + } + } + } + + return res; + }; + } + + // Array.prototype.map + // ======================================================== + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map + // Production steps of ECMA-262, Edition 5, 15.4.4.19 + // Reference: http://es5.github.io/#x15.4.4.19 + if (!Array.prototype.map) { + + Array.prototype.map = function(callback, thisArg) { + + var T, A, k; + + if (this == null) { + throw new TypeError(' this is null or not defined'); + } + + // 1. Let O be the result of calling ToObject passing the |this| + // value as the argument. + var O = Object(this); + + // 2. Let lenValue be the result of calling the Get internal + // method of O with the argument "length". + // 3. Let len be ToUint32(lenValue). + var len = O.length >>> 0; + + // 4. If IsCallable(callback) is false, throw a TypeError exception. + // See: http://es5.github.com/#x9.11 + if (typeof callback !== 'function') { + throw new TypeError(callback + ' is not a function'); + } + + // 5. If thisArg was supplied, let T be thisArg; else let T be undefined. + if (arguments.length > 1) { + T = thisArg; + } + + // 6. Let A be a new array created as if by the expression new Array(len) + // where Array is the standard built-in constructor with that name and + // len is the value of len. + A = new Array(len); + + // 7. Let k be 0 + k = 0; + + // 8. Repeat, while k < len + while (k < len) { + + var kValue, mappedValue; + + // a. Let Pk be ToString(k). + // This is implicit for LHS operands of the in operator + // b. Let kPresent be the result of calling the HasProperty internal + // method of O with argument Pk. + // This step can be combined with c + // c. If kPresent is true, then + if (k in O) { + + // i. Let kValue be the result of calling the Get internal + // method of O with argument Pk. + kValue = O[k]; + + // ii. Let mappedValue be the result of calling the Call internal + // method of callback with T as the this value and argument + // list containing kValue, k, and O. + mappedValue = callback.call(T, kValue, k, O); + + // iii. Call the DefineOwnProperty internal method of A with arguments + // Pk, Property Descriptor + // { Value: mappedValue, + // Writable: true, + // Enumerable: true, + // Configurable: true }, + // and false. + + // In browsers that support Object.defineProperty, use the following: + // Object.defineProperty(A, k, { + // value: mappedValue, + // writable: true, + // enumerable: true, + // configurable: true + // }); + + // For best browser support, use the following: + A[k] = mappedValue; + } + // d. Increase k by 1. + k++; + } + + // 9. return A + return A; + }; + } +} + /** * Inherit the prototype methods from one constructor into another. This is a * port of the Node.js implementation with an Object.create polyfill. diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index e0277a88f..d37560599 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -2,6 +2,7 @@ var sdk = require("../.."); var Room = sdk.Room; var RoomState = sdk.RoomState; +var MatrixEvent = sdk.MatrixEvent; var utils = require("../test-utils"); describe("Room", function() { @@ -549,4 +550,78 @@ describe("Room", function() { expect(name).toEqual("?"); }); }); + + describe("addReceipt", function() { + + var eventToAck = utils.mkMessage({ + room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE", + event: true + }); + + function mkReceipt(roomId, records) { + var content = {}; + records.forEach(function(r) { + if (!content[r.eventId]) { content[r.eventId] = {}; } + if (!content[r.eventId][r.type]) { content[r.eventId][r.type] = {}; } + content[r.eventId][r.type][r.userId] = { + ts: r.ts + }; + }); + return new MatrixEvent({ + content: content, + room_id: roomId, + type: "m.receipt" + }); + } + + function mkRecord(eventId, type, userId, ts) { + ts = ts || Date.now(); + return { + eventId: eventId, + type: type, + userId: userId, + ts: ts + }; + } + + it("should store the receipt so it can be obtained via getReceiptsForEvent", + function() { + var ts = 13787898424; + room.addReceipt(mkReceipt(roomId, [ + mkRecord(eventToAck.getId(), "m.read", userB, ts) + ])); + expect(room.getReceiptsForEvent(eventToAck)).toEqual([{ + type: "m.read", + userId: userB, + data: { + ts: ts + } + }]); + }); + + it("should clobber receipts based on type and user ID", function() { + + }); + + it("should persist multiple receipts for a single event ID", function() { + + }); + + it("should persist multiple receipts for a single receipt type", function() { + + }); + + it("should persist multiple receipts for a single user ID", function() { + + }); + + }); + + describe("getUsersReadUpTo", function() { + + it("should return user IDs read up to the given event", function() { + + }); + + }) });