1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-09 10:22:46 +03:00
Files
matrix-js-sdk/spec/unit/room.spec.ts
Florian D 810f7142e6 Remove legacy crypto (#4653)
* Remove deprecated calls in `webrtc/call.ts`

* Throw error when legacy call was used

* Remove `MatrixClient.initLegacyCrypto` (#4620)

* Remove `MatrixClient.initLegacyCrypto`

* Remove `MatrixClient.initLegacyCrypto` in README.md

* Remove tests using `MatrixClient.initLegacyCrypto`

* Remove legacy crypto support in `sync` api (#4622)

* Remove deprecated `DeviceInfo` in `webrtc/call.ts` (#4654)

* chore(legacy call): Remove `DeviceInfo` usage

* refactor(legacy call): throw `GroupCallUnknownDeviceError` at the end of `initOpponentCrypto`

* Remove deprecated methods and attributes of `MatrixClient` (#4659)

* feat(legacy crypto)!: remove deprecated methods of `MatrixClient`

* test(legacy crypto): update existing tests to not use legacy crypto

- `Embedded.spec.ts`: casting since `encryptAndSendToDevices` is removed from `MatrixClient`.
- `room.spec.ts`: remove deprecated usage of `MatrixClient.crypto`
- `matrix-client.spec.ts` & `matrix-client-methods.spec.ts`: remove calls of deprecated methods of `MatrixClient`

* test(legacy crypto): remove test files using `MatrixClient` deprecated methods

* test(legacy crypto): update existing integ tests to run successfully

* feat(legacy crypto!): remove `ICreateClientOpts.deviceToImport`.

`ICreateClientOpts.deviceToImport` was used in the legacy cryto. The rust crypto doesn't support to import devices in this way.

* feat(legacy crypto!): remove `{get,set}GlobalErrorOnUnknownDevices`

`globalErrorOnUnknownDevices` is not used in the rust-crypto. The API is marked as unstable, we can remove it.

* Remove usage of legacy crypto in `event.ts` (#4666)

* feat(legacy crypto!): remove legacy crypto usage in `event.ts`

* test(legacy crypto): update event.spec.ts to not use legacy crypto types

* Remove legacy crypto export in `matrix.ts` (#4667)

* feat(legacy crypto!): remove legacy crypto export in `matrix.ts`

* test(legacy crypto): update `megolm-backup.spec.ts` to import directly `CryptoApi`

* Remove usage of legacy crypto in integ tests (#4669)

* Clean up legacy stores (#4663)

* feat(legacy crypto!): keep legacy methods used in lib olm migration

The rust cryto needs these legacy stores in order to do the migration from the legacy crypto to the rust crypto. We keep the following methods of the stores:
- Used in `libolm_migration.ts`.
- Needed in the legacy store tests.
- Needed in the rust crypto test migration.

* feat(legacy crypto): extract legacy crypto types in legacy stores

In order to be able to delete the legacy crypto, these stores shouldn't rely on the legacy crypto. We need to extract the used types.

* feat(crypto store): remove `CryptoStore` functions used only by tests

* test(crypto store): use legacy `MemoryStore` type

* Remove deprecated methods of `CryptoBackend` (#4671)

* feat(CryptoBackend)!: remove deprecated methods

* feat(rust-crypto)!: remove deprecated methods of `CryptoBackend`

* test(rust-crypto): remove tests of deprecated methods of `CryptoBackend`

* Remove usage of legacy crypto in `embedded.ts` (#4668)

The interface of `encryptAndSendToDevices` changes because `DeviceInfo` is from the legacy crypto. In fact `encryptAndSendToDevices` only need pairs of userId and deviceId.

* Remove legacy crypto files (#4672)

* fix(legacy store): fix legacy store typing

In https://github.com/matrix-org/matrix-js-sdk/pull/4663, the storeXXX methods were removed of the CryptoStore interface but they are used internally by IndexedDBCryptoStore.

* feat(legacy crypto)!: remove content of `crypto/*` except legacy stores

* test(legacy crypto): remove `spec/unit/crypto/*` except legacy store tests

* refactor: remove unused types

* doc: fix broken link

* doc: remove link tag when typedoc is unable to find the CryptoApi

* Clean up integ test after legacy crypto removal (#4682)

* test(crypto): remove `newBackendOnly` test closure

* test(crypto): fix duplicate test name

* test(crypto): remove `oldBackendOnly` test closure

* test(crypto): remove `rust-sdk` comparison

* test(crypto): remove iteration on `CRYPTO_BACKEND`

* test(crypto): remove old legacy comments and tests

* test(crypto): fix documentations and removed unused expect

* Restore broken link to `CryptoApi` (#4692)

* chore: fix linting and formatting due to merge

* Remove unused crypto type and missing doc (#4696)

* chore(crypto): remove unused types

* doc(crypto): add missing link

* test(call): add test when crypto is enabled
2025-02-07 12:31:40 +00:00

4280 lines
180 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.
*/
/**
* This is an internal module. See {@link MatrixClient} for the public class.
*/
import { mocked } from "jest-mock";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, type Optional, PollStartEvent } from "matrix-events-sdk";
import * as utils from "../test-utils/test-utils";
import { emitPromise, type IMessageOpts } from "../test-utils/test-utils";
import {
Direction,
DuplicateStrategy,
EventStatus,
type EventTimelineSet,
EventType,
Filter,
FILTER_RELATED_BY_REL_TYPES,
FILTER_RELATED_BY_SENDERS,
type IContent,
type IEvent,
type IRelationsRequestOpts,
type IStateEventWithRoomId,
JoinRule,
type MatrixClient,
MatrixEvent,
MatrixEventEvent,
PendingEventOrdering,
PollEvent,
RelationType,
RoomEvent,
type RoomMember,
} from "../../src";
import { EventTimeline } from "../../src/models/event-timeline";
import { NotificationCountType, Room } from "../../src/models/room";
import { RoomState } from "../../src/models/room-state";
import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
import { TestClient } from "../TestClient";
import { ReceiptType, type WrappedReceipt } from "../../src/@types/read_receipts";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
import * as threadUtils from "../test-utils/thread";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
import { logger } from "../../src/logger";
import { flushPromises } from "../test-utils/flushPromises";
import { KnownMembership } from "../../src/@types/membership";
import type { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
describe("Room", function () {
const roomId = "!foo:bar";
const userA = "@alice:bar";
const userB = "@bertha:bar";
const userC = "@clarissa:bar";
const userD = "@dorothy:bar";
let room: Room;
const mkMessage = (opts?: Partial<IMessageOpts>) =>
utils.mkMessage(
{
...opts,
event: true,
user: userA,
room: roomId,
},
room.client,
);
const mkReply = (target: MatrixEvent) =>
utils.mkEvent(
{
event: true,
type: EventType.RoomMessage,
user: userA,
room: roomId,
content: {
"body": "Reply :: " + Math.random(),
"m.relates_to": {
"m.in_reply_to": {
event_id: target.getId()!,
},
},
},
},
room.client,
);
const mkEdit = (target: MatrixEvent, salt = Math.random()) =>
utils.mkEvent(
{
event: true,
type: EventType.RoomMessage,
user: userA,
room: roomId,
content: {
"body": "* Edit of :: " + target.getId() + " :: " + salt,
"m.new_content": {
body: "Edit of :: " + target.getId() + " :: " + salt,
},
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: target.getId()!,
},
},
},
room.client,
);
const mkThreadResponse = (root: MatrixEvent, opts?: Partial<IMessageOpts>) =>
utils.mkEvent(
{
...opts,
event: true,
type: EventType.RoomMessage,
user: userA,
room: roomId,
content: {
"body": "Thread response :: " + Math.random(),
"m.relates_to": {
"event_id": root.getId()!,
"m.in_reply_to": {
event_id: root.getId()!,
},
"rel_type": "m.thread",
},
},
},
room.client,
);
const mkRedaction = (target: MatrixEvent) =>
utils.mkEvent(
{
event: true,
type: EventType.RoomRedaction,
user: userA,
room: roomId,
redacts: target.getId()!,
content: {},
},
room.client,
);
/**
* @see threadUtils.mkThread
*/
const mkThread = (
opts: Partial<Parameters<typeof threadUtils.mkThread>[0]>,
): ReturnType<typeof threadUtils.mkThread> => {
return threadUtils.mkThread({
room,
client: new TestClient().client,
authorId: "@bob:example.org",
participantUserIds: ["@bob:example.org"],
...opts,
});
};
/**
* Creates a message and adds it to the end of the main live timeline.
*
* @param room - Room to add the message to
* @param timestamp - Timestamp of the message
* @return The message event
*/
const mkMessageInRoom = async (room: Room, timestamp: number) => {
const message = mkMessage({ ts: timestamp });
await room.addLiveEvents([message], { addToState: false });
return message;
};
/**
* Creates a message in a thread and adds it to the end of the thread live timeline.
*
* @param thread - Thread to add the message to
* @param timestamp - Timestamp of the message
* @returns The thread message event
*/
const mkMessageInThread = (thread: Thread, timestamp: number) => {
const message = mkThreadResponse(thread.rootEvent!, { ts: timestamp });
thread.liveTimeline.addEvent(message, { toStartOfTimeline: false, addToState: false });
return message;
};
const addRoomThreads = (
room: Room,
thread1EventTs: Optional<number>,
thread2EventTs: Optional<number>,
): { thread1?: Thread; thread2?: Thread } => {
const result: { thread1?: Thread; thread2?: Thread } = {};
if (thread1EventTs !== null) {
const { rootEvent: thread1RootEvent, thread: thread1 } = mkThread({ room });
const thread1Event = mkThreadResponse(thread1RootEvent, { ts: thread1EventTs });
thread1.liveTimeline.addEvent(thread1Event, { toStartOfTimeline: true, addToState: false });
result.thread1 = thread1;
}
if (thread2EventTs !== null) {
const { rootEvent: thread2RootEvent, thread: thread2 } = mkThread({ room });
const thread2Event = mkThreadResponse(thread2RootEvent, { ts: thread2EventTs });
thread2.liveTimeline.addEvent(thread2Event, { toStartOfTimeline: true, addToState: false });
result.thread2 = thread2;
}
return result;
};
beforeEach(function () {
room = new Room(roomId, new TestClient(userA, "device").client, userA);
// mock RoomStates
// @ts-ignore
room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState");
// @ts-ignore
room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState");
jest.spyOn(logger, "warn");
});
describe("getCreator", () => {
it("should return the sender from m.room.create", function () {
// @ts-ignore - mocked doesn't handle overloads sanely
mocked(room.currentState.getStateEvents).mockImplementation(function (type, key) {
if (type === EventType.RoomCreate && key === "") {
return utils.mkEvent({
event: true,
type: EventType.RoomCreate,
skey: "",
room: roomId,
user: userA,
content: {
creator: userB, // The creator field was dropped in room version 11 but a malicious client might still send it
},
});
}
});
const roomCreator = room.getCreator();
expect(roomCreator).toStrictEqual(userA);
});
it("should return null if the sender is undefined", function () {
// @ts-ignore - mocked doesn't handle overloads sanely
mocked(room.currentState.getStateEvents).mockImplementation(function (type, key) {
if (type === EventType.RoomCreate && key === "") {
return utils.mkEvent({
event: true,
type: EventType.RoomCreate,
skey: "",
room: roomId,
user: undefined,
content: {},
});
}
});
const roomCreator = room.getCreator();
expect(roomCreator).toBeNull();
});
});
describe("getAvatarUrl", function () {
const hsUrl = "https://my.home.server";
it("should return the URL from m.room.avatar preferentially", function () {
// @ts-ignore - mocked doesn't handle overloads sanely
mocked(room.currentState.getStateEvents).mockImplementation(function (type, key) {
if (type === EventType.RoomAvatar && key === "") {
return utils.mkEvent({
event: true,
type: EventType.RoomAvatar,
skey: "",
room: roomId,
user: userA,
content: {
url: "mxc://flibble/wibble",
},
});
}
});
const url = room.getAvatarUrl(hsUrl, 100, 100, "scale");
// we don't care about how the mxc->http conversion is done, other
// than it contains the mxc body.
expect(url?.indexOf("flibble/wibble")).not.toEqual(-1);
});
it("should return nothing if there is no m.room.avatar and allowDefault=false", function () {
const url = room.getAvatarUrl(hsUrl, 64, 64, "crop", false);
expect(url).toEqual(null);
});
it("should return unauthenticated media URL if useAuthentication is not set", function () {
// @ts-ignore - mocked doesn't handle overloads sanely
mocked(room.currentState.getStateEvents).mockImplementation(function (type, key) {
if (type === EventType.RoomAvatar && key === "") {
return utils.mkEvent({
event: true,
type: EventType.RoomAvatar,
skey: "",
room: roomId,
user: userA,
content: {
url: "mxc://flibble/wibble",
},
});
}
});
const url = room.getAvatarUrl(hsUrl, 100, 100, "scale");
// Check for unauthenticated media prefix
expect(url?.indexOf("/_matrix/media/v3/")).not.toEqual(-1);
});
it("should return authenticated media URL if useAuthentication=true", function () {
// @ts-ignore - mocked doesn't handle overloads sanely
mocked(room.currentState.getStateEvents).mockImplementation(function (type, key) {
if (type === EventType.RoomAvatar && key === "") {
return utils.mkEvent({
event: true,
type: EventType.RoomAvatar,
skey: "",
room: roomId,
user: userA,
content: {
url: "mxc://flibble/wibble",
},
});
}
});
const url = room.getAvatarUrl(hsUrl, 100, 100, "scale", undefined, true);
// Check for authenticated media prefix
expect(url?.indexOf("/_matrix/client/v1/media/")).not.toEqual(-1);
});
});
describe("getMember", function () {
beforeEach(function () {
mocked(room.currentState.getMember).mockImplementation(function (userId) {
return (
{
"@alice:bar": {
userId: userA,
roomId: roomId,
} as unknown as RoomMember,
}[userId] || null
);
});
});
it("should return null if the member isn't in current state", function () {
expect(room.getMember("@bar:foo")).toEqual(null);
});
it("should return the member from current state", function () {
expect(room.getMember(userA)).not.toEqual(null);
});
});
describe("addLiveEvents", function () {
const events: MatrixEvent[] = [
utils.mkMessage({
room: roomId,
user: userA,
msg: "changing room name",
event: true,
}),
utils.mkEvent({
type: EventType.RoomName,
room: roomId,
user: userA,
event: true,
content: { name: "New Room Name" },
}),
];
it("should replace a timeline event if dupe strategy is 'replace'", async function () {
// make a duplicate
const dupe = utils.mkMessage({
room: roomId,
user: userA,
msg: "dupe",
event: true,
});
dupe.event.event_id = events[0].getId();
await room.addLiveEvents(events, { addToState: false });
expect(room.timeline[0]).toEqual(events[0]);
await room.addLiveEvents([dupe], {
duplicateStrategy: DuplicateStrategy.Replace,
addToState: false,
});
expect(room.timeline[0]).toEqual(dupe);
});
it("should ignore a given dupe event if dupe strategy is 'ignore'", async function () {
// make a duplicate
const dupe = utils.mkMessage({
room: roomId,
user: userA,
msg: "dupe",
event: true,
});
dupe.event.event_id = events[0].getId();
await room.addLiveEvents(events, { addToState: false });
expect(room.timeline[0]).toEqual(events[0]);
// @ts-ignore
await room.addLiveEvents([dupe], {
duplicateStrategy: DuplicateStrategy.Ignore,
});
expect(room.timeline[0]).toEqual(events[0]);
});
it("should emit 'Room.timeline' events", async function () {
let callCount = 0;
room.on(RoomEvent.Timeline, function (event, emitRoom, toStart) {
callCount += 1;
expect(room.timeline.length).toEqual(callCount);
expect(event).toEqual(events[callCount - 1]);
expect(emitRoom).toEqual(room);
expect(toStart).toBeFalsy();
});
await room.addLiveEvents(events, { addToState: false });
expect(callCount).toEqual(2);
});
it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", async function () {
const events: MatrixEvent[] = [
utils.mkMembership({
room: roomId,
mship: KnownMembership.Invite,
user: userB,
skey: userA,
event: true,
}),
utils.mkEvent({
type: EventType.RoomName,
room: roomId,
user: userB,
event: true,
content: {
name: "New room",
},
}),
];
await room.addLiveEvents(events, { addToState: true });
expect(room.currentState.setStateEvents).toHaveBeenCalledWith([events[0]], { timelineWasEmpty: false });
expect(room.currentState.setStateEvents).toHaveBeenCalledWith([events[1]], { timelineWasEmpty: false });
expect(events[0].forwardLooking).toBe(true);
expect(events[1].forwardLooking).toBe(true);
expect(room.oldState.setStateEvents).not.toHaveBeenCalled();
});
it("should synthesize read receipts for the senders of events", async function () {
const sentinel = {
userId: userA,
membership: KnownMembership.Join,
name: "Alice",
} as unknown as RoomMember;
mocked(room.currentState.getSentinelMember).mockImplementation(function (uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
await room.addLiveEvents(events, { addToState: false });
expect(room.getEventReadUpTo(userA)).toEqual(events[1].getId());
});
it("should emit Room.localEchoUpdated when a local echo is updated", async function () {
const localEvent = utils.mkMessage({
room: roomId,
user: userA,
event: true,
});
localEvent.status = EventStatus.SENDING;
const localEventId = localEvent.getId();
const remoteEvent = utils.mkMessage({
room: roomId,
user: userA,
event: true,
});
remoteEvent.event.unsigned = { transaction_id: "TXN_ID" };
const remoteEventId = remoteEvent.getId();
const stub = jest.fn();
room.on(RoomEvent.LocalEchoUpdated, stub);
// first add the local echo
room.addPendingEvent(localEvent, "TXN_ID");
expect(room.timeline.length).toEqual(1);
expect(stub.mock.calls[0][0].getId()).toEqual(localEventId);
expect(stub.mock.calls[0][0].status).toEqual(EventStatus.SENDING);
expect(stub.mock.calls[0][1]).toEqual(room);
expect(stub.mock.calls[0][2]).toBeUndefined();
expect(stub.mock.calls[0][3]).toBeUndefined();
// then the remoteEvent
await room.addLiveEvents([remoteEvent], { addToState: false });
expect(room.timeline.length).toEqual(1);
expect(stub).toHaveBeenCalledTimes(2);
expect(stub.mock.calls[1][0].getId()).toEqual(remoteEventId);
expect(stub.mock.calls[1][0].status).toBeNull();
expect(stub.mock.calls[1][1]).toEqual(room);
expect(stub.mock.calls[1][2]).toEqual(localEventId);
expect(stub.mock.calls[1][3]).toBe(EventStatus.SENDING);
});
it("should be able to update local echo without a txn ID (/send then /sync)", async function () {
const eventJson = utils.mkMessage({
room: roomId,
user: userA,
event: false,
});
delete eventJson["txn_id"];
delete eventJson["event_id"];
const localEvent = new MatrixEvent(Object.assign({ event_id: "$temp" }, eventJson));
localEvent.status = EventStatus.SENDING;
expect(localEvent.getTxnId()).toBeUndefined();
expect(room.timeline.length).toEqual(0);
// first add the local echo. This is done before the /send request is even sent.
const txnId = "My_txn_id";
room.addPendingEvent(localEvent, txnId);
expect(room.getEventForTxnId(txnId)).toEqual(localEvent);
expect(room.timeline.length).toEqual(1);
// now the /send request returns the true event ID.
const realEventId = "$real-event-id";
room.updatePendingEvent(localEvent, EventStatus.SENT, realEventId);
// then /sync returns the remoteEvent, it should de-dupe based on the event ID.
const remoteEvent = new MatrixEvent(Object.assign({ event_id: realEventId }, eventJson));
expect(remoteEvent.getTxnId()).toBeUndefined();
await room.addLiveEvents([remoteEvent], { addToState: false });
// the duplicate strategy code should ensure we don't add a 2nd event to the live timeline
expect(room.timeline.length).toEqual(1);
// but without the event ID matching we will still have the local event in pending events
expect(room.getEventForTxnId(txnId)).toBeUndefined();
});
it("should be able to update local echo without a txn ID (/sync then /send)", async function () {
const eventJson = utils.mkMessage({
room: roomId,
user: userA,
event: false,
});
delete eventJson["txn_id"];
delete eventJson["event_id"];
const txnId = "My_txn_id";
const localEvent = new MatrixEvent(Object.assign({ event_id: "$temp", txn_id: txnId }, eventJson));
localEvent.status = EventStatus.SENDING;
expect(localEvent.getTxnId()).toEqual(txnId);
expect(room.timeline.length).toEqual(0);
// first add the local echo. This is done before the /send request is even sent.
room.addPendingEvent(localEvent, txnId);
expect(room.getEventForTxnId(txnId)).toEqual(localEvent);
expect(room.timeline.length).toEqual(1);
// now the /sync returns the remoteEvent, it is impossible for the JS SDK to de-dupe this.
const realEventId = "$real-event-id";
const remoteEvent = new MatrixEvent(Object.assign({ event_id: realEventId }, eventJson));
expect(remoteEvent.getUnsigned().transaction_id).toBeUndefined();
await room.addLiveEvents([remoteEvent], { addToState: false });
expect(room.timeline.length).toEqual(2); // impossible to de-dupe as no txn ID or matching event ID
// then the /send request returns the real event ID.
// Now it is possible for the JS SDK to de-dupe this.
room.updatePendingEvent(localEvent, EventStatus.SENT, realEventId);
// the 2nd event should be removed from the timeline.
expect(room.timeline.length).toEqual(1);
// but without the event ID matching we will still have the local event in pending events
expect(room.getEventForTxnId(txnId)).toBeUndefined();
});
it("should correctly handle remote echoes from other devices", async () => {
const remoteEvent = utils.mkMessage({
room: roomId,
user: userA,
event: true,
});
remoteEvent.event.unsigned = { transaction_id: "TXN_ID" };
// add the remoteEvent
await room.addLiveEvents([remoteEvent], { addToState: false });
expect(room.timeline.length).toEqual(1);
});
});
describe("addEphemeralEvents", () => {
it("should call RoomState.setTypingEvent on m.typing events", function () {
const typing = utils.mkEvent({
room: roomId,
type: EventType.Typing,
event: true,
content: {
user_ids: [userA],
},
});
room.addEphemeralEvents([typing]);
expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing);
});
});
describe("addEventsToTimeline", function () {
const events = [
utils.mkMessage({
room: roomId,
user: userA,
msg: "changing room name",
event: true,
}),
utils.mkEvent({
type: EventType.RoomName,
room: roomId,
user: userA,
event: true,
content: { name: "New Room Name" },
}),
];
it("should not be able to add events to the end", function () {
expect(function () {
room.addEventsToTimeline(events, false, false, room.getLiveTimeline());
}).toThrow();
});
it("should be able to add events to the start", function () {
room.addEventsToTimeline(events, true, false, room.getLiveTimeline());
expect(room.timeline.length).toEqual(2);
expect(room.timeline[0]).toEqual(events[1]);
expect(room.timeline[1]).toEqual(events[0]);
});
it("should emit 'Room.timeline' events when added to the start", function () {
let callCount = 0;
room.on(RoomEvent.Timeline, function (event, emitRoom, toStart) {
callCount += 1;
expect(room.timeline.length).toEqual(callCount);
expect(event).toEqual(events[callCount - 1]);
expect(emitRoom).toEqual(room);
expect(toStart).toBe(true);
});
room.addEventsToTimeline(events, true, false, room.getLiveTimeline());
expect(callCount).toEqual(2);
});
});
describe("event metadata handling", function () {
it("should set event.sender for new and old events", async function () {
const sentinel = {
userId: userA,
membership: KnownMembership.Join,
name: "Alice",
} as unknown as RoomMember;
const oldSentinel = {
userId: userA,
membership: KnownMembership.Join,
name: "Old Alice",
} as unknown as RoomMember;
mocked(room.currentState.getSentinelMember).mockImplementation(function (uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
mocked(room.oldState.getSentinelMember).mockImplementation(function (uid) {
if (uid === userA) {
return oldSentinel;
}
return null;
});
const newEv = utils.mkEvent({
type: EventType.RoomName,
room: roomId,
user: userA,
event: true,
content: { name: "New Room Name" },
});
const oldEv = utils.mkEvent({
type: EventType.RoomName,
room: roomId,
user: userA,
event: true,
content: { name: "Old Room Name" },
});
await room.addLiveEvents([newEv], { addToState: false });
expect(newEv.sender).toEqual(sentinel);
room.addEventsToTimeline([oldEv], true, false, room.getLiveTimeline());
expect(oldEv.sender).toEqual(oldSentinel);
});
it("should set event.target for new and old m.room.member events", async function () {
const sentinel = {
userId: userA,
membership: KnownMembership.Join,
name: "Alice",
} as unknown as RoomMember;
const oldSentinel = {
userId: userA,
membership: KnownMembership.Join,
name: "Old Alice",
} as unknown as RoomMember;
mocked(room.currentState.getSentinelMember).mockImplementation(function (uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
mocked(room.oldState.getSentinelMember).mockImplementation(function (uid) {
if (uid === userA) {
return oldSentinel;
}
return null;
});
const newEv = utils.mkMembership({
room: roomId,
mship: KnownMembership.Invite,
user: userB,
skey: userA,
event: true,
});
const oldEv = utils.mkMembership({
room: roomId,
mship: KnownMembership.Ban,
user: userB,
skey: userA,
event: true,
});
await room.addLiveEvents([newEv], { addToState: false });
expect(newEv.target).toEqual(sentinel);
room.addEventsToTimeline([oldEv], true, false, room.getLiveTimeline());
expect(oldEv.target).toEqual(oldSentinel);
});
it(
"should call setStateEvents on the right RoomState with the right " + "forwardLooking value for old events",
function () {
const events: MatrixEvent[] = [
utils.mkMembership({
room: roomId,
mship: KnownMembership.Invite,
user: userB,
skey: userA,
event: true,
}),
utils.mkEvent({
type: EventType.RoomName,
room: roomId,
user: userB,
event: true,
content: {
name: "New room",
},
}),
];
room.addEventsToTimeline(events, true, true, room.getLiveTimeline());
expect(room.oldState.setStateEvents).toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
expect(room.oldState.setStateEvents).toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
expect(events[0].forwardLooking).toBe(false);
expect(events[1].forwardLooking).toBe(false);
expect(room.currentState.setStateEvents).not.toHaveBeenCalled();
},
);
});
const resetTimelineTests = function (timelineSupport: boolean) {
let events: MatrixEvent[];
beforeEach(function () {
room = new Room(roomId, new TestClient(userA).client, userA, { timelineSupport: timelineSupport });
// set events each time to avoid resusing Event objects (which
// doesn't work because they get frozen)
events = [
utils.mkMessage({
room: roomId,
user: userA,
msg: "A message",
event: true,
}),
utils.mkEvent({
type: EventType.RoomName,
room: roomId,
user: userA,
event: true,
content: { name: "New Room Name" },
}),
utils.mkEvent({
type: EventType.RoomName,
room: roomId,
user: userA,
event: true,
content: { name: "Another New Name" },
}),
];
});
it("should copy state from previous timeline", async function () {
await room.addLiveEvents([events[0], events[1]], { addToState: false });
expect(room.getLiveTimeline().getEvents().length).toEqual(2);
room.resetLiveTimeline("sometoken", "someothertoken");
await room.addLiveEvents([events[2]], { addToState: false });
const oldState = room.getLiveTimeline().getState(EventTimeline.BACKWARDS);
const newState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
expect(room.getLiveTimeline().getEvents().length).toEqual(1);
expect(oldState?.getStateEvents(EventType.RoomName, "")).toEqual(events[1]);
expect(newState?.getStateEvents(EventType.RoomName, "")).toEqual(events[2]);
});
it("should reset the legacy timeline fields", async function () {
await room.addLiveEvents([events[0], events[1]], { addToState: false });
expect(room.timeline.length).toEqual(2);
const oldStateBeforeRunningReset = room.oldState;
let oldStateUpdateEmitCount = 0;
room.on(RoomEvent.OldStateUpdated, function (room, previousOldState, oldState) {
expect(previousOldState).toBe(oldStateBeforeRunningReset);
expect(oldState).toBe(room.oldState);
oldStateUpdateEmitCount += 1;
});
const currentStateBeforeRunningReset = room.currentState;
let currentStateUpdateEmitCount = 0;
room.on(RoomEvent.CurrentStateUpdated, function (room, previousCurrentState, currentState) {
expect(previousCurrentState).toBe(currentStateBeforeRunningReset);
expect(currentState).toBe(room.currentState);
currentStateUpdateEmitCount += 1;
});
room.resetLiveTimeline("sometoken", "someothertoken");
await room.addLiveEvents([events[2]], { addToState: false });
const newLiveTimeline = room.getLiveTimeline();
expect(room.timeline).toEqual(newLiveTimeline.getEvents());
expect(room.oldState).toEqual(newLiveTimeline.getState(EventTimeline.BACKWARDS));
expect(room.currentState).toEqual(newLiveTimeline.getState(EventTimeline.FORWARDS));
// Make sure `RoomEvent.OldStateUpdated` was emitted
expect(oldStateUpdateEmitCount).toEqual(1);
// Make sure `RoomEvent.OldStateUpdated` was emitted if necessary
expect(currentStateUpdateEmitCount).toEqual(timelineSupport ? 1 : 0);
});
it("should emit Room.timelineReset event and set the correct pagination token", function () {
let callCount = 0;
room.on(RoomEvent.TimelineReset, function (emitRoom) {
callCount += 1;
expect(emitRoom).toEqual(room);
// make sure that the pagination token has been set before the event is emitted.
const tok = emitRoom?.getLiveTimeline().getPaginationToken(EventTimeline.BACKWARDS);
expect(tok).toEqual("pagToken");
});
room.resetLiveTimeline("pagToken");
expect(callCount).toEqual(1);
});
it("should " + (timelineSupport ? "remember" : "forget") + " old timelines", async function () {
await room.addLiveEvents([events[0]], { addToState: false });
expect(room.timeline.length).toEqual(1);
const firstLiveTimeline = room.getLiveTimeline();
room.resetLiveTimeline("sometoken", "someothertoken");
const tl = room.getTimelineForEvent(events[0].getId()!);
expect(tl).toBe(timelineSupport ? firstLiveTimeline : null);
});
};
describe("resetLiveTimeline with timeline support enabled", () => {
resetTimelineTests.bind(null, true);
});
describe("resetLiveTimeline with timeline support disabled", () => {
resetTimelineTests.bind(null, false);
});
describe("compareEventOrdering", function () {
beforeEach(function () {
room = new Room(roomId, new TestClient(userA).client, userA, { timelineSupport: true });
});
const events: MatrixEvent[] = [
utils.mkMessage({
room: roomId,
user: userA,
msg: "1111",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "2222",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "3333",
event: true,
}),
];
it("should handle events in the same timeline", async function () {
await room.addLiveEvents(events, { addToState: false });
expect(
room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!),
).toBeLessThan(0);
expect(
room.getUnfilteredTimelineSet().compareEventOrdering(events[2].getId()!, events[1].getId()!),
).toBeGreaterThan(0);
expect(
room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId()!, events[1].getId()!),
).toEqual(0);
});
it("should handle events in adjacent timelines", async function () {
const oldTimeline = room.addTimeline();
oldTimeline.setNeighbouringTimeline(room.getLiveTimeline(), Direction.Forward);
room.getLiveTimeline().setNeighbouringTimeline(oldTimeline, Direction.Backward);
room.addEventsToTimeline([events[0]], false, false, oldTimeline);
await room.addLiveEvents([events[1]], { addToState: false });
expect(
room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!),
).toBeLessThan(0);
expect(
room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId()!, events[0].getId()!),
).toBeGreaterThan(0);
});
it("should return null for events in non-adjacent timelines", async function () {
const oldTimeline = room.addTimeline();
room.addEventsToTimeline([events[0]], false, false, oldTimeline);
await room.addLiveEvents([events[1]], { addToState: false });
expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!)).toBe(
null,
);
expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[1].getId()!, events[0].getId()!)).toBe(
null,
);
});
it("should return null for unknown events", async function () {
await room.addLiveEvents(events, { addToState: false });
expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, "xxx")).toBe(null);
expect(room.getUnfilteredTimelineSet().compareEventOrdering("xxx", events[0].getId()!)).toBe(null);
expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[0].getId()!)).toBe(
0,
);
});
});
describe("getJoinedMembers", function () {
it("should return members whose membership is 'join'", function () {
mocked(room.currentState.getMembers).mockImplementation(function () {
return [
{ userId: "@alice:bar", membership: KnownMembership.Join } as unknown as RoomMember,
{ userId: "@bob:bar", membership: KnownMembership.Invite } as unknown as RoomMember,
{ userId: "@cleo:bar", membership: KnownMembership.Leave } as unknown as RoomMember,
];
});
const res = room.getJoinedMembers();
expect(res.length).toEqual(1);
expect(res[0].userId).toEqual("@alice:bar");
});
it("should return an empty list if no membership is 'join'", function () {
mocked(room.currentState.getMembers).mockImplementation(function () {
return [{ userId: "@bob:bar", membership: KnownMembership.Invite } as unknown as RoomMember];
});
const res = room.getJoinedMembers();
expect(res.length).toEqual(0);
});
});
describe("hasMembershipState", function () {
it("should return true for a matching userId and membership", function () {
mocked(room.currentState.getMember).mockImplementation(function (userId) {
return {
"@alice:bar": { userId: "@alice:bar", membership: KnownMembership.Join },
"@bob:bar": { userId: "@bob:bar", membership: KnownMembership.Invite },
}[userId] as unknown as RoomMember;
});
expect(room.hasMembershipState("@bob:bar", KnownMembership.Invite)).toBe(true);
});
it("should return false if match membership but no match userId", function () {
mocked(room.currentState.getMember).mockImplementation(function (userId) {
return {
"@alice:bar": { userId: "@alice:bar", membership: KnownMembership.Join },
}[userId] as unknown as RoomMember;
});
expect(room.hasMembershipState("@bob:bar", KnownMembership.Join)).toBe(false);
});
it("should return false if match userId but no match membership", function () {
mocked(room.currentState.getMember).mockImplementation(function (userId) {
return {
"@alice:bar": { userId: "@alice:bar", membership: KnownMembership.Join },
}[userId] as unknown as RoomMember;
});
expect(room.hasMembershipState("@alice:bar", KnownMembership.Ban)).toBe(false);
});
it("should return false if no match membership or userId", function () {
mocked(room.currentState.getMember).mockImplementation(function (userId) {
return {
"@alice:bar": { userId: "@alice:bar", membership: KnownMembership.Join },
}[userId] as unknown as RoomMember;
});
expect(room.hasMembershipState("@bob:bar", KnownMembership.Invite)).toBe(false);
});
it("should return false if no members exist", function () {
expect(room.hasMembershipState("@foo:bar", KnownMembership.Join)).toBe(false);
});
});
describe("recalculate", function () {
const setJoinRule = async function (rule: JoinRule) {
await room.addLiveEvents(
[
utils.mkEvent({
type: EventType.RoomJoinRules,
room: roomId,
user: userA,
content: {
join_rule: rule,
},
event: true,
}),
],
{ addToState: true },
);
};
const setAltAliases = async function (aliases: string[]) {
await room.addLiveEvents(
[
utils.mkEvent({
type: EventType.RoomCanonicalAlias,
room: roomId,
skey: "",
content: {
alt_aliases: aliases,
},
event: true,
}),
],
{ addToState: true },
);
};
const setAlias = async function (alias: string) {
await room.addLiveEvents(
[
utils.mkEvent({
type: EventType.RoomCanonicalAlias,
room: roomId,
skey: "",
content: { alias },
event: true,
}),
],
{ addToState: true },
);
};
const setRoomName = async function (name: string) {
await room.addLiveEvents(
[
utils.mkEvent({
type: EventType.RoomName,
room: roomId,
user: userA,
content: {
name: name,
},
event: true,
}),
],
{ addToState: true },
);
};
const addMember = async function (userId: string, state = KnownMembership.Join, opts: any = {}) {
opts.room = roomId;
opts.mship = state;
opts.user = opts.user || userId;
opts.skey = userId;
opts.event = true;
const event = utils.mkMembership(opts);
await room.addLiveEvents([event], { addToState: true });
return event;
};
beforeEach(function () {
// no mocking
room = new Room(roomId, new TestClient(userA).client, userA);
});
describe("Room.recalculate => Stripped State Events", function () {
it(
"should set stripped state events as actual state events if the " + "room is an invite room",
async function () {
const roomName = "flibble";
const event = await addMember(userA, KnownMembership.Invite);
event.event.unsigned = {};
event.event.unsigned.invite_room_state = [
{
type: EventType.RoomName,
state_key: "",
content: {
name: roomName,
},
sender: "@bob:foobar",
},
];
room.recalculate();
expect(room.name).toEqual(roomName);
},
);
it("should not clobber state events if it isn't an invite room", async function () {
const event = await addMember(userA, KnownMembership.Join);
const roomName = "flibble";
setRoomName(roomName);
const roomNameToIgnore = "ignoreme";
event.event.unsigned = {};
event.event.unsigned.invite_room_state = [
{
type: EventType.RoomName,
state_key: "",
content: {
name: roomNameToIgnore,
},
sender: "@bob:foobar",
},
];
room.recalculate();
expect(room.name).toEqual(roomName);
});
});
describe("Room.recalculate => Room Name using room summary", function () {
it("should use room heroes if available", function () {
addMember(userA, KnownMembership.Invite);
addMember(userB);
addMember(userC);
addMember(userD);
room.setSummary({
"m.heroes": [userB, userC, userD],
});
room.recalculate();
expect(room.name).toEqual(`${userB} and 2 others`);
});
it("missing hero member state reverts to mxid", function () {
room.setSummary({
"m.heroes": [userB],
"m.joined_member_count": 2,
});
room.recalculate();
expect(room.name).toEqual(userB);
});
it("uses hero name from state", function () {
const name = "Mr B";
addMember(userA, KnownMembership.Invite);
addMember(userB, KnownMembership.Join, { name });
room.setSummary({
"m.heroes": [userB],
});
room.recalculate();
expect(room.name).toEqual(name);
});
it("uses counts from summary", function () {
const name = "Mr B";
addMember(userB, KnownMembership.Join, { name });
room.setSummary({
"m.heroes": [userB],
"m.joined_member_count": 50,
"m.invited_member_count": 50,
});
room.recalculate();
expect(room.name).toEqual(`${name} and 98 others`);
});
it("relies on heroes in case of absent counts", function () {
const nameB = "Mr Bean";
const nameC = "Mel C";
addMember(userB, KnownMembership.Join, { name: nameB });
addMember(userC, KnownMembership.Join, { name: nameC });
room.setSummary({
"m.heroes": [userB, userC],
});
room.recalculate();
expect(room.name).toEqual(`${nameB} and ${nameC}`);
});
it("uses only heroes", function () {
const nameB = "Mr Bean";
addMember(userB, KnownMembership.Join, { name: nameB });
addMember(userC, KnownMembership.Join);
room.setSummary({
"m.heroes": [userB],
});
room.recalculate();
expect(room.name).toEqual(nameB);
});
it("reverts to empty room in case of self chat", function () {
room.setSummary({
"m.heroes": [],
"m.invited_member_count": 1,
});
room.recalculate();
expect(room.name).toEqual("Empty room");
});
it("emits an update event", function () {
const spy = jest.fn();
const summary = {
"m.heroes": [],
"m.invited_member_count": 1,
};
room.once(RoomEvent.Summary, spy);
room.setSummary(summary);
room.recalculate();
expect(spy).toHaveBeenCalledWith(summary);
});
});
describe("Room.recalculate => Room Name", function () {
it(
"should return the names of members in a private (invite join_rules)" +
" room if a room name and alias don't exist and there are >3 members.",
function () {
setJoinRule(JoinRule.Invite);
addMember(userA);
addMember(userB);
addMember(userC);
addMember(userD);
room.recalculate();
const name = room.name;
// we expect at least 1 member to be mentioned
const others = [userB, userC, userD];
let found = false;
for (let i = 0; i < others.length; i++) {
if (name.indexOf(others[i]) !== -1) {
found = true;
break;
}
}
expect(found).toEqual(true);
},
);
it(
"should return the names of members in a private (invite join_rules)" +
" room if a room name and alias don't exist and there are >2 members.",
function () {
setJoinRule(JoinRule.Invite);
addMember(userA);
addMember(userB);
addMember(userC);
room.recalculate();
const name = room.name;
expect(name.indexOf(userB)).not.toEqual(-1);
expect(name.indexOf(userC)).not.toEqual(-1);
},
);
it(
"should return the names of members in a public (public join_rules)" +
" room if a room name and alias don't exist and there are >2 members.",
function () {
setJoinRule(JoinRule.Public);
addMember(userA);
addMember(userB);
addMember(userC);
room.recalculate();
const name = room.name;
expect(name.indexOf(userB)).not.toEqual(-1);
expect(name.indexOf(userC)).not.toEqual(-1);
},
);
it(
"should show the other user's name for public (public join_rules)" +
" rooms if a room name and alias don't exist and it is a 1:1-chat.",
function () {
setJoinRule(JoinRule.Public);
addMember(userA);
addMember(userB);
room.recalculate();
const name = room.name;
expect(name.indexOf(userB)).not.toEqual(-1);
},
);
it(
"should show the other user's name for private " +
"(invite join_rules) rooms if a room name and alias don't exist and it" +
" is a 1:1-chat.",
function () {
setJoinRule(JoinRule.Invite);
addMember(userA);
addMember(userB);
room.recalculate();
const name = room.name;
expect(name.indexOf(userB)).not.toEqual(-1);
},
);
it(
"should show the other user's name for private" +
" (invite join_rules) rooms if you are invited to it.",
function () {
setJoinRule(JoinRule.Invite);
addMember(userA, KnownMembership.Invite, { user: userB });
addMember(userB);
room.recalculate();
const name = room.name;
expect(name.indexOf(userB)).not.toEqual(-1);
},
);
it(
"should show the room alias if one exists for private " +
"(invite join_rules) rooms if a room name doesn't exist.",
function () {
const alias = "#room_alias:here";
setJoinRule(JoinRule.Invite);
setAlias(alias);
room.recalculate();
const name = room.name;
expect(name).toEqual(alias);
},
);
it(
"should show the room alias if one exists for public " +
"(public join_rules) rooms if a room name doesn't exist.",
function () {
const alias = "#room_alias:here";
setJoinRule(JoinRule.Public);
setAlias(alias);
room.recalculate();
const name = room.name;
expect(name).toEqual(alias);
},
);
it("should not show alt aliases if a room name does not exist", () => {
const alias = "#room_alias:here";
setAltAliases([alias, "#another:here"]);
room.recalculate();
const name = room.name;
expect(name).not.toEqual(alias);
});
it("should show the room name if one exists for private " + "(invite join_rules) rooms.", function () {
const roomName = "A mighty name indeed";
setJoinRule(JoinRule.Invite);
setRoomName(roomName);
room.recalculate();
const name = room.name;
expect(name).toEqual(roomName);
});
it("should show the room name if one exists for public " + "(public join_rules) rooms.", function () {
const roomName = "A mighty name indeed";
setJoinRule(JoinRule.Public);
setRoomName(roomName);
room.recalculate();
expect(room.name).toEqual(roomName);
});
it(
"should return 'Empty room' for private (invite join_rules) rooms if" +
" a room name and alias don't exist and it is a self-chat.",
function () {
setJoinRule(JoinRule.Invite);
addMember(userA);
room.recalculate();
expect(room.name).toEqual("Empty room");
},
);
it(
"should return 'Empty room' for public (public join_rules) rooms if a" +
" room name and alias don't exist and it is a self-chat.",
function () {
setJoinRule(JoinRule.Public);
addMember(userA);
room.recalculate();
const name = room.name;
expect(name).toEqual("Empty room");
},
);
it("should return 'Empty room' if there is no name, " + "alias or members in the room.", function () {
room.recalculate();
const name = room.name;
expect(name).toEqual("Empty room");
});
it("should return '[inviter display name] if state event " + "available", function () {
setJoinRule(JoinRule.Invite);
addMember(userB, KnownMembership.Join, { name: "Alice" });
addMember(userA, KnownMembership.Invite, { user: userA });
room.recalculate();
const name = room.name;
expect(name).toEqual("Alice");
});
it("should return inviter mxid if display name not available", function () {
setJoinRule(JoinRule.Invite);
addMember(userB);
addMember(userA, KnownMembership.Invite, { user: userA });
room.recalculate();
const name = room.name;
expect(name).toEqual(userB);
});
});
});
describe("receipts", function () {
const eventToAck = utils.mkMessage({
room: roomId,
user: userA,
msg: "PLEASE ACKNOWLEDGE MY EXISTENCE",
event: true,
});
function mkReceipt(roomId: string, records: Array<ReturnType<typeof mkRecord>>) {
const content: IContent = {};
records.forEach(function (r) {
if (!content[r.eventId]) {
content[r.eventId] = {};
}
if (!content[r.eventId][r.type]) {
content[r.eventId][r.type] = {};
}
content[r.eventId][r.type][r.userId] = {
ts: r.ts,
};
});
return new MatrixEvent({
content: content,
room_id: roomId,
type: "m.receipt",
});
}
function mkRecord(eventId: string, type: string, userId: string, ts: number) {
ts = ts || Date.now();
return {
eventId: eventId,
type: type,
userId: userId,
ts: ts,
};
}
describe("addReceipt", function () {
describe("resets the unread count", () => {
const event1 = utils.mkMessage({ room: roomId, user: userA, msg: "1", event: true });
const event2 = utils.mkMessage({ room: roomId, user: userA, msg: "2", event: true });
it("should reset the unread count when our non-synthetic receipt points to the latest event", () => {
// Given a room with 2 events, and an unread count set.
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
room.setUnread(NotificationCountType.Total, 45);
room.setUnread(NotificationCountType.Highlight, 57);
// Sanity check:
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
// When I receive a receipt for me for the last event
const receipt = mkReceipt(roomId, [mkRecord(event2.getId()!, "m.read", userA, 123)]);
room.addReceipt(receipt);
// Then the count is set to 0
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(0);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(0);
});
it("should not reset the unread count when someone else's receipt points to the latest event", () => {
// Given a room with 2 events, and an unread count set.
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
room.setUnread(NotificationCountType.Total, 45);
room.setUnread(NotificationCountType.Highlight, 57);
// Sanity check:
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
// When I receive a receipt for someone else for the last event
const receipt = mkReceipt(roomId, [mkRecord(event2.getId()!, "m.read", userB, 123)]);
room.addReceipt(receipt);
// Then the count is unchanged because it's not my receipt
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
});
it("should not reset the unread count when our non-synthetic receipt points to an earlier event", () => {
// Given a room with 2 events, and an unread count set.
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
room.setUnread(NotificationCountType.Total, 45);
room.setUnread(NotificationCountType.Highlight, 57);
// Sanity check:
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
// When I receive a receipt for me for an earlier event
const receipt = mkReceipt(roomId, [mkRecord(event1.getId()!, "m.read", userA, 123)]);
room.addReceipt(receipt);
// Then the count is unchanged because it wasn't the latest event
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
});
it("should not reset the unread count when our a synthetic receipt points to the latest event", () => {
// Given a room with 2 events, and an unread count set.
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
room.setUnread(NotificationCountType.Total, 45);
room.setUnread(NotificationCountType.Highlight, 57);
// Sanity check:
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
// When I receive a synthetic receipt for me for the last event
const receipt = mkReceipt(roomId, [mkRecord(event2.getId()!, "m.read", userA, 123)]);
room.addReceipt(receipt, true);
// Then the count is unchanged because the receipt was synthetic
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(45);
expect(room.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(57);
});
});
it("should store the receipt so it can be obtained via getReceiptsForEvent", function () {
const ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
expect(room.getReceiptsForEvent(eventToAck)).toEqual([
{
type: "m.read",
userId: userB,
data: {
ts: ts,
},
},
]);
});
it("should emit an event when a receipt is added", function () {
const listener = jest.fn();
room.on(RoomEvent.Receipt, listener);
const ts = 13787898424;
const receiptEvent = mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]);
room.addReceipt(receiptEvent);
expect(listener).toHaveBeenCalledWith(receiptEvent, room);
});
it("should clobber receipts based on type and user ID", function () {
const nextEventToAck = utils.mkMessage({
room: roomId,
user: userA,
msg: "I AM HERE YOU KNOW",
event: true,
});
const ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
const ts2 = 13787899999;
room.addReceipt(mkReceipt(roomId, [mkRecord(nextEventToAck.getId()!, "m.read", userB, ts2)]));
expect(room.getReceiptsForEvent(eventToAck)).toEqual([]);
expect(room.getReceiptsForEvent(nextEventToAck)).toEqual([
{
type: "m.read",
userId: userB,
data: {
ts: ts2,
},
},
]);
});
it("should persist multiple receipts for a single event ID", function () {
const ts = 13787898424;
room.addReceipt(
mkReceipt(roomId, [
mkRecord(eventToAck.getId()!, "m.read", userB, ts),
mkRecord(eventToAck.getId()!, "m.read", userC, ts),
mkRecord(eventToAck.getId()!, "m.read", userD, ts),
]),
);
expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB, userC, userD]);
});
it("should persist multiple receipts for a single receipt type", function () {
const eventTwo = utils.mkMessage({
room: roomId,
user: userA,
msg: "2222",
event: true,
});
const eventThree = utils.mkMessage({
room: roomId,
user: userA,
msg: "3333",
event: true,
});
const ts = 13787898424;
room.addReceipt(
mkReceipt(roomId, [
mkRecord(eventToAck.getId()!, "m.read", userB, ts),
mkRecord(eventTwo.getId()!, "m.read", userC, ts),
mkRecord(eventThree.getId()!, "m.read", userD, ts),
]),
);
expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]);
expect(room.getUsersReadUpTo(eventTwo)).toEqual([userC]);
expect(room.getUsersReadUpTo(eventThree)).toEqual([userD]);
});
it("should persist multiple receipts for a single user ID", function () {
room.addReceipt(
mkReceipt(roomId, [
mkRecord(eventToAck.getId()!, "m.delivered", userB, 13787898424),
mkRecord(eventToAck.getId()!, "m.read", userB, 22222222),
mkRecord(eventToAck.getId()!, "m.seen", userB, 33333333),
]),
);
expect(room.getReceiptsForEvent(eventToAck)).toEqual([
{
type: "m.delivered",
userId: userB,
data: {
ts: 13787898424,
},
},
{
type: "m.read",
userId: userB,
data: {
ts: 22222222,
},
},
{
type: "m.seen",
userId: userB,
data: {
ts: 33333333,
},
},
]);
});
it("should prioritise the most recent event", async function () {
const events: MatrixEvent[] = [
utils.mkMessage({
room: roomId,
user: userA,
msg: "1111",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "2222",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "3333",
event: true,
}),
];
await room.addLiveEvents(events, { addToState: false });
const ts = 13787898424;
// check it initialises correctly
room.addReceipt(mkReceipt(roomId, [mkRecord(events[0].getId()!, "m.read", userB, ts)]));
expect(room.getEventReadUpTo(userB)).toEqual(events[0].getId());
// 2>0, so it should move forward
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, ts)]));
expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId());
// 1<2, so it should stay put
room.addReceipt(mkReceipt(roomId, [mkRecord(events[1].getId()!, "m.read", userB, ts)]));
expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId());
});
it("should prioritise the most recent event even if it is synthetic", async () => {
const events: MatrixEvent[] = [
utils.mkMessage({
room: roomId,
user: userA,
msg: "1111",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "2222",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "3333",
event: true,
}),
];
await room.addLiveEvents(events, { addToState: false });
const ts = 13787898424;
// check it initialises correctly
room.addReceipt(mkReceipt(roomId, [mkRecord(events[0].getId()!, "m.read", userB, ts)]));
expect(room.getEventReadUpTo(userB)).toEqual(events[0].getId());
// 2>0, so it should move forward
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, ts)]), true);
expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId());
expect(room.getReceiptsForEvent(events[2])).toEqual([{ data: { ts }, type: "m.read", userId: userB }]);
// 1<2, so it should stay put
room.addReceipt(mkReceipt(roomId, [mkRecord(events[1].getId()!, "m.read", userB, ts)]));
expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId());
expect(room.getEventReadUpTo(userB, true)).toEqual(events[1].getId());
expect(room.getReceiptsForEvent(events[2])).toEqual([{ data: { ts }, type: "m.read", userId: userB }]);
});
});
describe("getUsersReadUpTo", function () {
it("should return user IDs read up to the given event", function () {
const ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]);
});
});
describe("hasUserReadUpTo", function () {
it("returns true if there is a receipt for this event (main timeline)", function () {
const ts = 13787898424;
room.addLiveEvents([eventToAck], { addToState: false });
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
room.findEventById = jest.fn().mockReturnValue({ getThread: jest.fn() } as unknown as MatrixEvent);
expect(room.hasUserReadEvent(userB, eventToAck.getId()!)).toEqual(true);
});
it("returns true if there is a receipt for a later event (main timeline)", async function () {
// Given some events exist in the room
const events: MatrixEvent[] = [
utils.mkMessage({
room: roomId,
user: userA,
msg: "1111",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "2222",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "3333",
event: true,
}),
];
await room.addLiveEvents(events, { addToState: false });
// When I add a receipt for the latest one
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
// Then the older ones are read too
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
expect(room.hasUserReadEvent(userB, events[1].getId()!)).toEqual(true);
});
describe("threads enabled", () => {
beforeEach(() => {
jest.spyOn(room.client, "supportsThreads").mockReturnValue(true);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("returns true if there is an unthreaded receipt for a later event in a thread", async () => {
// Given a thread exists in the room
const { thread, events } = mkThread({ room, length: 3 });
thread.initialEventsFetched = true;
await room.addLiveEvents(events, { addToState: false });
// When I add an unthreaded receipt for the latest thread message
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
// Then the main timeline message is read
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
});
});
it("returns false for an unknown event", function () {
expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false);
});
});
});
describe("tags", function () {
function mkTags(roomId: string, tags: object) {
const content = { tags: tags };
return new MatrixEvent({
content: content,
room_id: roomId,
type: "m.tag",
});
}
describe("addTag", function () {
it(
"should set tags on rooms from event stream so " + "they can be obtained by the tags property",
function () {
const tags = { "m.foo": { order: 0.5 } };
room.addTags(mkTags(roomId, tags));
expect(room.tags).toEqual(tags);
},
);
it("should emit Room.tags event when new tags are " + "received on the event stream", function () {
const listener = jest.fn();
room.on(RoomEvent.Tags, listener);
const tags = { "m.foo": { order: 0.5 } };
const event = mkTags(roomId, tags);
room.addTags(event);
expect(listener).toHaveBeenCalledWith(event, room);
});
// XXX: shouldn't we try injecting actual m.tag events onto the eventstream
// rather than injecting via room.addTags()?
});
});
describe("addPendingEvent", function () {
it(
"should add pending events to the pendingEventList if " + "pendingEventOrdering == 'detached'",
async function () {
const client = new TestClient("@alice:example.com", "alicedevice").client;
client.supportsThreads = () => true;
const room = new Room(roomId, client, userA, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const eventA = utils.mkMessage({
room: roomId,
user: userA,
msg: "remote 1",
event: true,
});
const eventB = utils.mkMessage({
room: roomId,
user: userA,
msg: "local 1",
event: true,
});
eventB.status = EventStatus.SENDING;
const eventC = utils.mkMessage({
room: roomId,
user: userA,
msg: "remote 2",
event: true,
});
await room.addLiveEvents([eventA], { addToState: false });
room.addPendingEvent(eventB, "TXN1");
await room.addLiveEvents([eventC], { addToState: false });
expect(room.timeline).toEqual([eventA, eventC]);
expect(room.getPendingEvents()).toEqual([eventB]);
},
);
it(
"should add pending events to the timeline if " + "pendingEventOrdering == 'chronological'",
async function () {
const room = new Room(roomId, new TestClient(userA).client, userA, {
pendingEventOrdering: PendingEventOrdering.Chronological,
});
const eventA = utils.mkMessage({
room: roomId,
user: userA,
msg: "remote 1",
event: true,
});
const eventB = utils.mkMessage({
room: roomId,
user: userA,
msg: "local 1",
event: true,
});
eventB.status = EventStatus.SENDING;
const eventC = utils.mkMessage({
room: roomId,
user: userA,
msg: "remote 2",
event: true,
});
await room.addLiveEvents([eventA], { addToState: false });
room.addPendingEvent(eventB, "TXN1");
await room.addLiveEvents([eventC], { addToState: false });
expect(room.timeline).toEqual([eventA, eventB, eventC]);
},
);
it("should apply redactions eagerly in the pending event list", () => {
const client = new TestClient("@alice:example.com", "alicedevice").client;
const room = new Room(roomId, client, userA, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const eventA = utils.mkMessage({
room: roomId,
user: userA,
msg: "remote 1",
event: true,
});
eventA.status = EventStatus.SENDING;
const redactA = utils.mkEvent({
room: roomId,
user: userA,
type: EventType.RoomRedaction,
content: {},
redacts: eventA.getId()!,
event: true,
});
redactA.status = EventStatus.SENDING;
room.addPendingEvent(eventA, "TXN1");
expect(room.getPendingEvents()).toEqual([eventA]);
room.addPendingEvent(redactA, "TXN2");
expect(room.getPendingEvents()).toEqual([eventA, redactA]);
expect(eventA.isRedacted()).toBeTruthy();
});
});
describe("updatePendingEvent", function () {
it("should remove cancelled events from the pending list", function () {
const client = new TestClient("@alice:example.com", "alicedevice").client;
const room = new Room(roomId, client, userA, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const eventA = utils.mkMessage({
room: roomId,
user: userA,
event: true,
});
eventA.status = EventStatus.SENDING;
const eventId = eventA.getId();
room.addPendingEvent(eventA, "TXN1");
expect(room.getPendingEvents()).toEqual([eventA]);
// the event has to have been failed or queued before it can be
// cancelled
room.updatePendingEvent(eventA, EventStatus.NOT_SENT);
let callCount = 0;
room.on(RoomEvent.LocalEchoUpdated, function (event, emitRoom, oldEventId, oldStatus) {
expect(event).toEqual(eventA);
expect(event.status).toEqual(EventStatus.CANCELLED);
expect(emitRoom).toEqual(room);
expect(oldEventId).toEqual(eventId);
expect(oldStatus).toEqual(EventStatus.NOT_SENT);
callCount++;
});
room.updatePendingEvent(eventA, EventStatus.CANCELLED);
expect(room.getPendingEvents()).toEqual([]);
expect(callCount).toEqual(1);
});
it("should remove cancelled events from the timeline", function () {
const room = new Room(roomId, null!, userA);
const eventA = utils.mkMessage({
room: roomId,
user: userA,
event: true,
});
eventA.status = EventStatus.SENDING;
const eventId = eventA.getId();
room.addPendingEvent(eventA, "TXN1");
expect(room.getLiveTimeline().getEvents()).toEqual([eventA]);
// the event has to have been failed or queued before it can be
// cancelled
room.updatePendingEvent(eventA, EventStatus.NOT_SENT);
let callCount = 0;
room.on(RoomEvent.LocalEchoUpdated, function (event, emitRoom, oldEventId, oldStatus) {
expect(event).toEqual(eventA);
expect(event.status).toEqual(EventStatus.CANCELLED);
expect(emitRoom).toEqual(room);
expect(oldEventId).toEqual(eventId);
expect(oldStatus).toEqual(EventStatus.NOT_SENT);
callCount++;
});
room.updatePendingEvent(eventA, EventStatus.CANCELLED);
expect(room.getLiveTimeline().getEvents()).toEqual([]);
expect(callCount).toEqual(1);
});
});
describe("loadMembersIfNeeded", function () {
function createClientMock(
serverResponse: Error | MatrixEvent[],
storageResponse: MatrixEvent[] | Error | null = null,
) {
return {
getEventMapper: function () {
// events should already be MatrixEvents
return function (event: MatrixEvent) {
return event;
};
},
isCryptoEnabled() {
return true;
},
isRoomEncrypted: function () {
return false;
},
members: jest.fn().mockImplementation(() => {
if (serverResponse instanceof Error) {
return Promise.reject(serverResponse);
} else {
return Promise.resolve({ chunk: serverResponse });
}
}),
store: {
storageResponse,
storedMembers: [] as IStateEventWithRoomId[] | null,
getOutOfBandMembers: function () {
if (this.storageResponse instanceof Error) {
return Promise.reject(this.storageResponse);
} else {
return Promise.resolve(this.storageResponse);
}
},
setOutOfBandMembers: function (roomId: string, memberEvents: IStateEventWithRoomId[]) {
this.storedMembers = memberEvents;
return Promise.resolve();
},
getSyncToken: () => "sync_token",
getPendingEvents: jest.fn().mockResolvedValue([]),
setPendingEvents: jest.fn().mockResolvedValue(undefined),
},
};
}
const memberEvent = utils.mkMembership({
user: "@user_a:bar",
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
});
it("should load members from server on first call", async function () {
const client = createClientMock([memberEvent]);
const room = new Room(roomId, client as any, null!, { lazyLoadMembers: true });
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar")!;
expect(memberA.name).toEqual("User A");
const storedMembers = client.store.storedMembers!;
expect(storedMembers.length).toEqual(1);
expect(storedMembers[0].event_id).toEqual(memberEvent.getId());
});
it("should take members from storage if available", async function () {
const memberEvent2 = utils.mkMembership({
user: "@user_a:bar",
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Ms A",
});
const client = createClientMock([memberEvent2], [memberEvent]);
const room = new Room(roomId, client as any, null!, { lazyLoadMembers: true });
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar")!;
expect(memberA.name).toEqual("User A");
});
it("should allow retry on error", async function () {
const client = createClientMock(new Error("server says no"));
const room = new Room(roomId, client as any, null!, { lazyLoadMembers: true });
await expect(room.loadMembersIfNeeded()).rejects.toBeTruthy();
client.members.mockReturnValue({ chunk: [memberEvent] });
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar")!;
expect(memberA.name).toEqual("User A");
});
});
describe("getMyMembership", function () {
it("should return synced membership if membership isn't available yet", function () {
const room = new Room(roomId, null!, userA);
room.updateMyMembership(KnownMembership.Invite);
expect(room.getMyMembership()).toEqual(JoinRule.Invite);
});
it("should emit a Room.myMembership event on a change", function () {
const room = new Room(roomId, null!, userA);
const events: {
membership: string;
oldMembership?: string;
}[] = [];
room.on(RoomEvent.MyMembership, (_room, membership, oldMembership) => {
events.push({ membership, oldMembership });
});
room.updateMyMembership(KnownMembership.Invite);
expect(room.getMyMembership()).toEqual(JoinRule.Invite);
expect(events[0]).toEqual({ membership: KnownMembership.Invite, oldMembership: undefined });
events.splice(0); //clear
room.updateMyMembership(KnownMembership.Invite);
expect(events.length).toEqual(0);
room.updateMyMembership(KnownMembership.Join);
expect(room.getMyMembership()).toEqual(KnownMembership.Join);
expect(events[0]).toEqual({ membership: KnownMembership.Join, oldMembership: KnownMembership.Invite });
});
});
describe("getDMInviter", () => {
it("should delegate to RoomMember::getDMInviter if available", () => {
const room = new Room(roomId, null!, userA);
room.currentState.markOutOfBandMembersStarted();
room.currentState.setOutOfBandMembers([
new MatrixEvent({
type: EventType.RoomMember,
state_key: userA,
sender: userB,
content: {
membership: KnownMembership.Invite,
is_direct: true,
},
}),
]);
expect(room.getDMInviter()).toBe(userB);
});
it("should fall back to summary heroes and return the first one", () => {
const room = new Room(roomId, null!, userA);
room.updateMyMembership(KnownMembership.Invite);
room.setSummary({
"m.heroes": [userA, userC],
"m.joined_member_count": 1,
"m.invited_member_count": 1,
});
expect(room.getDMInviter()).toBe(userC);
});
it("should return undefined if we're not joined or invited to the room", () => {
const room = new Room(roomId, null!, userA);
expect(room.getDMInviter()).toBeUndefined();
room.updateMyMembership(KnownMembership.Leave);
expect(room.getDMInviter()).toBeUndefined();
});
});
describe("guessDMUserId", function () {
it("should return first hero id", function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
room.setSummary({
"m.heroes": [userB],
"m.joined_member_count": 1,
"m.invited_member_count": 1,
});
expect(room.guessDMUserId()).toEqual(userB);
});
it("should return first member that isn't self", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
}),
],
{ addToState: true },
);
expect(room.guessDMUserId()).toEqual(userB);
});
it("should return self if only member present", function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
expect(room.guessDMUserId()).toEqual(userA);
});
});
describe("getAvatarFallbackMember", () => {
it("should return undefined if the room isn't a 1:1", () => {
const room = new Room(roomId, null!, userA);
room.currentState.setJoinedMemberCount(2);
room.currentState.setInvitedMemberCount(1);
expect(room.getAvatarFallbackMember()).toBeUndefined();
});
it("should use summary heroes member if 1:1", () => {
const room = new Room(roomId, null!, userA);
room.currentState.markOutOfBandMembersStarted();
room.currentState.setOutOfBandMembers([
new MatrixEvent({
type: EventType.RoomMember,
state_key: userD,
sender: userD,
content: {
membership: KnownMembership.Join,
},
}),
]);
room.setSummary({
"m.heroes": [userA, userD],
"m.joined_member_count": 1,
"m.invited_member_count": 1,
});
expect(room.getAvatarFallbackMember()?.userId).toBe(userD);
});
it("should return undefined if the room is a 1:1 plus functional member", async function () {
const room = new Room(roomId, null!, userA);
await room.currentState.setStateEvents([
utils.mkMembership({
user: userA,
mship: "join",
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: "join",
room: roomId,
event: true,
name: "User B",
}),
utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "",
room: roomId,
event: true,
content: {
service_members: [userB],
},
}),
]);
expect(room.getAvatarFallbackMember()).toBeUndefined();
});
it("should pick nonfunctional member from summary heroes if room is a 1:1 plus functional member", async function () {
const room = new Room(roomId, null!, userA);
await room.currentState.setStateEvents([
utils.mkMembership({
user: userA,
mship: "join",
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: "join",
room: roomId,
event: true,
name: "User B",
}),
utils.mkMembership({
user: userD,
mship: "join",
room: roomId,
event: true,
name: "User D",
}),
utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "",
room: roomId,
event: true,
content: {
service_members: [userB],
},
}),
]);
room.setSummary({
"m.heroes": [userA, userD, userB],
"m.joined_member_count": 2,
"m.invited_member_count": 1,
});
expect(room.getAvatarFallbackMember()?.userId).toBe(userD);
});
});
describe("maySendMessage", function () {
it("should return false if synced membership not join", function () {
const room = new Room(roomId, { isRoomEncrypted: () => false } as any, userA);
room.updateMyMembership(KnownMembership.Invite);
expect(room.maySendMessage()).toEqual(false);
room.updateMyMembership(KnownMembership.Leave);
expect(room.maySendMessage()).toEqual(false);
room.updateMyMembership(KnownMembership.Join);
expect(room.maySendMessage()).toEqual(true);
});
});
describe("getDefaultRoomName", function () {
it("should return 'Empty room' if a user is the only member", function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
expect(room.getDefaultRoomName(userA)).toEqual("Empty room");
});
it("should return a display name if one other member is in the room", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("User B");
});
it("should return a display name if one other member is banned", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Ban,
room: roomId,
event: true,
name: "User B",
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)");
});
it("should return a display name if one other member is invited", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Invite,
room: roomId,
event: true,
name: "User B",
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("User B");
});
it("should return 'Empty room (was User B)' if User B left the room", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Leave,
room: roomId,
event: true,
name: "User B",
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)");
});
it("should return 'User B and User C' if in a room with two other users", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
utils.mkMembership({
user: userC,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User C",
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("User B and User C");
});
it("should return 'User B and 2 others' if in a room with three other users", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
utils.mkMembership({
user: userC,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User C",
}),
utils.mkMembership({
user: userD,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User D",
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others");
});
});
describe("io.element.functional_users", function () {
it("should return a display name (default behaviour) if no one is marked as a functional member", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "",
room: roomId,
event: true,
content: {
service_members: [],
},
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("User B");
});
it("should return a display name (default behaviour) if service members is a number (invalid)", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "",
room: roomId,
event: true,
content: {
service_members: 1,
},
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("User B");
});
it("should return a display name (default behaviour) if service members is a string (invalid)", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "",
room: roomId,
event: true,
content: {
service_members: userB,
},
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("User B");
});
it("should return 'Empty room' if the only other member is a functional member", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "",
room: roomId,
event: true,
content: {
service_members: [userB],
},
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("Empty room");
});
it("should return 'User B' if User B is the only other member who isn't a functional member", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
utils.mkMembership({
user: userC,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User C",
}),
utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "",
room: roomId,
event: true,
user: userA,
content: {
service_members: [userC],
},
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("User B");
});
it("should return 'Empty room' if all other members are functional members", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
utils.mkMembership({
user: userC,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User C",
}),
utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "",
room: roomId,
event: true,
user: userA,
content: {
service_members: [userB, userC],
},
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("Empty room");
});
it("should not break if an unjoined user is marked as a service user", async function () {
const room = new Room(roomId, new TestClient(userA).client, userA);
await room.addLiveEvents(
[
utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User A",
}),
utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "User B",
}),
utils.mkEvent({
type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name,
skey: "",
room: roomId,
event: true,
user: userA,
content: {
service_members: [userC],
},
}),
],
{ addToState: true },
);
expect(room.getDefaultRoomName(userA)).toEqual("User B");
});
});
describe("threads", function () {
beforeEach(() => {
const client = new TestClient("@alice:example.com", "alicedevice").client;
room = new Room(roomId, client, userA);
client.getRoom = () => room;
});
it("allow create threads without a root event", function () {
const eventWithoutARootEvent = new MatrixEvent({
event_id: "$123",
room_id: roomId,
content: {
"m.relates_to": {
rel_type: "m.thread",
event_id: "$000",
},
},
unsigned: {
age: 1,
},
});
room.createThread("$000", undefined, [eventWithoutARootEvent], false);
const rootEvent = new MatrixEvent({
event_id: "$666",
room_id: roomId,
content: {},
unsigned: {
"age": 1,
"m.relations": {
"m.thread": {
latest_event: null,
count: 1,
current_user_participated: false,
},
},
},
});
expect(() => room.createThread(rootEvent.getId()!, rootEvent, [], false)).not.toThrow();
});
it("returns the same model when creating a thread twice", () => {
const { thread, rootEvent } = mkThread({ room });
expect(thread).toBeInstanceOf(Thread);
const duplicateThread = room.createThread(rootEvent.getId()!, rootEvent, [], false);
expect(duplicateThread).toBe(thread);
});
it("creating thread from edited event should not conflate old versions of the event", () => {
const message = mkMessage();
const edit = mkEdit(message);
message.makeReplaced(edit);
const thread = room.createThread("$000", message, [], true);
expect(thread).toHaveLength(0);
});
it("Edits update the lastReply event", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const randomMessage = mkMessage();
const threadRoot = mkMessage();
const threadResponse = mkThreadResponse(threadRoot);
threadResponse.localTimestamp += 1000;
const threadResponseEdit = mkEdit(threadResponse);
threadResponseEdit.localTimestamp += 2000;
room.client.fetchRoomEvent = (eventId: string) =>
Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse.event,
count: 2,
current_user_participated: true,
},
},
},
});
room.client.fetchRelations = (
roomId: string,
eventId: string,
relationType?: RelationType | string | null,
eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward },
) =>
Promise.resolve({
chunk: [threadResponse.event] as IEvent[],
next_batch: "start_token",
});
const prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([randomMessage, threadRoot, threadResponse], { addToState: false });
const thread: Thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread.initialEventsFetched).toBeTruthy();
expect(thread.replyToEvent!.event).toEqual(threadResponse.event);
expect(thread.replyToEvent!.getContent().body).toBe(threadResponse.getContent().body);
room.client.fetchRoomEvent = (eventId: string) =>
Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
[THREAD_RELATION_TYPE.name]: {
latest_event: {
...threadResponse.event,
content: threadResponseEdit.getContent()["m.new_content"],
},
count: 2,
current_user_participated: true,
},
},
},
});
// XXX: If we add the relation to the thread response before the thread finishes fetching via /relations
// then the test will fail
await emitPromise(room, ThreadEvent.Update);
await Promise.all([
emitPromise(room, ThreadEvent.Update),
room.addLiveEvents([threadResponseEdit], { addToState: false }),
]);
expect(thread.replyToEvent!.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body);
});
it("emits event for the first event added to a thread", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
await room.addLiveEvents([threadRoot], { addToState: false });
const onEvent = jest.fn();
room.on(RoomEvent.Timeline, onEvent);
await room.addLiveEvents([threadResponse1], { addToState: false });
expect(onEvent).toHaveBeenCalled();
});
it("contains the events added as soon as it's created", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
const newThreadEventPromise = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1], { addToState: false });
const thread = await newThreadEventPromise;
expect(thread.timeline).toContain(threadResponse1);
});
it("Redactions to thread responses decrement the length", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
threadResponse1.localTimestamp += 1000;
const threadResponse2 = mkThreadResponse(threadRoot);
threadResponse2.localTimestamp += 2000;
room.client.fetchRoomEvent = (eventId: string) =>
Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event,
count: 2,
current_user_participated: true,
},
},
},
});
let prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2], { addToState: false });
const thread = await prom;
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
thread.timelineSet.addEventToTimeline(threadResponse1, thread.liveTimeline, {
toStartOfTimeline: true,
fromCache: false,
roomState: thread.roomState,
});
thread.timelineSet.addEventToTimeline(threadResponse2, thread.liveTimeline, {
toStartOfTimeline: true,
fromCache: false,
roomState: thread.roomState,
});
room.client.fetchRoomEvent = (eventId: string) =>
Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event,
count: 1,
current_user_participated: true,
},
},
},
});
prom = emitPromise(thread, ThreadEvent.Update);
const threadResponse1Redaction = mkRedaction(threadResponse1);
await room.addLiveEvents([threadResponse1Redaction], { addToState: false });
await prom;
expect(thread).toHaveLength(1);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
});
it("Redactions to reactions in threads do not decrement the length", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
threadResponse1.localTimestamp += 1000;
const threadResponse2 = mkThreadResponse(threadRoot);
threadResponse2.localTimestamp += 2000;
const threadResponse2Reaction = utils.mkReaction(threadResponse2, room.client, userA, roomId);
room.client.fetchRoomEvent = (eventId: string) =>
Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event,
count: 2,
current_user_participated: true,
},
},
},
});
room.client.fetchRelations = jest.fn().mockResolvedValue({
chunk: [threadResponse2Reaction.event, threadResponse2.event, threadResponse1.event],
});
const prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction], {
addToState: false,
});
const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
const threadResponse2ReactionRedaction = mkRedaction(threadResponse2Reaction);
await room.addLiveEvents([threadResponse2ReactionRedaction], { addToState: false });
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
});
it("should not decrement the length when the thread root is redacted", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
threadResponse1.localTimestamp += 1000;
const threadResponse2 = mkThreadResponse(threadRoot);
threadResponse2.localTimestamp += 2000;
const threadResponse2Reaction = utils.mkReaction(threadResponse2, room.client, userA, roomId);
room.client.fetchRoomEvent = (eventId: string) =>
Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event,
count: 2,
current_user_participated: true,
},
},
},
});
const prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction], {
addToState: false,
});
const thread = await prom;
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
const threadRootRedaction = mkRedaction(threadRoot);
await room.addLiveEvents([threadRootRedaction], { addToState: false });
// We can't wait for a thread update here because there shouldn't be one (which is
// what we're asserting). Flush any promises to try to get more certainty that an
// update is not happening some time after the event is added.
await flushPromises();
expect(thread).toHaveLength(2);
});
it("Redacting the lastEvent finds a new lastEvent", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
room.client.createThreadListMessagesRequest = () =>
Promise.resolve({
chunk: [],
state: [],
});
await room.createThreadsTimelineSets();
await room.fetchRoomThreads();
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
threadResponse1.localTimestamp += 1000;
const threadResponse2 = mkThreadResponse(threadRoot);
threadResponse2.localTimestamp += 2000;
room.client.fetchRoomEvent = (eventId: string) =>
Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
"m.thread": {
latest_event: threadResponse2.event,
count: 1,
current_user_participated: true,
},
},
},
});
room.client.fetchRelations = (
roomId: string,
eventId: string,
relationType?: RelationType | string | null,
eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward },
) =>
Promise.resolve({
chunk: [threadResponse1.event] as IEvent[],
next_batch: "start_token",
});
let prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents([threadRoot, threadResponse1], { addToState: false });
const thread: Thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread.initialEventsFetched).toBeTruthy();
await room.addLiveEvents([threadResponse2], { addToState: false });
expect(thread).toHaveLength(2);
expect(thread.replyToEvent!.getId()).toBe(threadResponse2.getId());
room.client.fetchRoomEvent = (eventId: string) =>
Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
"m.thread": {
latest_event: threadResponse1.event,
count: 1,
current_user_participated: true,
},
},
},
});
await emitPromise(room, ThreadEvent.Update);
const threadResponse2Redaction = mkRedaction(threadResponse2);
await room.addLiveEvents([threadResponse2Redaction], { addToState: false });
expect(thread).toHaveLength(1);
expect(thread.replyToEvent!.getId()).toBe(threadResponse1.getId());
room.client.fetchRoomEvent = (eventId: string) =>
Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
"m.thread": {
latest_event: threadRoot.event,
count: 0,
current_user_participated: true,
},
},
},
});
prom = emitPromise(room, ThreadEvent.Delete);
const prom2 = emitPromise(room, RoomEvent.Timeline);
const threadResponse1Redaction = mkRedaction(threadResponse1);
await room.addLiveEvents([threadResponse1Redaction], { addToState: false });
await prom;
await prom2;
expect(thread).toHaveLength(0);
expect(thread.replyToEvent!.getId()).toBe(threadRoot.getId());
});
it("should add event to thread without server side support", async () => {
room.client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.None);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
threadResponse1.getContent()["m.relates_to"]!.rel_type = "io.element.thread";
const thread = room.createThread(threadRoot.getId()!, threadRoot, [threadResponse1], false)!;
expect(thread.events).toContain(threadResponse1);
});
afterAll(() => {
// Clear the latch created by `Thread.setServerSideSupport(FeatureSupport.None);`
FILTER_RELATED_BY_SENDERS.setPreferUnstable(false);
FILTER_RELATED_BY_REL_TYPES.setPreferUnstable(false);
THREAD_RELATION_TYPE.setPreferUnstable(false);
});
});
describe("eventShouldLiveIn", () => {
const client = new TestClient(userA).client;
const room = new Room(roomId, client, userA);
beforeEach(() => {
client.supportsThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
});
it("thread root and its relations&redactions should be in main timeline", () => {
const randomMessage = mkMessage();
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
const threadReaction1 = utils.mkReaction(threadRoot, room.client, userA, roomId);
const threadReaction2 = utils.mkReaction(threadRoot, room.client, userA, roomId);
const threadReaction2Redaction = mkRedaction(threadReaction2);
const roots = new Set([threadRoot.getId()!]);
const events = [
randomMessage,
threadRoot,
threadResponse1,
threadReaction1,
threadReaction2,
threadReaction2Redaction,
];
const thread = room.createThread(threadRoot.getId()!, threadRoot, [], false);
events.slice(1).forEach((ev) => ev.setThread(thread));
expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(randomMessage, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadRoot, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadResponse1, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeFalsy();
});
it("thread response and its relations&redactions should be only in thread timeline", () => {
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
const threadReaction1 = utils.mkReaction(threadResponse1, room.client, userA, roomId);
const threadReaction2 = utils.mkReaction(threadResponse1, room.client, userA, roomId);
const threadReaction2Redaction = mkRedaction(threadReaction2);
const roots = new Set([threadRoot.getId()!]);
const events = [threadRoot, threadResponse1, threadReaction1, threadReaction2, threadReaction2Redaction];
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadReaction1, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction1, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadReaction2, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction2, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadReaction2Redaction, events, roots).threadId).toBe(threadRoot.getId());
});
it("reply to thread response and its relations&redactions should be only in thread timeline", () => {
const threadRoot = mkMessage();
const threadResp1 = mkThreadResponse(threadRoot);
const threadResp1Reply1 = mkReply(threadResp1);
const threadResp1Reply1Reaction1 = utils.mkReaction(threadResp1Reply1, room.client, userA, roomId);
const threadResp1Reply1Reaction2 = utils.mkReaction(threadResp1Reply1, room.client, userA, roomId);
const thResp1Rep1React2Redaction = mkRedaction(threadResp1Reply1);
const roots = new Set([threadRoot.getId()!]);
const events = [
threadRoot,
threadResp1,
threadResp1Reply1,
threadResp1Reply1Reaction1,
threadResp1Reply1Reaction2,
thResp1Rep1React2Redaction,
];
const thread = room.createThread(threadRoot.getId()!, threadRoot, [], false);
events.forEach((ev) => ev.setThread(thread));
expect(room.eventShouldLiveIn(threadResp1Reply1, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadResp1Reply1, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadResp1Reply1, events, roots).threadId).toBe(thread.id);
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction1, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction1, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction1, events, roots).threadId).toBe(thread.id);
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction2, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction2, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadResp1Reply1Reaction2, events, roots).threadId).toBe(thread.id);
expect(room.eventShouldLiveIn(thResp1Rep1React2Redaction, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(thResp1Rep1React2Redaction, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(thResp1Rep1React2Redaction, events, roots).threadId).toBe(thread.id);
});
it("reply to reply to thread root should only be in the main timeline", () => {
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
const reply1 = mkReply(threadRoot);
const reply2 = mkReply(reply1);
const roots = new Set([threadRoot.getId()!]);
const events = [threadRoot, threadResponse1, reply1, reply2];
const thread = room.createThread(threadRoot.getId()!, threadRoot, [], false);
threadResponse1.setThread(thread);
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reply1, events, roots).shouldLiveInThread).toBeFalsy();
expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reply2, events, roots).shouldLiveInThread).toBeFalsy();
});
it("edit to thread root should live in main timeline only", () => {
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
const threadRootEdit = mkEdit(threadRoot);
threadRoot.makeReplaced(threadRootEdit);
const thread = room.createThread(threadRoot.getId()!, threadRoot, [threadResponse1], false);
threadResponse1.setThread(thread);
threadRootEdit.setThread(thread);
const roots = new Set([threadRoot.getId()!]);
const events = [threadRoot, threadResponse1, threadRootEdit];
expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadRoot, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadRoot, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInRoom).toBeFalsy();
expect(room.eventShouldLiveIn(threadResponse1, events, roots).shouldLiveInThread).toBeTruthy();
expect(room.eventShouldLiveIn(threadResponse1, events, roots).threadId).toBe(threadRoot.getId());
expect(room.eventShouldLiveIn(threadRootEdit, events, roots).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(threadRootEdit, events, roots).shouldLiveInThread).toBeFalsy();
});
it("should aggregate relations in thread event timeline set", async () => {
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const rootReaction = utils.mkReaction(threadRoot, room.client, userA, roomId);
const threadResponse = mkThreadResponse(threadRoot);
const threadReaction = utils.mkReaction(threadResponse, room.client, userA, roomId);
const events = [threadRoot, rootReaction, threadResponse, threadReaction];
const prom = emitPromise(room, ThreadEvent.New);
await room.addLiveEvents(events, { addToState: false });
const thread = await prom;
expect(thread).toBe(threadRoot.getThread());
expect(thread.rootEvent).toBe(threadRoot);
const rootRelations = thread.timelineSet.relations
.getChildEventsForEvent(threadRoot.getId()!, RelationType.Annotation, EventType.Reaction)!
.getSortedAnnotationsByKey();
expect(rootRelations).toHaveLength(1);
expect(rootRelations![0][0]).toEqual(rootReaction.getRelation()!.key);
expect(rootRelations![0][1].size).toEqual(1);
expect(rootRelations![0][1].has(rootReaction)).toBeTruthy();
const responseRelations = thread.timelineSet.relations
.getChildEventsForEvent(threadResponse.getId()!, RelationType.Annotation, EventType.Reaction)!
.getSortedAnnotationsByKey();
expect(responseRelations).toHaveLength(1);
expect(responseRelations![0][0]).toEqual(threadReaction.getRelation()!.key);
expect(responseRelations![0][1].size).toEqual(1);
expect(responseRelations![0][1].has(threadReaction)).toBeTruthy();
});
it("a non-thread reply to an unknown parent event should live in the main timeline only", async () => {
const message = mkMessage(); // we do not add this message to any timelines
const reply = mkReply(message);
expect(room.eventShouldLiveIn(reply).shouldLiveInRoom).toBeTruthy();
expect(room.eventShouldLiveIn(reply).shouldLiveInThread).toBeFalsy();
});
});
describe("getEventReadUpTo()", () => {
const client = new TestClient(userA).client;
const room = new Room(roomId, client, userA);
describe("invalid receipts", () => {
beforeEach(() => {
// Clear the spies on logger.warn
jest.clearAllMocks();
});
it("ignores receipts pointing at missing events", () => {
// Given a receipt exists
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
return { eventId: "missingEventId" } as WrappedReceipt;
};
// But the event ID it contains does not refer to an event we have
room.findEventById = jest.fn().mockReturnValue(null);
// When we ask what they have read
// Then we say "nothing"
expect(room.getEventReadUpTo(userA)).toBeNull();
});
it("ignores receipts pointing at the wrong thread", () => {
// Given a threaded receipt exists
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
return { eventId: "wrongThreadEventId", data: { ts: 0, thread_id: "thread1" } } as WrappedReceipt;
};
// But the event it refers to is in a thread
room.findEventById = jest.fn().mockReturnValue({ threadRootId: "thread2" } as MatrixEvent);
// When we ask what they have read
// Then we say "nothing"
expect(room.getEventReadUpTo(userA)).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
"Ignoring receipt because its thread_id (thread1) disagrees with the thread root (thread2) " +
"of the referenced event (event ID = wrongThreadEventId)",
);
});
it("accepts unthreaded receipts pointing at an event in a thread", () => {
// Given an unthreaded receipt exists
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
return { eventId: "inThreadEventId" } as WrappedReceipt;
};
// And the event it refers to is in a thread
room.findEventById = jest.fn().mockReturnValue({ threadRootId: "thread2" } as MatrixEvent);
// When we ask what they have read
// Then we say the event
expect(room.getEventReadUpTo(userA)).toEqual("inThreadEventId");
});
it("accepts main thread receipts pointing at an event in main timeline", () => {
// Given a threaded receipt exists, in main thread
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
return { eventId: "mainThreadEventId", data: { ts: 12, thread_id: "main" } } as WrappedReceipt;
};
// And the event it refers to is in a thread
room.findEventById = jest.fn().mockReturnValue({ threadRootId: undefined } as MatrixEvent);
// When we ask what they have read
// Then we say the event
expect(room.getEventReadUpTo(userA)).toEqual("mainThreadEventId");
});
it("accepts main thread receipts pointing at a thread root", () => {
// Given a threaded receipt exists, in main thread
room.getReadReceiptForUserId = (): WrappedReceipt | null => {
return { eventId: "rootId", data: { ts: 12, thread_id: "main" } } as WrappedReceipt;
};
// And the event it refers to is in a thread, because it is a thread root
room.findEventById = jest
.fn()
.mockReturnValue({ isThreadRoot: true, threadRootId: "thread1" } as MatrixEvent);
// When we ask what they have read
// Then we say the event
expect(room.getEventReadUpTo(userA)).toEqual("rootId");
});
});
describe("valid receipts", () => {
beforeEach(() => {
// When we look up the event referred to by the receipt, it exists
room.findEventById = jest.fn().mockReturnValue({} as MatrixEvent);
});
it("handles missing receipt type", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
return receiptType === ReceiptType.ReadPrivate ? ({ eventId: "eventId" } as WrappedReceipt) : null;
};
expect(room.getEventReadUpTo(userA)).toEqual("eventId");
});
describe("prefers newer receipt", () => {
it("should compare correctly using timelines", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1" } as WrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2" } as WrappedReceipt;
}
return null;
};
for (let i = 1; i <= 2; i++) {
room.getUnfilteredTimelineSet = () =>
({
compareEventOrdering: (event1: string, _event2: string) => {
return event1 === `eventId${i}` ? 1 : -1;
},
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
}) as unknown as EventTimelineSet;
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
}
});
describe("correctly compares by timestamp", () => {
it("should correctly compare, if we have all receipts", () => {
for (let i = 1; i <= 2; i++) {
room.getUnfilteredTimelineSet = () =>
({
compareEventOrdering: () => null,
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
}) as unknown as EventTimelineSet;
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt;
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2", data: { ts: i === 2 ? 2 : 1 } } as WrappedReceipt;
}
return null;
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
}
});
it("should correctly compare, if private read receipt is missing", () => {
room.getUnfilteredTimelineSet = () =>
({
compareEventOrdering: () => null,
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
}) as unknown as EventTimelineSet;
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt;
}
return null;
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId2`);
});
});
describe("fallback precedence", () => {
beforeAll(() => {
room.getUnfilteredTimelineSet = () =>
({
compareEventOrdering: () => null,
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
}) as unknown as EventTimelineSet;
});
it("should give precedence to m.read.private", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1", data: { ts: 123 } };
}
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2", data: { ts: 123 } };
}
return null;
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId1`);
});
it("should give precedence to m.read", () => {
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId3" } as WrappedReceipt;
}
return null;
};
expect(room.getEventReadUpTo(userA)).toEqual(`eventId3`);
});
});
});
});
});
describe("roomNameGenerator", () => {
const client = new TestClient(userA).client;
client.roomNameGenerator = jest.fn().mockReturnValue(null);
const room = new Room(roomId, client, userA);
it("should call fn when recalculating room name", () => {
(client.roomNameGenerator as jest.Mock).mockClear();
room.recalculate();
expect(client.roomNameGenerator).toHaveBeenCalled();
});
});
describe("thread notifications", () => {
let room: Room;
beforeEach(() => {
const client = new TestClient(userA).client;
room = new Room(roomId, client, userA);
});
it("defaults to undefined", () => {
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(0);
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(0);
});
it("lets you set values", () => {
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1);
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1);
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(0);
room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 10);
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(1);
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(10);
});
it("lets you reset threads notifications", () => {
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666);
room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 123);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
room.resetThreadUnreadNotificationCountFromSync();
expect(room.threadsAggregateNotificationType).toBe(null);
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Total)).toBe(0);
expect(room.getThreadUnreadNotificationCount("123", NotificationCountType.Highlight)).toBe(0);
});
it("sets the room threads notification type", () => {
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Total);
room.setThreadUnreadNotificationCount("123", NotificationCountType.Highlight, 123);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 333);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
});
it("emits event on notifications reset", () => {
const cb = jest.fn();
room.on(RoomEvent.UnreadNotifications, cb);
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 666);
room.setThreadUnreadNotificationCount("456", NotificationCountType.Highlight, 123);
room.resetThreadUnreadNotificationCountFromSync();
expect(cb).toHaveBeenLastCalledWith();
});
});
describe("hasThreadUnreadNotification", () => {
it("has no notifications by default", () => {
expect(room.hasThreadUnreadNotification()).toBe(false);
});
it("main timeline notification does not affect this", () => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
expect(room.hasThreadUnreadNotification()).toBe(false);
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
expect(room.hasThreadUnreadNotification()).toBe(false);
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1);
expect(room.hasThreadUnreadNotification()).toBe(true);
});
it("lets you reset", () => {
room.setThreadUnreadNotificationCount("123", NotificationCountType.Total, 1);
expect(room.hasThreadUnreadNotification()).toBe(true);
room.resetThreadUnreadNotificationCountFromSync();
expect(room.hasThreadUnreadNotification()).toBe(false);
});
});
describe("threadsAggregateNotificationType", () => {
it("defaults to null", () => {
expect(room.threadsAggregateNotificationType).toBeNull();
});
it("counts multiple threads", () => {
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Total);
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Total);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 0);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Total);
});
it("allows reset", () => {
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1);
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Total);
room.resetThreadUnreadNotificationCountFromSync();
expect(room.threadsAggregateNotificationType).toBeNull();
});
it("retains highlight for encrypted rooms on reset", () => {
room.hasEncryptionStateEvent = jest.fn().mockReturnValue(true);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 2);
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
room.resetThreadUnreadNotificationCountFromSync();
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
});
it("resets highlight for unencrypted rooms on reset", () => {
room.hasEncryptionStateEvent = jest.fn().mockReturnValue(false);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 2);
room.setThreadUnreadNotificationCount("$456", NotificationCountType.Total, 1);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
expect(room.threadsAggregateNotificationType).toBe(NotificationCountType.Highlight);
room.resetThreadUnreadNotificationCountFromSync();
expect(room.threadsAggregateNotificationType).toBe(null);
expect(room.getThreadUnreadNotificationCount("$123", NotificationCountType.Highlight)).toBe(0);
});
});
it("should load pending events from from the store and decrypt if needed", async () => {
const client = new TestClient(userA).client;
client["cryptoBackend"] = {
decryptEvent: jest.fn().mockResolvedValue({ clearEvent: { body: "enc" } }),
} as unknown as CryptoBackend;
client.store.getPendingEvents = jest.fn(async (roomId) => [
{
event_id: "$1:server",
type: "m.room.message",
content: { body: "1" },
sender: "@1:server",
room_id: roomId,
origin_server_ts: 1,
txn_id: "txn1",
},
{
event_id: "$2:server",
type: "m.room.encrypted",
content: { body: "2" },
sender: "@2:server",
room_id: roomId,
origin_server_ts: 2,
txn_id: "txn2",
},
]);
const room = new Room(roomId, client, userA, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
await emitPromise(room, RoomEvent.LocalEchoUpdated);
await emitPromise(client, MatrixEventEvent.Decrypted);
await emitPromise(room, RoomEvent.LocalEchoUpdated);
const pendingEvents = room.getPendingEvents();
expect(pendingEvents).toHaveLength(2);
expect(pendingEvents[1].isDecryptionFailure()).toBeFalsy();
expect(pendingEvents[1].isBeingDecrypted()).toBeFalsy();
expect(pendingEvents[1].isEncrypted()).toBeTruthy();
for (const ev of pendingEvents) {
expect(room.getPendingEvent(ev.getId()!)).toBe(ev);
}
});
describe("getBlacklistUnverifiedDevices", () => {
it("defaults to null", () => {
expect(room.getBlacklistUnverifiedDevices()).toBeNull();
});
it("is updated by setBlacklistUnverifiedDevices", () => {
room.setBlacklistUnverifiedDevices(false);
expect(room.getBlacklistUnverifiedDevices()).toBe(false);
});
});
describe("processPollEvents()", () => {
let room: Room;
let client: MatrixClient;
beforeEach(() => {
client = getMockClientWithEventEmitter({
decryptEventIfNeeded: jest.fn(),
});
room = new Room(roomId, client, userA);
jest.spyOn(room, "emit").mockClear();
});
const makePollStart = (id: string): MatrixEvent => {
const event = new MatrixEvent({
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
room_id: roomId,
});
event.event.event_id = id;
return event;
};
it("adds poll models to room state for a poll start event", async () => {
const pollStartEvent = makePollStart("1");
const events = [pollStartEvent];
await room.processPollEvents(events);
expect(client.decryptEventIfNeeded).toHaveBeenCalledWith(pollStartEvent);
const pollInstance = room.polls.get(pollStartEvent.getId()!);
expect(pollInstance).toBeTruthy();
expect(room.emit).toHaveBeenCalledWith(PollEvent.New, pollInstance);
});
it("adds related events to poll models and log errors", async () => {
const pollStartEvent = makePollStart("1");
const pollStartEvent2 = makePollStart("2");
const events = [pollStartEvent, pollStartEvent2];
const pollResponseEvent = new MatrixEvent({
type: M_POLL_RESPONSE.name,
content: {
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: pollStartEvent.getId(),
},
},
});
const messageEvent = new MatrixEvent({
type: "m.room.messsage",
content: {
text: "hello",
},
});
const errorEvent = new MatrixEvent({
type: M_POLL_START.name,
content: {
text: "Error!!!!",
},
});
const error = new Error("Test error");
mocked(client.decryptEventIfNeeded).mockImplementation(async (event: MatrixEvent) => {
if (event === errorEvent) throw error;
});
// init poll
await room.processPollEvents(events);
const poll = room.polls.get(pollStartEvent.getId()!)!;
const poll2 = room.polls.get(pollStartEvent2.getId()!)!;
jest.spyOn(poll, "onNewRelation");
jest.spyOn(poll2, "onNewRelation");
await room.processPollEvents([errorEvent, messageEvent, pollResponseEvent]);
// only called for relevant event
expect(poll.onNewRelation).toHaveBeenCalledTimes(1);
expect(poll.onNewRelation).toHaveBeenCalledWith(pollResponseEvent);
// only called on poll with relation
expect(poll2.onNewRelation).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith("Error processing poll event", errorEvent.getId(), error);
});
it("should retry on decryption", async () => {
const pollStartEventId = "poll1";
const pollStartEvent = makePollStart(pollStartEventId);
// simulate decryption failure
const isDecryptionFailureSpy = jest.spyOn(pollStartEvent, "isDecryptionFailure").mockReturnValue(true);
await room.processPollEvents([pollStartEvent]);
// do not expect a poll to show up for the room
expect(room.polls.get(pollStartEventId)).toBeUndefined();
// now emit a Decrypted event but keep the decryption failure
pollStartEvent.emit(MatrixEventEvent.Decrypted, pollStartEvent);
// still do not expect a poll to show up for the room
expect(room.polls.get(pollStartEventId)).toBeUndefined();
// clear decryption failure and emit a Decrypted event again
isDecryptionFailureSpy.mockRestore();
pollStartEvent.emit(MatrixEventEvent.Decrypted, pollStartEvent);
// the poll should now show up in the room's polls
const poll = room.polls.get(pollStartEventId);
expect(poll?.pollId).toBe(pollStartEventId);
});
it("removes poll from state when redacted", async () => {
const pollStartEvent = makePollStart("1");
const events = [pollStartEvent];
await room.processPollEvents(events);
expect(room.polls.get(pollStartEvent.getId()!)).toBeTruthy();
const redactedEvent = new MatrixEvent({ type: "m.room.redaction" });
pollStartEvent.makeRedacted(redactedEvent, room);
await flushPromises();
// removed from poll state
expect(room.polls.get(pollStartEvent.getId()!)).toBeFalsy();
});
});
describe("findPredecessorRoomId", () => {
let client: MatrixClient | null = null;
beforeEach(() => {
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
isInitialSyncComplete: jest.fn().mockReturnValue(false),
supportsThreads: jest.fn().mockReturnValue(true),
});
});
function roomCreateEvent(newRoomId: string, predecessorRoomId: string | null): MatrixEvent {
const content: {
["m.federate"]: boolean;
room_version: string;
predecessor: { event_id: string; room_id: string } | undefined;
} = {
"predecessor": undefined,
"m.federate": true,
"room_version": "9",
};
if (predecessorRoomId) {
content.predecessor = {
event_id: "id_of_last_known_event",
room_id: predecessorRoomId,
};
}
return new MatrixEvent({
content,
event_id: `create_event_id_pred_${predecessorRoomId}`,
origin_server_ts: 1432735824653,
room_id: newRoomId,
sender: "@daryl:alexandria.example.com",
state_key: "",
type: "m.room.create",
});
}
function predecessorEvent(
newRoomId: string,
predecessorRoomId: string,
tombstoneEventId: string | null = null,
viaServers: string[] = [],
): MatrixEvent {
const content =
tombstoneEventId === null
? { predecessor_room_id: predecessorRoomId, via_servers: viaServers }
: {
predecessor_room_id: predecessorRoomId,
last_known_event_id: tombstoneEventId,
via_servers: viaServers,
};
return new MatrixEvent({
content,
event_id: `predecessor_event_id_pred_${predecessorRoomId}`,
origin_server_ts: 1432735824653,
room_id: newRoomId,
sender: "@daryl:alexandria.example.com",
state_key: "",
type: "org.matrix.msc3946.room_predecessor",
});
}
it("Returns null if there is no create event", () => {
const room = new Room("roomid", client!, "@u:example.com");
expect(room.findPredecessor()).toBeNull();
});
it("Returns null if the create event has no predecessor", async () => {
const room = new Room("roomid", client!, "@u:example.com");
await room.addLiveEvents([roomCreateEvent("roomid", null)], { addToState: true });
expect(room.findPredecessor()).toBeNull();
});
it("Returns the predecessor ID if one is provided via create event", async () => {
const room = new Room("roomid", client!, "@u:example.com");
await room.addLiveEvents([roomCreateEvent("roomid", "replacedroomid")], { addToState: true });
expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" });
});
it("Prefers the m.predecessor event if one exists", async () => {
const room = new Room("roomid", client!, "@u:example.com");
await room.addLiveEvents(
[roomCreateEvent("roomid", "replacedroomid"), predecessorEvent("roomid", "otherreplacedroomid")],
{ addToState: true },
);
const useMsc3946 = true;
expect(room.findPredecessor(useMsc3946)).toEqual({
roomId: "otherreplacedroomid",
eventId: undefined, // m.predecessor did not include an event_id
viaServers: [],
});
});
it("uses the m.predecessor event ID if provided", async () => {
const room = new Room("roomid", client!, "@u:example.com");
await room.addLiveEvents(
[
roomCreateEvent("roomid", "replacedroomid"),
predecessorEvent("roomid", "otherreplacedroomid", "lstevtid", [
"one.example.com",
"two.example.com",
]),
],
{ addToState: true },
);
const useMsc3946 = true;
expect(room.findPredecessor(useMsc3946)).toEqual({
roomId: "otherreplacedroomid",
eventId: "lstevtid",
viaServers: ["one.example.com", "two.example.com"],
});
});
it("Ignores the m.predecessor event if we don't ask to use it", async () => {
const room = new Room("roomid", client!, "@u:example.com");
await room.addLiveEvents(
[roomCreateEvent("roomid", "replacedroomid"), predecessorEvent("roomid", "otherreplacedroomid")],
{ addToState: true },
);
// Don't provide an argument for msc3946ProcessDynamicPredecessor -
// we should ignore the predecessor event.
expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" });
});
it("Ignores the m.predecessor event and returns null if we don't ask to use it", async () => {
const room = new Room("roomid", client!, "@u:example.com");
await room.addLiveEvents(
[
roomCreateEvent("roomid", null), // Create event has no predecessor
predecessorEvent("roomid", "otherreplacedroomid", "lastevtid"),
],
{ addToState: true },
);
// Don't provide an argument for msc3946ProcessDynamicPredecessor -
// we should ignore the predecessor event.
expect(room.findPredecessor()).toBeNull();
});
});
describe("getLastLiveEvent", () => {
it("when there are no events, it should return undefined", () => {
expect(room.getLastLiveEvent()).toBeUndefined();
});
it("when there is only an event in the main timeline and there are no threads, it should return the last event from the main timeline", async () => {
const lastEventInMainTimeline = await mkMessageInRoom(room, 23);
expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline);
});
/**
* This should normally not happen. The test exists only for the sake of completeness.
* No event is added to the room's live timeline here.
*/
it("when there is no event in the room live timeline but in a thread, it should return the last event from the thread", () => {
const { thread } = mkThread({ room, length: 0 });
const lastEventInThread = mkMessageInThread(thread, 42);
expect(room.getLastLiveEvent()).toBe(lastEventInThread);
});
describe("when there are events in both, the main timeline and threads", () => {
it("and the last event is in a thread, it should return the last event from the thread", async () => {
await mkMessageInRoom(room, 23);
const { thread } = mkThread({ room, length: 0 });
const lastEventInThread = mkMessageInThread(thread, 42);
expect(room.getLastLiveEvent()).toBe(lastEventInThread);
});
it("and the last event is in the main timeline, it should return the last event from the main timeline", async () => {
const lastEventInMainTimeline = await mkMessageInRoom(room, 42);
const { thread } = mkThread({ room, length: 0 });
mkMessageInThread(thread, 23);
expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline);
});
it("and both events have the same timestamp, it should return the last event from the thread", async () => {
await mkMessageInRoom(room, 23);
const { thread } = mkThread({ room, length: 0 });
const lastEventInThread = mkMessageInThread(thread, 23);
expect(room.getLastLiveEvent()).toBe(lastEventInThread);
});
it("and there is a thread without any messages, it should return the last event from the main timeline", async () => {
const lastEventInMainTimeline = await mkMessageInRoom(room, 23);
mkThread({ room, length: 0 });
expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline);
});
});
});
describe("getLastThread", () => {
it("when there is no thread, it should return undefined", () => {
expect(room.getLastThread()).toBeUndefined();
});
it("when there is only one thread, it should return this one", () => {
const { thread1 } = addRoomThreads(room, 23, null);
expect(room.getLastThread()).toBe(thread1);
});
it("when there are tho threads, it should return the one with the recent event I", () => {
const { thread2 } = addRoomThreads(room, 23, 42);
expect(room.getLastThread()).toBe(thread2);
});
it("when there are tho threads, it should return the one with the recent event II", () => {
const { thread1 } = addRoomThreads(room, 42, 23);
expect(room.getLastThread()).toBe(thread1);
});
it("when there is a thread with the last event ts undefined, it should return the thread with the defined event ts", () => {
const { thread2 } = addRoomThreads(room, undefined, 23);
expect(room.getLastThread()).toBe(thread2);
});
it("when the last event ts of all threads is undefined, it should return the last added thread", () => {
const { thread2 } = addRoomThreads(room, undefined, undefined);
expect(room.getLastThread()).toBe(thread2);
});
});
describe("getRecommendedVersion", () => {
it("returns the server's recommended version from capabilities", async () => {
const client = new TestClient(userA).client;
client.getCapabilities = jest.fn().mockReturnValue({
["m.room_versions"]: {
default: "1",
available: ["1", "2"],
},
});
const room = new Room(roomId, client, userA);
expect(await room.getRecommendedVersion()).toEqual({
version: "1",
needsUpgrade: false,
urgent: false,
});
});
it("force-refreshes versions to make sure an upgrade is necessary", async () => {
const client = new TestClient(userA).client;
client.getCapabilities = jest.fn().mockReturnValue({
["m.room_versions"]: {
default: "5",
available: ["5"],
},
});
client.fetchCapabilities = jest.fn().mockResolvedValue({
["m.room_versions"]: {
default: "1",
available: ["1"],
},
});
const room = new Room(roomId, client, userA);
expect(await room.getRecommendedVersion()).toEqual({
version: "1",
needsUpgrade: false,
urgent: false,
});
expect(client.fetchCapabilities).toHaveBeenCalled();
});
});
describe("getOrCreateFilteredTimelineSet", () => {
it("should locally filter events if prepopulateTimeline=true", () => {
room.addLiveEvents(
[
utils.mkEvent(
{
event: true,
type: EventType.RoomMessage,
user: userA,
room: roomId,
content: {
body: "ev1",
},
},
room.client,
),
utils.mkEvent(
{
event: true,
type: "custom.event.type",
user: userA,
room: roomId,
content: {
body: "ev2",
},
},
room.client,
),
utils.mkEvent(
{
event: true,
type: EventType.RoomMessage,
user: userA,
room: roomId,
content: {
body: "ev3",
},
},
room.client,
),
],
{
addToState: false,
},
);
const filter = Filter.fromJson(room.client.getUserId(), "filterId", {
room: {
timeline: {
types: ["custom.event.type"],
},
},
});
const timelineSet = room.getOrCreateFilteredTimelineSet(filter, { prepopulateTimeline: true });
const filteredEvents = timelineSet.getLiveTimeline().getEvents();
expect(filteredEvents).toHaveLength(1);
expect(filteredEvents[0].getContent().body).toEqual("ev2");
});
});
});