1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

Fix thread & main timeline partitioning logic (#2264)

This commit is contained in:
Michael Telatynski
2022-03-31 13:57:37 +01:00
committed by GitHub
parent 4360ae7ff8
commit d6f1c6cfdc
11 changed files with 1251 additions and 1054 deletions

View File

@ -556,9 +556,11 @@ describe("MatrixClient", function() {
}); });
describe("partitionThreadedEvents", function() { describe("partitionThreadedEvents", function() {
const room = new Room("!STrMRsukXHtqQdSeHa:matrix.org", client, userId);
it("returns empty arrays when given an empty arrays", function() { it("returns empty arrays when given an empty arrays", function() {
const events = []; const events = [];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = client.partitionThreadedEvents(room, events);
expect(timeline).toEqual([]); expect(timeline).toEqual([]);
expect(threaded).toEqual([]); expect(threaded).toEqual([]);
}); });
@ -566,24 +568,24 @@ describe("MatrixClient", function() {
it("copies pre-thread in-timeline vote events onto both timelines", function() { it("copies pre-thread in-timeline vote events onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
const eventMessageInThread = buildEventMessageInThread();
const eventPollResponseReference = buildEventPollResponseReference(); const eventPollResponseReference = buildEventPollResponseReference();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const events = [ const events = [
eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
eventPollResponseReference, eventPollResponseReference,
eventPollStartThreadRoot,
]; ];
// Vote has no threadId yet // Vote has no threadId yet
expect(eventPollResponseReference.threadId).toBeFalsy(); expect(eventPollResponseReference.threadId).toBeFalsy();
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = client.partitionThreadedEvents(room, events);
expect(timeline).toEqual([ expect(timeline).toEqual([
// The message that was sent in a thread is missing // The message that was sent in a thread is missing
eventPollResponseReference,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventPollResponseReference,
]); ]);
// The vote event has been copied into the thread // The vote event has been copied into the thread
@ -592,33 +594,34 @@ describe("MatrixClient", function() {
expect(eventRefWithThreadId.threadId).toBeTruthy(); expect(eventRefWithThreadId.threadId).toBeTruthy();
expect(threaded).toEqual([ expect(threaded).toEqual([
eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
eventRefWithThreadId, eventRefWithThreadId,
// Thread does not see thread root
]); ]);
}); });
it("copies pre-thread in-timeline reactions onto both timelines", function() { it("copies pre-thread in-timeline reactions onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
const eventMessageInThread = buildEventMessageInThread();
const eventReaction = buildEventReaction();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventReaction = buildEventReaction(eventPollStartThreadRoot);
const events = [ const events = [
eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
eventReaction, eventReaction,
eventPollStartThreadRoot,
]; ];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = client.partitionThreadedEvents(room, events);
expect(timeline).toEqual([ expect(timeline).toEqual([
eventReaction,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventReaction,
]); ]);
expect(threaded).toEqual([ expect(threaded).toEqual([
eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
withThreadId(eventReaction, eventPollStartThreadRoot.getId()), withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
]); ]);
@ -628,23 +631,24 @@ describe("MatrixClient", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
const eventPollResponseReference = buildEventPollResponseReference(); const eventPollResponseReference = buildEventPollResponseReference();
const eventMessageInThread = buildEventMessageInThread();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const events = [ const events = [
eventPollStartThreadRoot,
eventPollResponseReference, eventPollResponseReference,
eventMessageInThread, eventMessageInThread,
eventPollStartThreadRoot,
]; ];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = client.partitionThreadedEvents(room, events);
expect(timeline).toEqual([ expect(timeline).toEqual([
eventPollResponseReference,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventPollResponseReference,
]); ]);
expect(threaded).toEqual([ expect(threaded).toEqual([
eventPollStartThreadRoot,
withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()), withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()),
eventMessageInThread, eventMessageInThread,
]); ]);
@ -653,26 +657,27 @@ describe("MatrixClient", function() {
it("copies post-thread in-timeline reactions onto both timelines", function() { it("copies post-thread in-timeline reactions onto both timelines", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
const eventReaction = buildEventReaction();
const eventMessageInThread = buildEventMessageInThread();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventReaction = buildEventReaction(eventPollStartThreadRoot);
const events = [ const events = [
eventReaction,
eventMessageInThread,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventMessageInThread,
eventReaction,
]; ];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = client.partitionThreadedEvents(room, events);
expect(timeline).toEqual([ expect(timeline).toEqual([
eventReaction,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventReaction,
]); ]);
expect(threaded).toEqual([ expect(threaded).toEqual([
withThreadId(eventReaction, eventPollStartThreadRoot.getId()), eventPollStartThreadRoot,
eventMessageInThread, eventMessageInThread,
withThreadId(eventReaction, eventPollStartThreadRoot.getId()),
]); ]);
}); });
@ -680,9 +685,9 @@ describe("MatrixClient", function() {
client.clientOpts = { experimentalThreadSupport: true }; client.clientOpts = { experimentalThreadSupport: true };
// This is based on recording the events in a real room: // This is based on recording the events in a real room:
const eventMessageInThread = buildEventMessageInThread();
const eventPollResponseReference = buildEventPollResponseReference();
const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); const eventPollStartThreadRoot = buildEventPollStartThreadRoot();
const eventPollResponseReference = buildEventPollResponseReference();
const eventMessageInThread = buildEventMessageInThread(eventPollStartThreadRoot);
const eventRoomName = buildEventRoomName(); const eventRoomName = buildEventRoomName();
const eventEncryption = buildEventEncryption(); const eventEncryption = buildEventEncryption();
const eventGuestAccess = buildEventGuestAccess(); const eventGuestAccess = buildEventGuestAccess();
@ -693,9 +698,9 @@ describe("MatrixClient", function() {
const eventCreate = buildEventCreate(); const eventCreate = buildEventCreate();
const events = [ const events = [
eventMessageInThread,
eventPollResponseReference,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventPollResponseReference,
eventMessageInThread,
eventRoomName, eventRoomName,
eventEncryption, eventEncryption,
eventGuestAccess, eventGuestAccess,
@ -705,12 +710,12 @@ describe("MatrixClient", function() {
eventMember, eventMember,
eventCreate, eventCreate,
]; ];
const [timeline, threaded] = client.partitionThreadedEvents(events); const [timeline, threaded] = client.partitionThreadedEvents(room, events);
expect(timeline).toEqual([ expect(timeline).toEqual([
// The message that was sent in a thread is missing // The message that was sent in a thread is missing
eventPollResponseReference,
eventPollStartThreadRoot, eventPollStartThreadRoot,
eventPollResponseReference,
eventRoomName, eventRoomName,
eventEncryption, eventEncryption,
eventGuestAccess, eventGuestAccess,
@ -721,11 +726,95 @@ describe("MatrixClient", function() {
eventCreate, eventCreate,
]); ]);
// Thread should contain only stuff that happened in the thread - // Thread should contain only stuff that happened in the thread - no room state events
// no thread root, and no room state events
expect(threaded).toEqual([ expect(threaded).toEqual([
eventMessageInThread, eventPollStartThreadRoot,
withThreadId(eventPollResponseReference, eventPollStartThreadRoot.getId()), 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; return ret;
} }
const buildEventMessageInThread = () => new MatrixEvent({ const buildEventMessageInThread = (root) => new MatrixEvent({
"age": 80098509, "age": 80098509,
"content": { "content": {
"algorithm": "m.megolm.v1.aes-sha2", "algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "ENCRYPTEDSTUFF", "ciphertext": "ENCRYPTEDSTUFF",
"device_id": "XISFUZSKHH", "device_id": "XISFUZSKHH",
"m.relates_to": { "m.relates_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", "event_id": root.getId(),
"m.in_reply_to": { "m.in_reply_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", "event_id": root.getId(),
}, },
"rel_type": "m.thread", "rel_type": "m.thread",
}, },
@ -784,10 +873,10 @@ const buildEventPollResponseReference = () => new MatrixEvent({
"user_id": "@andybalaam-test1:matrix.org", "user_id": "@andybalaam-test1:matrix.org",
}); });
const buildEventReaction = () => new MatrixEvent({ const buildEventReaction = (event) => new MatrixEvent({
"content": { "content": {
"m.relates_to": { "m.relates_to": {
"event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", "event_id": event.getId(),
"key": "🤗", "key": "🤗",
"rel_type": "m.annotation", "rel_type": "m.annotation",
}, },
@ -803,6 +892,22 @@ const buildEventReaction = () => new MatrixEvent({
"room_id": "!STrMRsukXHtqQdSeHa:matrix.org", "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({ const buildEventPollStartThreadRoot = () => new MatrixEvent({
"age": 80108647, "age": 80108647,
"content": { "content": {
@ -821,6 +926,29 @@ const buildEventPollStartThreadRoot = () => new MatrixEvent({
"user_id": "@andybalaam-test1:matrix.org", "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({ const buildEventRoomName = () => new MatrixEvent({
"age": 80123249, "age": 80123249,
"content": { "content": {

View File

@ -735,8 +735,7 @@ describe("MatrixClient syncing", function() {
expect(tok).toEqual("pagTok"); expect(tok).toEqual("pagTok");
}), }),
// first flush the filter request; this will make syncLeftRooms // first flush the filter request; this will make syncLeftRooms make its /sync call
// make its /sync call
httpBackend.flush("/filter").then(function() { httpBackend.flush("/filter").then(function() {
return httpBackend.flushAllExpected(); return httpBackend.flushAllExpected();
}), }),

View File

@ -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));

View File

@ -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<void> {
if (count <= 0) {
return Promise.resolve();
}
const p = new Promise<void>((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<T>(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<IEvent> = {
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<string, any> = {};
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<MatrixEvent> {
// 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<any> => new Promise(r => e.once(k, r));

View File

@ -20,11 +20,33 @@ import anotherjson from 'another-json';
import * as olmlib from "../../../src/crypto/olmlib"; import * as olmlib from "../../../src/crypto/olmlib";
import { TestClient } from '../../TestClient'; import { TestClient } from '../../TestClient';
import { HttpResponse, setHttpResponses } from '../../test-utils/test-utils';
import { resetCrossSigningKeys } from "./crypto-utils"; import { resetCrossSigningKeys } from "./crypto-utils";
import { MatrixError } from '../../../src/http-api'; import { MatrixError } from '../../../src/http-api';
import { logger } from '../../../src/logger'; 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) { async function makeTestClient(userInfo, options, keys) {
if (!keys) keys = {}; if (!keys) keys = {};
@ -237,7 +259,7 @@ describe("Cross Signing", function() {
// feed sync result that includes master key, ssk, device key // feed sync result that includes master key, ssk, device key
const responses = [ const responses = [
HttpResponse.PUSH_RULES_RESPONSE, PUSH_RULES_RESPONSE,
{ {
method: "POST", method: "POST",
path: "/keys/upload", path: "/keys/upload",
@ -248,7 +270,7 @@ describe("Cross Signing", function() {
}, },
}, },
}, },
HttpResponse.filterResponse("@alice:example.com"), filterResponse("@alice:example.com"),
{ {
method: "GET", method: "GET",
path: "/sync", path: "/sync",
@ -493,7 +515,7 @@ describe("Cross Signing", function() {
// - master key signed by her usk (pretend that it was signed by another // - master key signed by her usk (pretend that it was signed by another
// of Alice's devices) // of Alice's devices)
const responses = [ const responses = [
HttpResponse.PUSH_RULES_RESPONSE, PUSH_RULES_RESPONSE,
{ {
method: "POST", method: "POST",
path: "/keys/upload", path: "/keys/upload",
@ -504,7 +526,7 @@ describe("Cross Signing", function() {
}, },
}, },
}, },
HttpResponse.filterResponse("@alice:example.com"), filterResponse("@alice:example.com"),
{ {
method: "GET", method: "GET",
path: "/sync", path: "/sync",

View File

@ -1,4 +1,5 @@
import { import {
MatrixEvent,
RelationType, RelationType,
} from "../../src"; } from "../../src";
import { FilterComponent } from "../../src/filter-component"; import { FilterComponent } from "../../src/filter-component";
@ -13,7 +14,7 @@ describe("Filter Component", function() {
content: { }, content: { },
room: 'roomId', room: 'roomId',
event: true, event: true,
}); }) as MatrixEvent;
const checkResult = filter.check(event); const checkResult = filter.check(event);
@ -27,7 +28,7 @@ describe("Filter Component", function() {
content: { }, content: { },
room: 'roomId', room: 'roomId',
event: true, event: true,
}); }) as MatrixEvent;
const checkResult = filter.check(event); const checkResult = filter.check(event);
@ -54,7 +55,7 @@ describe("Filter Component", function() {
}, },
}, },
}, },
}); }) as MatrixEvent;
expect(filter.check(threadRootNotParticipated)).toBe(false); expect(filter.check(threadRootNotParticipated)).toBe(false);
}); });
@ -79,7 +80,7 @@ describe("Filter Component", function() {
user: '@someone-else:server.org', user: '@someone-else:server.org',
room: 'roomId', room: 'roomId',
event: true, event: true,
}); }) as MatrixEvent;
expect(filter.check(threadRootParticipated)).toBe(true); expect(filter.check(threadRootParticipated)).toBe(true);
}); });
@ -99,7 +100,7 @@ describe("Filter Component", function() {
[RelationType.Reference]: {}, [RelationType.Reference]: {},
}, },
}, },
}); }) as MatrixEvent;
expect(filter.check(referenceRelationEvent)).toBe(false); expect(filter.check(referenceRelationEvent)).toBe(false);
}); });
@ -122,7 +123,7 @@ describe("Filter Component", function() {
}, },
room: 'roomId', room: 'roomId',
event: true, event: true,
}); }) as MatrixEvent;
const eventWithMultipleRelations = mkEvent({ const eventWithMultipleRelations = mkEvent({
"type": "m.room.message", "type": "m.room.message",
@ -147,7 +148,7 @@ describe("Filter Component", function() {
}, },
"room": 'roomId', "room": 'roomId',
"event": true, "event": true,
}); }) as MatrixEvent;
const noMatchEvent = mkEvent({ const noMatchEvent = mkEvent({
"type": "m.room.message", "type": "m.room.message",
@ -159,7 +160,7 @@ describe("Filter Component", function() {
}, },
"room": 'roomId', "room": 'roomId',
"event": true, "event": true,
}); }) as MatrixEvent;
expect(filter.check(threadRootEvent)).toBe(true); expect(filter.check(threadRootEvent)).toBe(true);
expect(filter.check(eventWithMultipleRelations)).toBe(true); expect(filter.check(eventWithMultipleRelations)).toBe(true);

View File

@ -32,6 +32,7 @@ import { Preset } from "../../src/@types/partials";
import * as testUtils from "../test-utils/test-utils"; import * as testUtils from "../test-utils/test-utils";
import { makeBeaconInfoContent } from "../../src/content-helpers"; import { makeBeaconInfoContent } from "../../src/content-helpers";
import { M_BEACON_INFO } from "../../src/@types/beacon"; import { M_BEACON_INFO } from "../../src/@types/beacon";
import { Room } from "../../src";
jest.useFakeTimers(); jest.useFakeTimers();
@ -957,6 +958,7 @@ describe("MatrixClient", function() {
it("partitions root events to room timeline and thread timeline", () => { it("partitions root events to room timeline and thread timeline", () => {
const supportsExperimentalThreads = client.supportsExperimentalThreads; const supportsExperimentalThreads = client.supportsExperimentalThreads;
client.supportsExperimentalThreads = () => true; client.supportsExperimentalThreads = () => true;
const room = new Room("!room1:matrix.org", client, userId);
const rootEvent = new MatrixEvent({ const rootEvent = new MatrixEvent({
"content": {}, "content": {},
@ -979,9 +981,9 @@ describe("MatrixClient", function() {
expect(rootEvent.isThreadRoot).toBe(true); expect(rootEvent.isThreadRoot).toBe(true);
const [room, threads] = client.partitionThreadedEvents([rootEvent]); const [roomEvents, threadEvents] = client.partitionThreadedEvents(room, [rootEvent]);
expect(room).toHaveLength(1); expect(roomEvents).toHaveLength(1);
expect(threads).toHaveLength(1); expect(threadEvents).toHaveLength(1);
// Restore method // Restore method
client.supportsExperimentalThreads = supportsExperimentalThreads; client.supportsExperimentalThreads = supportsExperimentalThreads;

File diff suppressed because it is too large Load Diff

View File

@ -5179,7 +5179,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
room.currentState.setUnknownStateEvents(stateEvents); room.currentState.setUnknownStateEvents(stateEvents);
} }
const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(matrixEvents); const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(room, matrixEvents);
room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline()); room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
await this.processThreadEvents(room, threadedEvents, true); await this.processThreadEvents(room, threadedEvents, true);
@ -5281,7 +5281,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// functions contiguously, so we have to jump through some hoops to get our target event in it. // functions contiguously, so we have to jump through some hoops to get our target event in it.
// XXX: workaround for https://github.com/vector-im/element-meta/issues/150 // XXX: workaround for https://github.com/vector-im/element-meta/issues/150
if (Thread.hasServerSideSupport && event.isRelation(THREAD_RELATION_TYPE.name)) { if (Thread.hasServerSideSupport && event.isRelation(THREAD_RELATION_TYPE.name)) {
const [, threadedEvents] = this.partitionThreadedEvents(events); const [, threadedEvents] = this.partitionThreadedEvents(timelineSet.room, events);
const thread = await timelineSet.room.createThreadFetchRoot(event.threadRootId, threadedEvents, true); const thread = await timelineSet.room.createThreadFetchRoot(event.threadRootId, threadedEvents, true);
let nextBatch: string; let nextBatch: string;
@ -5316,7 +5316,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end; timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end;
} }
const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(events); const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(timelineSet.room, events);
timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start); timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
// The target event is not in a thread but process the contextual events, so we can show any threads around it. // The target event is not in a thread but process the contextual events, so we can show any threads around it.
await this.processThreadEvents(timelineSet.room, threadedEvents, true); await this.processThreadEvents(timelineSet.room, threadedEvents, true);
@ -5446,9 +5446,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
matrixEvents[i] = event; matrixEvents[i] = event;
} }
const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(matrixEvents);
const timelineSet = eventTimeline.getTimelineSet(); const timelineSet = eventTimeline.getTimelineSet();
const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(timelineSet.room, matrixEvents);
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token); timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
await this.processThreadEvents(timelineSet.room, threadedEvents, backwards); await this.processThreadEvents(timelineSet.room, threadedEvents, backwards);
@ -5484,10 +5483,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const token = res.end; const token = res.end;
const matrixEvents = res.chunk.map(this.getEventMapper()); const matrixEvents = res.chunk.map(this.getEventMapper());
const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(matrixEvents); const timelineSet = eventTimeline.getTimelineSet();
const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(timelineSet.room, matrixEvents);
eventTimeline.getTimelineSet() timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
await this.processThreadEvents(room, threadedEvents, backwards); await this.processThreadEvents(room, threadedEvents, backwards);
// if we've hit the end of the timeline, we need to stop trying to // if we've hit the end of the timeline, we need to stop trying to
@ -8868,60 +8866,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return threadRoots; return threadRoots;
} }
private eventShouldLiveIn(event: MatrixEvent, room: Room, events: MatrixEvent[], roots: Set<string>): { public partitionThreadedEvents(room: Room, events: MatrixEvent[]): [
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[]): [
timelineEvents: MatrixEvent[], timelineEvents: MatrixEvent[],
threadedEvents: MatrixEvent[], threadedEvents: MatrixEvent[],
] { ] {
@ -8931,13 +8876,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (this.supportsExperimentalThreads()) { if (this.supportsExperimentalThreads()) {
const threadRoots = this.findThreadRoots(events); const threadRoots = this.findThreadRoots(events);
return events.reduce((memo, event: MatrixEvent) => { return events.reduce((memo, event: MatrixEvent) => {
const room = this.getRoom(event.getRoomId());
const { const {
shouldLiveInRoom, shouldLiveInRoom,
shouldLiveInThread, shouldLiveInThread,
threadId, threadId,
} = this.eventShouldLiveIn(event, room, events, threadRoots); } = room.eventShouldLiveIn(event, events, threadRoots);
if (shouldLiveInRoom) { if (shouldLiveInRoom) {
memo[ROOM].push(event); memo[ROOM].push(event);

View File

@ -1562,19 +1562,68 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} }
} }
public findThreadForEvent(event: MatrixEvent): Thread | null { public eventShouldLiveIn(event: MatrixEvent, events?: MatrixEvent[], roots?: Set<string>): {
if (!event) { shouldLiveInRoom: boolean;
return null; 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) { if (event.isThreadRelation) {
return this.threads.get(event.threadRootId); return {
} else if (event.isThreadRoot) { shouldLiveInRoom: false,
return this.threads.get(event.getId()); shouldLiveInThread: true,
} else { threadId: event.relationEventId,
const parentEvent = this.findEventById(event.getAssociatedId()); };
return this.findThreadForEvent(parentEvent);
} }
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( public async createThreadFetchRoot(
@ -1895,14 +1944,6 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
} }
} }
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 * Used to aggregate the local echo for a relation, and also
* for re-applying a relation after it's redaction has been cancelled, * for re-applying a relation after it's redaction has been cancelled,
@ -1914,10 +1955,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated.
*/ */
private aggregateNonLiveRelation(event: MatrixEvent): void { 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); thread?.timelineSet.aggregateRelations(event);
if (this.shouldAddEventToMainTimeline(thread, event)) { if (shouldLiveInRoom) {
// TODO: We should consider whether this means it would be a better // TODO: We should consider whether this means it would be a better
// design to lift the relations handling up to the room instead. // design to lift the relations handling up to the room instead.
for (let i = 0; i < this.timelineSets.length; i++) { for (let i = 0; i < this.timelineSets.length; i++) {
@ -1973,10 +2015,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// any, which is good, because we don't want to try decoding it again). // any, which is good, because we don't want to try decoding it again).
localEvent.handleRemoteEcho(remoteEvent.event); 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); thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
if (this.shouldAddEventToMainTimeline(thread, remoteEvent)) { if (shouldLiveInRoom) {
for (let i = 0; i < this.timelineSets.length; i++) { for (let i = 0; i < this.timelineSets.length; i++) {
const timelineSet = this.timelineSets[i]; const timelineSet = this.timelineSets[i];
@ -2042,10 +2085,11 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
// update the event id // update the event id
event.replaceLocalEventId(newEventId); event.replaceLocalEventId(newEventId);
const thread = this.findThreadForEvent(event); const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event);
const thread = this.getThread(threadId);
thread?.timelineSet.replaceEventId(oldEventId, newEventId); 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 // if the event was already in the timeline (which will be the case if
// opts.pendingEventOrdering==chronological), we need to update the // opts.pendingEventOrdering==chronological), we need to update the
// timeline map. // timeline map.
@ -2105,13 +2149,12 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
* @throws If <code>duplicateStrategy</code> is not falsey, 'replace' or 'ignore'. * @throws If <code>duplicateStrategy</code> is not falsey, 'replace' or 'ignore'.
*/ */
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void { public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void {
let i;
if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
} }
// sanity check that the live timeline is still live // 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(); const liveTimeline = this.timelineSets[i].getLiveTimeline();
if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) {
throw new Error( throw new Error(
@ -2120,21 +2163,13 @@ export class Room extends TypedEventEmitter<EmittedEvents, RoomEventHandlerMap>
); );
} }
if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) {
throw new Error( throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`);
"live timeline " + i + " is no longer live - " +
"it has a neighbouring timeline",
);
} }
} }
for (i = 0; i < events.length; i++) { for (let i = 0; i < events.length; i++) {
// TODO: We should have a filter to say "only add state event // TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
// types X Y Z to the timeline".
this.addLiveEvent(events[i], duplicateStrategy, fromCache); this.addLiveEvent(events[i], duplicateStrategy, fromCache);
const thread = this.findThreadForEvent(events[i]);
if (thread) {
thread.addEvent(events[i], true);
}
} }
} }

View File

@ -276,15 +276,13 @@ export class SyncApi {
return client.http.authedRequest<any>( // TODO types return client.http.authedRequest<any>( // TODO types
undefined, Method.Get, "/sync", qps as any, undefined, localTimeoutMs, undefined, Method.Get, "/sync", qps as any, undefined, localTimeoutMs,
); );
}).then((data) => { }).then(async (data) => {
let leaveRooms = []; let leaveRooms = [];
if (data.rooms?.leave) { if (data.rooms?.leave) {
leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave);
} }
const rooms = []; return Promise.all(leaveRooms.map(async (leaveObj) => {
leaveRooms.forEach(async (leaveObj) => {
const room = leaveObj.room; const room = leaveObj.room;
rooms.push(room);
if (!leaveObj.isBrandNewRoom) { if (!leaveObj.isBrandNewRoom) {
// the intention behind syncLeftRooms is to add in rooms which were // the intention behind syncLeftRooms is to add in rooms which were
// *omitted* from the initial /sync. Rooms the user were joined to // *omitted* from the initial /sync. Rooms the user were joined to
@ -298,25 +296,22 @@ export class SyncApi {
} }
leaveObj.timeline = leaveObj.timeline || {}; leaveObj.timeline = leaveObj.timeline || {};
const events = this.mapSyncEventsFormat(leaveObj.timeline, room); const events = this.mapSyncEventsFormat(leaveObj.timeline, room);
const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events);
const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room);
// set the back-pagination token. Do this *before* adding any // set the back-pagination token. Do this *before* adding any
// events so that clients can start back-paginating. // events so that clients can start back-paginating.
room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS);
EventTimeline.BACKWARDS);
this.processRoomEvents(room, stateEvents, timelineEvents); await this.processRoomEvents(room, stateEvents, events);
await this.processThreadEvents(room, threadedEvents, false);
room.recalculate(); room.recalculate();
client.store.storeRoom(room); client.store.storeRoom(room);
client.emit(ClientEvent.Room, room); client.emit(ClientEvent.Room, room);
this.processEventsForNotifs(room, events); this.processEventsForNotifs(room, events);
}); return room;
return rooms; }));
}); });
} }
@ -759,7 +754,7 @@ export class SyncApi {
try { try {
await this.processSyncResponse(syncEventData, data); await this.processSyncResponse(syncEventData, data);
} catch (e) { } 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: // Don't emit a prepared if we've bailed because the store is invalid:
@ -834,7 +829,7 @@ export class SyncApi {
} catch (e) { } catch (e) {
// log the exception with stack if we have it, else fall back // log the exception with stack if we have it, else fall back
// to the plain description // to the plain description
logger.error("Caught /sync error", e.stack || e); logger.error("Caught /sync error", e);
// Emit the exception for client handling // Emit the exception for client handling
this.client.emit(ClientEvent.SyncUnexpectedError, e); this.client.emit(ClientEvent.SyncUnexpectedError, e);
@ -1087,9 +1082,7 @@ export class SyncApi {
} }
// handle to-device events // handle to-device events
if (data.to_device && Array.isArray(data.to_device.events) && if (Array.isArray(data.to_device?.events) && data.to_device.events.length > 0) {
data.to_device.events.length > 0
) {
const cancelledKeyVerificationTxns = []; const cancelledKeyVerificationTxns = [];
data.to_device.events data.to_device.events
.map(client.getEventMapper()) .map(client.getEventMapper())
@ -1163,11 +1156,11 @@ export class SyncApi {
this.notifEvents = []; this.notifEvents = [];
// Handle invites // Handle invites
inviteRooms.forEach((inviteObj) => { await utils.promiseMapSeries(inviteRooms, async (inviteObj) => {
const room = inviteObj.room; const room = inviteObj.room;
const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room); const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room);
this.processRoomEvents(room, stateEvents); await this.processRoomEvents(room, stateEvents);
if (inviteObj.isBrandNewRoom) { if (inviteObj.isBrandNewRoom) {
room.recalculate(); room.recalculate();
client.store.storeRoom(room); client.store.storeRoom(room);
@ -1274,10 +1267,7 @@ export class SyncApi {
} }
} }
const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache);
this.processRoomEvents(room, stateEvents, timelineEvents, syncEventData.fromCache);
await this.processThreadEvents(room, threadedEvents, false);
// set summary after processing events, // set summary after processing events,
// because it will trigger a name calculation // because it will trigger a name calculation
@ -1318,8 +1308,7 @@ export class SyncApi {
}; };
await utils.promiseMapSeries(stateEvents, processRoomEvent); await utils.promiseMapSeries(stateEvents, processRoomEvent);
await utils.promiseMapSeries(timelineEvents, processRoomEvent); await utils.promiseMapSeries(events, processRoomEvent);
await utils.promiseMapSeries(threadedEvents, processRoomEvent);
ephemeralEvents.forEach(function(e) { ephemeralEvents.forEach(function(e) {
client.emit(ClientEvent.Event, e); client.emit(ClientEvent.Event, e);
}); });
@ -1336,16 +1325,13 @@ export class SyncApi {
}); });
// Handle leaves (e.g. kicked rooms) // Handle leaves (e.g. kicked rooms)
leaveRooms.forEach(async (leaveObj) => { await utils.promiseMapSeries(leaveRooms, async (leaveObj) => {
const room = leaveObj.room; const room = leaveObj.room;
const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room);
const events = this.mapSyncEventsFormat(leaveObj.timeline, room); const events = this.mapSyncEventsFormat(leaveObj.timeline, room);
const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data); const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data);
const [timelineEvents, threadedEvents] = this.client.partitionThreadedEvents(events); await this.processRoomEvents(room, stateEvents, events);
this.processRoomEvents(room, stateEvents, timelineEvents);
await this.processThreadEvents(room, threadedEvents, false);
room.addAccountData(accountDataEvents); room.addAccountData(accountDataEvents);
room.recalculate(); room.recalculate();
@ -1359,10 +1345,7 @@ export class SyncApi {
stateEvents.forEach(function(e) { stateEvents.forEach(function(e) {
client.emit(ClientEvent.Event, e); client.emit(ClientEvent.Event, e);
}); });
timelineEvents.forEach(function(e) { events.forEach(function(e) {
client.emit(ClientEvent.Event, e);
});
threadedEvents.forEach(function(e) {
client.emit(ClientEvent.Event, e); client.emit(ClientEvent.Event, e);
}); });
accountDataEvents.forEach(function(e) { accountDataEvents.forEach(function(e) {
@ -1592,16 +1575,16 @@ export class SyncApi {
* @param {Room} room * @param {Room} room
* @param {MatrixEvent[]} stateEventList A list of state events. This is the state * @param {MatrixEvent[]} stateEventList A list of state events. This is the state
* at the *START* of the timeline list if it is supplied. * 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 * @param {boolean} fromCache whether the sync response came from cache
* is earlier in time. Higher index is later. * is earlier in time. Higher index is later.
*/ */
private processRoomEvents( private async processRoomEvents(
room: Room, room: Room,
stateEventList: MatrixEvent[], stateEventList: MatrixEvent[],
timelineEventList?: MatrixEvent[], timelineEventList?: MatrixEvent[],
fromCache = false, fromCache = false,
): void { ): Promise<void> {
// If there are no events in the timeline yet, initialise it with // If there are no events in the timeline yet, initialise it with
// the given state events // the given state events
const liveTimeline = room.getLiveTimeline(); const liveTimeline = room.getLiveTimeline();
@ -1651,11 +1634,14 @@ export class SyncApi {
room.oldState.setStateEvents(stateEventList || []); room.oldState.setStateEvents(stateEventList || []);
room.currentState.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. // 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 // This also needs to be done before running push rules on the events as they need
// to be decorated with sender etc. // 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);
} }
/** /**