diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..afc29f014 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +patreon: matrixdotorg +liberapay: matrixdotorg diff --git a/README.md b/README.md index e5cd9051b..0504eabcc 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ Then visit ``http://localhost:8005`` to see the API docs. End-to-end encryption support ============================= -The SDK supports end-to-end encryption via the and Megolm protocols, using +The SDK supports end-to-end encryption via the Olm and Megolm protocols, using [libolm](https://gitlab.matrix.org/matrix-org/olm). It is left up to the application to make libolm available, via the ``Olm`` global. diff --git a/spec/unit/autodiscovery.spec.js b/spec/unit/autodiscovery.spec.js index f6cb486a1..6d4e5a3e9 100644 --- a/spec/unit/autodiscovery.spec.js +++ b/spec/unit/autodiscovery.spec.js @@ -275,7 +275,7 @@ describe("AutoDiscovery", function() { "m.homeserver": { state: "FAIL_ERROR", error: AutoDiscovery.ERROR_INVALID_HOMESERVER, - base_url: null, + base_url: "https://example.org", }, "m.identity_server": { state: "PROMPT", @@ -304,7 +304,7 @@ describe("AutoDiscovery", function() { "m.homeserver": { state: "FAIL_ERROR", error: AutoDiscovery.ERROR_INVALID_HOMESERVER, - base_url: null, + base_url: "https://example.org", }, "m.identity_server": { state: "PROMPT", @@ -335,7 +335,7 @@ describe("AutoDiscovery", function() { "m.homeserver": { state: "FAIL_ERROR", error: AutoDiscovery.ERROR_INVALID_HOMESERVER, - base_url: null, + base_url: "https://example.org", }, "m.identity_server": { state: "PROMPT", @@ -528,7 +528,7 @@ describe("AutoDiscovery", function() { "m.identity_server": { state: "FAIL_ERROR", error: AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, - base_url: null, + base_url: "https://identity.example.org", }, }; @@ -569,7 +569,7 @@ describe("AutoDiscovery", function() { "m.identity_server": { state: "FAIL_ERROR", error: AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, - base_url: null, + base_url: "https://identity.example.org", }, }; diff --git a/spec/unit/scheduler.spec.js b/spec/unit/scheduler.spec.js index 502edb3f3..b518e8323 100644 --- a/spec/unit/scheduler.spec.js +++ b/spec/unit/scheduler.spec.js @@ -48,7 +48,7 @@ describe("MatrixScheduler", function() { clock.uninstall(); }); - it("should process events in a queue in a FIFO manner", function(done) { + it("should process events in a queue in a FIFO manner", async function() { retryFn = function() { return 0; }; @@ -57,28 +57,30 @@ describe("MatrixScheduler", function() { }; const deferA = Promise.defer(); const deferB = Promise.defer(); - let resolvedA = false; + let yieldedA = false; scheduler.setProcessFunction(function(event) { - if (resolvedA) { + if (yieldedA) { expect(event).toEqual(eventB); return deferB.promise; } else { + yieldedA = true; expect(event).toEqual(eventA); return deferA.promise; } }); - scheduler.queueEvent(eventA); - scheduler.queueEvent(eventB).done(function() { - expect(resolvedA).toBe(true); - done(); - }); - deferA.resolve({}); - resolvedA = true; - deferB.resolve({}); + const abPromise = Promise.all([ + scheduler.queueEvent(eventA), + scheduler.queueEvent(eventB), + ]); + deferB.resolve({b: true}); + deferA.resolve({a: true}); + const [a, b] = await abPromise; + expect(a.a).toEqual(true); + expect(b.b).toEqual(true); }); it("should invoke the retryFn on failure and wait the amount of time specified", - function(done) { + async function() { const waitTimeMs = 1500; const retryDefer = Promise.defer(); retryFn = function() { @@ -97,24 +99,26 @@ describe("MatrixScheduler", function() { return defer.promise; } else if (procCount === 2) { // don't care about this defer - return Promise.defer().promise; + return new Promise(); } expect(procCount).toBeLessThan(3); }); scheduler.queueEvent(eventA); + // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) + // wait just long enough before it does + await Promise.resolve(); expect(procCount).toEqual(1); defer.reject({}); - retryDefer.promise.done(function() { - expect(procCount).toEqual(1); - clock.tick(waitTimeMs); - expect(procCount).toEqual(2); - done(); - }); + await retryDefer.promise; + expect(procCount).toEqual(1); + clock.tick(waitTimeMs); + await Promise.resolve(); + expect(procCount).toEqual(2); }); it("should give up if the retryFn on failure returns -1 and try the next event", - function(done) { + async function() { // Queue A & B. // Reject A and return -1 on retry. // Expect B to be tried next and the promise for A to be rejected. @@ -122,8 +126,8 @@ describe("MatrixScheduler", function() { return -1; }; queueFn = function() { - return "yep"; -}; + return "yep"; + }; const deferA = Promise.defer(); const deferB = Promise.defer(); @@ -142,13 +146,17 @@ describe("MatrixScheduler", function() { const globalA = scheduler.queueEvent(eventA); scheduler.queueEvent(eventB); - + // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) + // wait just long enough before it does + await Promise.resolve(); expect(procCount).toEqual(1); deferA.reject({}); - globalA.catch(function() { + try { + await globalA; + } catch(err) { + await Promise.resolve(); expect(procCount).toEqual(2); - done(); - }); + } }); it("should treat each queue separately", function(done) { @@ -300,7 +308,11 @@ describe("MatrixScheduler", function() { expect(ev).toEqual(eventA); return defer.promise; }); - expect(procCount).toEqual(1); + // as queueing doesn't start processing synchronously anymore (see commit bbdb5ac) + // wait just long enough before it does + Promise.resolve().then(() => { + expect(procCount).toEqual(1); + }); }); it("should not call the processFn if there are no queued events", function() { diff --git a/src/autodiscovery.js b/src/autodiscovery.js index 10f714d24..900c01663 100644 --- a/src/autodiscovery.js +++ b/src/autodiscovery.js @@ -256,6 +256,11 @@ export class AutoDiscovery { if (!hsVersions || !hsVersions.raw["versions"]) { logger.error("Invalid /versions response"); clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER; + + // Supply the base_url to the caller because they may be ignoring liveliness + // errors, like this one. + clientConfig["m.homeserver"].base_url = hsUrl; + return Promise.resolve(clientConfig); } @@ -311,6 +316,11 @@ export class AutoDiscovery { logger.error("Invalid /api/v1 response"); failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER; + + // Supply the base_url to the caller because they may be ignoring + // liveliness errors, like this one. + failingClientConfig["m.identity_server"].base_url = isUrl; + return Promise.resolve(failingClientConfig); } } diff --git a/src/base-apis.js b/src/base-apis.js index 646ad301e..fbae5efa1 100644 --- a/src/base-apis.js +++ b/src/base-apis.js @@ -151,13 +151,14 @@ MatrixBaseApis.prototype.isUsernameAvailable = function(username) { * threepid uses during registration in the ID server. Set 'msisdn' to * true to bind msisdn. * @param {string} guestAccessToken + * @param {string} inhibitLogin * @param {module:client.callback} callback Optional. * @return {module:client.Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.register = function( username, password, - sessionId, auth, bindThreepids, guestAccessToken, + sessionId, auth, bindThreepids, guestAccessToken, inhibitLogin, callback, ) { // backwards compat @@ -166,6 +167,10 @@ MatrixBaseApis.prototype.register = function( } else if (bindThreepids === null || bindThreepids === undefined) { bindThreepids = {}; } + if (typeof inhibitLogin === 'function') { + callback = inhibitLogin; + inhibitLogin = undefined; + } if (auth === undefined || auth === null) { auth = {}; @@ -192,6 +197,9 @@ MatrixBaseApis.prototype.register = function( if (guestAccessToken !== undefined && guestAccessToken !== null) { params.guest_access_token = guestAccessToken; } + if (inhibitLogin !== undefined && inhibitLogin !== null) { + params.inhibit_login = inhibitLogin; + } // Temporary parameter added to make the register endpoint advertise // msisdn flows. This exists because there are clients that break // when given stages they don't recognise. This parameter will cease diff --git a/src/client.js b/src/client.js index 8e64988d5..fa3543a52 100644 --- a/src/client.js +++ b/src/client.js @@ -740,19 +740,19 @@ async function _setDeviceVerification( * Request a key verification from another user. * * @param {string} userId the user to request verification with - * @param {Array} devices array of device IDs to send requests to. Defaults to - * all devices owned by the user * @param {Array} methods array of verification methods to use. Defaults to * all known methods + * @param {Array} devices array of device IDs to send requests to. Defaults to + * all devices owned by the user * * @returns {Promise} resolves to a verifier * when the request is accepted by the other user */ -MatrixClient.prototype.requestVerification = function(userId, devices, methods) { +MatrixClient.prototype.requestVerification = function(userId, methods, devices) { if (this._crypto === null) { throw new Error("End-to-end encryption disabled"); } - return this._crypto.requestVerification(userId, devices); + return this._crypto.requestVerification(userId, methods, devices); }; /** @@ -2047,6 +2047,9 @@ MatrixClient.prototype._sendCompleteEvent = function(roomId, eventObject, txnId, txnId = this.makeTxnId(); } + // we always construct a MatrixEvent when sending because the store and + // scheduler use them. We'll extract the params back out if it turns out + // the client has no scheduler or store. const localEvent = new MatrixEvent(Object.assign(eventObject, { event_id: "~" + roomId + ":" + txnId, user_id: this.credentials.userId, @@ -2054,13 +2057,23 @@ MatrixClient.prototype._sendCompleteEvent = function(roomId, eventObject, txnId, origin_server_ts: new Date().getTime(), })); + const room = this.getRoom(roomId); + + // if this is a relation or redaction of an event + // that hasn't been sent yet (e.g. with a local id starting with a ~) + // then listen for the remote echo of that event so that by the time + // this event does get sent, we have the correct event_id + const targetId = localEvent.getAssociatedId(); + if (targetId && targetId.startsWith("~")) { + const target = room.getPendingEvents().find(e => e.getId() === targetId); + target.once("Event.localEventIdReplaced", () => { + localEvent.updateAssociatedId(target.getId()); + }); + } + const type = localEvent.getType(); logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`); - // we always construct a MatrixEvent when sending because the store and - // scheduler use them. We'll extract the params back out if it turns out - // the client has no scheduler or store. - const room = this.getRoom(roomId); localEvent._txnId = txnId; localEvent.setStatus(EventStatus.SENDING); @@ -2214,9 +2227,11 @@ function _sendEventHttpRequest(client, event) { pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey"; } path = utils.encodeUri(pathTemplate, pathParams); - } else if (event.getType() === "m.room.redaction") { - const pathTemplate = `/rooms/$roomId/redact/${event.event.redacts}/$txnId`; - path = utils.encodeUri(pathTemplate, pathParams); + } else if (event.isRedaction()) { + const pathTemplate = `/rooms/$roomId/redact/$redactsEventId/$txnId`; + path = utils.encodeUri(pathTemplate, Object.assign({ + $redactsEventId: event.event.redacts, + }, pathParams)); } else { path = utils.encodeUri( "/rooms/$roomId/send/$eventType/$txnId", pathParams, diff --git a/src/crypto/index.js b/src/crypto/index.js index cfcf142ee..b29e77053 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -693,7 +693,9 @@ Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) { try { await olmlib.verifySignature( this._olmDevice, - backupInfo.auth_data, + // verifySignature modifies the object so we need to copy + // if we verify more than one sig + Object.assign({}, backupInfo.auth_data), this._userId, device.deviceId, device.getFingerprint(), @@ -2095,6 +2097,11 @@ Crypto.prototype._onKeyVerificationRequest = function(event) { } const sender = event.getSender(); + if (sender === this._userId && content.from_device === this._deviceId) { + // ignore requests from ourselves, because it doesn't make sense for a + // device to verify itself + return; + } if (this._verificationTransactions.has(sender)) { if (this._verificationTransactions.get(sender).has(content.transaction_id)) { // transaction already exists: cancel it and drop the existing @@ -2147,7 +2154,7 @@ Crypto.prototype._onKeyVerificationRequest = function(event) { }, ); } else { - // notify the application that of the verification request, so it can + // notify the application of the verification request, so it can // decide what to do with it const request = { event: event, diff --git a/src/http-api.js b/src/http-api.js index 6ec936155..0c42f765e 100644 --- a/src/http-api.js +++ b/src/http-api.js @@ -165,9 +165,21 @@ module.exports.MatrixHttpApi.prototype = { const contentType = opts.type || file.type || 'application/octet-stream'; const fileName = opts.name || file.name; - // we used to recommend setting file.stream to the thing to upload on - // nodejs. - const body = file.stream ? file.stream : file; + // We used to recommend setting file.stream to the thing to upload on + // Node.js. As of 2019-06-11, this is still in widespread use in various + // clients, so we should preserve this for simple objects used in + // Node.js. File API objects (via either the File or Blob interfaces) in + // the browser now define a `stream` method, which leads to trouble + // here, so we also check the type of `stream`. + let body = file; + if (body.stream && typeof body.stream !== "function") { + logger.warn( + "Using `file.stream` as the content to upload. Future " + + "versions of the js-sdk will change this to expect `file` to " + + "be the content directly.", + ); + body = body.stream; + } // backwards-compatibility hacks where we used to do different things // between browser and node. diff --git a/src/interactive-auth.js b/src/interactive-auth.js index 48ca538f0..57f164bd1 100644 --- a/src/interactive-auth.js +++ b/src/interactive-auth.js @@ -49,11 +49,18 @@ const MSISDN_STAGE_TYPE = "m.login.msisdn"; * @param {object?} opts.authData error response from the last request. If * null, a request will be made with no auth before starting. * - * @param {function(object?, bool?): module:client.Promise} opts.doRequest - * called with the new auth dict to submit the request and a flag set - * to true if this request is a background request. Should return a - * promise which resolves to the successful response or rejects with a - * MatrixError. + * @param {function(object?): module:client.Promise} opts.doRequest + * called with the new auth dict to submit the request. Also passes a + * second deprecated arg which is a flag set to true if this request + * is a background request. The busyChanged callback should be used + * instead of the backfround flag. Should return a promise which resolves + * to the successful response or rejects with a MatrixError. + * + * @param {function(bool): module:client.Promise} opts.busyChanged + * called whenever the interactive auth logic becomes busy submitting + * information provided by the user or finsihes. After this has been + * called with true the UI should indicate that a request is in progress + * until it is called again with false. * * @param {function(string, object?)} opts.stateUpdated * called when the status of the UI auth changes, ie. when the state of @@ -101,6 +108,7 @@ function InteractiveAuth(opts) { this._matrixClient = opts.matrixClient; this._data = opts.authData || {}; this._requestCallback = opts.doRequest; + this._busyChangedCallback = opts.busyChanged; // startAuthStage included for backwards compat this._stateUpdatedCallback = opts.stateUpdated || opts.startAuthStage; this._resolveFunc = null; @@ -112,9 +120,14 @@ function InteractiveAuth(opts) { this._clientSecret = opts.clientSecret || this._matrixClient.generateClientSecret(); this._emailSid = opts.emailSid; if (this._emailSid === undefined) this._emailSid = null; + this._requestingEmailToken = false; this._chosenFlow = null; this._currentStage = null; + + // if we are currently trying to submit an auth dict (which includes polling) + // the promise the will resolve/reject when it completes + this._submitPromise = null; } InteractiveAuth.prototype = { @@ -135,7 +148,10 @@ InteractiveAuth.prototype = { // if we have no flows, try a request (we'll have // just a session ID in _data if resuming) if (!this._data.flows) { - this._doRequest(this._data); + if (this._busyChangedCallback) this._busyChangedCallback(true); + this._doRequest(this._data).finally(() => { + if (this._busyChangedCallback) this._busyChangedCallback(false); + }); } else { this._startNextAuthStage(); } @@ -147,8 +163,11 @@ InteractiveAuth.prototype = { * completed out-of-band. If so, the attemptAuth promise will * be resolved. */ - poll: function() { + poll: async function() { if (!this._data.session) return; + // if we currently have a request in flight, there's no point making + // another just to check what the status is + if (this._submitPromise) return; let authDict = {}; if (this._currentStage == EMAIL_STAGE_TYPE) { @@ -221,18 +240,44 @@ InteractiveAuth.prototype = { * in the attemptAuth promise being rejected. This can be set to true * for requests that just poll to see if auth has been completed elsewhere. */ - submitAuthDict: function(authData, background) { + submitAuthDict: async function(authData, background) { if (!this._resolveFunc) { throw new Error("submitAuthDict() called before attemptAuth()"); } + if (!background && this._busyChangedCallback) { + this._busyChangedCallback(true); + } + + // if we're currently trying a request, wait for it to finish + // as otherwise we can get multiple 200 responses which can mean + // things like multiple logins for register requests. + // (but discard any expections as we only care when its done, + // not whether it worked or not) + while (this._submitPromise) { + try { + await this._submitPromise; + } catch (e) { + } + } + // use the sessionid from the last request. const auth = { session: this._data.session, }; utils.extend(auth, authData); - this._doRequest(auth, background); + try { + // NB. the 'background' flag is deprecated by the busyChanged + // callback and is here for backwards compat + this._submitPromise = this._doRequest(auth, background); + await this._submitPromise; + } finally { + this._submitPromise = null; + if (!background && this._busyChangedCallback) { + this._busyChangedCallback(false); + } + } }, /** @@ -305,12 +350,14 @@ InteractiveAuth.prototype = { if ( !this._emailSid && + !this._requestingEmailToken && this._chosenFlow.stages.includes('m.login.email.identity') ) { // If we've picked a flow with email auth, we send the email // now because we want the request to fail as soon as possible // if the email address is not valid (ie. already taken or not // registered, depending on what the operation is). + this._requestingEmailToken = true; try { const requestTokenResult = await this._requestEmailTokenCallback( this._inputs.emailAddress, @@ -333,6 +380,8 @@ InteractiveAuth.prototype = { // the failure up as the user can't complete auth if we can't // send the email, foe whatever reason. this._rejectFunc(e); + } finally { + this._requestingEmailToken = false; } } } diff --git a/src/models/event-timeline-set.js b/src/models/event-timeline-set.js index af2d48759..8f2fad30e 100644 --- a/src/models/event-timeline-set.js +++ b/src/models/event-timeline-set.js @@ -20,6 +20,7 @@ limitations under the License. const EventEmitter = require("events").EventEmitter; const utils = require("../utils"); const EventTimeline = require("./event-timeline"); +import {EventStatus} from "./event"; import logger from '../../src/logger'; import Relations from './relations'; @@ -749,6 +750,10 @@ EventTimelineSet.prototype.aggregateRelations = function(event) { return; } + if (event.isRedacted() || event.status === EventStatus.CANCELLED) { + return; + } + // If the event is currently encrypted, wait until it has been decrypted. if (event.isBeingDecrypted()) { event.once("Event.decrypted", () => { diff --git a/src/models/event.js b/src/models/event.js index e16d49b4d..50adc34a8 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -753,6 +753,15 @@ utils.extend(module.exports.MatrixEvent.prototype, { return Boolean(this.getUnsigned().redacted_because); }, + /** + * Check if this event is a redaction of another event + * + * @return {boolean} True if this event is a redaction + */ + isRedaction: function() { + return this.getType() === "m.room.redaction"; + }, + /** * Get the push actions, if known, for this event * @@ -776,9 +785,26 @@ utils.extend(module.exports.MatrixEvent.prototype, { * @param {Object} event the object to assign to the `event` property */ handleRemoteEcho: function(event) { + const oldUnsigned = this.getUnsigned(); + const oldId = this.getId(); this.event = event; + // if this event was redacted before it was sent, it's locally marked as redacted. + // At this point, we've received the remote echo for the event, but not yet for + // the redaction that we are sending ourselves. Preserve the locally redacted + // state by copying over redacted_because so we don't get a flash of + // redacted, not-redacted, redacted as remote echos come in + if (oldUnsigned.redacted_because) { + if (!this.event.unsigned) { + this.event.unsigned = {}; + } + this.event.unsigned.redacted_because = oldUnsigned.redacted_because; + } // successfully sent. this.setStatus(null); + if (this.getId() !== oldId) { + // emit the event if it changed + this.emit("Event.localEventIdReplaced", this); + } }, /** @@ -801,6 +827,11 @@ utils.extend(module.exports.MatrixEvent.prototype, { this.emit("Event.status", this, status); }, + replaceLocalEventId(eventId) { + this.event.event_id = eventId; + this.emit("Event.localEventIdReplaced", this); + }, + /** * Get whether the event is a relation event, and of a given type if * `relType` is passed in. @@ -876,6 +907,46 @@ utils.extend(module.exports.MatrixEvent.prototype, { return this._replacingEvent; }, + /** + * For relations and redactions, returns the event_id this event is referring to. + * + * @return {string?} + */ + getAssociatedId() { + const relation = this.getRelation(); + if (relation) { + return relation.event_id; + } else if (this.isRedaction()) { + return this.event.redacts; + } + }, + + /** + * Checks if this event is associated with another event. See `getAssociatedId`. + * + * @return {bool} + */ + hasAssocation() { + return !!this.getAssociatedId(); + }, + + /** + * Update the related id with a new one. + * + * Used to replace a local id with remote one before sending + * an event with a related id. + * + * @param {string} eventId the new event id + */ + updateAssociatedId(eventId) { + const relation = this.getRelation(); + if (relation) { + relation.event_id = eventId; + } else if (this.isRedaction()) { + this.event.redacts = eventId; + } + }, + /** * Summarise the event as JSON for debugging. If encrypted, include both the * decrypted and encrypted view of the event. This is named `toJSON` for use diff --git a/src/models/relations.js b/src/models/relations.js index aa5c17d3f..22ed41669 100644 --- a/src/models/relations.js +++ b/src/models/relations.js @@ -242,12 +242,7 @@ export default class Relations extends EventEmitter { redactedEvent.removeListener("Event.beforeRedaction", this._onBeforeRedaction); - // Dispatch a redaction event on this collection. `setTimeout` is used - // to wait until the next event loop iteration by which time the event - // has actually been marked as redacted. - setTimeout(() => { - this.emit("Relations.redaction"); - }, 0); + this.emit("Relations.redaction"); } /** diff --git a/src/models/room.js b/src/models/room.js index 1146f73c3..a77aaa24f 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -1032,7 +1032,7 @@ Room.prototype.removeFilteredTimelineSet = function(filter) { * @private */ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { - if (event.getType() === "m.room.redaction") { + if (event.isRedaction()) { const redactId = event.event.redacts; // if we know about this event, redact its contents now. @@ -1141,9 +1141,13 @@ Room.prototype.addPendingEvent = function(event, txnId) { this._aggregateNonLiveRelation(event); } - if (event.getType() === "m.room.redaction") { + if (event.isRedaction()) { const redactId = event.event.redacts; - const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); + let redactedEvent = this._pendingEventList && + this._pendingEventList.find(e => e.getId() === redactId); + if (!redactedEvent) { + redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); + } if (redactedEvent) { redactedEvent.markLocallyRedacted(event); this.emit("Room.redaction", event, this); @@ -1211,7 +1215,7 @@ Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) { const oldStatus = localEvent.status; // no longer pending - delete this._txnToEvent[remoteEvent.transaction_id]; + delete this._txnToEvent[remoteEvent.getUnsigned().transaction_id]; // if it's in the pending list, remove it if (this._pendingEventList) { @@ -1315,7 +1319,7 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { if (newStatus == EventStatus.SENT) { // update the event id - event.event.event_id = newEventId; + event.replaceLocalEventId(newEventId); // if the event was already in the timeline (which will be the case if // opts.pendingEventOrdering==chronological), we need to update the @@ -1329,7 +1333,7 @@ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { const idx = this._pendingEventList.findIndex(ev => ev.getId() === oldEventId); if (idx !== -1) { const [removedEvent] = this._pendingEventList.splice(idx, 1); - if (removedEvent.getType() === "m.room.redaction") { + if (removedEvent.isRedaction()) { this._revertRedactionLocalEcho(removedEvent); } } @@ -1435,7 +1439,7 @@ Room.prototype.removeEvent = function(eventId) { for (let i = 0; i < this._timelineSets.length; i++) { const removed = this._timelineSets[i].removeEvent(eventId); if (removed) { - if (removed.getType() === "m.room.redaction") { + if (removed.isRedaction()) { this._revertRedactionLocalEcho(removed); } removedAny = true; diff --git a/src/scheduler.js b/src/scheduler.js index c6e9e4e71..a799597b9 100644 --- a/src/scheduler.js +++ b/src/scheduler.js @@ -177,7 +177,8 @@ MatrixScheduler.RETRY_BACKOFF_RATELIMIT = function(event, attempts, err) { * @see module:scheduler~queueAlgorithm */ MatrixScheduler.QUEUE_MESSAGES = function(event) { - if (event.getType() === "m.room.message") { + // enqueue messages or events that associate with another event (redactions and relations) + if (event.getType() === "m.room.message" || event.hasAssocation()) { // put these events in the 'message' queue. return "message"; } @@ -220,7 +221,14 @@ function _processQueue(scheduler, queueName) { ); // fire the process function and if it resolves, resolve the deferred. Else // invoke the retry algorithm. - scheduler._procFn(obj.event).done(function(res) { + + // First wait for a resolved promise, so the resolve handlers for + // the deferred of the previously sent event can run. + // This way enqueued relations/redactions to enqueued events can receive + // the remove id of their target before being sent. + Promise.resolve().then(() => { + return scheduler._procFn(obj.event); + }).then(function(res) { // remove this from the queue _removeNextEvent(scheduler, queueName); debuglog("Queue '%s' sent event %s", queueName, obj.event.getId()); diff --git a/src/store/indexeddb.js b/src/store/indexeddb.js index 8ca3f5244..dd8978b76 100644 --- a/src/store/indexeddb.js +++ b/src/store/indexeddb.js @@ -198,11 +198,13 @@ IndexedDBStore.prototype.wantsSave = function() { /** * Possibly write data to the database. + * + * @param {bool} force True to force a save to happen * @return {Promise} Promise resolves after the write completes * (or immediately if no write is performed) */ -IndexedDBStore.prototype.save = function() { - if (this.wantsSave()) { +IndexedDBStore.prototype.save = function(force) { + if (force || this.wantsSave()) { return this._reallySave(); } return Promise.resolve(); diff --git a/src/store/memory.js b/src/store/memory.js index df74f9860..7a666d071 100644 --- a/src/store/memory.js +++ b/src/store/memory.js @@ -335,8 +335,10 @@ module.exports.MemoryStore.prototype = { /** * Save does nothing as there is no backing data store. + * @param {bool} force True to force a save (but the memory + * store still can't save anything) */ - save: function() {}, + save: function(force) {}, /** * Startup does nothing as this store doesn't require starting up.