1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-04-19 18:02:16 +03:00
matrix-js-sdk/spec/integ/matrix-client-event-timeline.spec.ts
Hugh Nimmo-Smith ff1db2b538
Bump eslint-plugin-matrix-org to enable @typescript-eslint/consistent-type-imports rule (#4680)
* Bump eslint-plugin-matrix-org to enable @typescript-eslint/consistent-type-imports rule

* Re-lint after merge
2025-02-05 12:15:20 +00:00

2078 lines
83 KiB
TypeScript

/*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as utils from "../test-utils/test-utils";
import {
ClientEvent,
Direction,
EventStatus,
EventTimeline,
EventTimelineSet,
EventType,
Filter,
type IEvent,
type MatrixClient,
MatrixEvent,
PendingEventOrdering,
RelationType,
Room,
} from "../../src/matrix";
import { logger } from "../../src/logger";
import { encodeParams, encodeUri, type QueryDict, replaceParam } from "../../src/utils";
import { TestClient } from "../TestClient";
import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread";
import { emitPromise } from "../test-utils/test-utils";
import { Feature, ServerSupport } from "../../src/feature";
import { KnownMembership } from "../../src/@types/membership";
const userId = "@alice:localhost";
const userName = "Alice";
const accessToken = "aseukfgwef";
const roomId = "!foo:bar";
const otherUserId = "@bob:localhost";
const withoutRoomId = (e: Partial<IEvent>): Partial<IEvent> => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { room_id: _, ...copy } = e;
return copy;
};
/**
* Our httpBackend only allows matching calls if we have the exact same query, in the exact same order
* This method allows building queries with the exact same parameter order as the fetchRelations method in client
* @param params query parameters
*/
const buildRelationPaginationQuery = (params: QueryDict): string => {
if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) {
params = replaceParam("dir", "org.matrix.msc3715.dir", params);
}
return "?" + encodeParams(params).toString();
};
const USER_MEMBERSHIP_EVENT = utils.mkMembership({
room: roomId,
mship: KnownMembership.Join,
user: userId,
name: userName,
event: false,
});
const ROOM_NAME_EVENT = utils.mkEvent({
type: "m.room.name",
room: roomId,
user: otherUserId,
content: {
name: "Old room name",
},
event: false,
});
const INITIAL_SYNC_DATA = {
next_batch: "s_5_3",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
utils.mkMessage({
user: otherUserId,
msg: "hello",
event: false,
}),
],
prev_batch: "f_1_1",
},
state: {
events: [
withoutRoomId(ROOM_NAME_EVENT),
utils.mkMembership({
mship: KnownMembership.Join,
user: otherUserId,
name: "Bob",
event: false,
}),
withoutRoomId(USER_MEMBERSHIP_EVENT),
utils.mkEvent({
type: "m.room.create",
user: userId,
content: {},
event: false,
}),
],
},
},
},
},
};
const EVENTS = [
utils.mkMessage({
room: roomId,
user: userId,
msg: "we",
event: false,
}),
utils.mkMessage({
room: roomId,
user: userId,
msg: "could",
event: false,
}),
utils.mkMessage({
room: roomId,
user: userId,
msg: "be",
event: false,
}),
utils.mkMessage({
room: roomId,
user: userId,
msg: "heroes",
event: false,
}),
];
const THREAD_ROOT = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
body: "thread root",
msgtype: "m.text",
},
unsigned: {
"m.relations": {
"io.element.thread": {
//"latest_event": undefined,
count: 1,
current_user_participated: true,
},
},
},
event: false,
});
const THREAD_REPLY = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: false,
});
// @ts-ignore we know this is a defined path for THREAD ROOT
THREAD_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD_REPLY;
const SYNC_THREAD_ROOT = withoutRoomId(THREAD_ROOT);
const SYNC_THREAD_REPLY = withoutRoomId(THREAD_REPLY);
SYNC_THREAD_ROOT.unsigned = {
"m.relations": {
"io.element.thread": {
latest_event: SYNC_THREAD_REPLY,
count: 1,
current_user_participated: true,
},
},
};
type HttpBackend = TestClient["httpBackend"];
type ExpectedHttpRequest = ReturnType<HttpBackend["when"]>;
// start the client, and wait for it to initialise
function startClient(httpBackend: HttpBackend, client: MatrixClient) {
httpBackend.when("GET", "/versions").respond(200, {});
httpBackend.when("GET", "/pushrules").respond(200, {});
httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" });
httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA);
client.startClient({ threadSupport: true });
// set up a promise which will resolve once the client is initialised
const prom = new Promise<void>((resolve) => {
client.on(ClientEvent.Sync, function (state) {
logger.log("sync", state);
if (state != "SYNCING") {
return;
}
resolve();
});
});
return Promise.all([httpBackend.flushAllExpected(), prom]);
}
describe("getEventTimeline support", function () {
let httpBackend: HttpBackend;
let client: MatrixClient;
beforeEach(function () {
const testClient = new TestClient(userId, "DEVICE", accessToken);
client = testClient.client;
httpBackend = testClient.httpBackend;
});
afterEach(function () {
if (client) {
client.stopClient();
}
return httpBackend.stop();
});
it("timeline support must be enabled to work", function () {
const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: false });
client = testClient.client;
httpBackend = testClient.httpBackend;
return startClient(httpBackend, client).then(function () {
const room = client.getRoom(roomId)!;
const timelineSet = room!.getTimelineSets()[0];
return expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
});
});
it("timeline support works when enabled", function () {
const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true });
client = testClient.client;
httpBackend = testClient.httpBackend;
return startClient(httpBackend, client).then(() => {
const room = client.getRoom(roomId)!;
const timelineSet = room!.getTimelineSets()[0];
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/context/event`).respond(200, () => ({
event: {
event_id: "event",
},
events_after: [],
events_before: [],
state: [],
}));
return Promise.all([
expect(client.getEventTimeline(timelineSet, "event")).resolves.toBeTruthy(),
httpBackend.flushAllExpected(),
]);
});
});
it("only works with room timelines", function () {
const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true });
client = testClient.client;
httpBackend = testClient.httpBackend;
return startClient(httpBackend, client).then(function () {
const timelineSet = new EventTimelineSet(undefined);
return expect(client.getEventTimeline(timelineSet, "event")).rejects.toBeTruthy();
});
});
it("scrollback should be able to scroll back to before a gappy /sync", function () {
// need a client with timelineSupport disabled to make this work
let room: Room | undefined;
return startClient(httpBackend, client)
.then(function () {
room = client.getRoom(roomId)!;
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_4",
rooms: {
join: {
"!foo:bar": {
timeline: {
events: [withoutRoomId(EVENTS[0])],
prev_batch: "f_1_1",
},
},
},
},
});
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_5",
rooms: {
join: {
"!foo:bar": {
timeline: {
events: [withoutRoomId(EVENTS[1])],
limited: true,
prev_batch: "f_1_2",
},
},
},
},
});
return Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client, 2)]);
})
.then(function () {
expect(room!.timeline.length).toEqual(1);
expect(room!.timeline[0].event).toEqual(EVENTS[1]);
httpBackend.when("GET", "/messages").respond(200, {
chunk: [EVENTS[0]],
start: "pagin_start",
end: "pagin_end",
});
httpBackend.flush("/messages", 1);
return client.scrollback(room!);
})
.then(function () {
expect(room!.timeline.length).toEqual(2);
expect(room!.timeline[0].event).toEqual(EVENTS[0]);
expect(room!.timeline[1].event).toEqual(EVENTS[1]);
expect(room!.oldState.paginationToken).toEqual("pagin_end");
});
});
});
describe("MatrixClient event timelines", function () {
let client: MatrixClient;
let httpBackend: HttpBackend;
beforeEach(function () {
const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true });
client = testClient.client;
httpBackend = testClient.httpBackend;
return startClient(httpBackend, client);
});
afterEach(function () {
httpBackend.verifyNoOutstandingExpectation();
client.stopClient();
Thread.setServerSideSupport(FeatureSupport.None);
Thread.setServerSideListSupport(FeatureSupport.None);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.None);
});
async function flushHttp<T>(promise: Promise<T>): Promise<T> {
return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result);
}
describe("getEventTimeline", function () {
it("should create a new timeline for new events", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1%3Abar").respond(200, function () {
return {
start: "start_token",
events_before: [EVENTS[1], EVENTS[0]],
event: EVENTS[2],
events_after: [EVENTS[3]],
state: [ROOM_NAME_EVENT, USER_MEMBERSHIP_EVENT],
end: "end_token",
};
});
return Promise.all([
client.getEventTimeline(timelineSet, "event1:bar").then(function (tl) {
expect(tl!.getEvents().length).toEqual(4);
for (let i = 0; i < 4; i++) {
expect(tl!.getEvents()[i].event).toEqual(EVENTS[i]);
expect(tl!.getEvents()[i]?.sender?.name).toEqual(userName);
}
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token");
expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token");
}),
httpBackend.flushAllExpected(),
]);
});
it("should return existing timeline for known events", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_4",
rooms: {
join: {
"!foo:bar": {
timeline: {
events: [withoutRoomId(EVENTS[0])],
prev_batch: "f_1_2",
},
},
},
},
});
return Promise.all([httpBackend.flush("/sync"), utils.syncPromise(client)])
.then(function () {
return client.getEventTimeline(timelineSet, EVENTS[0].event_id!);
})
.then(function (tl) {
expect(tl!.getEvents().length).toEqual(2);
expect(tl!.getEvents()[1].event).toEqual(EVENTS[0]);
expect(tl!.getEvents()[1]?.sender?.name).toEqual(userName);
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("f_1_1");
// expect(tl.getPaginationToken(EventTimeline.FORWARDS))
// .toEqual("s_5_4");
});
});
it("should update timelines where they overlap a previous /sync", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_4",
rooms: {
join: {
"!foo:bar": {
timeline: {
events: [withoutRoomId(EVENTS[3])],
prev_batch: "f_1_2",
},
},
},
},
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[2].event_id!))
.respond(200, function () {
return {
start: "start_token",
events_before: [EVENTS[1]],
event: EVENTS[2],
events_after: [EVENTS[3]],
end: "end_token",
state: [],
};
});
const prom = new Promise((resolve, reject) => {
client.on(ClientEvent.Sync, function () {
client
.getEventTimeline(timelineSet, EVENTS[2].event_id!)
.then(function (tl) {
expect(tl!.getEvents().length).toEqual(4);
expect(tl!.getEvents()[0].event).toEqual(EVENTS[1]);
expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]);
expect(tl!.getEvents()[3].event).toEqual(EVENTS[3]);
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token");
// expect(tl.getPaginationToken(EventTimeline.FORWARDS))
// .toEqual("s_5_4");
})
.then(resolve, reject);
});
});
return Promise.all([httpBackend.flushAllExpected(), prom]);
});
it("should join timelines where they overlap a previous /context", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
// we fetch event 0, then 2, then 3, and finally 1. 1 is returned
// with context which joins them all up.
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!))
.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[2].event_id!))
.respond(200, function () {
return {
start: "start_token2",
events_before: [],
event: EVENTS[2],
events_after: [],
end: "end_token2",
state: [],
};
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[3].event_id!))
.respond(200, function () {
return {
start: "start_token3",
events_before: [],
event: EVENTS[3],
events_after: [],
end: "end_token3",
state: [],
};
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[1].event_id!))
.respond(200, function () {
return {
start: "start_token4",
events_before: [EVENTS[0]],
event: EVENTS[1],
events_after: [EVENTS[2], EVENTS[3]],
end: "end_token4",
state: [],
};
});
let tl0: EventTimeline;
let tl3: EventTimeline;
return Promise.all([
client
.getEventTimeline(timelineSet, EVENTS[0].event_id!)
.then(function (tl) {
expect(tl!.getEvents().length).toEqual(1);
tl0 = tl!;
return client.getEventTimeline(timelineSet, EVENTS[2].event_id!);
})
.then(function (tl) {
expect(tl!.getEvents().length).toEqual(1);
return client.getEventTimeline(timelineSet, EVENTS[3].event_id!);
})
.then(function (tl) {
expect(tl!.getEvents().length).toEqual(1);
tl3 = tl!;
return client.getEventTimeline(timelineSet, EVENTS[1].event_id!);
})
.then(function (tl) {
// we expect it to get merged in with event 2
expect(tl!.getEvents().length).toEqual(2);
expect(tl!.getEvents()[0].event).toEqual(EVENTS[1]);
expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]);
expect(tl!.getNeighbouringTimeline(EventTimeline.BACKWARDS)).toBe(tl0);
expect(tl!.getNeighbouringTimeline(EventTimeline.FORWARDS)).toBe(tl3);
expect(tl0.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token0");
expect(tl0.getPaginationToken(EventTimeline.FORWARDS)).toBe(null);
expect(tl3.getPaginationToken(EventTimeline.BACKWARDS)).toBe(null);
expect(tl3.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token3");
}),
httpBackend.flushAllExpected(),
]);
});
it("should fail gracefully if there is no event field", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
// we fetch event 0, then 2, then 3, and finally 1. 1 is returned
// with context which joins them all up.
httpBackend.when("GET", "/rooms/!foo%3Abar/context/event1").respond(200, function () {
return {
start: "start_token",
events_before: [],
events_after: [],
end: "end_token",
state: [],
};
});
return Promise.all([
client.getEventTimeline(timelineSet, "event1").then(
function (tl) {
// could do with a fail()
expect(true).toBeFalsy();
},
function (e) {
expect(String(e)).toMatch(/'event'/);
},
),
httpBackend.flushAllExpected(),
]);
});
it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
httpBackend
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function () {
return THREAD_ROOT;
});
httpBackend
.when(
"GET",
"/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) +
buildRelationPaginationQuery({ dir: Direction.Backward }),
)
.respond(200, function () {
return {
chunk: [THREAD_REPLY],
// no next batch as this is the oldest end of the timeline
};
});
const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false);
await httpBackend.flushAllExpected();
const timelineSet = thread.timelineSet;
const timeline = await client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!);
const eventIds = timeline!.getEvents().map((it) => it.getId());
expect(eventIds).toContain(THREAD_ROOT.event_id);
expect(eventIds).toContain(THREAD_REPLY.event_id);
});
it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const threadRoot = new MatrixEvent(THREAD_ROOT);
const thread = room.createThread(THREAD_ROOT.event_id!, threadRoot, [threadRoot], false)!;
const timelineSet = room.getTimelineSets()[0]!;
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: THREAD_ROOT,
events_after: [],
end: "end_token0",
state: [],
};
});
const [timeline] = await Promise.all([
client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!),
httpBackend.flushAllExpected(),
]);
expect(timeline!).not.toBe(thread.liveTimeline);
expect(timelineSet.getTimelines()).toContain(timeline);
expect(timeline!.getEvents().find((e) => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy();
});
it("should return undefined when event is not in the thread that the given timelineSet is representing", () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const threadRoot = new MatrixEvent(THREAD_ROOT);
const thread = room.createThread(THREAD_ROOT.event_id!, threadRoot, [threadRoot], false);
const timelineSet = thread.timelineSet;
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!))
.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeUndefined(),
httpBackend.flushAllExpected(),
]);
});
it("should return undefined when event is within a thread but timelineSet is not", () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id!))
.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: THREAD_REPLY,
events_after: [],
end: "end_token0",
state: [],
};
});
return Promise.all([
expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeUndefined(),
httpBackend.flushAllExpected(),
]);
});
it("should should add lazy loading filter when requested", async () => {
// @ts-ignore
client.clientOpts.lazyLoadMembers = true;
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!));
req.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
req.check((request) => {
expect(request.queryParams?.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER));
});
await Promise.all([
client.getEventTimeline(timelineSet, EVENTS[0].event_id!),
httpBackend.flushAllExpected(),
]);
});
});
describe("getLatestTimeline", function () {
it("timeline support must be enabled to work", async function () {
await client.stopClient();
const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: false });
client = testClient.client;
httpBackend = testClient.httpBackend;
await startClient(httpBackend, client);
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0]!;
await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy();
});
it("timeline support works when enabled", async function () {
await client.stopClient();
const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true });
client = testClient.client;
httpBackend = testClient.httpBackend;
return startClient(httpBackend, client).then(() => {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/context/event`).respond(200, () => ({
event: {
event_id: "event",
},
events_after: [],
events_before: [],
state: [],
}));
return Promise.all([
expect(client.getEventTimeline(timelineSet, "event")).resolves.toBeTruthy(),
httpBackend.flushAllExpected(),
]);
});
});
it("only works with room timelines", async function () {
await client.stopClient();
const testClient = new TestClient(userId, "DEVICE", accessToken, undefined, { timelineSupport: true });
client = testClient.client;
httpBackend = testClient.httpBackend;
await startClient(httpBackend, client);
const timelineSet = new EventTimelineSet(undefined);
await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy();
});
it("should create a new timeline for new events", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
const latestMessageId = "event1:bar";
httpBackend.when("GET", "/rooms/!foo%3Abar/messages").respond(200, function () {
return {
chunk: [
{
event_id: latestMessageId,
},
],
};
});
httpBackend
.when("GET", `/rooms/!foo%3Abar/context/${encodeURIComponent(latestMessageId)}`)
.respond(200, function () {
return {
start: "start_token",
events_before: [EVENTS[1], EVENTS[0]],
event: EVENTS[2],
events_after: [EVENTS[3]],
state: [ROOM_NAME_EVENT, USER_MEMBERSHIP_EVENT],
end: "end_token",
};
});
return Promise.all([
client.getLatestTimeline(timelineSet).then(function (tl) {
// Instead of this assertion logic, we could just add a spy
// for `getEventTimeline` and make sure it's called with the
// correct parameters. This doesn't feel too bad to make sure
// `getLatestTimeline` is doing the right thing though.
expect(tl!.getEvents().length).toEqual(4);
for (let i = 0; i < 4; i++) {
expect(tl!.getEvents()[i].event).toEqual(EVENTS[i]);
expect(tl!.getEvents()[i]?.sender?.name).toEqual(userName);
}
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token");
expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token");
}),
httpBackend.flushAllExpected(),
]);
});
it("should throw error when /messages does not return a message", () => {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend.when("GET", "/rooms/!foo%3Abar/messages").respond(200, () => {
return {
chunk: [
// No messages to return
],
};
});
return Promise.all([
expect(client.getLatestTimeline(timelineSet)).rejects.toThrow(),
httpBackend.flushAllExpected(),
]);
});
});
describe("paginateEventTimeline", function () {
it("should allow you to paginate backwards", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!))
.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/messages")
.check(function (req) {
const params = req.queryParams!;
expect(params.dir).toEqual("b");
expect(params.from).toEqual("start_token0");
expect(params.limit).toEqual("30");
})
.respond(200, function () {
return {
chunk: [EVENTS[1], EVENTS[2]],
end: "start_token1",
};
});
let tl: EventTimeline;
return Promise.all([
client
.getEventTimeline(timelineSet, EVENTS[0].event_id!)
.then(function (tl0) {
tl = tl0!;
return client.paginateEventTimeline(tl, { backwards: true });
})
.then(function (success) {
expect(success).toBeTruthy();
expect(tl!.getEvents().length).toEqual(3);
expect(tl!.getEvents()[0].event).toEqual(EVENTS[2]);
expect(tl!.getEvents()[1].event).toEqual(EVENTS[1]);
expect(tl!.getEvents()[2].event).toEqual(EVENTS[0]);
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token1");
expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0");
}),
httpBackend.flushAllExpected(),
]);
});
it("should stop paginating when it encounters no `end` token", () => {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!))
.respond(200, () => ({
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
}));
httpBackend
.when("GET", "/rooms/!foo%3Abar/messages")
.check(function (req) {
const params = req.queryParams!;
expect(params.dir).toEqual("b");
expect(params.from).toEqual("start_token0");
expect(params.limit).toEqual("30");
})
.respond(200, () => ({
start: "start_token0",
chunk: [],
}));
return Promise.all([
(async () => {
const tl = await client.getEventTimeline(timelineSet, EVENTS[0].event_id!);
const success = await client.paginateEventTimeline(tl!, { backwards: true });
expect(success).toBeFalsy();
expect(tl!.getEvents().length).toEqual(1);
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual(null);
expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0");
})(),
httpBackend.flushAllExpected(),
]);
});
it("should allow you to paginate forwards", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!))
.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/messages")
.check(function (req) {
const params = req.queryParams!;
expect(params.dir).toEqual("f");
expect(params.from).toEqual("end_token0");
expect(params.limit).toEqual("20");
})
.respond(200, function () {
return {
chunk: [EVENTS[1], EVENTS[2]],
end: "end_token1",
};
});
let tl: EventTimeline;
return Promise.all([
client
.getEventTimeline(timelineSet, EVENTS[0].event_id!)
.then(function (tl0) {
tl = tl0!;
return client.paginateEventTimeline(tl, { backwards: false, limit: 20 });
})
.then(function (success) {
expect(success).toBeTruthy();
expect(tl!.getEvents().length).toEqual(3);
expect(tl!.getEvents()[0].event).toEqual(EVENTS[0]);
expect(tl!.getEvents()[1].event).toEqual(EVENTS[1]);
expect(tl!.getEvents()[2].event).toEqual(EVENTS[2]);
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token0");
expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token1");
}),
httpBackend.flushAllExpected(),
]);
});
it("should create threads for thread roots discovered", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id!))
.respond(200, function () {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/messages")
.check(function (req) {
const params = req.queryParams!;
expect(params.dir).toEqual("b");
expect(params.from).toEqual("start_token0");
expect(params.limit).toEqual("30");
})
.respond(200, function () {
return {
chunk: [EVENTS[1], EVENTS[2], THREAD_ROOT],
end: "start_token1",
};
});
let tl: EventTimeline;
return Promise.all([
client
.getEventTimeline(timelineSet, EVENTS[0].event_id!)
.then(function (tl0) {
tl = tl0!;
return client.paginateEventTimeline(tl, { backwards: true });
})
.then(function (success) {
expect(success).toBeTruthy();
expect(tl!.getEvents().length).toEqual(4);
expect(tl!.getEvents()[0].event).toEqual(THREAD_ROOT);
expect(tl!.getEvents()[1].event).toEqual(EVENTS[2]);
expect(tl!.getEvents()[2].event).toEqual(EVENTS[1]);
expect(tl!.getEvents()[3].event).toEqual(EVENTS[0]);
expect(room.getThreads().map((it) => it.id)).toEqual([THREAD_ROOT.event_id!]);
expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("start_token1");
expect(tl!.getPaginationToken(EventTimeline.FORWARDS)).toEqual("end_token0");
}),
httpBackend.flushAllExpected(),
]);
});
});
it("should ensure thread events are ordered correctly", async () => {
// Test data for a second reply to the first thread
const THREAD_REPLY2 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 2",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY2.localTimestamp += 1000;
// Test data for a second reply to the first thread
const THREAD_REPLY3 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 3",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY3.localTimestamp += 2000;
// Test data for the first thread, with the second reply
const THREAD_ROOT_UPDATED = {
...THREAD_ROOT,
unsigned: {
...THREAD_ROOT.unsigned,
"m.relations": {
...THREAD_ROOT.unsigned!["m.relations"],
"io.element.thread": {
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
count: 3,
latest_event: THREAD_REPLY3.event,
},
},
},
};
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const prom = emitPromise(room, ThreadEvent.Update);
// Assume we're seeing the reply while loading backlog
await room.addLiveEvents([THREAD_REPLY2], { addToState: false });
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT_UPDATED.event_id!),
)
.respond(200, {
chunk: [THREAD_REPLY3.event, THREAD_REPLY2.event, THREAD_REPLY],
});
await flushHttp(prom);
// but while loading the metadata, a new reply has arrived
await room.addLiveEvents([THREAD_REPLY3], { addToState: false });
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
// then the events should still be all in the right order
expect(thread.events.map((it) => it.getId())).toEqual([
THREAD_ROOT.event_id,
THREAD_REPLY.event_id,
THREAD_REPLY2.getId(),
THREAD_REPLY3.getId(),
]);
});
it("should ensure thread events don't get reordered with recursive relations", async () => {
// Test data for a second reply to the first thread
const THREAD_REPLY2 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 2",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY2.localTimestamp += 1000;
const THREAD_ROOT_REACTION = utils.mkEvent({
event: true,
type: EventType.Reaction,
user: userId,
room: roomId,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: THREAD_ROOT.event_id!,
key: Math.random().toString(),
},
},
});
THREAD_ROOT_REACTION.localTimestamp += 2000;
// Test data for a second reply to the first thread
const THREAD_REPLY3 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 3",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY3.localTimestamp += 3000;
// Test data for the first thread, with the second reply
const THREAD_ROOT_UPDATED = {
...THREAD_ROOT,
unsigned: {
...THREAD_ROOT.unsigned,
"m.relations": {
...THREAD_ROOT.unsigned!["m.relations"],
"io.element.thread": {
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
count: 3,
latest_event: THREAD_REPLY3.event,
},
},
},
};
// @ts-ignore
client.clientOpts.threadSupport = true;
client.canSupport.set(Feature.RelationsRecursion, ServerSupport.Stable);
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const prom = emitPromise(room, ThreadEvent.Update);
// Assume we're seeing the reply while loading backlog
await room.addLiveEvents([THREAD_REPLY2], { addToState: false });
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
buildRelationPaginationQuery({
dir: Direction.Backward,
recurse: true,
}),
)
.respond(200, {
chunk: [THREAD_REPLY3.event, THREAD_ROOT_REACTION, THREAD_REPLY2.event, THREAD_REPLY],
});
await flushHttp(prom);
// but while loading the metadata, a new reply has arrived
await room.addLiveEvents([THREAD_REPLY3], { addToState: false });
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
// then the events should still be all in the right order
expect(thread.events.map((it) => it.getId())).toEqual([
THREAD_ROOT.event_id,
THREAD_REPLY.event_id,
THREAD_REPLY2.getId(),
THREAD_REPLY3.getId(),
]);
});
describe("paginateEventTimeline for thread list timeline", function () {
const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c";
function respondToFilter(): ExpectedHttpRequest {
const request = httpBackend.when("POST", "/filter");
request.respond(200, { filter_id: "fid" });
return request;
}
function respondToSync(): ExpectedHttpRequest {
const request = httpBackend.when("GET", "/sync");
request.respond(200, INITIAL_SYNC_DATA);
return request;
}
function respondToThreads(
response = {
chunk: [THREAD_ROOT],
state: [],
next_batch: RANDOM_TOKEN as string | null,
},
): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
encodeUri("/_matrix/client/v1/rooms/$roomId/threads", {
$roomId: roomId,
}),
);
request.respond(200, response);
return request;
}
function respondToThread(root: Partial<IEvent>, replies: Partial<IEvent>[]): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(root.event_id!) + "?dir=b",
);
request.respond(200, function () {
return {
original_event: root,
chunk: replies,
// no next batch as this is the oldest end of the timeline
};
});
return request;
}
function respondToContext(event: Partial<IEvent> = THREAD_ROOT): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
encodeUri("/_matrix/client/v3/rooms/$roomId/context/$eventId", {
$roomId: roomId,
$eventId: event.event_id!,
}),
);
request.respond(200, {
end: `${Direction.Forward}${RANDOM_TOKEN}1`,
start: `${Direction.Backward}${RANDOM_TOKEN}1`,
state: [],
events_before: [],
events_after: [],
event: event,
});
return request;
}
function respondToEvent(event: Partial<IEvent> = THREAD_ROOT): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
encodeUri("/_matrix/client/v3/rooms/$roomId/event/$eventId", {
$roomId: roomId,
$eventId: event.event_id!,
}),
);
request.respond(200, event);
return request;
}
function respondToMessagesRequest(): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
encodeUri("/_matrix/client/v3/rooms/$roomId/messages", {
$roomId: roomId,
}),
);
request.respond(200, {
chunk: [THREAD_ROOT],
state: [],
start: `${Direction.Forward}${RANDOM_TOKEN}2`,
end: `${Direction.Backward}${RANDOM_TOKEN}2`,
});
return request;
}
describe("with server compatibility", function () {
beforeEach(() => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
});
async function testPagination(timelineSet: EventTimelineSet, direction: Direction) {
respondToContext();
await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!));
respondToThreads();
const timeline = await flushHttp(client.getLatestTimeline(timelineSet));
expect(timeline).not.toBeNull();
respondToThreads();
const success = await flushHttp(
client.paginateEventTimeline(timeline!, {
backwards: direction === Direction.Backward,
}),
);
expect(success).toBeTruthy();
expect(timeline!.getEvents().map((it) => it.event)).toEqual([THREAD_ROOT]);
expect(timeline!.getPaginationToken(direction)).toEqual(RANDOM_TOKEN);
}
it("should allow you to paginate all threads backwards", async function () {
const room = client.getRoom(roomId);
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
const [allThreads, myThreads] = timelineSets!;
await testPagination(allThreads, Direction.Backward);
await testPagination(myThreads, Direction.Backward);
});
it("should allow you to paginate all threads forwards", async function () {
const room = client.getRoom(roomId);
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
const [allThreads, myThreads] = timelineSets!;
await testPagination(allThreads, Direction.Forward);
await testPagination(myThreads, Direction.Forward);
});
it("should allow fetching all threads", async function () {
const room = client.getRoom(roomId)!;
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
respondToThreads();
respondToThreads();
httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA);
await flushHttp(room.fetchRoomThreads());
});
it("should prevent displaying pending events", async function () {
const room = new Room("room123", client, "john", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
const event = utils.mkMessage({
room: roomId,
user: userId,
msg: "a body",
event: true,
});
event.status = EventStatus.SENDING;
room.addPendingEvent(event, "txn");
const [allThreads, myThreads] = timelineSets!;
expect(allThreads.getPendingEvents()).toHaveLength(0);
expect(myThreads.getPendingEvents()).toHaveLength(0);
expect(room.getPendingEvents()).toHaveLength(1);
});
it("should handle new thread replies by reordering the thread list", async () => {
// Test data for a second thread
const THREAD2_ROOT = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
body: "thread root",
msgtype: "m.text",
},
unsigned: {
"m.relations": {
"io.element.thread": {
//"latest_event": undefined,
count: 1,
current_user_participated: true,
},
},
},
event: false,
});
const THREAD2_REPLY = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread2 reply",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: false,
});
// @ts-ignore we know this is a defined path for THREAD ROOT
THREAD2_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD2_REPLY;
// Test data for a second reply to the first thread
const THREAD_REPLY2 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply2",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
// this has to come after THREAD_REPLY which hasn't been instantiated by us
THREAD_REPLY2.localTimestamp += 10000000;
// Test data for the first thread, with the second reply
const THREAD_ROOT_UPDATED = {
...THREAD_ROOT,
unsigned: {
...THREAD_ROOT.unsigned,
"m.relations": {
...THREAD_ROOT.unsigned!["m.relations"],
"io.element.thread": {
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
count: 2,
latest_event: THREAD_REPLY2.event,
},
},
},
};
// Response with test data for the thread list request
const threadsResponse = {
chunk: [THREAD2_ROOT, THREAD_ROOT],
state: [],
next_batch: RANDOM_TOKEN as string | null,
};
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
// Setup room threads
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
respondToThreads(threadsResponse);
respondToThreads(threadsResponse);
respondToEvent(THREAD2_ROOT);
respondToThread(THREAD2_ROOT, [THREAD2_REPLY]);
await flushHttp(room.fetchRoomThreads());
const threadIds = room.getThreads().map((thread) => thread.id);
expect(threadIds).toContain(THREAD_ROOT.event_id);
expect(threadIds).toContain(THREAD2_ROOT.event_id);
const [allThreads] = timelineSets!;
const timeline = allThreads.getLiveTimeline()!;
// Test threads are in chronological order (first thread should be first because it has a more recent reply)
expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([
THREAD_ROOT.event_id,
THREAD2_ROOT.event_id,
]);
// Test adding a second event to the first thread
const thread = room.getThread(THREAD_ROOT.event_id!)!;
thread.initialEventsFetched = true;
const prom = emitPromise(room, ThreadEvent.NewReply);
respondToEvent(THREAD_ROOT_UPDATED);
await room.addLiveEvents([THREAD_REPLY2], { addToState: false });
await httpBackend.flushAllExpected();
await prom;
expect(thread.length).toBe(2);
// Test threads are in chronological order
expect(timeline!.getEvents().map((it) => it.event.event_id)).toEqual([
THREAD2_ROOT.event_id,
THREAD_ROOT.event_id,
]);
});
it("should not reorder the thread list on other thread updates", async () => {
// Test data for a second thread
const THREAD2_ROOT = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
body: "thread root",
msgtype: "m.text",
},
unsigned: {
"m.relations": {
"io.element.thread": {
//"latest_event": undefined,
count: 1,
current_user_participated: true,
},
},
},
event: false,
});
const THREAD2_REPLY = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread2 reply",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: false,
});
// @ts-ignore we know this is a defined path for THREAD ROOT
THREAD2_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD2_REPLY;
const THREAD_REPLY_REACTION = utils.mkEvent({
room: roomId,
user: userId,
type: "m.reaction",
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: THREAD_REPLY.event_id,
key: "🪿",
},
},
event: true,
});
THREAD_REPLY_REACTION.localTimestamp += 1000;
// Modified thread root event containing latest thread reply in its unsigned
const THREAD_ROOT_UPDATED = {
...THREAD_ROOT,
unsigned: {
...THREAD_ROOT.unsigned,
"m.relations": {
...THREAD_ROOT.unsigned!["m.relations"],
"io.element.thread": {
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
count: 1,
latest_event: THREAD_REPLY,
},
},
},
};
// Response with test data for the thread list request
const threadsResponse = {
chunk: [THREAD2_ROOT, THREAD_ROOT],
state: [],
next_batch: RANDOM_TOKEN as string | null,
};
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
// Set up room threads
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
respondToThreads(threadsResponse);
respondToThreads(threadsResponse);
respondToEvent(THREAD_ROOT);
respondToEvent(THREAD2_ROOT);
respondToThread(THREAD_ROOT, [THREAD_REPLY]);
respondToThread(THREAD2_ROOT, [THREAD2_REPLY]);
await flushHttp(room.fetchRoomThreads());
const threadIds = room.getThreads().map((thread) => thread.id);
expect(threadIds).toContain(THREAD_ROOT.event_id);
expect(threadIds).toContain(THREAD2_ROOT.event_id);
const [allThreads] = timelineSets!;
const timeline = allThreads.getLiveTimeline()!;
// Test threads are in chronological order
expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([
THREAD_ROOT.event_id,
THREAD2_ROOT.event_id,
]);
// Test adding a second event to the first thread
const thread = room.getThread(THREAD_ROOT.event_id!)!;
thread.initialEventsFetched = true;
const prom = emitPromise(room, ThreadEvent.Update);
respondToEvent(THREAD_ROOT_UPDATED);
await room.addLiveEvents([THREAD_REPLY_REACTION], { addToState: false });
await httpBackend.flushAllExpected();
await prom;
expect(thread.length).toBe(1); // reactions don't count towards the length of a thread
// Test thread order is unchanged
expect(timeline!.getEvents().map((it) => it.event.event_id)).toEqual([
THREAD_ROOT.event_id,
THREAD2_ROOT.event_id,
]);
});
});
describe("without server compatibility", function () {
beforeEach(() => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.None);
});
async function testPagination(timelineSet: EventTimelineSet, direction: Direction) {
respondToContext();
respondToSync();
await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!));
respondToMessagesRequest();
const timeline = await flushHttp(client.getLatestTimeline(timelineSet));
expect(timeline).not.toBeNull();
respondToMessagesRequest();
const success = await flushHttp(
client.paginateEventTimeline(timeline!, {
backwards: direction === Direction.Backward,
}),
);
expect(success).toBeTruthy();
expect(timeline!.getEvents().map((it) => it.event)).toEqual([THREAD_ROOT]);
expect(timeline!.getPaginationToken(direction)).toEqual(`${direction}${RANDOM_TOKEN}2`);
}
it("should allow you to paginate all threads", async function () {
const room = client.getRoom(roomId);
respondToFilter();
respondToSync();
respondToFilter();
respondToSync();
const timelineSetsPromise = room?.createThreadsTimelineSets();
expect(timelineSetsPromise).not.toBeNull();
const timelineSets = await flushHttp(timelineSetsPromise!);
expect(timelineSets).not.toBeNull();
const [allThreads, myThreads] = timelineSets!;
await testPagination(allThreads, Direction.Backward);
await testPagination(myThreads, Direction.Backward);
});
it("should allow fetching all threads", async function () {
const room = client.getRoom(roomId)!;
respondToFilter();
respondToSync();
respondToFilter();
respondToSync();
const timelineSetsPromise = room.createThreadsTimelineSets();
expect(timelineSetsPromise).not.toBeNull();
await flushHttp(timelineSetsPromise!);
respondToFilter();
respondToSync();
respondToSync();
respondToSync();
respondToMessagesRequest();
await flushHttp(room.fetchRoomThreads());
});
});
it("should add lazy loading filter", async () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.Stable);
// @ts-ignore
client.clientOpts.lazyLoadMembers = true;
const room = client.getRoom(roomId);
const timelineSets = await room?.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
const [allThreads] = timelineSets!;
respondToThreads().check((request) => {
expect(request.queryParams?.filter).toEqual(
JSON.stringify({
lazy_load_members: true,
}),
);
});
await flushHttp(
client.paginateEventTimeline(allThreads.getLiveTimeline(), {
backwards: true,
}),
);
});
it("should correctly pass pagination token", async () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.Stable);
const room = client.getRoom(roomId);
const timelineSets = await room?.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
const [allThreads] = timelineSets!;
respondToThreads({
chunk: [THREAD_ROOT],
state: [],
next_batch: null,
}).check((request) => {
expect(request.queryParams?.from).toEqual(RANDOM_TOKEN);
});
allThreads.getLiveTimeline().setPaginationToken(RANDOM_TOKEN, Direction.Backward);
await flushHttp(
client.paginateEventTimeline(allThreads.getLiveTimeline(), {
backwards: true,
}),
);
});
});
describe("event timeline for sent events", function () {
const TXN_ID = "txn1";
const event = utils.mkMessage({
room: roomId,
user: userId,
msg: "a body",
});
event.unsigned = { transaction_id: TXN_ID };
beforeEach(function () {
// set up handlers for both the message send, and the
// /sync
httpBackend.when("PUT", "/send/m.room.message/" + TXN_ID).respond(200, {
event_id: event.event_id,
});
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_4",
rooms: {
join: {
"!foo:bar": {
timeline: {
events: [withoutRoomId(event)],
prev_batch: "f_1_1",
},
},
},
},
});
});
it("should work when /send returns before /sync", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0]!;
return Promise.all([
client
.sendTextMessage(roomId, "a body", TXN_ID)
.then(function (res) {
expect(res.event_id).toEqual(event.event_id!);
return client.getEventTimeline(timelineSet, event.event_id!);
})
.then(function (tl) {
// 2 because the initial sync contained an event
expect(tl!.getEvents().length).toEqual(2);
expect(tl!.getEvents()[1].getContent().body).toEqual("a body");
// now let the sync complete, and check it again
return Promise.all([httpBackend.flush("/sync", 1), utils.syncPromise(client)]);
})
.then(function () {
return client.getEventTimeline(timelineSet, event.event_id!);
})
.then(function (tl) {
expect(tl!.getEvents().length).toEqual(2);
expect(tl!.getEvents()[1].event).toEqual(event);
}),
httpBackend.flush("/send/m.room.message/" + TXN_ID, 1),
]);
});
it("should work when /send returns after /sync", function () {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
return Promise.all([
// initiate the send, and set up checks to be done when it completes
// - but note that it won't complete until after the /sync does, below.
client
.sendTextMessage(roomId, "a body", TXN_ID)
.then(function (res) {
logger.log("sendTextMessage completed");
expect(res.event_id).toEqual(event.event_id!);
return client.getEventTimeline(timelineSet, event.event_id!);
})
.then(function (tl) {
logger.log("getEventTimeline completed (2)");
expect(tl!.getEvents().length).toEqual(2);
expect(tl!.getEvents()[1].getContent().body).toEqual("a body");
}),
Promise.all([httpBackend.flush("/sync", 1), utils.syncPromise(client)])
.then(function () {
return client.getEventTimeline(timelineSet, event.event_id!);
})
.then(function (tl) {
logger.log("getEventTimeline completed (1)");
expect(tl!.getEvents().length).toEqual(2);
expect(tl!.getEvents()[1].event).toEqual(event);
// now let the send complete.
return httpBackend.flush("/send/m.room.message/" + TXN_ID, 1);
}),
]);
});
});
it("should handle gappy syncs after redactions", function () {
// https://github.com/vector-im/vector-web/issues/1389
// a state event, followed by a redaction thereof
const event = utils.mkMembership({
mship: KnownMembership.Join,
user: otherUserId,
});
const redaction = utils.mkEvent({
type: "m.room.redaction",
sender: otherUserId,
content: {},
});
redaction.redacts = event.event_id;
const syncData = {
next_batch: "batch1",
rooms: {
join: {
[roomId]: {
timeline: {
events: [event, redaction],
limited: false,
},
},
},
},
};
httpBackend.when("GET", "/sync").respond(200, syncData);
return Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)])
.then(function () {
const room = client.getRoom(roomId)!;
const tl = room.getLiveTimeline()!;
expect(tl!.getEvents().length).toEqual(3);
expect(tl!.getEvents()[1].isRedacted()).toBe(true);
const sync2 = {
next_batch: "batch2",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
utils.mkMessage({
user: otherUserId,
msg: "world",
}),
],
limited: true,
prev_batch: "newerTok",
},
},
},
},
};
httpBackend.when("GET", "/sync").respond(200, sync2);
return Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
})
.then(function () {
const room = client.getRoom(roomId)!;
const tl = room.getLiveTimeline()!;
expect(tl.getEvents().length).toEqual(1);
});
});
describe("should re-insert room IDs for bundled thread relation events", () => {
async function doTest() {
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_4",
rooms: {
join: {
[roomId]: {
timeline: {
events: [SYNC_THREAD_ROOT],
prev_batch: "f_1_1",
},
},
},
},
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function () {
return THREAD_ROOT;
});
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) +
buildRelationPaginationQuery({ dir: Direction.Backward }),
)
.respond(200, function () {
return {
chunk: [THREAD_REPLY],
};
});
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
const room = client.getRoom(roomId)!;
const thread = room.getThread(THREAD_ROOT.event_id!)!;
expect(thread.initialEventsFetched).toBeTruthy();
const timelineSet = thread.timelineSet;
const timeline = await client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!);
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_5",
rooms: {
join: {
[roomId]: {
timeline: {
events: [SYNC_THREAD_REPLY],
prev_batch: "f_1_2",
},
},
},
},
});
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
expect(timeline!.getEvents()[1]!.event).toEqual(THREAD_REPLY);
}
it("in stable mode", async () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
return doTest();
});
it("in backwards compatible unstable mode", async () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.Experimental);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Experimental);
return doTest();
});
it("in backwards compatible mode", async () => {
// @ts-ignore
client.clientOpts.threadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.None);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.None);
return doTest();
});
});
});