diff --git a/spec/integ/megolm-integ.spec.js b/spec/integ/megolm-integ.spec.js index 8c49b1c28..c8b117953 100644 --- a/spec/integ/megolm-integ.spec.js +++ b/spec/integ/megolm-integ.spec.js @@ -484,8 +484,9 @@ describe("megolm", function() { return aliceTestClient.flushSync().then(() => { return aliceTestClient.flushSync(); }); - }).then(function() { + }).then(async function() { const room = aliceTestClient.client.getRoom(ROOM_ID); + await room.decryptCriticalEvents(); const event = room.getLiveTimeline().getEvents()[0]; expect(event.getContent().body).toEqual('42'); }); @@ -933,8 +934,9 @@ describe("megolm", function() { aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); return aliceTestClient.flushSync(); - }).then(function() { + }).then(async function() { const room = aliceTestClient.client.getRoom(ROOM_ID); + await room.decryptCriticalEvents(); const event = room.getLiveTimeline().getEvents()[0]; expect(event.getContent().body).toEqual('42'); diff --git a/spec/test-utils.js b/spec/test-utils.js index 3ca1a5b9a..12d2cb648 100644 --- a/spec/test-utils.js +++ b/spec/test-utils.js @@ -212,18 +212,21 @@ MockStorageApi.prototype = { * @returns {Promise} promise which resolves (to `event`) when the event has been decrypted */ export function awaitDecryption(event) { - if (!event.isBeingDecrypted()) { - return Promise.resolve(event); - } + // An event is not always decrypted ahead of time + // getClearContent is a good signal to know whether an event has been decrypted + // already + if (event.getClearContent() !== null) { + return event; + } else { + logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`); - logger.log(`${Date.now()} event ${event.getId()} is being decrypted; waiting`); - - return new Promise((resolve, reject) => { - event.once('Event.decrypted', (ev) => { - logger.log(`${Date.now()} event ${event.getId()} now decrypted`); - resolve(ev); + return new Promise((resolve, reject) => { + event.once('Event.decrypted', (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); + }); }); - }); + } } diff --git a/src/client.js b/src/client.js index 63514c2e5..5d9d1d2cf 100644 --- a/src/client.js +++ b/src/client.js @@ -5554,8 +5554,9 @@ function _resolve(callback, resolve, res) { resolve(res); } -function _PojoToMatrixEventMapper(client, options) { - const preventReEmit = Boolean(options && options.preventReEmit); +function _PojoToMatrixEventMapper(client, options = {}) { + const preventReEmit = Boolean(options.preventReEmit); + const decrypt = options.decrypt !== false; function mapper(plainOldJsObject) { const event = new MatrixEvent(plainOldJsObject); if (event.isEncrypted()) { @@ -5564,7 +5565,9 @@ function _PojoToMatrixEventMapper(client, options) { "Event.decrypted", ]); } - event.attemptDecryption(client._crypto); + if (decrypt) { + event.attemptDecryption(client._crypto); + } } if (!preventReEmit) { client.reEmitter.reEmit(event, ["Event.replaced"]); @@ -5577,6 +5580,7 @@ function _PojoToMatrixEventMapper(client, options) { /** * @param {object} [options] * @param {bool} options.preventReEmit don't reemit events emitted on an event mapped by this mapper on the client + * @param {bool} options.decrypt decrypt event proactively * @return {Function} */ MatrixClient.prototype.getEventMapper = function(options = undefined) { diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 0bf77cd29..f2552ed1c 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -1692,7 +1692,7 @@ MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionI await Promise.all([...pending].map(async (ev) => { try { - await ev.attemptDecryption(this._crypto, true); + await ev.attemptDecryption(this._crypto, { isRetry: true }); } catch (e) { // don't die if something goes wrong } diff --git a/src/models/event.js b/src/models/event.js index 767539895..c2bd94724 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -399,6 +399,12 @@ utils.extend(MatrixEvent.prototype, { this._clearEvent.content.msgtype === "m.bad.encrypted"; }, + shouldAttemptDecryption: function() { + return this.isEncrypted() + && !this.isBeingDecrypted() + && this.getClearContent() === null; + }, + /** * Start the process of trying to decrypt this event. * @@ -407,12 +413,22 @@ utils.extend(MatrixEvent.prototype, { * @internal * * @param {module:crypto} crypto crypto module - * @param {bool} isRetry True if this is a retry (enables more logging) + * @param {object} options + * @param {bool} options.isRetry True if this is a retry (enables more logging) + * @param {bool} options.emit Emits "event.decrypted" if set to true * * @returns {Promise} promise which resolves (to undefined) when the decryption * attempt is completed. */ - attemptDecryption: async function(crypto, isRetry) { + attemptDecryption: async function(crypto, options = {}) { + // For backwards compatibility purposes + // The function signature used to be attemptDecryption(crypto, isRetry) + if (typeof options === "boolean") { + options = { + isRetry: options, + }; + } + // start with a couple of sanity checks. if (!this.isEncrypted()) { throw new Error("Attempt to decrypt event which isn't encrypted"); @@ -442,7 +458,7 @@ utils.extend(MatrixEvent.prototype, { return this._decryptionPromise; } - this._decryptionPromise = this._decryptionLoop(crypto, isRetry); + this._decryptionPromise = this._decryptionLoop(crypto, options); return this._decryptionPromise; }, @@ -487,7 +503,7 @@ utils.extend(MatrixEvent.prototype, { return recipients; }, - _decryptionLoop: async function(crypto, isRetry) { + _decryptionLoop: async function(crypto, options = {}) { // make sure that this method never runs completely synchronously. // (doing so would mean that we would clear _decryptionPromise *before* // it is set in attemptDecryption - and hence end up with a stuck @@ -504,7 +520,7 @@ utils.extend(MatrixEvent.prototype, { res = this._badEncryptedMessage("Encryption not enabled"); } else { res = await crypto.decryptEvent(this); - if (isRetry) { + if (options.isRetry === true) { logger.info(`Decrypted event on retry (id=${this.getId()})`); } } @@ -512,7 +528,7 @@ utils.extend(MatrixEvent.prototype, { if (e.name !== "DecryptionError") { // not a decryption error: log the whole exception as an error // (and don't bother with a retry) - const re = isRetry ? 're' : ''; + const re = options.isRetry ? 're' : ''; logger.error( `Error ${re}decrypting event ` + `(id=${this.getId()}): ${e.stack || e}`, @@ -578,7 +594,9 @@ utils.extend(MatrixEvent.prototype, { // pick up the wrong contents. this.setPushActions(null); - this.emit("Event.decrypted", this, err); + if (options.emit !== false) { + this.emit("Event.decrypted", this, err); + } return; } diff --git a/src/models/room.js b/src/models/room.js index b573c0622..c99df7718 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -228,6 +228,51 @@ function pendingEventsKey(roomId) { utils.inherits(Room, EventEmitter); + +/** + * Bulk decrypt critical events in a room + * + * Critical events represents the minimal set of events to decrypt + * for a typical UI to function properly + * + * - Last event of every room (to generate likely message preview) + * - All events up to the read receipt (to calculate an accurate notification count) + * + * @returns {Promise} Signals when all events have been decrypted + */ +Room.prototype.decryptCriticalEvents = function() { + const readReceiptEventId = this.getEventReadUpTo(this._client.getUserId(), true); + const events = this.getLiveTimeline().getEvents(); + const readReceiptTimelineIndex = events.findIndex(matrixEvent => { + return matrixEvent.event.event_id === readReceiptEventId; + }); + + const decryptionPromises = events + .slice(readReceiptTimelineIndex) + .filter(event => event.shouldAttemptDecryption()) + .reverse() + .map(event => event.attemptDecryption(this._client._crypto, { isRetry: true })); + + return Promise.allSettled(decryptionPromises); +}; + +/** + * Bulk decrypt events in a room + * + * @returns {Promise} Signals when all events have been decrypted + */ +Room.prototype.decryptAllEvents = function() { + const decryptionPromises = this + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter(event => event.shouldAttemptDecryption()) + .reverse() + .map(event => event.attemptDecryption(this._client._crypto, { isRetry: true })); + + return Promise.allSettled(decryptionPromises); +}; + /** * Gets the version of the room * @returns {string} The version of the room, or null if it could not be determined diff --git a/src/sync.js b/src/sync.js index d48091091..3cb82fa31 100644 --- a/src/sync.js +++ b/src/sync.js @@ -1158,10 +1158,15 @@ SyncApi.prototype._processSyncResponse = async function( await utils.promiseMapSeries(joinRooms, async function(joinObj) { const room = joinObj.room; const stateEvents = self._mapSyncEventsFormat(joinObj.state, room); - const timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room); + // Prevent events from being decrypted ahead of time + // this helps large account to speed up faster + // room::decryptCriticalEvent is in charge of decrypting all the events + // required for a client to function properly + const timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room, false); const ephemeralEvents = self._mapSyncEventsFormat(joinObj.ephemeral); const accountDataEvents = self._mapSyncEventsFormat(joinObj.account_data); + const encrypted = client.isRoomEncrypted(room.roomId); // we do this first so it's correct when any of the events fire if (joinObj.unread_notifications) { room.setUnreadNotificationCount( @@ -1172,7 +1177,6 @@ SyncApi.prototype._processSyncResponse = async function( // bother setting it here. We trust our calculations better than the // server's for this case, and therefore will assume that our non-zero // count is accurate. - const encrypted = client.isRoomEncrypted(room.roomId); if (!encrypted || (encrypted && room.getUnreadNotificationCount('highlight') <= 0)) { room.setUnreadNotificationCount( @@ -1294,6 +1298,11 @@ SyncApi.prototype._processSyncResponse = async function( }); room.updateMyMembership("join"); + + // Decrypt only the last message in all rooms to make sure we can generate a preview + // And decrypt all events after the recorded read receipt to ensure an accurate + // notification count + room.decryptCriticalEvents(); }); // Handle leaves (e.g. kicked rooms) @@ -1516,13 +1525,14 @@ SyncApi.prototype._mapSyncResponseToRoomArray = function(obj) { /** * @param {Object} obj * @param {Room} room + * @param {bool} decrypt * @return {MatrixEvent[]} */ -SyncApi.prototype._mapSyncEventsFormat = function(obj, room) { +SyncApi.prototype._mapSyncEventsFormat = function(obj, room, decrypt = true) { if (!obj || !utils.isArray(obj.events)) { return []; } - const mapper = this.client.getEventMapper(); + const mapper = this.client.getEventMapper({ decrypt }); return obj.events.map(function(e) { if (room) { e.room_id = room.roomId;