From d6f1c6cfdc5a4f3d7b4ec67fe9f4d89d7319d8f7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 31 Mar 2022 13:57:37 +0100 Subject: [PATCH] Fix thread & main timeline partitioning logic (#2264) --- spec/integ/matrix-client-methods.spec.js | 200 +++- spec/integ/matrix-client-syncing.spec.js | 3 +- spec/test-utils/test-utils.js | 369 ------- spec/test-utils/test-utils.ts | 282 ++++++ spec/unit/crypto/cross-signing.spec.js | 32 +- spec/unit/filter-component.spec.ts | 17 +- spec/unit/matrix-client.spec.ts | 8 +- spec/unit/room.spec.ts | 1150 +++++++++++++--------- src/client.ts | 75 +- src/models/room.ts | 107 +- src/sync.ts | 62 +- 11 files changed, 1251 insertions(+), 1054 deletions(-) delete mode 100644 spec/test-utils/test-utils.js create mode 100644 spec/test-utils/test-utils.ts diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.js index 0df2dafd1..7b6a7be0a 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.js @@ -556,9 +556,11 @@ describe("MatrixClient", function() { }); describe("partitionThreadedEvents", function() { + const room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId); + it("returns empty arrays when given an empty arrays", function() { const events = []; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([]); expect(threaded).toEqual([]); }); @@ -566,24 +568,24 @@ describe("MatrixClient", function() { it("copies pre-thread in-timeline vote events onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; - const eventMessageInThread = buildEventMessageInThread(); const eventPollResponseReference = buildEventPollResponseReference(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); const events = [ + eventPollStartThreadRoot, eventMessageInThread, eventPollResponseReference, - eventPollStartThreadRoot, ]; // Vote has no threadId yet expect(eventPollResponseReference.threadId).toBeFalsy(); - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ // The message that was sent in a thread is missing - eventPollResponseReference, eventPollStartThreadRoot, + eventPollResponseReference, ]); // The vote event has been copied into the thread @@ -592,33 +594,34 @@ describe("MatrixClient", function() { expect(eventRefWithThreadId.threadId).toBeTruthy(); expect(threaded).toEqual([ + eventPollStartThreadRoot, eventMessageInThread, eventRefWithThreadId, - // Thread does not see thread root ]); }); it("copies pre-thread in-timeline reactions onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; - const eventMessageInThread = buildEventMessageInThread(); - const eventReaction = buildEventReaction(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); + const eventReaction = buildEventReaction(eventPollStartThreadRoot); const events = [ + eventPollStartThreadRoot, eventMessageInThread, eventReaction, - eventPollStartThreadRoot, ]; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ - eventReaction, eventPollStartThreadRoot, + eventReaction, ]); expect(threaded).toEqual([ + eventPollStartThreadRoot, eventMessageInThread, withThreadId(eventReaction, eventPollStartThreadRoot.getId()), ]); @@ -628,23 +631,24 @@ describe("MatrixClient", function() { client.clientOpts = { experimentalThreadSupport: true }; const eventPollResponseReference = buildEventPollResponseReference(); - const eventMessageInThread = buildEventMessageInThread(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); const events = [ + eventPollStartThreadRoot, eventPollResponseReference, eventMessageInThread, - eventPollStartThreadRoot, ]; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ - eventPollResponseReference, eventPollStartThreadRoot, + eventPollResponseReference, ]); expect(threaded).toEqual([ + eventPollStartThreadRoot, withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()), eventMessageInThread, ]); @@ -653,26 +657,27 @@ describe("MatrixClient", function() { it("copies post-thread in-timeline reactions onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; - const eventReaction = buildEventReaction(); - const eventMessageInThread = buildEventMessageInThread(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); + const eventReaction = buildEventReaction(eventPollStartThreadRoot); const events = [ - eventReaction, - eventMessageInThread, eventPollStartThreadRoot, + eventMessageInThread, + eventReaction, ]; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ - eventReaction, eventPollStartThreadRoot, + eventReaction, ]); expect(threaded).toEqual([ - withThreadId(eventReaction, eventPollStartThreadRoot.getId()), + eventPollStartThreadRoot, eventMessageInThread, + withThreadId(eventReaction, eventPollStartThreadRoot.getId()), ]); }); @@ -680,9 +685,9 @@ describe("MatrixClient", function() { client.clientOpts = { experimentalThreadSupport: true }; // This is based on recording the events in a real room: - const eventMessageInThread = buildEventMessageInThread(); - const eventPollResponseReference = buildEventPollResponseReference(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventPollResponseReference = buildEventPollResponseReference(); + const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot); const eventRoomName = buildEventRoomName(); const eventEncryption = buildEventEncryption(); const eventGuestAccess = buildEventGuestAccess(); @@ -693,9 +698,9 @@ describe("MatrixClient", function() { const eventCreate = buildEventCreate(); const events = [ - eventMessageInThread, - eventPollResponseReference, eventPollStartThreadRoot, + eventPollResponseReference, + eventMessageInThread, eventRoomName, eventEncryption, eventGuestAccess, @@ -705,12 +710,12 @@ describe("MatrixClient", function() { eventMember, eventCreate, ]; - const [timeline, threaded] = client.partitionThreadedEvents(events); + const [timeline, threaded] = client.partitionThreadedEvents(room, events); expect(timeline).toEqual([ // The message that was sent in a thread is missing - eventPollResponseReference, eventPollStartThreadRoot, + eventPollResponseReference, eventRoomName, eventEncryption, eventGuestAccess, @@ -721,11 +726,95 @@ describe("MatrixClient", function() { eventCreate, ]); - // Thread should contain only stuff that happened in the thread - - // no thread root, and no room state events + // Thread should contain only stuff that happened in the thread - no room state events expect(threaded).toEqual([ - eventMessageInThread, + eventPollStartThreadRoot, withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()), + eventMessageInThread, + ]); + }); + + it("sends redactions of reactions to thread responses to thread timeline only", () => { + client.clientOpts = { experimentalThreadSupport: true }; + + const threadRootEvent = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(threadRootEvent); + const threadedReaction = buildEventReaction(eventMessageInThread); + const threadedReactionRedaction = buildEventRedaction(threadedReaction); + + const events = [ + threadRootEvent, + eventMessageInThread, + threadedReaction, + threadedReactionRedaction, + ]; + + const [timeline, threaded] = client.partitionThreadedEvents(room, events); + + expect(timeline).toEqual([ + threadRootEvent, + ]); + + expect(threaded).toEqual([ + threadRootEvent, + eventMessageInThread, + threadedReaction, + threadedReactionRedaction, + ]); + }); + + it("sends reply to reply to thread root outside of thread to main timeline only", () => { + client.clientOpts = { experimentalThreadSupport: true }; + + const threadRootEvent = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(threadRootEvent); + const directReplyToThreadRoot = buildEventReply(threadRootEvent); + const replyToReply = buildEventReply(directReplyToThreadRoot); + + const events = [ + threadRootEvent, + eventMessageInThread, + directReplyToThreadRoot, + replyToReply, + ]; + + const [timeline, threaded] = client.partitionThreadedEvents(room, events); + + expect(timeline).toEqual([ + threadRootEvent, + directReplyToThreadRoot, + replyToReply, + ]); + + expect(threaded).toEqual([ + threadRootEvent, + eventMessageInThread, + ]); + }); + + it("sends reply to thread responses to thread timeline only", () => { + client.clientOpts = { experimentalThreadSupport: true }; + + const threadRootEvent = buildEventPollStartThreadRoot(); + const eventMessageInThread = buildEventMessageInThread(threadRootEvent); + const replyToThreadResponse = buildEventReply(eventMessageInThread); + + const events = [ + threadRootEvent, + eventMessageInThread, + replyToThreadResponse, + ]; + + const [timeline, threaded] = client.partitionThreadedEvents(room, events); + + expect(timeline).toEqual([ + threadRootEvent, + ]); + + expect(threaded).toEqual([ + threadRootEvent, + eventMessageInThread, + replyToThreadResponse, ]); }); }); @@ -737,16 +826,16 @@ function withThreadId(event, newThreadId) { return ret; } -const buildEventMessageInThread = () => new MatrixEvent({ +const buildEventMessageInThread = (root) => new MatrixEvent({ "age": 80098509, "content": { "algorithm": "m.megolm.v1.aes-sha2", "ciphertext": "ENCRYPTEDSTUFF", "device_id": "XISFUZSKHH", "m.relates_to": { - "event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", + "event_id": root.getId(), "m.in_reply_to": { - "event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", + "event_id": root.getId(), }, "rel_type": "m.thread", }, @@ -784,10 +873,10 @@ const buildEventPollResponseReference = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const buildEventReaction = () => new MatrixEvent({ +const buildEventReaction = (event) => new MatrixEvent({ "content": { "m.relates_to": { - "event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", + "event_id": event.getId(), "key": "🤗", "rel_type": "m.annotation", }, @@ -803,6 +892,22 @@ const buildEventReaction = () => new MatrixEvent({ "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", }); +const buildEventRedaction = (event) => new MatrixEvent({ + "content": { + + }, + "origin_server_ts": 1643977249239, + "sender": "@andybalaam-test1:matrix.org", + "redacts": event.getId(), + "type": "m.room.redaction", + "unsigned": { + "age": 22597, + "transaction_id": "m1643977249073.17", + }, + "event_id": "$86B2b-x3LgE4DlV4y24b7UHnt72LIA3rzjvMysTtAfB", + "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", +}); + const buildEventPollStartThreadRoot = () => new MatrixEvent({ "age": 80108647, "content": { @@ -821,6 +926,29 @@ const buildEventPollStartThreadRoot = () => new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); +const buildEventReply = (target) => new MatrixEvent({ + "age": 80098509, + "content": { + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "ENCRYPTEDSTUFF", + "device_id": "XISFUZSKHH", + "m.relates_to": { + "m.in_reply_to": { + "event_id": target.getId(), + }, + }, + "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", + "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", + }, + "event_id": target.getId() + Math.random(), + "origin_server_ts": 1643815466378, + "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", + "sender": "@andybalaam-test1:matrix.org", + "type": "m.room.encrypted", + "unsigned": { "age": 80098509 }, + "user_id": "@andybalaam-test1:matrix.org", +}); + const buildEventRoomName = () => new MatrixEvent({ "age": 80123249, "content": { diff --git a/spec/integ/matrix-client-syncing.spec.js b/spec/integ/matrix-client-syncing.spec.js index adeef9dda..6adb35a50 100644 --- a/spec/integ/matrix-client-syncing.spec.js +++ b/spec/integ/matrix-client-syncing.spec.js @@ -735,8 +735,7 @@ describe("MatrixClient syncing", function() { expect(tok).toEqual("pagTok"); }), - // first flush the filter request; this will make syncLeftRooms - // make its /sync call + // first flush the filter request; this will make syncLeftRooms make its /sync call httpBackend.flush("/filter").then(function() { return httpBackend.flushAllExpected(); }), diff --git a/spec/test-utils/test-utils.js b/spec/test-utils/test-utils.js deleted file mode 100644 index b2c180205..000000000 --- a/spec/test-utils/test-utils.js +++ /dev/null @@ -1,369 +0,0 @@ -// load olm before the sdk if possible -import '../olm-loader'; - -import { logger } from '../../src/logger'; -import { MatrixEvent } from "../../src/models/event"; - -/** - * Return a promise that is resolved when the client next emits a - * SYNCING event. - * @param {Object} client The client - * @param {Number=} count Number of syncs to wait for (default 1) - * @return {Promise} Resolves once the client has emitted a SYNCING event - */ -export function syncPromise(client, count) { - if (count === undefined) { - count = 1; - } - if (count <= 0) { - return Promise.resolve(); - } - - const p = new Promise((resolve, reject) => { - const cb = (state) => { - logger.log(`${Date.now()} syncPromise(${count}): ${state}`); - if (state === 'SYNCING') { - resolve(); - } else { - client.once('sync', cb); - } - }; - client.once('sync', cb); - }); - - return p.then(() => { - return syncPromise(client, count-1); - }); -} - -/** - * Create a spy for an object and automatically spy its methods. - * @param {*} constr The class constructor (used with 'new') - * @param {string} name The name of the class - * @return {Object} An instantiated object with spied methods/properties. - */ -export function mock(constr, name) { - // Based on - // http://eclipsesource.com/blogs/2014/03/27/mocks-in-jasmine-tests/ - const HelperConstr = new Function(); // jshint ignore:line - HelperConstr.prototype = constr.prototype; - const result = new HelperConstr(); - result.toString = function() { - return "mock" + (name ? " of " + name : ""); - }; - for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in - try { - if (constr.prototype[key] instanceof Function) { - result[key] = jest.fn(); - } - } catch (ex) { - // Direct access to some non-function fields of DOM prototypes may - // cause exceptions. - // Overwriting will not work either in that case. - } - } - return result; -} - -/** - * Create an Event. - * @param {Object} opts Values for the event. - * @param {string} opts.type The event.type - * @param {string} opts.room The event.room_id - * @param {string} opts.sender The event.sender - * @param {string} opts.skey Optional. The state key (auto inserts empty string) - * @param {Object} opts.content The event.content - * @param {boolean} opts.event True to make a MatrixEvent. - * @return {Object} a JSON object representing this event. - */ -export function mkEvent(opts) { - if (!opts.type || !opts.content) { - throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); - } - const event = { - type: opts.type, - room_id: opts.room, - sender: opts.sender || opts.user, // opts.user for backwards-compat - content: opts.content, - unsigned: opts.unsigned || {}, - event_id: "$" + Math.random() + "-" + Math.random(), - }; - if (opts.skey !== undefined) { - event.state_key = opts.skey; - } else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules", - "m.room.power_levels", "m.room.topic", - "com.example.state"].includes(opts.type)) { - event.state_key = ""; - } - return opts.event ? new MatrixEvent(event) : event; -} - -/** - * Create an m.presence event. - * @param {Object} opts Values for the presence. - * @return {Object|MatrixEvent} The event - */ -export function mkPresence(opts) { - if (!opts.user) { - throw new Error("Missing user"); - } - const event = { - event_id: "$" + Math.random() + "-" + Math.random(), - type: "m.presence", - sender: opts.sender || opts.user, // opts.user for backwards-compat - content: { - avatar_url: opts.url, - displayname: opts.name, - last_active_ago: opts.ago, - presence: opts.presence || "offline", - }, - }; - return opts.event ? new MatrixEvent(event) : event; -} - -/** - * Create an m.room.member event. - * @param {Object} opts Values for the membership. - * @param {string} opts.room The room ID for the event. - * @param {string} opts.mship The content.membership for the event. - * @param {string} opts.sender The sender user ID for the event. - * @param {string} opts.skey The target user ID for the event if applicable - * e.g. for invites/bans. - * @param {string} opts.name The content.displayname for the event. - * @param {string} opts.url The content.avatar_url for the event. - * @param {boolean} opts.event True to make a MatrixEvent. - * @return {Object|MatrixEvent} The event - */ -export function mkMembership(opts) { - opts.type = "m.room.member"; - if (!opts.skey) { - opts.skey = opts.sender || opts.user; - } - if (!opts.mship) { - throw new Error("Missing .mship => " + JSON.stringify(opts)); - } - opts.content = { - membership: opts.mship, - }; - if (opts.name) { - opts.content.displayname = opts.name; - } - if (opts.url) { - opts.content.avatar_url = opts.url; - } - return mkEvent(opts); -} - -/** - * Create an m.room.message event. - * @param {Object} opts Values for the message - * @param {string} opts.room The room ID for the event. - * @param {string} opts.user The user ID for the event. - * @param {string} opts.msg Optional. The content.body for the event. - * @param {boolean} opts.event True to make a MatrixEvent. - * @return {Object|MatrixEvent} The event - */ -export function mkMessage(opts) { - opts.type = "m.room.message"; - if (!opts.msg) { - opts.msg = "Random->" + Math.random(); - } - if (!opts.room || !opts.user) { - throw new Error("Missing .room or .user from %s", opts); - } - opts.content = { - msgtype: "m.text", - body: opts.msg, - }; - return mkEvent(opts); -} - -/** - * A mock implementation of webstorage - * - * @constructor - */ -export function MockStorageApi() { - this.data = {}; -} -MockStorageApi.prototype = { - get length() { - return Object.keys(this.data).length; - }, - key: function(i) { - return Object.keys(this.data)[i]; - }, - setItem: function(k, v) { - this.data[k] = v; - }, - getItem: function(k) { - return this.data[k] || null; - }, - removeItem: function(k) { - delete this.data[k]; - }, -}; - -/** - * If an event is being decrypted, wait for it to finish being decrypted. - * - * @param {MatrixEvent} event - * @returns {Promise} promise which resolves (to `event`) when the event has been decrypted - */ -export function awaitDecryption(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`); - - return new Promise((resolve, reject) => { - event.once('Event.decrypted', (ev) => { - logger.log(`${Date.now()} event ${event.getId()} now decrypted`); - resolve(ev); - }); - }); - } -} - -export function HttpResponse( - httpLookups, acceptKeepalives, ignoreUnhandledSync, -) { - this.httpLookups = httpLookups; - this.acceptKeepalives = acceptKeepalives === undefined ? true : acceptKeepalives; - this.ignoreUnhandledSync = ignoreUnhandledSync; - this.pendingLookup = null; -} - -HttpResponse.prototype.request = function( - cb, method, path, qp, data, prefix, -) { - if (path === HttpResponse.KEEP_ALIVE_PATH && this.acceptKeepalives) { - return Promise.resolve(); - } - const next = this.httpLookups.shift(); - const logLine = ( - "MatrixClient[UT] RECV " + method + " " + path + " " + - "EXPECT " + (next ? next.method : next) + " " + (next ? next.path : next) - ); - logger.log(logLine); - - if (!next) { // no more things to return - if (method === "GET" && path === "/sync" && this.ignoreUnhandledSync) { - logger.log("MatrixClient[UT] Ignoring."); - return new Promise(() => {}); - } - if (this.pendingLookup) { - if (this.pendingLookup.method === method - && this.pendingLookup.path === path) { - return this.pendingLookup.promise; - } - // >1 pending thing, and they are different, whine. - expect(false).toBe( - true, ">1 pending request. You should probably handle them. " + - "PENDING: " + JSON.stringify(this.pendingLookup) + " JUST GOT: " + - method + " " + path, - ); - } - this.pendingLookup = { - promise: new Promise(() => {}), - method: method, - path: path, - }; - return this.pendingLookup.promise; - } - if (next.path === path && next.method === method) { - logger.log( - "MatrixClient[UT] Matched. Returning " + - (next.error ? "BAD" : "GOOD") + " response", - ); - if (next.expectBody) { - expect(next.expectBody).toEqual(data); - } - if (next.expectQueryParams) { - Object.keys(next.expectQueryParams).forEach(function(k) { - expect(qp[k]).toEqual(next.expectQueryParams[k]); - }); - } - - if (next.thenCall) { - process.nextTick(next.thenCall, 0); // next tick so we return first. - } - - if (next.error) { - return Promise.reject({ - errcode: next.error.errcode, - httpStatus: next.error.httpStatus, - name: next.error.errcode, - message: "Expected testing error", - data: next.error, - }); - } - return Promise.resolve(next.data); - } else if (method === "GET" && path === "/sync" && this.ignoreUnhandledSync) { - logger.log("MatrixClient[UT] Ignoring."); - this.httpLookups.unshift(next); - return new Promise(() => {}); - } - expect(true).toBe(false, "Expected different request. " + logLine); - return new Promise(() => {}); -}; - -HttpResponse.KEEP_ALIVE_PATH = "/_matrix/client/versions"; - -HttpResponse.PUSH_RULES_RESPONSE = { - method: "GET", - path: "/pushrules/", - data: {}, -}; - -HttpResponse.PUSH_RULES_RESPONSE = { - method: "GET", - path: "/pushrules/", - data: {}, -}; - -HttpResponse.USER_ID = "@alice:bar"; - -HttpResponse.filterResponse = function(userId) { - const filterPath = "/user/" + encodeURIComponent(userId) + "/filter"; - return { - method: "POST", - path: filterPath, - data: { filter_id: "f1lt3r" }, - }; -}; - -HttpResponse.SYNC_DATA = { - next_batch: "s_5_3", - presence: { events: [] }, - rooms: {}, -}; - -HttpResponse.SYNC_RESPONSE = { - method: "GET", - path: "/sync", - data: HttpResponse.SYNC_DATA, -}; - -HttpResponse.defaultResponses = function(userId) { - return [ - HttpResponse.PUSH_RULES_RESPONSE, - HttpResponse.filterResponse(userId), - HttpResponse.SYNC_RESPONSE, - ]; -}; - -export function setHttpResponses( - httpBackend, responses, -) { - responses.forEach(response => { - httpBackend - .when(response.method, response.path) - .respond(200, response.data); - }); -} - -export const emitPromise = (e, k) => new Promise(r => e.once(k, r)); diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts new file mode 100644 index 000000000..24f7b9966 --- /dev/null +++ b/spec/test-utils/test-utils.ts @@ -0,0 +1,282 @@ +// eslint-disable-next-line no-restricted-imports +import EventEmitter from "events"; + +// load olm before the sdk if possible +import '../olm-loader'; + +import { logger } from '../../src/logger'; +import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event"; +import { ClientEvent, EventType, MatrixClient } from "../../src"; +import { SyncState } from "../../src/sync"; + +/** + * Return a promise that is resolved when the client next emits a + * SYNCING event. + * @param {Object} client The client + * @param {Number=} count Number of syncs to wait for (default 1) + * @return {Promise} Resolves once the client has emitted a SYNCING event + */ +export function syncPromise(client: MatrixClient, count = 1): Promise { + if (count <= 0) { + return Promise.resolve(); + } + + const p = new Promise((resolve) => { + const cb = (state: SyncState) => { + logger.log(`${Date.now()} syncPromise(${count}): ${state}`); + if (state === SyncState.Syncing) { + resolve(); + } else { + client.once(ClientEvent.Sync, cb); + } + }; + client.once(ClientEvent.Sync, cb); + }); + + return p.then(() => { + return syncPromise(client, count - 1); + }); +} + +/** + * Create a spy for an object and automatically spy its methods. + * @param {*} constr The class constructor (used with 'new') + * @param {string} name The name of the class + * @return {Object} An instantiated object with spied methods/properties. + */ +export function mock(constr: { new(...args: any[]): T }, name: string): T { + // Based on http://eclipsesource.com/blogs/2014/03/27/mocks-in-jasmine-tests/ + const HelperConstr = new Function(); // jshint ignore:line + HelperConstr.prototype = constr.prototype; + // @ts-ignore + const result = new HelperConstr(); + result.toString = function() { + return "mock" + (name ? " of " + name : ""); + }; + for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in + try { + if (constr.prototype[key] instanceof Function) { + result[key] = jest.fn(); + } + } catch (ex) { + // Direct access to some non-function fields of DOM prototypes may + // cause exceptions. + // Overwriting will not work either in that case. + } + } + return result; +} + +interface IEventOpts { + type: EventType | string; + room: string; + sender?: string; + skey?: string; + content: IContent; + event?: boolean; + user?: string; + unsigned?: IUnsigned; + redacts?: string; +} + +/** + * Create an Event. + * @param {Object} opts Values for the event. + * @param {string} opts.type The event.type + * @param {string} opts.room The event.room_id + * @param {string} opts.sender The event.sender + * @param {string} opts.skey Optional. The state key (auto inserts empty string) + * @param {Object} opts.content The event.content + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object} a JSON object representing this event. + */ +export function mkEvent(opts: IEventOpts): object | MatrixEvent { + if (!opts.type || !opts.content) { + throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); + } + const event: Partial = { + type: opts.type as string, + room_id: opts.room, + sender: opts.sender || opts.user, // opts.user for backwards-compat + content: opts.content, + unsigned: opts.unsigned || {}, + event_id: "$" + Math.random() + "-" + Math.random(), + redacts: opts.redacts, + }; + if (opts.skey !== undefined) { + event.state_key = opts.skey; + } else if ([ + EventType.RoomName, + EventType.RoomTopic, + EventType.RoomCreate, + EventType.RoomJoinRules, + EventType.RoomPowerLevels, + EventType.RoomTopic, + "com.example.state", + ].includes(opts.type)) { + event.state_key = ""; + } + return opts.event ? new MatrixEvent(event) : event; +} + +interface IPresenceOpts { + user?: string; + sender?: string; + url: string; + name: string; + ago: number; + presence?: string; + event?: boolean; +} + +/** + * Create an m.presence event. + * @param {Object} opts Values for the presence. + * @return {Object|MatrixEvent} The event + */ +export function mkPresence(opts: IPresenceOpts): object | MatrixEvent { + const event = { + event_id: "$" + Math.random() + "-" + Math.random(), + type: "m.presence", + sender: opts.sender || opts.user, // opts.user for backwards-compat + content: { + avatar_url: opts.url, + displayname: opts.name, + last_active_ago: opts.ago, + presence: opts.presence || "offline", + }, + }; + return opts.event ? new MatrixEvent(event) : event; +} + +interface IMembershipOpts { + room: string; + mship: string; + sender?: string; + user?: string; + skey?: string; + name?: string; + url?: string; + event?: boolean; +} + +/** + * Create an m.room.member event. + * @param {Object} opts Values for the membership. + * @param {string} opts.room The room ID for the event. + * @param {string} opts.mship The content.membership for the event. + * @param {string} opts.sender The sender user ID for the event. + * @param {string} opts.skey The target user ID for the event if applicable + * e.g. for invites/bans. + * @param {string} opts.name The content.displayname for the event. + * @param {string} opts.url The content.avatar_url for the event. + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object|MatrixEvent} The event + */ +export function mkMembership(opts: IMembershipOpts): object | MatrixEvent { + const eventOpts: IEventOpts = { + ...opts, + type: EventType.RoomMember, + content: { + membership: opts.mship, + }, + }; + + if (!opts.skey) { + eventOpts.skey = opts.sender || opts.user; + } + if (opts.name) { + eventOpts.content.displayname = opts.name; + } + if (opts.url) { + eventOpts.content.avatar_url = opts.url; + } + return mkEvent(eventOpts); +} + +interface IMessageOpts { + room: string; + user: string; + msg?: string; + event?: boolean; +} + +/** + * Create an m.room.message event. + * @param {Object} opts Values for the message + * @param {string} opts.room The room ID for the event. + * @param {string} opts.user The user ID for the event. + * @param {string} opts.msg Optional. The content.body for the event. + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object|MatrixEvent} The event + */ +export function mkMessage(opts: IMessageOpts): object | MatrixEvent { + const eventOpts: IEventOpts = { + ...opts, + type: EventType.RoomMessage, + content: { + msgtype: "m.text", + body: opts.msg, + }, + }; + + if (!eventOpts.content.body) { + eventOpts.content.body = "Random->" + Math.random(); + } + return mkEvent(eventOpts); +} + +/** + * A mock implementation of webstorage + * + * @constructor + */ +export class MockStorageApi { + private data: Record = {}; + + public get length() { + return Object.keys(this.data).length; + } + + public key(i: number): any { + return Object.keys(this.data)[i]; + } + + public setItem(k: string, v: any): void { + this.data[k] = v; + } + + public getItem(k: string): any { + return this.data[k] || null; + } + + public removeItem(k: string): void { + delete this.data[k]; + } +} + +/** + * If an event is being decrypted, wait for it to finish being decrypted. + * + * @param {MatrixEvent} event + * @returns {Promise} promise which resolves (to `event`) when the event has been decrypted + */ +export async function awaitDecryption(event: MatrixEvent): Promise { + // 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`); + + return new Promise((resolve) => { + event.once(MatrixEventEvent.Decrypted, (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); + }); + }); + } +} + +export const emitPromise = (e: EventEmitter, k: string): Promise => new Promise(r => e.once(k, r)); diff --git a/spec/unit/crypto/cross-signing.spec.js b/spec/unit/crypto/cross-signing.spec.js index 691c1612f..f8639781b 100644 --- a/spec/unit/crypto/cross-signing.spec.js +++ b/spec/unit/crypto/cross-signing.spec.js @@ -20,11 +20,33 @@ import anotherjson from 'another-json'; import * as olmlib from "../../../src/crypto/olmlib"; import { TestClient } from '../../TestClient'; -import { HttpResponse, setHttpResponses } from '../../test-utils/test-utils'; import { resetCrossSigningKeys } from "./crypto-utils"; import { MatrixError } from '../../../src/http-api'; import { logger } from '../../../src/logger'; +const PUSH_RULES_RESPONSE = { + method: "GET", + path: "/pushrules/", + data: {}, +}; + +const filterResponse = function(userId) { + const filterPath = "/user/" + encodeURIComponent(userId) + "/filter"; + return { + method: "POST", + path: filterPath, + data: { filter_id: "f1lt3r" }, + }; +}; + +function setHttpResponses(httpBackend, responses) { + responses.forEach(response => { + httpBackend + .when(response.method, response.path) + .respond(200, response.data); + }); +} + async function makeTestClient(userInfo, options, keys) { if (!keys) keys = {}; @@ -237,7 +259,7 @@ describe("Cross Signing", function() { // feed sync result that includes master key, ssk, device key const responses = [ - HttpResponse.PUSH_RULES_RESPONSE, + PUSH_RULES_RESPONSE, { method: "POST", path: "/keys/upload", @@ -248,7 +270,7 @@ describe("Cross Signing", function() { }, }, }, - HttpResponse.filterResponse("@alice:example.com"), + filterResponse("@alice:example.com"), { method: "GET", path: "/sync", @@ -493,7 +515,7 @@ describe("Cross Signing", function() { // - master key signed by her usk (pretend that it was signed by another // of Alice's devices) const responses = [ - HttpResponse.PUSH_RULES_RESPONSE, + PUSH_RULES_RESPONSE, { method: "POST", path: "/keys/upload", @@ -504,7 +526,7 @@ describe("Cross Signing", function() { }, }, }, - HttpResponse.filterResponse("@alice:example.com"), + filterResponse("@alice:example.com"), { method: "GET", path: "/sync", diff --git a/spec/unit/filter-component.spec.ts b/spec/unit/filter-component.spec.ts index 6773556e4..47ffb37cf 100644 --- a/spec/unit/filter-component.spec.ts +++ b/spec/unit/filter-component.spec.ts @@ -1,4 +1,5 @@ import { + MatrixEvent, RelationType, } from "../../src"; import { FilterComponent } from "../../src/filter-component"; @@ -13,7 +14,7 @@ describe("Filter Component", function() { content: { }, room: 'roomId', event: true, - }); + }) as MatrixEvent; const checkResult = filter.check(event); @@ -27,7 +28,7 @@ describe("Filter Component", function() { content: { }, room: 'roomId', event: true, - }); + }) as MatrixEvent; const checkResult = filter.check(event); @@ -54,7 +55,7 @@ describe("Filter Component", function() { }, }, }, - }); + }) as MatrixEvent; expect(filter.check(threadRootNotParticipated)).toBe(false); }); @@ -79,7 +80,7 @@ describe("Filter Component", function() { user: '@someone-else:server.org', room: 'roomId', event: true, - }); + }) as MatrixEvent; expect(filter.check(threadRootParticipated)).toBe(true); }); @@ -99,7 +100,7 @@ describe("Filter Component", function() { [RelationType.Reference]: {}, }, }, - }); + }) as MatrixEvent; expect(filter.check(referenceRelationEvent)).toBe(false); }); @@ -122,7 +123,7 @@ describe("Filter Component", function() { }, room: 'roomId', event: true, - }); + }) as MatrixEvent; const eventWithMultipleRelations = mkEvent({ "type": "m.room.message", @@ -147,7 +148,7 @@ describe("Filter Component", function() { }, "room": 'roomId', "event": true, - }); + }) as MatrixEvent; const noMatchEvent = mkEvent({ "type": "m.room.message", @@ -159,7 +160,7 @@ describe("Filter Component", function() { }, "room": 'roomId', "event": true, - }); + }) as MatrixEvent; expect(filter.check(threadRootEvent)).toBe(true); expect(filter.check(eventWithMultipleRelations)).toBe(true); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 251ade94c..c49bdccc6 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -32,6 +32,7 @@ import { Preset } from "../../src/@types/partials"; import * as testUtils from "../test-utils/test-utils"; import { makeBeaconInfoContent } from "../../src/content-helpers"; import { M_BEACON_INFO } from "../../src/@types/beacon"; +import { Room } from "../../src"; jest.useFakeTimers(); @@ -957,6 +958,7 @@ describe("MatrixClient", function() { it("partitions root events to room timeline and thread timeline", () => { const supportsExperimentalThreads = client.supportsExperimentalThreads; client.supportsExperimentalThreads = () => true; + const room = new Room("!room1:matrix.org", client, userId); const rootEvent = new MatrixEvent({ "content": {}, @@ -979,9 +981,9 @@ describe("MatrixClient", function() { expect(rootEvent.isThreadRoot).toBe(true); - const [room, threads] = client.partitionThreadedEvents([rootEvent]); - expect(room).toHaveLength(1); - expect(threads).toHaveLength(1); + const [roomEvents, threadEvents] = client.partitionThreadedEvents(room, [rootEvent]); + expect(roomEvents).toHaveLength(1); + expect(threadEvents).toHaveLength(1); // Restore method client.supportsExperimentalThreads = supportsExperimentalThreads; diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 6977c862f..faa73ba29 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -20,7 +20,16 @@ limitations under the License. */ import * as utils from "../test-utils/test-utils"; -import { DuplicateStrategy, EventStatus, MatrixEvent, PendingEventOrdering, RoomEvent } from "../../src"; +import { + DuplicateStrategy, + EventStatus, + EventType, + JoinRule, + MatrixEvent, + PendingEventOrdering, + RelationType, + RoomEvent, +} from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; import { Room } from "../../src/models/room"; import { RoomState } from "../../src/models/room-state"; @@ -38,10 +47,8 @@ describe("Room", function() { beforeEach(function() { room = new Room(roomId, null, userA); // mock RoomStates - room.oldState = room.getLiveTimeline().startState = - utils.mock(RoomState, "oldState"); - room.currentState = room.getLiveTimeline().endState = - utils.mock(RoomState, "currentState"); + room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState"); + room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); }); describe("getAvatarUrl", function() { @@ -49,10 +56,10 @@ describe("Room", function() { it("should return the URL from m.room.avatar preferentially", function() { room.currentState.getStateEvents.mockImplementation(function(type, key) { - if (type === "m.room.avatar" && key === "") { + if (type === EventType.RoomAvatar && key === "") { return utils.mkEvent({ event: true, - type: "m.room.avatar", + type: EventType.RoomAvatar, skey: "", room: roomId, user: userA, @@ -97,20 +104,20 @@ describe("Room", function() { }); describe("addLiveEvents", function() { - const events = [ + const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "changing room name", event: true, - }), + }) as MatrixEvent, utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, - }), + }) as MatrixEvent, ]; it("should call RoomState.setTypingEvent on m.typing events", function() { const typing = utils.mkEvent({ room: roomId, - type: "m.typing", + type: EventType.Typing, event: true, content: { user_ids: [userA], @@ -130,7 +137,7 @@ describe("Room", function() { // make a duplicate const dupe = utils.mkMessage({ room: roomId, user: userA, msg: "dupe", event: true, - }); + }) as MatrixEvent; dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); @@ -142,7 +149,7 @@ describe("Room", function() { // make a duplicate const dupe = utils.mkMessage({ room: roomId, user: userA, msg: "dupe", event: true, - }); + }) as MatrixEvent; dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); @@ -166,16 +173,16 @@ describe("Room", function() { it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", function() { - const events = [ + const events: MatrixEvent[] = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }), + }) as MatrixEvent, utils.mkEvent({ - type: "m.room.name", room: roomId, user: userB, event: true, + type: EventType.RoomName, room: roomId, user: userB, event: true, content: { name: "New room", }, - }), + }) as MatrixEvent, ]; room.addLiveEvents(events); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( @@ -208,13 +215,13 @@ describe("Room", function() { it("should emit Room.localEchoUpdated when a local echo is updated", function() { const localEvent = utils.mkMessage({ room: roomId, user: userA, event: true, - }); + }) as MatrixEvent; localEvent.status = EventStatus.SENDING; const localEventId = localEvent.getId(); const remoteEvent = utils.mkMessage({ room: roomId, user: userA, event: true, - }); + }) as MatrixEvent; remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; const remoteEventId = remoteEvent.getId(); @@ -259,7 +266,7 @@ describe("Room", function() { room: roomId, user: userA, msg: "changing room name", event: true, }), utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, }), ]; @@ -318,13 +325,13 @@ describe("Room", function() { }); const newEv = utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, - }); + }) as MatrixEvent; const oldEv = utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "Old Room Name" }, - }); + }) as MatrixEvent; room.addLiveEvents([newEv]); expect(newEv.sender).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); @@ -358,10 +365,10 @@ describe("Room", function() { const newEv = utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }); + }) as MatrixEvent; const oldEv = utils.mkMembership({ room: roomId, mship: "ban", user: userB, skey: userA, event: true, - }); + }) as MatrixEvent; room.addLiveEvents([newEv]); expect(newEv.target).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); @@ -370,16 +377,16 @@ describe("Room", function() { it("should call setStateEvents on the right RoomState with the right " + "forwardLooking value for old events", function() { - const events = [ + const events: MatrixEvent[] = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }), + }) as MatrixEvent, utils.mkEvent({ - type: "m.room.name", room: roomId, user: userB, event: true, + type: EventType.RoomName, room: roomId, user: userB, event: true, content: { name: "New room", }, - }), + }) as MatrixEvent, ]; room.addEventsToTimeline(events, true, room.getLiveTimeline()); @@ -407,11 +414,11 @@ describe("Room", function() { room: roomId, user: userA, msg: "A message", event: true, }), utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, }), utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, event: true, + type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "Another New Name" }, }), ]; @@ -426,8 +433,8 @@ describe("Room", function() { const oldState = room.getLiveTimeline().getState(EventTimeline.BACKWARDS); const newState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); expect(room.getLiveTimeline().getEvents().length).toEqual(1); - expect(oldState.getStateEvents("m.room.name", "")).toEqual(events[1]); - expect(newState.getStateEvents("m.room.name", "")).toEqual(events[2]); + expect(oldState.getStateEvents(EventType.RoomName, "")).toEqual(events[1]); + expect(newState.getStateEvents(EventType.RoomName, "")).toEqual(events[2]); }); it("should reset the legacy timeline fields", function() { @@ -474,26 +481,24 @@ describe("Room", function() { }); }; - describe("resetLiveTimeline with timelinesupport enabled", - resetTimelineTests.bind(null, true)); - describe("resetLiveTimeline with timelinesupport disabled", - resetTimelineTests.bind(null, false)); + describe("resetLiveTimeline with timeline support enabled", resetTimelineTests.bind(null, true)); + describe("resetLiveTimeline with timeline support disabled", resetTimelineTests.bind(null, false)); describe("compareEventOrdering", function() { beforeEach(function() { room = new Room(roomId, null, null, { timelineSupport: true }); }); - const events = [ + const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }), + }) as MatrixEvent, ]; it("should handle events in the same timeline", function() { @@ -629,39 +634,39 @@ describe("Room", function() { }); describe("recalculate", function() { - const setJoinRule = function(rule) { + const setJoinRule = function(rule: JoinRule) { room.addLiveEvents([utils.mkEvent({ - type: "m.room.join_rules", room: roomId, user: userA, content: { + type: EventType.RoomJoinRules, room: roomId, user: userA, content: { join_rule: rule, }, event: true, - })]); + }) as MatrixEvent]); }; - const setAltAliases = function(aliases) { + const setAltAliases = function(aliases: string[]) { room.addLiveEvents([utils.mkEvent({ - type: "m.room.canonical_alias", room: roomId, skey: "", content: { + type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alt_aliases: aliases, }, event: true, - })]); + }) as MatrixEvent]); }; - const setAlias = function(alias) { + const setAlias = function(alias: string) { room.addLiveEvents([utils.mkEvent({ - type: "m.room.canonical_alias", room: roomId, skey: "", content: { alias }, event: true, - })]); + type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alias }, event: true, + }) as MatrixEvent]); }; - const setRoomName = function(name) { + const setRoomName = function(name: string) { room.addLiveEvents([utils.mkEvent({ - type: "m.room.name", room: roomId, user: userA, content: { + type: EventType.RoomName, room: roomId, user: userA, content: { name: name, }, event: true, - })]); + }) as MatrixEvent]); }; - const addMember = function(userId, state = "join", opts: any = {}) { + const addMember = function(userId: string, state = "join", opts: any = {}) { opts.room = roomId; opts.mship = state; opts.user = opts.user || userId; opts.skey = userId; opts.event = true; - const event = utils.mkMembership(opts); + const event = utils.mkMembership(opts) as MatrixEvent; room.addLiveEvents([event]); return event; }; @@ -678,15 +683,14 @@ describe("Room", function() { const event = addMember(userA, "invite"); event.event.unsigned = {}; - event.event.unsigned.invite_room_state = [ - { - type: "m.room.name", - state_key: "", - content: { - name: roomName, - }, + event.event.unsigned.invite_room_state = [{ + type: EventType.RoomName, + state_key: "", + content: { + name: roomName, }, - ]; + sender: "@bob:foobar", + }]; room.recalculate(); expect(room.name).toEqual(roomName); @@ -698,15 +702,14 @@ describe("Room", function() { setRoomName(roomName); const roomNameToIgnore = "ignoreme"; event.event.unsigned = {}; - event.event.unsigned.invite_room_state = [ - { - type: "m.room.name", - state_key: "", - content: { - name: roomNameToIgnore, - }, + event.event.unsigned.invite_room_state = [{ + type: EventType.RoomName, + state_key: "", + content: { + name: roomNameToIgnore, }, - ]; + sender: "@bob:foobar", + }]; room.recalculate(); expect(room.name).toEqual(roomName); @@ -798,7 +801,7 @@ describe("Room", function() { it("should return the names of members in a private (invite join_rules)" + " room if a room name and alias don't exist and there are >3 members.", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userA); addMember(userB); addMember(userC); @@ -818,9 +821,8 @@ describe("Room", function() { }); it("should return the names of members in a private (invite join_rules)" + - " room if a room name and alias don't exist and there are >2 members.", - function() { - setJoinRule("invite"); + " room if a room name and alias don't exist and there are >2 members.", function() { + setJoinRule(JoinRule.Invite); addMember(userA); addMember(userB); addMember(userC); @@ -831,9 +833,8 @@ describe("Room", function() { }); it("should return the names of members in a public (public join_rules)" + - " room if a room name and alias don't exist and there are >2 members.", - function() { - setJoinRule("public"); + " room if a room name and alias don't exist and there are >2 members.", function() { + setJoinRule(JoinRule.Public); addMember(userA); addMember(userB); addMember(userC); @@ -844,9 +845,8 @@ describe("Room", function() { }); it("should show the other user's name for public (public join_rules)" + - " rooms if a room name and alias don't exist and it is a 1:1-chat.", - function() { - setJoinRule("public"); + " rooms if a room name and alias don't exist and it is a 1:1-chat.", function() { + setJoinRule(JoinRule.Public); addMember(userA); addMember(userB); room.recalculate(); @@ -857,7 +857,7 @@ describe("Room", function() { it("should show the other user's name for private " + "(invite join_rules) rooms if a room name and alias don't exist and it" + " is a 1:1-chat.", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userA); addMember(userB); room.recalculate(); @@ -867,7 +867,7 @@ describe("Room", function() { it("should show the other user's name for private" + " (invite join_rules) rooms if you are invited to it.", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userA, "invite", { user: userB }); addMember(userB); room.recalculate(); @@ -878,7 +878,7 @@ describe("Room", function() { it("should show the room alias if one exists for private " + "(invite join_rules) rooms if a room name doesn't exist.", function() { const alias = "#room_alias:here"; - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); setAlias(alias); room.recalculate(); const name = room.name; @@ -888,7 +888,7 @@ describe("Room", function() { it("should show the room alias if one exists for public " + "(public join_rules) rooms if a room name doesn't exist.", function() { const alias = "#room_alias:here"; - setJoinRule("public"); + setJoinRule(JoinRule.Public); setAlias(alias); room.recalculate(); const name = room.name; @@ -906,7 +906,7 @@ describe("Room", function() { it("should show the room name if one exists for private " + "(invite join_rules) rooms.", function() { const roomName = "A mighty name indeed"; - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); setRoomName(roomName); room.recalculate(); const name = room.name; @@ -916,7 +916,7 @@ describe("Room", function() { it("should show the room name if one exists for public " + "(public join_rules) rooms.", function() { const roomName = "A mighty name indeed"; - setJoinRule("public"); + setJoinRule(JoinRule.Public); setRoomName(roomName); room.recalculate(); expect(room.name).toEqual(roomName); @@ -924,7 +924,7 @@ describe("Room", function() { it("should return 'Empty room' for private (invite join_rules) rooms if" + " a room name and alias don't exist and it is a self-chat.", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userA); room.recalculate(); expect(room.name).toEqual("Empty room"); @@ -932,7 +932,7 @@ describe("Room", function() { it("should return 'Empty room' for public (public join_rules) rooms if a" + " room name and alias don't exist and it is a self-chat.", function() { - setJoinRule("public"); + setJoinRule(JoinRule.Public); addMember(userA); room.recalculate(); const name = room.name; @@ -950,7 +950,7 @@ describe("Room", function() { it("should return '[inviter display name] if state event " + "available", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userB, 'join', { name: "Alice" }); addMember(userA, "invite", { user: userA }); room.recalculate(); @@ -960,7 +960,7 @@ describe("Room", function() { it("should return inviter mxid if display name not available", function() { - setJoinRule("invite"); + setJoinRule(JoinRule.Invite); addMember(userB); addMember(userA, "invite", { user: userA }); room.recalculate(); @@ -974,9 +974,9 @@ describe("Room", function() { const eventToAck = utils.mkMessage({ room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE", event: true, - }); + }) as MatrixEvent; - function mkReceipt(roomId, records) { + function mkReceipt(roomId: string, records) { const content = {}; records.forEach(function(r) { if (!content[r.eventId]) { @@ -996,7 +996,7 @@ describe("Room", function() { }); } - function mkRecord(eventId, type, userId, ts) { + function mkRecord(eventId: string, type: string, userId: string, ts: number) { ts = ts || Date.now(); return { eventId: eventId, @@ -1007,20 +1007,19 @@ describe("Room", function() { } describe("addReceipt", function() { - it("should store the receipt so it can be obtained via getReceiptsForEvent", - function() { - const 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 store the receipt so it can be obtained via getReceiptsForEvent", function() { + const 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 emit an event when a receipt is added", function() { @@ -1041,7 +1040,7 @@ describe("Room", function() { const nextEventToAck = utils.mkMessage({ room: roomId, user: userA, msg: "I AM HERE YOU KNOW", event: true, - }); + }) as MatrixEvent; const ts = 13787898424; room.addReceipt(mkReceipt(roomId, [ mkRecord(eventToAck.getId(), "m.read", userB, ts), @@ -1076,11 +1075,11 @@ describe("Room", function() { const eventTwo = utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }); + }) as MatrixEvent; const eventThree = utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }); + }) as MatrixEvent; const ts = 13787898424; room.addReceipt(mkReceipt(roomId, [ mkRecord(eventToAck.getId(), "m.read", userB, ts), @@ -1124,19 +1123,19 @@ describe("Room", function() { }); it("should prioritise the most recent event", function() { - const events = [ + const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }), + }) as MatrixEvent, ]; room.addLiveEvents(events); @@ -1162,19 +1161,19 @@ describe("Room", function() { }); it("should prioritise the most recent event even if it is synthetic", () => { - const events = [ + const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }), + }) as MatrixEvent, utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }), + }) as MatrixEvent, ]; room.addLiveEvents(events); @@ -1265,14 +1264,14 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, - }); + }) as MatrixEvent; const eventB = utils.mkMessage({ room: roomId, user: userA, msg: "local 1", event: true, - }); + }) as MatrixEvent; eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ room: roomId, user: userA, msg: "remote 2", event: true, - }); + }) as MatrixEvent; room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); @@ -1291,14 +1290,14 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, - }); + }) as MatrixEvent; const eventB = utils.mkMessage({ room: roomId, user: userA, msg: "local 1", event: true, - }); + }) as MatrixEvent; eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ room: roomId, user: userA, msg: "remote 2", event: true, - }); + }) as MatrixEvent; room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); @@ -1318,7 +1317,7 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, - }); + }) as MatrixEvent; eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); @@ -1351,7 +1350,7 @@ describe("Room", function() { const room = new Room(roomId, null, userA); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, - }); + }) as MatrixEvent; eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); @@ -1424,9 +1423,12 @@ describe("Room", function() { } const memberEvent = utils.mkMembership({ - user: "@user_a:bar", mship: "join", - room: roomId, event: true, name: "User A", - }); + user: "@user_a:bar", + mship: "join", + room: roomId, + event: true, + name: "User A", + }) as MatrixEvent; it("should load members from server on first call", async function() { const client = createClientMock([memberEvent]); @@ -1441,9 +1443,12 @@ describe("Room", function() { it("should take members from storage if available", async function() { const memberEvent2 = utils.mkMembership({ - user: "@user_a:bar", mship: "join", - room: roomId, event: true, name: "Ms A", - }); + user: "@user_a:bar", + mship: "join", + room: roomId, + event: true, + name: "Ms A", + }) as MatrixEvent; const client = createClientMock([memberEvent2], [memberEvent]); const room = new Room(roomId, client as any, null, { lazyLoadMembers: true }); @@ -1475,8 +1480,8 @@ describe("Room", function() { it("should return synced membership if membership isn't available yet", function() { const room = new Room(roomId, null, userA); - room.updateMyMembership("invite"); - expect(room.getMyMembership()).toEqual("invite"); + room.updateMyMembership(JoinRule.Invite); + expect(room.getMyMembership()).toEqual(JoinRule.Invite); }); it("should emit a Room.myMembership event on a change", function() { @@ -1485,11 +1490,11 @@ describe("Room", function() { room.on(RoomEvent.MyMembership, (_room, membership, oldMembership) => { events.push({ membership, oldMembership }); }); - room.updateMyMembership("invite"); - expect(room.getMyMembership()).toEqual("invite"); + room.updateMyMembership(JoinRule.Invite); + expect(room.getMyMembership()).toEqual(JoinRule.Invite); expect(events[0]).toEqual({ membership: "invite", oldMembership: null }); events.splice(0); //clear - room.updateMyMembership("invite"); + room.updateMyMembership(JoinRule.Invite); expect(events.length).toEqual(0); room.updateMyMembership("join"); expect(room.getMyMembership()).toEqual("join"); @@ -1498,374 +1503,537 @@ describe("Room", function() { }); describe("guessDMUserId", function() { - it("should return first hero id", - function() { - const room = new Room(roomId, null, userA); - room.setSummary({ - 'm.heroes': [userB], - 'm.joined_member_count': 1, - 'm.invited_member_count': 1, - }); - expect(room.guessDMUserId()).toEqual(userB); - }); - it("should return first member that isn't self", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, - })]); - expect(room.guessDMUserId()).toEqual(userB); - }); - it("should return self if only member present", - function() { - const room = new Room(roomId, null, userA); - expect(room.guessDMUserId()).toEqual(userA); + it("should return first hero id", function() { + const room = new Room(roomId, null, userA); + room.setSummary({ + 'm.heroes': [userB], + 'm.joined_member_count': 1, + 'm.invited_member_count': 1, }); + expect(room.guessDMUserId()).toEqual(userB); + }); + it("should return first member that isn't self", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([utils.mkMembership({ + user: userB, + mship: "join", + room: roomId, + event: true, + }) as MatrixEvent]); + expect(room.guessDMUserId()).toEqual(userB); + }); + it("should return self if only member present", function() { + const room = new Room(roomId, null, userA); + expect(room.guessDMUserId()).toEqual(userA); + }); }); describe("maySendMessage", function() { - it("should return false if synced membership not join", - function() { - const room = new Room(roomId, { isRoomEncrypted: () => false } as any, userA); - room.updateMyMembership("invite"); - expect(room.maySendMessage()).toEqual(false); - room.updateMyMembership("leave"); - expect(room.maySendMessage()).toEqual(false); - room.updateMyMembership("join"); - expect(room.maySendMessage()).toEqual(true); - }); + it("should return false if synced membership not join", function() { + const room = new Room(roomId, { isRoomEncrypted: () => false } as any, userA); + room.updateMyMembership(JoinRule.Invite); + expect(room.maySendMessage()).toEqual(false); + room.updateMyMembership("leave"); + expect(room.maySendMessage()).toEqual(false); + room.updateMyMembership("join"); + expect(room.maySendMessage()).toEqual(true); + }); }); describe("getDefaultRoomName", function() { - it("should return 'Empty room' if a user is the only member", - function() { - const room = new Room(roomId, null, userA); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); - }); - - it("should return a display name if one other member is in the room", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); - - it("should return a display name if one other member is banned", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "ban", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); - }); - - it("should return a display name if one other member is invited", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "invite", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); - - it("should return 'Empty room (was User B)' if User B left the room", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "leave", - room: roomId, event: true, name: "User B", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); - }); - - it("should return 'User B and User C' if in a room with two other users", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); - }); - - it("should return 'User B and 2 others' if in a room with three other users", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - utils.mkMembership({ - user: userD, mship: "join", - room: roomId, event: true, name: "User D", - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); - }); - - describe("io.element.functional_users", function() { - it("should return a display name (default behaviour) if no one is marked as a functional member", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: [], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); - - it("should return a display name (default behaviour) if service members is a number (invalid)", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: 1, - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); - - it("should return a display name (default behaviour) if service members is a string (invalid)", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: userB, - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); - - it("should return 'Empty room' if the only other member is a functional member", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, - content: { - service_members: [userB], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); - }); - - it("should return 'User B' if User B is the only other member who isn't a functional member", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, - content: { - service_members: [userC], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); - - it("should return 'Empty room' if all other members are functional members", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkMembership({ - user: userC, mship: "join", - room: roomId, event: true, name: "User C", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, - content: { - service_members: [userB, userC], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); - }); - - it("should not break if an unjoined user is marked as a service user", - function() { - const room = new Room(roomId, null, userA); - room.addLiveEvents([ - utils.mkMembership({ - user: userA, mship: "join", - room: roomId, event: true, name: "User A", - }), - utils.mkMembership({ - user: userB, mship: "join", - room: roomId, event: true, name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", - room: roomId, event: true, user: userA, - content: { - service_members: [userC], - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); + it("should return 'Empty room' if a user is the only member", function() { + const room = new Room(roomId, null, userA); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); - describe("threads", function() { - beforeEach(() => { - const client = (new TestClient( - "@alice:example.com", "alicedevice", - )).client; - room = new Room(roomId, client, userA); - }); + it("should return a display name if one other member is in the room", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); - it("allow create threads without a root event", function() { - const eventWithoutARootEvent = new MatrixEvent({ - event_id: "$123", - room_id: roomId, + it("should return a display name if one other member is banned", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "ban", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); + }); + + it("should return a display name if one other member is invited", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "invite", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return 'Empty room (was User B)' if User B left the room", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "leave", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); + }); + + it("should return 'User B and User C' if in a room with two other users", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); + }); + + it("should return 'User B and 2 others' if in a room with three other users", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }) as MatrixEvent, + utils.mkMembership({ + user: userD, mship: "join", + room: roomId, event: true, name: "User D", + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); + }); + }); + + describe("io.element.functional_users", function() { + it("should return a display name (default behaviour) if no one is marked as a functional member", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, content: { - "m.relates_to": { - "rel_type": "m.thread", - "event_id": "$000", - }, + service_members: [], }, - unsigned: { - "age": 1, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return a display name (default behaviour) if service members is a number (invalid)", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + content: { + service_members: 1, }, - }); + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); - room.createThread(undefined, [eventWithoutARootEvent]); - - const rootEvent = new MatrixEvent({ - event_id: "$666", - room_id: roomId, - content: {}, - unsigned: { - "age": 1, - "m.relations": { - "m.thread": { - latest_event: null, - count: 1, - current_user_participated: false, - }, - }, + it("should return a display name (default behaviour) if service members is a string (invalid)", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: userB, }, - }); + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); - expect(() => room.createThread(rootEvent, [])).not.toThrow(); + it("should return 'Empty room' if the only other member is a functional member", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, + content: { + service_members: [userB], + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); + + it("should return 'User B' if User B is the only other member who isn't a functional member", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userC], + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return 'Empty room' if all other members are functional members", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkMembership({ + user: userC, mship: "join", + room: roomId, event: true, name: "User C", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userB, userC], + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); + }); + + it("should not break if an unjoined user is marked as a service user", function() { + const room = new Room(roomId, null, userA); + room.addLiveEvents([ + utils.mkMembership({ + user: userA, mship: "join", + room: roomId, event: true, name: "User A", + }) as MatrixEvent, + utils.mkMembership({ + user: userB, mship: "join", + room: roomId, event: true, name: "User B", + }) as MatrixEvent, + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", + room: roomId, event: true, user: userA, + content: { + service_members: [userC], + }, + }) as MatrixEvent, + ]); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + }); + + describe("threads", function() { + beforeEach(() => { + const client = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + room = new Room(roomId, client, userA); + }); + + it("allow create threads without a root event", function() { + const eventWithoutARootEvent = new MatrixEvent({ + event_id: "$123", + room_id: roomId, + content: { + "m.relates_to": { + "rel_type": "m.thread", + "event_id": "$000", + }, + }, + unsigned: { + "age": 1, + }, }); + + room.createThread(undefined, [eventWithoutARootEvent]); + + const rootEvent = new MatrixEvent({ + event_id: "$666", + room_id: roomId, + content: {}, + unsigned: { + "age": 1, + "m.relations": { + "m.thread": { + latest_event: null, + count: 1, + current_user_participated: false, + }, + }, + }, + }); + + expect(() => room.createThread(rootEvent, [])).not.toThrow(); + }); + }); + + describe("eventShouldLiveIn", () => { + const room = new Room(roomId, null, userA); + + const mkMessage = () => utils.mkMessage({ + event: true, + user: userA, + room: roomId, + }) as MatrixEvent; + + const mkReply = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Reply :: " + Math.random(), + "m.relates_to": { + "m.in_reply_to": { + "event_id": target.getId(), + }, + }, + }, + }) as MatrixEvent; + + const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + "event_id": root.getId(), + }, + "rel_type": "m.thread", + }, + }, + }) as MatrixEvent; + + const mkReaction = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.Reaction, + user: userA, + room: roomId, + content: { + "m.relates_to": { + "rel_type": RelationType.Annotation, + "event_id": target.getId(), + "key": Math.random().toString(), + }, + }, + }) as MatrixEvent; + + const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomRedaction, + user: userA, + room: roomId, + redacts: target.getId(), + content: {}, + }) as MatrixEvent; + + it("thread root and its relations&redactions should be in both", () => { + const randomMessage = mkMessage(); + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + const threadReaction1 = mkReaction(threadRoot); + const threadReaction2 = mkReaction(threadRoot); + const threadReaction2Redaction = mkRedaction(threadReaction2); + + const roots = new Set([threadRoot.getId()]); + const events = [ + randomMessage, + threadRoot, + threadResponse1, + threadReaction1, + threadReaction2, + threadReaction2Redaction, + ]; + + expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInThread).toBeFalsy(); + + expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadRoot, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadResponse1, events, roots).threadId).toBe(threadRoot.getId()); + + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId()); + }); + + it("thread response and its relations&redactions should be only in thread timeline", () => { + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + const threadReaction1 = mkReaction(threadResponse1); + const threadReaction2 = mkReaction(threadResponse1); + const threadReaction2Redaction = mkRedaction(threadReaction2); + + const roots = new Set([threadRoot.getId()]); + const events = [threadRoot, threadResponse1, threadReaction1, threadReaction2, threadReaction2Redaction]; + + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId()); + }); + + it("reply to thread response and its relations&redactions should be only in thread timeline", () => { + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + const reply1 = mkReply(threadResponse1); + const threadReaction1 = mkReaction(reply1); + const threadReaction2 = mkReaction(reply1); + const threadReaction2Redaction = mkRedaction(reply1); + + const roots = new Set([threadRoot.getId()]); + const events = [ + threadRoot, + threadResponse1, + reply1, + threadReaction1, + threadReaction2, + threadReaction2Redaction, + ]; + + expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(reply1, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction1, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2, events, roots).threadId).toBe(threadRoot.getId()); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeFalsy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeTruthy(); + expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId()); + }); + + it("reply to reply to thread root should only be in the main timeline", () => { + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + const reply1 = mkReply(threadRoot); + const reply2 = mkReply(reply1); + + const roots = new Set([threadRoot.getId()]); + const events = [ + threadRoot, + threadResponse1, + reply1, + reply2, + ]; + + expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeFalsy(); + expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInRoom).toBeTruthy(); + expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInThread).toBeFalsy(); }); }); }); diff --git a/src/client.ts b/src/client.ts index d1b6dff72..11ec7fa4d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5179,7 +5179,7 @@ export class MatrixClient extends TypedEventEmitter): { - shouldLiveInRoom: boolean; - shouldLiveInThread: boolean; - threadId?: string; - } { - if (event.isThreadRoot) { - return { - shouldLiveInRoom: true, - shouldLiveInThread: true, - threadId: event.getId(), - }; - } - - // A thread relation is always only shown in a thread - if (event.isThreadRelation) { - return { - shouldLiveInRoom: false, - shouldLiveInThread: true, - threadId: event.relationEventId, - }; - } - - const parentEventId = event.getAssociatedId(); - const parentEvent = room?.findEventById(parentEventId) ?? events.find((mxEv: MatrixEvent) => ( - mxEv.getId() === parentEventId - )); - - // A reaction targeting the thread root needs to be routed to both the main timeline and the associated thread - const targetingThreadRoot = parentEvent?.isThreadRoot || roots.has(event.relationEventId); - if (targetingThreadRoot) { - return { - shouldLiveInRoom: true, - shouldLiveInThread: true, - threadId: event.relationEventId, - }; - } - - // If the parent event also has an associated ID we want to re-run the - // computation for that parent event. - // In the case of the redaction of a reaction that targets a root event - // we want that redaction to be pushed to both timeline - if (parentEvent?.getAssociatedId()) { - return this.eventShouldLiveIn(parentEvent, room, events, roots); - } - - // We've exhausted all scenarios, can safely assume that this event - // should live in the room timeline - return { - shouldLiveInRoom: true, - shouldLiveInThread: false, - }; - } - - public partitionThreadedEvents(events: MatrixEvent[]): [ + public partitionThreadedEvents(room: Room, events: MatrixEvent[]): [ timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[], ] { @@ -8931,13 +8876,11 @@ export class MatrixClient extends TypedEventEmitter { - const room = this.getRoom(event.getRoomId()); - const { shouldLiveInRoom, shouldLiveInThread, threadId, - } = this.eventShouldLiveIn(event, room, events, threadRoots); + } = room.eventShouldLiveIn(event, events, threadRoots); if (shouldLiveInRoom) { memo[ROOM].push(event); diff --git a/src/models/room.ts b/src/models/room.ts index 9cd000d66..f6008d7b1 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1562,19 +1562,68 @@ export class Room extends TypedEventEmitter } } - public findThreadForEvent(event: MatrixEvent): Thread | null { - if (!event) { - return null; + public eventShouldLiveIn(event: MatrixEvent, events?: MatrixEvent[], roots?: Set): { + shouldLiveInRoom: boolean; + shouldLiveInThread: boolean; + threadId?: string; + } { + // A thread root is always shown in both timelines + if (event.isThreadRoot || roots?.has(event.getId())) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.getId(), + }; } + // A thread relation is always only shown in a thread if (event.isThreadRelation) { - return this.threads.get(event.threadRootId); - } else if (event.isThreadRoot) { - return this.threads.get(event.getId()); - } else { - const parentEvent = this.findEventById(event.getAssociatedId()); - return this.findThreadForEvent(parentEvent); + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: event.relationEventId, + }; } + + const parentEventId = event.getAssociatedId(); + const parentEvent = this.findEventById(parentEventId) ?? events?.find(e => e.getId() === parentEventId); + + // Treat relations and redactions as extensions of their parents so evaluate parentEvent instead + if (parentEvent && (event.isRelation() || event.isRedaction())) { + return this.eventShouldLiveIn(parentEvent, events, roots); + } + + // Edge case where we know the event is a relation but don't have the parentEvent + if (roots?.has(event.relationEventId)) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.relationEventId, + }; + } + + // A reply directly to a thread response is shown as part of the thread only, this is to provide a better + // experience when communicating with users using clients without full threads support + if (parentEvent?.isThreadRelation) { + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: parentEvent.threadRootId, + }; + } + + // We've exhausted all scenarios, can safely assume that this event should live in the room timeline only + return { + shouldLiveInRoom: true, + shouldLiveInThread: false, + }; + } + + public findThreadForEvent(event?: MatrixEvent): Thread | null { + if (!event) return null; + + const { threadId } = this.eventShouldLiveIn(event); + return threadId ? this.getThread(threadId) : null; } public async createThreadFetchRoot( @@ -1895,14 +1944,6 @@ export class Room extends TypedEventEmitter } } - private shouldAddEventToMainTimeline(thread: Thread, event: MatrixEvent): boolean { - if (!thread) { - return true; - } - - return !event.isThreadRelation && thread.id === event.getAssociatedId(); - } - /** * Used to aggregate the local echo for a relation, and also * for re-applying a relation after it's redaction has been cancelled, @@ -1914,10 +1955,11 @@ export class Room extends TypedEventEmitter * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. */ private aggregateNonLiveRelation(event: MatrixEvent): void { - const thread = this.findThreadForEvent(event); + const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event); + const thread = this.getThread(threadId); thread?.timelineSet.aggregateRelations(event); - if (this.shouldAddEventToMainTimeline(thread, event)) { + if (shouldLiveInRoom) { // TODO: We should consider whether this means it would be a better // design to lift the relations handling up to the room instead. for (let i = 0; i < this.timelineSets.length; i++) { @@ -1973,10 +2015,11 @@ export class Room extends TypedEventEmitter // any, which is good, because we don't want to try decoding it again). localEvent.handleRemoteEcho(remoteEvent.event); - const thread = this.findThreadForEvent(remoteEvent); + const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(remoteEvent); + const thread = this.getThread(threadId); thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); - if (this.shouldAddEventToMainTimeline(thread, remoteEvent)) { + if (shouldLiveInRoom) { for (let i = 0; i < this.timelineSets.length; i++) { const timelineSet = this.timelineSets[i]; @@ -2042,10 +2085,11 @@ export class Room extends TypedEventEmitter // update the event id event.replaceLocalEventId(newEventId); - const thread = this.findThreadForEvent(event); + const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event); + const thread = this.getThread(threadId); thread?.timelineSet.replaceEventId(oldEventId, newEventId); - if (this.shouldAddEventToMainTimeline(thread, event)) { + if (shouldLiveInRoom) { // if the event was already in the timeline (which will be the case if // opts.pendingEventOrdering==chronological), we need to update the // timeline map. @@ -2105,13 +2149,12 @@ export class Room extends TypedEventEmitter * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. */ public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void { - let i; if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); } // sanity check that the live timeline is still live - for (i = 0; i < this.timelineSets.length; i++) { + for (let i = 0; i < this.timelineSets.length; i++) { const liveTimeline = this.timelineSets[i].getLiveTimeline(); if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { throw new Error( @@ -2120,21 +2163,13 @@ export class Room extends TypedEventEmitter ); } if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline " + i + " is no longer live - " + - "it has a neighbouring timeline", - ); + throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`); } } - for (i = 0; i < events.length; i++) { - // TODO: We should have a filter to say "only add state event - // types X Y Z to the timeline". + for (let i = 0; i < events.length; i++) { + // TODO: We should have a filter to say "only add state event types X Y Z to the timeline". this.addLiveEvent(events[i], duplicateStrategy, fromCache); - const thread = this.findThreadForEvent(events[i]); - if (thread) { - thread.addEvent(events[i], true); - } } } diff --git a/src/sync.ts b/src/sync.ts index 629997739..bab1da970 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -276,15 +276,13 @@ export class SyncApi { return client.http.authedRequest( // TODO types undefined, Method.Get, "/sync", qps as any, undefined, localTimeoutMs, ); - }).then((data) => { + }).then(async (data) => { let leaveRooms = []; if (data.rooms?.leave) { leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); } - const rooms = []; - leaveRooms.forEach(async (leaveObj) => { + return Promise.all(leaveRooms.map(async (leaveObj) => { const room = leaveObj.room; - rooms.push(room); if (!leaveObj.isBrandNewRoom) { // the intention behind syncLeftRooms is to add in rooms which were // *omitted* from the initial /sync. Rooms the user were joined to @@ -298,25 +296,22 @@ export class SyncApi { } leaveObj.timeline = leaveObj.timeline || {}; const events = this.mapSyncEventsFormat(leaveObj.timeline, room); - const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); // set the back-pagination token. Do this *before* adding any // events so that clients can start back-paginating. - room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, - EventTimeline.BACKWARDS); + room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS); - this.processRoomEvents(room, stateEvents, timelineEvents); - await this.processThreadEvents(room, threadedEvents, false); + await this.processRoomEvents(room, stateEvents, events); room.recalculate(); client.store.storeRoom(room); client.emit(ClientEvent.Room, room); this.processEventsForNotifs(room, events); - }); - return rooms; + return room; + })); }); } @@ -759,7 +754,7 @@ export class SyncApi { try { await this.processSyncResponse(syncEventData, data); } catch (e) { - logger.error("Error processing cached sync", e.stack || e); + logger.error("Error processing cached sync", e); } // Don't emit a prepared if we've bailed because the store is invalid: @@ -834,7 +829,7 @@ export class SyncApi { } catch (e) { // log the exception with stack if we have it, else fall back // to the plain description - logger.error("Caught /sync error", e.stack || e); + logger.error("Caught /sync error", e); // Emit the exception for client handling this.client.emit(ClientEvent.SyncUnexpectedError, e); @@ -1087,9 +1082,7 @@ export class SyncApi { } // handle to-device events - if (data.to_device && Array.isArray(data.to_device.events) && - data.to_device.events.length > 0 - ) { + if (Array.isArray(data.to_device?.events) && data.to_device.events.length > 0) { const cancelledKeyVerificationTxns = []; data.to_device.events .map(client.getEventMapper()) @@ -1163,11 +1156,11 @@ export class SyncApi { this.notifEvents = []; // Handle invites - inviteRooms.forEach((inviteObj) => { + await utils.promiseMapSeries(inviteRooms, async (inviteObj) => { const room = inviteObj.room; const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room); - this.processRoomEvents(room, stateEvents); + await this.processRoomEvents(room, stateEvents); if (inviteObj.isBrandNewRoom) { room.recalculate(); client.store.storeRoom(room); @@ -1274,10 +1267,7 @@ export class SyncApi { } } - const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); - - this.processRoomEvents(room, stateEvents, timelineEvents, syncEventData.fromCache); - await this.processThreadEvents(room, threadedEvents, false); + await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache); // set summary after processing events, // because it will trigger a name calculation @@ -1318,8 +1308,7 @@ export class SyncApi { }; await utils.promiseMapSeries(stateEvents, processRoomEvent); - await utils.promiseMapSeries(timelineEvents, processRoomEvent); - await utils.promiseMapSeries(threadedEvents, processRoomEvent); + await utils.promiseMapSeries(events, processRoomEvent); ephemeralEvents.forEach(function(e) { client.emit(ClientEvent.Event, e); }); @@ -1336,16 +1325,13 @@ export class SyncApi { }); // Handle leaves (e.g. kicked rooms) - leaveRooms.forEach(async (leaveObj) => { + await utils.promiseMapSeries(leaveRooms, async (leaveObj) => { const room = leaveObj.room; const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); const events = this.mapSyncEventsFormat(leaveObj.timeline, room); const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data); - const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); - - this.processRoomEvents(room, stateEvents, timelineEvents); - await this.processThreadEvents(room, threadedEvents, false); + await this.processRoomEvents(room, stateEvents, events); room.addAccountData(accountDataEvents); room.recalculate(); @@ -1359,10 +1345,7 @@ export class SyncApi { stateEvents.forEach(function(e) { client.emit(ClientEvent.Event, e); }); - timelineEvents.forEach(function(e) { - client.emit(ClientEvent.Event, e); - }); - threadedEvents.forEach(function(e) { + events.forEach(function(e) { client.emit(ClientEvent.Event, e); }); accountDataEvents.forEach(function(e) { @@ -1592,16 +1575,16 @@ export class SyncApi { * @param {Room} room * @param {MatrixEvent[]} stateEventList A list of state events. This is the state * at the *START* of the timeline list if it is supplied. - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index + * @param {MatrixEvent[]} [timelineEventList] A list of timeline events, including threaded. Lower index * @param {boolean} fromCache whether the sync response came from cache * is earlier in time. Higher index is later. */ - private processRoomEvents( + private async processRoomEvents( room: Room, stateEventList: MatrixEvent[], timelineEventList?: MatrixEvent[], fromCache = false, - ): void { + ): Promise { // If there are no events in the timeline yet, initialise it with // the given state events const liveTimeline = room.getLiveTimeline(); @@ -1651,11 +1634,14 @@ export class SyncApi { room.oldState.setStateEvents(stateEventList || []); room.currentState.setStateEvents(stateEventList || []); } - // execute the timeline events. This will continue to diverge the current state + + // Execute the timeline events. This will continue to diverge the current state // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. - room.addLiveEvents(timelineEventList || [], null, fromCache); + const [mainTimelineEvents, threadedEvents] = this.client.partitionThreadedEvents(room, timelineEventList || []); + room.addLiveEvents(mainTimelineEvents, null, fromCache); + await this.processThreadEvents(room, threadedEvents, false); } /**