diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b51feeb9..86c9b82dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +Changes in [0.11.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.1) (2018-10-01) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.1-rc.1...v0.11.1) + + * No changes since rc.1 + +Changes in [0.11.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.1-rc.1) (2018-09-27) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.0...v0.11.1-rc.1) + + * make usage of hub compatible with latest version (2.5) + [\#747](https://github.com/matrix-org/matrix-js-sdk/pull/747) + * Detect when lazy loading has been toggled in client.startClient + [\#746](https://github.com/matrix-org/matrix-js-sdk/pull/746) + * Add getMediaLimits to client + [\#644](https://github.com/matrix-org/matrix-js-sdk/pull/644) + * Split npm start into an init and watch script + [\#742](https://github.com/matrix-org/matrix-js-sdk/pull/742) + * Revert "room name should only take canonical alias into account" + [\#738](https://github.com/matrix-org/matrix-js-sdk/pull/738) + * fix display name disambiguation with LL + [\#737](https://github.com/matrix-org/matrix-js-sdk/pull/737) + * Introduce Room.myMembership event + [\#735](https://github.com/matrix-org/matrix-js-sdk/pull/735) + * room name should only take canonical alias into account + [\#733](https://github.com/matrix-org/matrix-js-sdk/pull/733) + * state events from context response were not wrapped in a MatrixEvent + [\#732](https://github.com/matrix-org/matrix-js-sdk/pull/732) + * Reduce amount of promises created when inserting members + [\#724](https://github.com/matrix-org/matrix-js-sdk/pull/724) + * dont wait for LL members to be stored to resolve the members + [\#726](https://github.com/matrix-org/matrix-js-sdk/pull/726) + * RoomState.members emitted with wrong argument order for OOB members + [\#728](https://github.com/matrix-org/matrix-js-sdk/pull/728) + Changes in [0.11.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.11.0) (2018-09-10) ================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.11.0-rc.1...v0.11.0) diff --git a/package.json b/package.json index 6f1ecfc14..17c3ec5ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "0.11.0", + "version": "0.11.1", "description": "Matrix Client-Server SDK for Javascript", "main": "index.js", "scripts": { @@ -10,7 +10,9 @@ "test": "npm run test:build && npm run test:run", "check": "npm run test:build && _mocha --recursive specbuild --colors", "gendoc": "babel --no-babelrc -d .jsdocbuild src && jsdoc -r .jsdocbuild -P package.json -R README.md -d .jsdoc", - "start": "babel -s -w -d lib src", + "start": "npm run start:init && npm run start:watch", + "start:watch": "babel -s -w --skip-initial-build -d lib src", + "start:init": "babel -s -d lib src", "clean": "rimraf lib dist", "build": "babel -s -d lib src && rimraf dist && mkdir dist && browserify -d browser-index.js | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js && uglifyjs -c -m -o dist/browser-matrix.min.js --source-map dist/browser-matrix.min.js.map --in-source-map dist/browser-matrix.js.map dist/browser-matrix.js", "dist": "npm run build", @@ -53,6 +55,7 @@ "babel-runtime": "^6.26.0", "bluebird": "^3.5.0", "browser-request": "^0.3.3", + "bs58": "^4.0.1", "content-type": "^1.0.2", "request": "^2.53.0" }, @@ -72,8 +75,8 @@ "jsdoc": "^3.5.5", "lolex": "^1.5.2", "matrix-mock-request": "^1.2.0", - "mocha": "^3.2.0", - "mocha-jenkins-reporter": "^0.3.6", + "mocha": "^5.2.0", + "mocha-jenkins-reporter": "^0.4.0", "rimraf": "^2.5.4", "source-map-support": "^0.4.11", "sourceify": "^0.1.0", diff --git a/release.sh b/release.sh index 5a0538a58..0f4ab4086 100755 --- a/release.sh +++ b/release.sh @@ -245,7 +245,7 @@ release_text=`mktemp` echo "$tag" > "${release_text}" echo >> "${release_text}" cat "${latest_changes}" >> "${release_text}" -hub release create $hubflags $assets -f "${release_text}" "$tag" +hub release create $hubflags $assets -F "${release_text}" "$tag" if [ $dodist -eq 0 ]; then rm -rf "$builddir" @@ -281,7 +281,7 @@ fi echo "updating master branch" git checkout master git pull -git merge --ff-only "$rel_branch" +git merge "$rel_branch" # push master and docs (if generated) to github git push origin master diff --git a/spec/integ/matrix-client-opts.spec.js b/spec/integ/matrix-client-opts.spec.js index c7388678a..201ae7960 100644 --- a/spec/integ/matrix-client-opts.spec.js +++ b/spec/integ/matrix-client-opts.spec.js @@ -94,7 +94,7 @@ describe("MatrixClient opts", function() { httpBackend.flush("/txn1", 1); }); - it("should be able to sync / get new events", function(done) { + it("should be able to sync / get new events", async function() { const expectedEventTypes = [ // from /initialSync "m.room.message", "m.room.name", "m.room.member", "m.room.member", "m.room.create", @@ -110,20 +110,16 @@ describe("MatrixClient opts", function() { httpBackend.when("GET", "/pushrules").respond(200, {}); httpBackend.when("POST", "/filter").respond(200, { filter_id: "foo" }); httpBackend.when("GET", "/sync").respond(200, syncData); - client.startClient(); - httpBackend.flush("/pushrules", 1).then(function() { - return httpBackend.flush("/filter", 1); - }).then(function() { - return Promise.all([ - httpBackend.flush("/sync", 1), - utils.syncPromise(client), - ]); - }).done(function() { - expect(expectedEventTypes.length).toEqual( - 0, "Expected to see event types: " + expectedEventTypes, - ); - done(); - }); + await client.startClient(); + await httpBackend.flush("/pushrules", 1); + await httpBackend.flush("/filter", 1); + await Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client), + ]); + expect(expectedEventTypes.length).toEqual( + 0, "Expected to see event types: " + expectedEventTypes, + ); }); }); diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index 4d949e05e..1b28ad683 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -1,20 +1,24 @@ "use strict"; import 'source-map-support/register'; +import Crypto from '../../lib/crypto'; +import expect from 'expect'; const sdk = require("../.."); -let Crypto; -if (sdk.CRYPTO_ENABLED) { - Crypto = require("../../lib/crypto"); -} -import expect from 'expect'; +const Olm = global.Olm; describe("Crypto", function() { if (!sdk.CRYPTO_ENABLED) { return; } + + beforeEach(function(done) { + Olm.init().then(done); + }); + it("Crypto exposes the correct olm library version", function() { + console.log(Crypto); expect(Crypto.getOlmVersion()[0]).toEqual(2); }); }); diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index cf8e58f2e..6c777859e 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -13,20 +13,16 @@ import WebStorageSessionStore from '../../../../lib/store/session/webstorage'; import MemoryCryptoStore from '../../../../lib/crypto/store/memory-crypto-store.js'; import MockStorageApi from '../../../MockStorageApi'; import testUtils from '../../../test-utils'; - -// Crypto and OlmDevice won't import unless we have global.Olm -let OlmDevice; -let Crypto; -if (global.Olm) { - OlmDevice = require('../../../../lib/crypto/OlmDevice'); - Crypto = require('../../../../lib/crypto'); -} +import OlmDevice from '../../../../lib/crypto/OlmDevice'; +import Crypto from '../../../../lib/crypto'; const MatrixEvent = sdk.MatrixEvent; const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const ROOM_ID = '!ROOM:ID'; +const Olm = global.Olm; + describe("MegolmDecryption", function() { if (!global.Olm) { console.warn('Not running megolm unit tests: libolm not present'); @@ -69,7 +65,8 @@ describe("MegolmDecryption", function() { describe('receives some keys:', function() { let groupSession; - beforeEach(function() { + beforeEach(async function() { + await Olm.init(); groupSession = new global.Olm.OutboundGroupSession(); groupSession.create(); @@ -98,7 +95,7 @@ describe("MegolmDecryption", function() { }, }; - return event.attemptDecryption(mockCrypto).then(() => { + await event.attemptDecryption(mockCrypto).then(() => { megolmDecryption.onRoomKeyEvent(event); }); }); diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.js index de910592b..b1948c8a2 100644 --- a/spec/unit/crypto/backup.spec.js +++ b/spec/unit/crypto/backup.spec.js @@ -43,13 +43,14 @@ const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const ROOM_ID = '!ROOM:ID'; +const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc'; const ENCRYPTED_EVENT = new MatrixEvent({ type: 'm.room.encrypted', room_id: '!ROOM:ID', content: { algorithm: 'm.megolm.v1.aes-sha2', sender_key: 'SENDER_CURVE25519', - session_id: 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc', + session_id: SESSION_ID, ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N' + 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl' + 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs', @@ -412,7 +413,7 @@ describe("MegolmBackup", function() { "qx37WTQrjZLz5tId/uBX9B3/okqAbV1ofl9UnHKno1eipByCpXleAAlAZoJgYnCDOQZD" + "QWzo3luTSfkF9pU1mOILCbbouubs6TVeDyPfgGD9i86J8irHjA", ROOM_ID, - 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc', + SESSION_ID, ).then(() => { return megolmDecryption.decryptEvent(ENCRYPTED_EVENT); }).then((res) => { @@ -426,10 +427,10 @@ describe("MegolmBackup", function() { rooms: { [ROOM_ID]: { sessions: { - 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc': KEY_BACKUP_DATA, + SESSION_ID: KEY_BACKUP_DATA, }, }, - } + }, }); }; return client.restoreKeyBackups( diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js index 2fc5d2cf0..a59e0af43 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.js @@ -139,6 +139,9 @@ describe("MatrixClient", function() { store.getSavedSync = expect.createSpy().andReturn(Promise.resolve(null)); store.getSavedSyncToken = expect.createSpy().andReturn(Promise.resolve(null)); store.setSyncData = expect.createSpy().andReturn(Promise.resolve(null)); + store.getClientOptions = expect.createSpy().andReturn(Promise.resolve(null)); + store.storeClientOptions = expect.createSpy().andReturn(Promise.resolve(null)); + store.isNewlyCreated = expect.createSpy().andReturn(Promise.resolve(true)); client = new MatrixClient({ baseUrl: "https://my.home.server", idBaseUrl: identityServerUrl, @@ -182,7 +185,7 @@ describe("MatrixClient", function() { }); }); - it("should not POST /filter if a matching filter already exists", function(done) { + it("should not POST /filter if a matching filter already exists", async function() { httpLookups = []; httpLookups.push(PUSH_RULES_RESPONSE); httpLookups.push(SYNC_RESPONSE); @@ -191,15 +194,19 @@ describe("MatrixClient", function() { const filter = new sdk.Filter(0, filterId); filter.setDefinition({"room": {"timeline": {"limit": 8}}}); store.getFilter.andReturn(filter); - client.startClient(); - - client.on("sync", function syncListener(state) { - if (state === "SYNCING") { - expect(httpLookups.length).toEqual(0); - client.removeListener("sync", syncListener); - done(); - } + const syncPromise = new Promise((resolve, reject) => { + client.on("sync", function syncListener(state) { + if (state === "SYNCING") { + expect(httpLookups.length).toEqual(0); + client.removeListener("sync", syncListener); + resolve(); + } else if (state === "ERROR") { + reject(new Error("sync error")); + } + }); }); + await client.startClient(); + await syncPromise; }); describe("getSyncState", function() { @@ -207,15 +214,18 @@ describe("MatrixClient", function() { expect(client.getSyncState()).toBe(null); }); - it("should return the same sync state as emitted sync events", function(done) { - client.on("sync", function syncListener(state) { - expect(state).toEqual(client.getSyncState()); - if (state === "SYNCING") { - client.removeListener("sync", syncListener); - done(); - } + it("should return the same sync state as emitted sync events", async function() { + const syncingPromise = new Promise((resolve) => { + client.on("sync", function syncListener(state) { + expect(state).toEqual(client.getSyncState()); + if (state === "SYNCING") { + client.removeListener("sync", syncListener); + resolve(); + } + }); }); - client.startClient(); + await client.startClient(); + await syncingPromise; }); }); @@ -258,8 +268,8 @@ describe("MatrixClient", function() { }); describe("retryImmediately", function() { - it("should return false if there is no request waiting", function() { - client.startClient(); + it("should return false if there is no request waiting", async function() { + await client.startClient(); expect(client.retryImmediately()).toBe(false); }); diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 774217c41..4a8ea7e59 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -1402,13 +1402,25 @@ describe("Room", function() { it("should return synced membership if membership isn't available yet", function() { const room = new Room(roomId, null, userA); - room.setSyncedMembership("invite"); + room.updateMyMembership("invite"); expect(room.getMyMembership()).toEqual("invite"); - room.addLiveEvents([utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, - })]); + }); + it("should emit a Room.myMembership event on a change", + function() { + const room = new Room(roomId, null, userA); + const events = []; + room.on("Room.myMembership", (_room, membership, oldMembership) => { + events.push({membership, oldMembership}); + }); + room.updateMyMembership("invite"); + expect(room.getMyMembership()).toEqual("invite"); + expect(events[0]).toEqual({membership: "invite", oldMembership: null}); + events.splice(0); //clear + room.updateMyMembership("invite"); + expect(events.length).toEqual(0); + room.updateMyMembership("join"); expect(room.getMyMembership()).toEqual("join"); + expect(events[0]).toEqual({membership: "join", oldMembership: "invite"}); }); }); @@ -1439,11 +1451,11 @@ describe("Room", function() { it("should return false if synced membership not join", function() { const room = new Room(roomId, null, userA); - room.setSyncedMembership("invite"); + room.updateMyMembership("invite"); expect(room.maySendMessage()).toEqual(false); - room.setSyncedMembership("leave"); + room.updateMyMembership("leave"); expect(room.maySendMessage()).toEqual(false); - room.setSyncedMembership("join"); + room.updateMyMembership("join"); expect(room.maySendMessage()).toEqual(true); }); }); diff --git a/src/client.js b/src/client.js index 7080fbcf6..8ea7bda91 100644 --- a/src/client.js +++ b/src/client.js @@ -45,7 +45,15 @@ const olmlib = require("./crypto/olmlib"); import ReEmitter from './ReEmitter'; import RoomList from './crypto/RoomList'; +import {InvalidStoreError} from './errors'; +import Crypto from './crypto'; +import { isCryptoAvailable } from './crypto'; +import { encodeRecoveryKey, decodeRecoveryKey } from './crypto/recoverykey'; + +// Disable warnings for now: we use deprecated bluebird functions +// and need to migrate, but they spam the console with warnings. +Promise.config({warnings: false}); const LAZY_LOADING_MESSAGES_FILTER = { lazy_load_members: true, @@ -59,14 +67,7 @@ const LAZY_LOADING_SYNC_FILTER = { const SCROLLBACK_DELAY_MS = 3000; -let CRYPTO_ENABLED = false; - -try { - var Crypto = require("./crypto"); - CRYPTO_ENABLED = true; -} catch (e) { - console.warn("Unable to load crypto module: crypto will be disabled: " + e); -} +const CRYPTO_ENABLED = isCryptoAvailable(); function keysFromRecoverySession(sessions, decryptionKey, roomId) { const keys = []; @@ -164,6 +165,8 @@ function MatrixClient(opts) { MatrixBaseApis.call(this, opts); + this.olmVersion = null; // Populated after initCrypto is done + this.reEmitter = new ReEmitter(this); this.store = opts.store || new StubStore(); @@ -216,10 +219,6 @@ function MatrixClient(opts) { this._forceTURN = opts.forceTURN || false; - if (CRYPTO_ENABLED) { - this.olmVersion = Crypto.getOlmVersion(); - } - // List of which rooms have encryption enabled: separate from crypto because // we still want to know which rooms are encrypted even if crypto is disabled: // we don't want to start sending unencrypted events to them. @@ -409,6 +408,13 @@ MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) { * successfully initialised. */ MatrixClient.prototype.initCrypto = async function() { + if (!isCryptoAvailable()) { + throw new Error( + `End-to-end encryption not supported in this js-sdk build: did ` + + `you remember to load the olm library?`, + ); + } + if (this._crypto) { console.warn("Attempt to re-initialise e2e encryption on MatrixClient"); return; @@ -426,13 +432,6 @@ MatrixClient.prototype.initCrypto = async function() { // initialise the list of encrypted rooms (whether or not crypto is enabled) await this._roomList.init(); - if (!CRYPTO_ENABLED) { - throw new Error( - `End-to-end encryption not supported in this js-sdk build: did ` + - `you remember to load the olm library?`, - ); - } - const userId = this.getUserId(); if (userId === null) { throw new Error( @@ -464,6 +463,9 @@ MatrixClient.prototype.initCrypto = async function() { await crypto.init(); + this.olmVersion = Crypto.getOlmVersion(); + + // if crypto initialisation was successful, tell it to attach its event // handlers. crypto.registerEventHandlers(this); @@ -887,9 +889,7 @@ MatrixClient.prototype.prepareKeyBackupVersion = function() { auth_data: { public_key: publicKey, }, - // FIXME: pickle isn't the right thing to use, but we don't have - // anything else yet, so use it for now - recovery_key: decryption.pickle("secret_key"), + recovery_key: encodeRecoveryKey(decryption.get_private_key()), }; } finally { decryption.free(); @@ -996,26 +996,17 @@ MatrixClient.prototype.backupAllGroupSessions = function(version) { return this._crypto.backupAllGroupSessions(version); }; -MatrixClient.prototype.isValidRecoveryKey = function(decryptionKey) { - if (this._crypto === null) { - throw new Error("End-to-end encryption disabled"); - } - - const decryption = new global.Olm.PkDecryption(); +MatrixClient.prototype.isValidRecoveryKey = function(recoveryKey) { try { - // FIXME: see the FIXME in createKeyBackupVersion - decryption.unpickle("secret_key", decryptionKey); + decodeRecoveryKey(recoveryKey); return true; } catch (e) { - console.log(e); return false; - } finally { - decryption.free(); } }; MatrixClient.prototype.restoreKeyBackups = function( - decryptionKey, targetRoomId, targetSessionId, version, + recoveryKey, targetRoomId, targetSessionId, version, ) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); @@ -1026,9 +1017,10 @@ MatrixClient.prototype.restoreKeyBackups = function( const path = this._makeKeyBackupPath(targetRoomId, targetSessionId, version); // FIXME: see the FIXME in createKeyBackupVersion + const privkey = decodeRecoveryKey(recoveryKey); const decryption = new global.Olm.PkDecryption(); try { - decryption.unpickle("secret_key", decryptionKey); + decryption.init_with_private_key(privkey); } catch(e) { decryption.free(); throw e; @@ -1042,7 +1034,9 @@ MatrixClient.prototype.restoreKeyBackups = function( if (!roomData.sessions) continue; totalKeyCount += Object.keys(roomData.sessions).length; - const roomKeys = keysFromRecoverySession(roomData.sessions, decryption, roomId, roomKeys); + const roomKeys = keysFromRecoverySession( + roomData.sessions, decryption, roomId, roomKeys, + ); for (const k of roomKeys) { k.room_id = roomId; keys.push(k); @@ -2439,7 +2433,8 @@ MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) { self.getEventMapper())); timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end; } else { - timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state); + const stateEvents = utils.map(res.state, self.getEventMapper()); + timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(stateEvents); } timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start); @@ -3441,6 +3436,9 @@ MatrixClient.prototype.startClient = async function(opts) { // shallow-copy the opts dict before modifying and storing it opts = Object.assign({}, opts); + if (opts.lazyLoadMembers && this.isGuest()) { + opts.lazyLoadMembers = false; + } if (opts.lazyLoadMembers) { const supported = await this.doesServerSupportLazyLoading(); if (supported) { @@ -3451,7 +3449,12 @@ MatrixClient.prototype.startClient = async function(opts) { opts.lazyLoadMembers = false; } } - + // need to vape the store when enabling LL and wasn't enabled before + const shouldClear = await this._wasLazyLoadingToggled(opts.lazyLoadMembers); + if (shouldClear) { + const reason = InvalidStoreError.TOGGLED_LAZY_LOADING; + throw new InvalidStoreError(reason, !!opts.lazyLoadMembers); + } if (opts.lazyLoadMembers && this._crypto) { this._crypto.enableLazyLoading(); } @@ -3464,11 +3467,50 @@ MatrixClient.prototype.startClient = async function(opts) { return this._canResetTimelineCallback(roomId); }; this._clientOpts = opts; - + await this._storeClientOptions(this._clientOpts); this._syncApi = new SyncApi(this, opts); this._syncApi.sync(); }; +/** + * Is the lazy loading option different than in previous session? + * @param {bool} lazyLoadMembers current options for lazy loading + * @return {bool} whether or not the option has changed compared to the previous session */ +MatrixClient.prototype._wasLazyLoadingToggled = async function(lazyLoadMembers) { + lazyLoadMembers = !!lazyLoadMembers; + // assume it was turned off before + // if we don't know any better + let lazyLoadMembersBefore = false; + const isStoreNewlyCreated = await this.store.isNewlyCreated(); + if (!isStoreNewlyCreated) { + const prevClientOptions = await this.store.getClientOptions(); + if (prevClientOptions) { + lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers; + } + return lazyLoadMembersBefore !== lazyLoadMembers; + } + return false; +}; + +/** + * store client options with boolean/string/numeric values + * to know in the next session what flags the sync data was + * created with (e.g. lazy loading) + * @param {object} opts the complete set of client options + * @return {Promise} for store operation */ +MatrixClient.prototype._storeClientOptions = function(opts) { + const primTypes = ["boolean", "string", "number"]; + const serializableOpts = Object.entries(opts) + .filter(([key, value]) => { + return primTypes.includes(typeof value); + }) + .reduce((obj, [key, value]) => { + obj[key] = value; + return obj; + }, {}); + return this.store.storeClientOptions(serializableOpts); +}; + /** * High level helper method to stop the client from polling and allow a * clean shutdown. diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index 950faa8e2..818840054 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -17,17 +17,6 @@ limitations under the License. import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; -/** - * olm.js wrapper - * - * @module crypto/OlmDevice - */ -const Olm = global.Olm; -if (!Olm) { - throw new Error("global.Olm is not defined"); -} - - // The maximum size of an event is 65K, and we base64 the content, so this is a // reasonable approximation to the biggest plaintext we can encrypt. const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4; @@ -138,7 +127,7 @@ OlmDevice.prototype.init = async function() { await this._migrateFromSessionStore(); let e2eKeys; - const account = new Olm.Account(); + const account = new global.Olm.Account(); try { await _initialiseAccount( this._sessionStore, this._cryptoStore, this._pickleKey, account, @@ -172,7 +161,7 @@ async function _initialiseAccount(sessionStore, cryptoStore, pickleKey, account) * @return {array} The version of Olm. */ OlmDevice.getOlmVersion = function() { - return Olm.get_library_version(); + return global.Olm.get_library_version(); }; OlmDevice.prototype._migrateFromSessionStore = async function() { @@ -279,7 +268,7 @@ OlmDevice.prototype._migrateFromSessionStore = async function() { */ OlmDevice.prototype._getAccount = function(txn, func) { this._cryptoStore.getAccount(txn, (pickledAccount) => { - const account = new Olm.Account(); + const account = new global.Olm.Account(); try { account.unpickle(this._pickleKey, pickledAccount); func(account); @@ -332,7 +321,7 @@ OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) { * @private */ OlmDevice.prototype._unpickleSession = function(pickledSession, func) { - const session = new Olm.Session(); + const session = new global.Olm.Session(); try { session.unpickle(this._pickleKey, pickledSession); func(session); @@ -365,7 +354,7 @@ OlmDevice.prototype._saveSession = function(deviceKey, session, txn) { * @private */ OlmDevice.prototype._getUtility = function(func) { - const utility = new Olm.Utility(); + const utility = new global.Olm.Utility(); try { return func(utility); } finally { @@ -477,7 +466,7 @@ OlmDevice.prototype.createOutboundSession = async function( ], (txn) => { this._getAccount(txn, (account) => { - const session = new Olm.Session(); + const session = new global.Olm.Session(); try { session.create_outbound(account, theirIdentityKey, theirOneTimeKey); newSessionId = session.session_id(); @@ -521,7 +510,7 @@ OlmDevice.prototype.createInboundSession = async function( ], (txn) => { this._getAccount(txn, (account) => { - const session = new Olm.Session(); + const session = new global.Olm.Session(); try { session.create_inbound_from( account, theirDeviceIdentityKey, ciphertext, @@ -739,7 +728,7 @@ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) { throw new Error("Unknown outbound group session " + sessionId); } - const session = new Olm.OutboundGroupSession(); + const session = new global.Olm.OutboundGroupSession(); try { session.unpickle(this._pickleKey, pickled); return func(session); @@ -755,7 +744,7 @@ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) { * @return {string} sessionId for the outbound session. */ OlmDevice.prototype.createOutboundGroupSession = function() { - const session = new Olm.OutboundGroupSession(); + const session = new global.Olm.OutboundGroupSession(); try { session.create(); this._saveOutboundGroupSession(session); @@ -827,7 +816,7 @@ OlmDevice.prototype.getOutboundGroupSessionKey = function(sessionId) { * @return {*} result of func */ OlmDevice.prototype._unpickleInboundGroupSession = function(sessionData, func) { - const session = new Olm.InboundGroupSession(); + const session = new global.Olm.InboundGroupSession(); try { session.unpickle(this._pickleKey, sessionData.session); return func(session); @@ -908,7 +897,7 @@ OlmDevice.prototype.addInboundGroupSession = async function( } // new session. - const session = new Olm.InboundGroupSession(); + const session = new global.Olm.InboundGroupSession(); try { if (exportFormat) { session.import_session(sessionKey); diff --git a/src/crypto/index.js b/src/crypto/index.js index 7104320ee..0ca0d8506 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -36,9 +36,8 @@ const DeviceList = require('./DeviceList').default; import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager'; import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; -const Olm = global.Olm; -if (!Olm) { - throw new Error("global.Olm is not defined"); +export function isCryptoAvailable() { + return Boolean(global.Olm); } /** @@ -67,7 +66,7 @@ if (!Olm) { * * @param {RoomList} roomList An initialised RoomList object */ -function Crypto(baseApis, sessionStore, userId, deviceId, +export default function Crypto(baseApis, sessionStore, userId, deviceId, clientStore, cryptoStore, roomList) { this._baseApis = baseApis; this._sessionStore = sessionStore; @@ -137,6 +136,10 @@ utils.inherits(Crypto, EventEmitter); * Returns a promise which resolves once the crypto module is ready for use. */ Crypto.prototype.init = async function() { + // Olm is just an object with a .then, not a fully-fledged promise, so + // pass it into bluebird to make it a proper promise. + await global.Olm.init(); + const sessionStoreHasAccount = Boolean(this._sessionStore.getEndToEndAccount()); let cryptoStoreHasAccount; await this._cryptoStore.doTxn( @@ -1747,6 +1750,3 @@ class IncomingRoomKeyRequestCancellation { * @event module:client~MatrixClient#"crypto.warning" * @param {string} type One of the strings listed above */ - -/** */ -module.exports = Crypto; diff --git a/src/crypto/recoverykey.js b/src/crypto/recoverykey.js new file mode 100644 index 000000000..bb85697e8 --- /dev/null +++ b/src/crypto/recoverykey.js @@ -0,0 +1,67 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import bs58 from 'bs58'; + +// picked arbitrarily but to try & avoid clashing with any bitcoin ones +// (also base58 encoded, albeit with a lot of hashing) +const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; + +export function encodeRecoveryKey(key) { + const buf = new Uint8Array(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); + buf.set(OLM_RECOVERY_KEY_PREFIX, 0); + buf.set(key, OLM_RECOVERY_KEY_PREFIX.length); + + let parity = 0; + for (let i = 0; i < buf.length - 1; ++i) { + parity ^= buf[i]; + } + buf[buf.length - 1] = parity; + const base58key = bs58.encode(buf); + + + return base58key.match(/.{1,4}/g).join(" "); +} + +export function decodeRecoveryKey(recoverykey) { + const result = bs58.decode(recoverykey.replace(/ /g, '')); + + let parity = 0; + for (const b of result) { + parity ^= b; + } + if (parity !== 0) { + throw new Error("Incorrect parity"); + } + + for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) { + if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) { + throw new Error("Incorrect prefix"); + } + } + + if ( + result.length !== + OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1 + ) { + throw new Error("Incorrect length"); + } + + return result.slice( + OLM_RECOVERY_KEY_PREFIX.length, + OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH, + ); +} diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 000000000..04e14f2c8 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,25 @@ +// can't just do InvalidStoreError extends Error +// because of http://babeljs.io/docs/usage/caveats/#classes +function InvalidStoreError(reason, value) { + const message = `Store is invalid because ${reason}, ` + + `please delete all data and retry`; + const instance = Reflect.construct(Error, [message]); + Reflect.setPrototypeOf(instance, Reflect.getPrototypeOf(this)); + instance.reason = reason; + instance.value = value; + return instance; +} + +InvalidStoreError.TOGGLED_LAZY_LOADING = "TOGGLED_LAZY_LOADING"; + +InvalidStoreError.prototype = Object.create(Error.prototype, { + constructor: { + value: Error, + enumerable: false, + writable: true, + configurable: true, + }, +}); +Reflect.setPrototypeOf(InvalidStoreError, Error); + +module.exports.InvalidStoreError = InvalidStoreError; diff --git a/src/matrix.js b/src/matrix.js index bcc88e0e9..47791472e 100644 --- a/src/matrix.js +++ b/src/matrix.js @@ -34,6 +34,8 @@ module.exports.SyncAccumulator = require("./sync-accumulator"); module.exports.MatrixHttpApi = require("./http-api").MatrixHttpApi; /** The {@link module:http-api.MatrixError|MatrixError} class. */ module.exports.MatrixError = require("./http-api").MatrixError; +/** The {@link module:errors.InvalidStoreError|InvalidStoreError} class. */ +module.exports.InvalidStoreError = require("./errors").InvalidStoreError; /** The {@link module:client.MatrixClient|MatrixClient} class. */ module.exports.MatrixClient = require("./client").MatrixClient; /** The {@link module:models/room|Room} class. */ diff --git a/src/models/room-state.js b/src/models/room-state.js index 85d8a744f..52bc5ed36 100644 --- a/src/models/room-state.js +++ b/src/models/room-state.js @@ -180,7 +180,7 @@ RoomState.prototype.getSentinelMember = function(userId) { sentinel = new RoomMember(this.roomId, userId); const member = this.members[userId]; if (member) { - sentinel.setMembershipEvent(member.events.member); + sentinel.setMembershipEvent(member.events.member, this); } this._sentinels[userId] = sentinel; } @@ -501,7 +501,7 @@ RoomState.prototype._setOutOfBandMember = function(stateEvent) { } const member = this._getOrCreateMember(userId, stateEvent); - member.setMembershipEvent(stateEvent); + member.setMembershipEvent(stateEvent, this); // needed to know which members need to be stored seperately // as the are not part of the sync accumulator // this is cleared by setMembershipEvent so when it's updated through /sync diff --git a/src/models/room.js b/src/models/room.js index b5ce503cb..4b685c878 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -178,7 +178,7 @@ function Room(roomId, client, myUserId, opts) { // read by megolm; boolean value - null indicates "use global value" this._blacklistUnverifiedDevices = null; - this._syncedMembership = null; + this._selfMembership = null; this._summaryHeroes = null; // awaited by getEncryptionTargetMembers while room members are loading @@ -198,7 +198,10 @@ utils.inherits(Room, EventEmitter); */ Room.prototype.getVersion = function() { const createEvent = this.currentState.getStateEvents("m.room.create", ""); - if (!createEvent) return null; + if (!createEvent) { + console.warn("Room " + this.room_id + " does not have an m.room.create event"); + return '1'; + } const ver = createEvent.getContent()['room_version']; if (ver === undefined) return '1'; return ver; @@ -257,13 +260,7 @@ Room.prototype.getLiveTimeline = function() { * @return {string} the membership type (join | leave | invite) for the logged in user */ Room.prototype.getMyMembership = function() { - if (this.myUserId) { - const me = this.getMember(this.myUserId); - if (me) { - return me.membership; - } - } - return this._syncedMembership; + return this._selfMembership; }; /** @@ -278,7 +275,7 @@ Room.prototype.getDMInviter = function() { return me.getDMInviter(); } } - if (this._syncedMembership === "invite") { + if (this._selfMembership === "invite") { // fall back to summary information const memberCount = this.getInvitedAndJoinedMemberCount(); if (memberCount == 2 && this._summaryHeroes.length) { @@ -362,8 +359,15 @@ Room.prototype.getAvatarFallbackMember = function() { * Sets the membership this room was received as during sync * @param {string} membership join | leave | invite */ -Room.prototype.setSyncedMembership = function(membership) { - this._syncedMembership = membership; +Room.prototype.updateMyMembership = function(membership) { + const prevMembership = this._selfMembership; + this._selfMembership = membership; + if (prevMembership !== membership) { + if (membership === "leave") { + this._cleanupAfterLeaving(); + } + this.emit("Room.myMembership", this, membership, prevMembership); + } }; Room.prototype._loadMembersFromServer = async function() { @@ -470,7 +474,7 @@ Room.prototype.clearLoadedMembersIfNeeded = async function() { * called when sync receives this room in the leave section * to do cleanup after leaving a room. Possibly called multiple times. */ -Room.prototype.onLeft = function() { +Room.prototype._cleanupAfterLeaving = function() { this.clearLoadedMembersIfNeeded().catch((err) => { console.error(`error after clearing loaded members from ` + `room ${this.roomId} after leaving`); diff --git a/src/store/indexeddb-local-backend.js b/src/store/indexeddb-local-backend.js index 07d9f8139..e3ae1c1ae 100644 --- a/src/store/indexeddb-local-backend.js +++ b/src/store/indexeddb-local-backend.js @@ -19,7 +19,7 @@ import Promise from 'bluebird'; import SyncAccumulator from "../sync-accumulator"; import utils from "../utils"; -const VERSION = 2; +const VERSION = 3; function createDatabase(db) { // Make user store, clobber based on user ID. (userId property of User objects) @@ -41,6 +41,12 @@ function upgradeSchemaV2(db) { oobMembersStore.createIndex("room", "room_id"); } +function upgradeSchemaV3(db) { + db.createObjectStore("client_options", + { keyPath: ["clobber"]}); +} + + /** * Helper method to collect results from a Cursor and promiseify it. * @param {ObjectStore|Index} store The store to perform openCursor on. @@ -77,7 +83,7 @@ function txnAsPromise(txn) { resolve(event); }; txn.onerror = function(event) { - reject(event); + reject(event.target.error); }; }); } @@ -88,7 +94,7 @@ function reqAsEventPromise(req) { resolve(event); }; req.onerror = function(event) { - reject(event); + reject(event.target.error); }; }); } @@ -123,6 +129,7 @@ const LocalIndexedDBStoreBackend = function LocalIndexedDBStoreBackend( this.db = null; this._disconnected = true; this._syncAccumulator = new SyncAccumulator(); + this._isNewlyCreated = false; }; @@ -153,11 +160,15 @@ LocalIndexedDBStoreBackend.prototype = { `LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`, ); if (oldVersion < 1) { // The database did not previously exist. + this._isNewlyCreated = true; createDatabase(db); } if (oldVersion < 2) { upgradeSchemaV2(db); } + if (oldVersion < 3) { + upgradeSchemaV3(db); + } // Expand as needed. }; @@ -185,6 +196,10 @@ LocalIndexedDBStoreBackend.prototype = { return this._init(); }); }, + /** @return {bool} whether or not the database was newly created in this session. */ + isNewlyCreated: function() { + return Promise.resolve(this._isNewlyCreated); + }, /** * Having connected, load initial data from the database and prepare for use @@ -529,6 +544,28 @@ LocalIndexedDBStoreBackend.prototype = { }); }); }, + + getClientOptions: function() { + return Promise.resolve().then(() => { + const txn = this.db.transaction(["client_options"], "readonly"); + const store = txn.objectStore("client_options"); + return selectQuery(store, undefined, (cursor) => { + if (cursor.value && cursor.value && cursor.value.options) { + return cursor.value.options; + } + }).then((results) => results[0]); + }); + }, + + storeClientOptions: async function(options) { + const txn = this.db.transaction(["client_options"], "readwrite"); + const store = txn.objectStore("client_options"); + store.put({ + clobber: "-", // constant key so will always clobber + options: options, + }); // put == UPSERT + await txnAsPromise(txn); + }, }; export default LocalIndexedDBStoreBackend; diff --git a/src/store/indexeddb-remote-backend.js b/src/store/indexeddb-remote-backend.js index 85f07f86b..035a821ff 100644 --- a/src/store/indexeddb-remote-backend.js +++ b/src/store/indexeddb-remote-backend.js @@ -65,7 +65,10 @@ RemoteIndexedDBStoreBackend.prototype = { clearDatabase: function() { return this._ensureStarted().then(() => this._doCmd('clearDatabase')); }, - + /** @return {Promise} whether or not the database was newly created in this session. */ + isNewlyCreated: function() { + return this._doCmd('isNewlyCreated'); + }, /** * @return {Promise} Resolves with a sync response to restore the * client state to where it was at the last save, or null if there @@ -114,6 +117,14 @@ RemoteIndexedDBStoreBackend.prototype = { return this._doCmd('clearOutOfBandMembers', [roomId]); }, + getClientOptions: function() { + return this._doCmd('getClientOptions'); + }, + + storeClientOptions: function(options) { + return this._doCmd('storeClientOptions', [options]); + }, + /** * Load all user presence events from the database. This is not cached. * @return {Promise} A list of presence events in their raw form. @@ -173,7 +184,9 @@ RemoteIndexedDBStoreBackend.prototype = { if (msg.command == 'cmd_success') { def.resolve(msg.result); } else { - def.reject(msg.error); + const error = new Error(msg.error.message); + error.name = msg.error.name; + def.reject(error); } } else { console.warn("Unrecognised message from worker: " + msg); diff --git a/src/store/indexeddb-store-worker.js b/src/store/indexeddb-store-worker.js index adfc3535b..4e66f51aa 100644 --- a/src/store/indexeddb-store-worker.js +++ b/src/store/indexeddb-store-worker.js @@ -67,6 +67,9 @@ class IndexedDBStoreWorker { case 'connect': prom = this.backend.connect(); break; + case 'isNewlyCreated': + prom = this.backend.isNewlyCreated(); + break; case 'clearDatabase': prom = this.backend.clearDatabase().then((result) => { // This returns special classes which can't be cloned @@ -101,10 +104,16 @@ class IndexedDBStoreWorker { case 'setOutOfBandMembers': prom = this.backend.setOutOfBandMembers(msg.args[0], msg.args[1]); break; + case 'getClientOptions': + prom = this.backend.getClientOptions(); + break; + case 'storeClientOptions': + prom = this.backend.storeClientOptions(msg.args[0]); + break; } if (prom === undefined) { - postMessage({ + this.postMessage({ command: 'cmd_fail', seq: msg.seq, // Can't be an Error because they're not structured cloneable @@ -126,7 +135,10 @@ class IndexedDBStoreWorker { command: 'cmd_fail', seq: msg.seq, // Just send a string because Error objects aren't cloneable - error: "Error running command", + error: { + message: err.message, + name: err.name, + }, }); }); } diff --git a/src/store/indexeddb.js b/src/store/indexeddb.js index 0de57c89a..b8f47ad89 100644 --- a/src/store/indexeddb.js +++ b/src/store/indexeddb.js @@ -146,6 +146,11 @@ IndexedDBStore.prototype.getSavedSync = function() { return this.backend.getSavedSync(); }; +/** @return {Promise} whether or not the database was newly created in this session. */ +IndexedDBStore.prototype.isNewlyCreated = function() { + return this.backend.isNewlyCreated(); +}; + /** * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. @@ -246,4 +251,12 @@ IndexedDBStore.prototype.clearOutOfBandMembers = function(roomId) { return this.backend.clearOutOfBandMembers(roomId); }; +IndexedDBStore.prototype.getClientOptions = function() { + return this.backend.getClientOptions(); +}; + +IndexedDBStore.prototype.storeClientOptions = function(options) { + return this.backend.storeClientOptions(options); +}; + module.exports.IndexedDBStore = IndexedDBStore; diff --git a/src/store/memory.js b/src/store/memory.js index a5f72af4f..006742223 100644 --- a/src/store/memory.js +++ b/src/store/memory.js @@ -55,6 +55,7 @@ module.exports.MatrixInMemoryStore = function MatrixInMemoryStore(opts) { this._oobMembers = { // roomId: [member events] }; + this._clientOptions = {}; }; module.exports.MatrixInMemoryStore.prototype = { @@ -67,6 +68,10 @@ module.exports.MatrixInMemoryStore.prototype = { return this.syncToken; }, + /** @return {Promise} whether or not the database was newly created in this session. */ + isNewlyCreated: function() { + return Promise.resolve(true); + }, /** * Set the token to stream from. @@ -402,4 +407,13 @@ module.exports.MatrixInMemoryStore.prototype = { this._oobMembers[roomId] = membershipEvents; return Promise.resolve(); }, + + getClientOptions: function() { + return Promise.resolve(this._clientOptions); + }, + + storeClientOptions: function(options) { + this._clientOptions = Object.assign({}, options); + return Promise.resolve(); + }, }; diff --git a/src/store/stub.js b/src/store/stub.js index d0c2cabc5..f09dad6d7 100644 --- a/src/store/stub.js +++ b/src/store/stub.js @@ -32,6 +32,11 @@ function StubStore() { StubStore.prototype = { + /** @return {Promise} whether or not the database was newly created in this session. */ + isNewlyCreated: function() { + return Promise.resolve(true); + }, + /** * Get the sync token. * @return {string} @@ -276,6 +281,14 @@ StubStore.prototype = { clearOutOfBandMembers: function() { return Promise.resolve(); }, + + getClientOptions: function() { + return Promise.resolve(); + }, + + storeClientOptions: function() { + return Promise.resolve(); + }, }; /** Stub Store class. */ diff --git a/src/sync.js b/src/sync.js index bcb131c59..39e82abe4 100644 --- a/src/sync.js +++ b/src/sync.js @@ -123,6 +123,7 @@ SyncApi.prototype.createRoom = function(roomId) { "Room.timelineReset", "Room.localEchoUpdated", "Room.accountData", + "Room.myMembership", ]); this._registerStateListeners(room); return room; @@ -976,9 +977,10 @@ SyncApi.prototype._processSyncResponse = async function( // Handle invites inviteRooms.forEach(function(inviteObj) { const room = inviteObj.room; - room.setSyncedMembership("invite"); const stateEvents = self._mapSyncEventsFormat(inviteObj.invite_state, room); + + room.updateMyMembership("invite"); self._processRoomEvents(room, stateEvents); if (inviteObj.isBrandNewRoom) { room.recalculate(); @@ -993,7 +995,6 @@ SyncApi.prototype._processSyncResponse = async function( // Handle joins await Promise.mapSeries(joinRooms, async function(joinObj) { const room = joinObj.room; - room.setSyncedMembership("join"); const stateEvents = self._mapSyncEventsFormat(joinObj.state, room); const timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room); const ephemeralEvents = self._mapSyncEventsFormat(joinObj.ephemeral); @@ -1009,6 +1010,8 @@ SyncApi.prototype._processSyncResponse = async function( ); } + room.updateMyMembership("join"); + joinObj.timeline = joinObj.timeline || {}; if (joinObj.isBrandNewRoom) { @@ -1116,8 +1119,6 @@ SyncApi.prototype._processSyncResponse = async function( // Handle leaves (e.g. kicked rooms) leaveRooms.forEach(function(leaveObj) { const room = leaveObj.room; - room.setSyncedMembership("leave"); - const stateEvents = self._mapSyncEventsFormat(leaveObj.state, room); const timelineEvents = @@ -1125,6 +1126,8 @@ SyncApi.prototype._processSyncResponse = async function( const accountDataEvents = self._mapSyncEventsFormat(leaveObj.account_data); + room.updateMyMembership("leave"); + self._processRoomEvents(room, stateEvents, timelineEvents); room.addAccountData(accountDataEvents); @@ -1145,8 +1148,6 @@ SyncApi.prototype._processSyncResponse = async function( accountDataEvents.forEach(function(e) { client.emit("event", e); }); - - room.onLeft(); }); // update the notification timeline, if appropriate.