1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-06-08 15:21:53 +03:00
matrix-js-sdk/spec/unit/models/thread.spec.ts
David Baker 5bcd26e506
Support MSC4222 state_after (#4487)
* WIP support for state_after

* Fix sliding sync sdk / embedded tests

* Allow both state & state_after to be undefined

Since it must have allowed state to be undefined previously: the test
had it as such.

* Fix limited sync handling

* Need to use state_after being undefined

if state can be undefined anyway

* Make sliding sync sdk tests pass

* Remove deprecated interfaces & backwards-compat code

* Remove useless assignment

* Use updates unstable prefix

* Clarify docs

* Remove additional semi-backwards compatible overload

* Update unstable prefixes

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test for MSC4222 behaviour

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Tidy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add comments to explain why things work as they are.

* Fix sync accumulator for state_after sync handling

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Revert "Fix room state being updated with old (now overwritten) state and emitting for those updates. (#4242)"

This reverts commit 957329b21821c0f632de6c04fff53144f7c0e5dd.

* Fix Sync Accumulator toJSON putting start timeline state in state_after field

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test case

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Timo <toger5@hotmail.de>
2024-11-27 11:40:41 +00:00

832 lines
35 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 { mocked } from "jest-mock";
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
import { Room, RoomEvent } from "../../../src/models/room";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
import { makeThreadEvent, mkThread, populateThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
import { ReceiptType } from "../../../src/@types/read_receipts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { ReEmitter } from "../../../src/ReEmitter";
import { Feature, ServerSupport } from "../../../src/feature";
import { eventMapperFor } from "../../../src/event-mapper";
describe("Thread", () => {
describe("constructor", () => {
it("should explode for element-web#22141 logging", () => {
// Logging/debugging for https://github.com/vector-im/element-web/issues/22141
expect(() => {
new Thread("$event", undefined, {} as any); // deliberate cast to test error case
}).toThrow("element-web#22141: A thread requires a room in order to function");
});
});
it("includes pending events in replyCount", async () => {
const myUserId = "@bob:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, { timelineSupport: false });
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
const room = new Room("123", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
const { thread } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 3,
});
await emitPromise(thread, ThreadEvent.Update);
expect(thread.length).toBe(2);
const event = mkMessage({
room: room.roomId,
user: myUserId,
msg: "thread reply",
relatesTo: {
rel_type: THREAD_RELATION_TYPE.name,
event_id: thread.id,
},
event: true,
});
await thread.processEvent(event);
event.setStatus(EventStatus.SENDING);
room.addPendingEvent(event, "txn01");
await emitPromise(thread, ThreadEvent.Update);
expect(thread.length).toBe(3);
});
describe("hasUserReadEvent", () => {
let myUserId: string;
let client: MatrixClient;
let room: Room;
beforeEach(() => {
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
isInitialSyncComplete: jest.fn().mockReturnValue(false),
getRoom: jest.fn().mockImplementation(() => room),
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
supportsThreads: jest.fn().mockReturnValue(true),
});
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = new Map();
Object.keys(Feature).forEach((feature) => {
client.canSupport.set(feature as Feature, ServerSupport.Stable);
});
myUserId = client.getUserId()!;
room = new Room("123", client, myUserId);
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
// first threaded receipt
"$event0:localhost": {
[ReceiptType.Read]: {
[client.getUserId()!]: { ts: 100, thread_id: "$threadId:localhost" },
},
},
// last unthreaded receipt
"$event1:localhost": {
[ReceiptType.Read]: {
[client.getUserId()!]: { ts: 200 },
["@alice:example.org"]: { ts: 200 },
},
},
// last threaded receipt
"$event2:localhost": {
[ReceiptType.Read]: {
[client.getUserId()!]: { ts: 300, thread_id: "$threadId" },
},
},
},
});
room.addReceipt(receipt);
jest.spyOn(client, "getRoom").mockReturnValue(room);
});
afterAll(() => {
jest.resetAllMocks();
});
it("considers own events with no RR as read", () => {
const { thread, events } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: [myUserId],
length: 2,
});
// The event is automatically considered read as the current user is the sender
expect(thread.hasUserReadEvent(myUserId, events.at(-1)!.getId() ?? "")).toBeTruthy();
});
it("considers other events with no RR as unread", () => {
// Given a long thread exists
const { thread, events } = populateThread({
room,
client,
authorId: "@other:foo.com",
participantUserIds: ["@other:foo.com"],
length: 25,
ts: 190,
});
const event1 = events.at(1)!;
const event2 = events.at(2)!;
const event24 = events.at(24)!;
// And we have read the second message in it with an unthreaded receipt
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: room.roomId,
content: {
// unthreaded receipt for the second message in the thread
[event2.getId()!]: {
[ReceiptType.Read]: {
[myUserId]: { ts: 200 },
},
},
},
});
room.addReceipt(receipt);
// Then we have read the first message in the thread, and not the last
expect(thread.hasUserReadEvent(myUserId, event1.getId()!)).toBe(true);
expect(thread.hasUserReadEvent(myUserId, event24.getId()!)).toBe(false);
});
it("considers event as read if there's a more recent unthreaded receipt", () => {
const { thread, events } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 2,
ts: 150, // before the latest unthreaded receipt
});
expect(thread.hasUserReadEvent(client.getUserId()!, events.at(-1)!.getId() ?? "")).toBe(true);
});
it("considers event as unread if there's no more recent unthreaded receipt", () => {
const { thread, events } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 2,
ts: 1000,
});
expect(thread.hasUserReadEvent(client.getUserId()!, events.at(-1)!.getId() ?? "")).toBe(false);
});
});
describe("getEventReadUpTo", () => {
let myUserId: string;
let client: MatrixClient;
let room: Room;
beforeEach(() => {
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
isInitialSyncComplete: jest.fn().mockReturnValue(false),
getRoom: jest.fn().mockImplementation(() => room),
decryptEventIfNeeded: jest.fn().mockResolvedValue(void 0),
supportsThreads: jest.fn().mockReturnValue(true),
});
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = new Map();
Object.keys(Feature).forEach((feature) => {
client.canSupport.set(feature as Feature, ServerSupport.Stable);
});
myUserId = client.getUserId()!;
room = new Room("123", client, myUserId);
jest.spyOn(client, "getRoom").mockReturnValue(room);
});
afterAll(() => {
jest.resetAllMocks();
});
it("uses unthreaded receipt to figure out read up to", () => {
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
// last unthreaded receipt
"$event1:localhost": {
[ReceiptType.Read]: {
["@alice:example.org"]: { ts: 200 },
},
},
},
});
room.addReceipt(receipt);
const { thread, events } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: [myUserId],
length: 25,
ts: 190,
});
// The 10th event has been read, as alice's last unthreaded receipt is at ts 200
// and `mkThread` increment every thread response by 1ms.
expect(thread.getEventReadUpTo("@alice:example.org")).toBe(events.at(9)!.getId());
});
it("considers thread created before the first threaded receipt to be read", () => {
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: "!foo:bar",
content: {
// last unthreaded receipt
"$event1:localhost": {
[ReceiptType.Read]: {
[myUserId]: { ts: 200, thread_id: "$threadId" },
},
},
},
});
room.addReceipt(receipt);
const { thread, events } = mkThread({
room,
client,
authorId: "@alice:example.org",
participantUserIds: ["@alice:example.org"],
length: 2,
ts: 10,
});
// This is marked as read as it is before alice's first threaded receipt...
expect(thread.getEventReadUpTo(myUserId)).toBe(events.at(-1)!.getId());
const { thread: thread2 } = mkThread({
room,
client,
authorId: "@alice:example.org",
participantUserIds: ["@alice:example.org"],
length: 2,
ts: 1000,
});
// Nothing has been read, this thread is after the first threaded receipt...
expect(thread2.getEventReadUpTo(myUserId)).toBe(null);
});
});
describe("resetLiveTimeline", () => {
// ResetLiveTimeline is used when we have missing messages between the current live timeline's end and newly
// received messages. In that case, we want to replace the existing live timeline. To ensure pagination
// continues working correctly, new pagination tokens need to be set on both the old live timeline (which is
// now a regular timeline) and the new live timeline.
it("replaces the live timeline and correctly sets pagination tokens", async () => {
const myUserId = "@bob:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
const room = new Room("123", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
const { thread } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 3,
});
await emitPromise(thread, ThreadEvent.Update);
expect(thread.length).toBe(2);
jest.spyOn(client, "createMessagesRequest").mockImplementation((_, token) =>
Promise.resolve({
chunk: [],
start: `${token}-new`,
end: `${token}-new`,
}),
);
function timelines(): [string | null, string | null][] {
return thread.timelineSet
.getTimelines()
.map((it) => [it.getPaginationToken(Direction.Backward), it.getPaginationToken(Direction.Forward)]);
}
expect(timelines()).toEqual([[null, null]]);
const promise = thread.resetLiveTimeline("b1", "f1");
expect(timelines()).toEqual([
[null, "f1"],
["b1", null],
]);
await promise;
expect(timelines()).toEqual([
[null, "f1-new"],
["b1-new", null],
]);
});
// As the pagination tokens cannot be used right now, resetLiveTimeline needs to replace them before they can
// be used. But if in the future the bug in synapse is fixed, and they can actually be used, we can get into a
// state where the client has paginated (and changed the tokens) while resetLiveTimeline tries to set the
// corrected tokens. To prevent such a race condition, we make sure that resetLiveTimeline respects any
// changes done to the pagination tokens.
it("replaces the live timeline but does not replace changed pagination tokens", async () => {
const myUserId = "@bob:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
const room = new Room("123", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
const { thread } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 3,
});
await emitPromise(thread, ThreadEvent.Update);
expect(thread.length).toBe(2);
jest.spyOn(client, "createMessagesRequest").mockImplementation((_, token) =>
Promise.resolve({
chunk: [],
start: `${token}-new`,
end: `${token}-new`,
}),
);
function timelines(): [string | null, string | null][] {
return thread.timelineSet
.getTimelines()
.map((it) => [it.getPaginationToken(Direction.Backward), it.getPaginationToken(Direction.Forward)]);
}
expect(timelines()).toEqual([[null, null]]);
const promise = thread.resetLiveTimeline("b1", "f1");
expect(timelines()).toEqual([
[null, "f1"],
["b1", null],
]);
thread.timelineSet.getTimelines()[0].setPaginationToken("f2", Direction.Forward);
thread.timelineSet.getTimelines()[1].setPaginationToken("b2", Direction.Backward);
await promise;
expect(timelines()).toEqual([
[null, "f2"],
["b2", null],
]);
});
it("is correctly called by the room", async () => {
const myUserId = "@bob:example.org";
const testClient = new TestClient(myUserId, "DEVICE", "ACCESS_TOKEN", undefined, {
timelineSupport: false,
});
const client = testClient.client;
client.supportsThreads = jest.fn().mockReturnValue(true);
const room = new Room("123", client, myUserId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "getRoom").mockReturnValue(room);
const { thread } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 3,
});
await emitPromise(thread, ThreadEvent.Update);
expect(thread.length).toBe(2);
const mock = jest.spyOn(thread, "resetLiveTimeline");
mock.mockReturnValue(Promise.resolve());
room.resetLiveTimeline("b1", "f1");
expect(mock).toHaveBeenCalledWith("b1", "f1");
});
});
describe("insertEventIntoTimeline", () => {
it("Inserts a reaction in timestamp order", () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
const client = createClientWithEventMapper();
const userId = "user1";
const room = new Room("room1", client, userId);
// Given a thread with a root plus 5 messages
const { thread, events } = mkThread({
room,
client,
authorId: userId,
participantUserIds: ["@bob:hs", "@chia:hs", "@dv:hs"],
length: 6,
ts: 100, // Events will be at ts 100, 101, 102, 103, 104 and 105
});
// When we insert a reaction to the second thread message
const replyEvent = mkReaction(events[2], client, userId, room.roomId, 104);
thread.insertEventIntoTimeline(replyEvent);
// Then the reaction is inserted based on its timestamp
expect(thread.events.map((ev) => ev.getId())).toEqual([
events[0].getId(),
events[1].getId(),
events[2].getId(),
events[3].getId(),
events[4].getId(),
replyEvent.getId(),
events[5].getId(),
]);
});
describe("Without relations recursion support", () => {
it("Creates a local echo receipt for new events", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client without relations recursion support
const client = createClientWithEventMapper();
// And a thread with an added event (with later timestamp)
const userId = "user1";
const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, userId);
// Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt).toBeTruthy();
expect(receipt?.eventId).toEqual(message2.getId());
expect(receipt?.data.ts).toEqual(200);
expect(receipt?.data.thread_id).toEqual(thread.id);
// (And the receipt was synthetic)
expect(thread.getReadReceiptForUserId(userId, true)).toBeNull();
});
it("Doesn't create a local echo receipt for events before an existing receipt", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client without relations recursion support
const client = createClientWithEventMapper();
// And a thread with an added event with a lower timestamp than its other events
const userId = "user1";
const { thread, message1 } = await createThreadAnd2Events(client, 300, 200, 100, userId);
// Then the receipt is for the first message, because its
// timestamp is later. This happens because since we have no
// recursive relations support, we know that sometimes events
// appear out of order, so we have to check their timestamps as
// a guess of the correct order.
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
});
});
describe("With relations recursion support", () => {
it("Creates a local echo receipt for new events", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client WITH relations recursion support
const client = createClientWithEventMapper(
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
);
// And a thread with an added event (with later timestamp)
const userId = "user1";
const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, userId);
// Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message2.getId());
});
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client WITH relations recursion support
const client = createClientWithEventMapper(
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
);
// And a thread with an added event with a lower timestamp than its other events
const userId = "user1";
const { thread, message2 } = await createThreadAnd2Events(client, 300, 200, 100, userId);
// Then a receipt was added for the last message, even though it
// has lower ts, because relations recursion is available, so we
// trust the server to have provided us with events in the right
// order.
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message2.getId());
});
});
async function createThreadAnd2Events(
client: MatrixClient,
rootTs: number,
message1Ts: number,
message2Ts: number,
userId: string,
): Promise<{ thread: Thread; message1: MatrixEvent; message2: MatrixEvent }> {
const room = new Room("room1", client, userId);
// Given a thread
const { thread } = mkThread({
room,
client,
authorId: userId,
participantUserIds: [],
ts: rootTs,
});
// Sanity: there is no read receipt on the thread yet because the
// thread events don't get properly added to the room by mkThread.
expect(thread.getReadReceiptForUserId(userId)).toBeNull();
const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res()));
// Add a message with ts message1Ts
const message1 = makeThreadEvent({
event: true,
rootEventId: thread.id,
replyToEventId: thread.id,
user: userId,
room: room.roomId,
ts: message1Ts,
});
await thread.addEvent(message1, false, true);
await awaitTimelineEvent;
// Sanity: the thread now has a properly-added event, so this event
// has a synthetic receipt.
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
// Add a message with ts message2Ts
const message2 = makeThreadEvent({
event: true,
rootEventId: thread.id,
replyToEventId: thread.id,
user: userId,
room: room.roomId,
ts: message2Ts,
});
await thread.addEvent(message2, false, true);
await awaitTimelineEvent;
return { thread, message1, message2 };
}
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {
const client = mock(MatrixClient, "MatrixClient");
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = canSupport;
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
mocked(client.supportsThreads).mockReturnValue(true);
return client;
}
});
describe("Editing events", () => {
describe("Given server support for threads", () => {
let previousThreadHasServerSideSupport: FeatureSupport;
beforeAll(() => {
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
Thread.hasServerSideSupport = FeatureSupport.Stable;
});
afterAll(() => {
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
});
it("Adds edits from sync to the thread timeline and applies them", async () => {
// Given a thread
const client = createClient();
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
// When a message and an edit are added to the thread
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
await thread.addEvent(messageToEdit, false);
await thread.addEvent(editEvent, false);
// Then both events end up in the timeline
const lastEvent = thread.timeline.at(-1)!;
const secondLastEvent = thread.timeline.at(-2)!;
expect(lastEvent).toBe(editEvent);
expect(secondLastEvent).toBe(messageToEdit);
// And the first message has been edited
expect(secondLastEvent.getContent().body).toEqual("edit");
});
it("Adds edits fetched on demand to the thread timeline and applies them", async () => {
// Given we don't support recursive relations
const client = createClient(new Map([[Feature.RelationsRecursion, ServerSupport.Unsupported]]));
// And we have a thread
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
// When a message is added to the thread, and an edit to it is provided on demand
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
// (fetchEditsWhereNeeded only applies to encrypted messages for some reason)
messageToEdit.event.type = EventType.RoomMessageEncrypted;
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
mocked(client.relations).mockImplementation(async (_roomId, eventId) => {
if (eventId === messageToEdit.getId()) {
return { events: [editEvent] };
} else {
return { events: [] };
}
});
await thread.addEvent(messageToEdit, false);
// Then both events end up in the timeline
const lastEvent = thread.timeline.at(-1)!;
const secondLastEvent = thread.timeline.at(-2)!;
expect(lastEvent).toBe(editEvent);
expect(secondLastEvent).toBe(messageToEdit);
// And the first message has been edited
expect(secondLastEvent.getContent().body).toEqual("edit");
});
});
});
describe("addEvent", () => {
describe("Given server support for threads", () => {
let previousThreadHasServerSideSupport: FeatureSupport;
beforeAll(() => {
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
Thread.hasServerSideSupport = FeatureSupport.Stable;
});
afterAll(() => {
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
});
it("Adds events even if they appear out of order", async () => {
// Given a thread exists
const client = createClient();
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
const prevNumEvents = thread.timeline.length;
// When two messages come in but the later one has an older timestamp
const message1 = createThreadMessage(thread.id, user, room, "message1");
const message2 = createThreadMessage(thread.id, user, room, "message2");
message2.localTimestamp -= 10000;
await thread.addEvent(message1, false);
await thread.addEvent(message2, false);
// Then both events end up in the timeline
expect(thread.timeline.length - prevNumEvents).toEqual(2);
const lastEvent = thread.timeline.at(-1)!;
const secondLastEvent = thread.timeline.at(-2)!;
expect(lastEvent).toBe(message2);
expect(secondLastEvent).toBe(message1);
});
it("Adds events to start even if they appear out of order", async () => {
// Given a thread exists
const client = createClient();
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
const prevNumEvents = thread.timeline.length;
// When two messages come in but the later one has an older timestamp
const message1 = createThreadMessage(thread.id, user, room, "message1");
const message2 = createThreadMessage(thread.id, user, room, "message2");
message2.localTimestamp -= 10000;
await thread.addEvent(message1, false);
await thread.addEvent(message2, true);
// Then both events end up in the timeline
expect(thread.timeline.length - prevNumEvents).toEqual(2);
const lastEvent = thread.timeline.at(-1)!;
const firstEvent = thread.timeline.at(0)!;
expect(lastEvent).toBe(message1);
expect(firstEvent).toBe(message2);
});
});
});
});
/**
* Create a message event that lives in a thread
*/
function createThreadMessage(threadId: string, user: string, room: string, msg: string): MatrixEvent {
return makeThreadEvent({
event: true,
user,
room,
msg,
rootEventId: threadId,
replyToEventId: threadId,
});
}
/**
* Create a thread and wait for it to be properly initialised (so you can safely
* add events to it and expect them to appear in the timeline.
*/
async function createThread(client: MatrixClient, user: string, roomId: string): Promise<Thread> {
const root = mkMessage({ event: true, user, room: roomId, msg: "Thread root" });
const room = new Room(roomId, client, "@roomcreator:x");
// Ensure the root is in the room timeline
root.setThreadId(root.getId());
await room.addLiveEvents([root], { addToState: false });
// Create the thread and wait for it to be initialised
const thread = room.createThread(root.getId()!, root, [], false);
await new Promise<void>((res) => thread.once(RoomEvent.TimelineReset, () => res()));
return thread;
}
/**
* Create a MatrixClient that supports threads and has all the methods used when
* creating a thread that call out to HTTP endpoints mocked out.
*/
function createClient(canSupport = new Map()): MatrixClient {
const client = mock(MatrixClient, "MatrixClient");
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = canSupport;
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
// Mock methods that call out to HTTP endpoints
jest.spyOn(client, "paginateEventTimeline").mockResolvedValue(true);
jest.spyOn(client, "relations").mockResolvedValue({ events: [] });
jest.spyOn(client, "fetchRoomEvent").mockResolvedValue({});
return client;
}