Element Call video rooms (#9267)
* Add an element_call_url config option * Add a labs flag for Element Call video rooms * Add Element Call as another video rooms backend * Consolidate event power level defaults * Remember to clean up participantsExpirationTimer * Fix a code smell * Test the clean method * Fix some strict mode errors * Test that clean still works when there are no state events * Test auto-approval of Element Call widget capabilities * Deduplicate some code to placate SonarCloud * Fix more strict mode errors * Test that calls disconnect when leaving the room * Test the get methods of JitsiCall and ElementCall more * Test Call.ts even more * Test creation of Element video rooms * Test that createRoom works for non-video-rooms * Test Call's get method rather than the methods of derived classes * Ensure that the clean method is able to preserve devices * Remove duplicate clean method * Fix lints * Fix some strict mode errors in RoomPreviewCard * Test RoomPreviewCard changes * Quick and dirty hotfix for the community testing session * Revert "Quick and dirty hotfix for the community testing session" This reverts commit 37056514fbc040aaf1bff2539da770a1c8ba72a2. * Fix the event schema for org.matrix.msc3401.call.member devices * Remove org.matrix.call_duplicate_session from Element Call capabilities It's no longer used by Element Call when running as a widget. * Replace element_call_url with a map * Make PiPs work for virtual widgets * Auto-approve room timeline capability Because Element Call uses this now * Create a reusable isVideoRoom util
This commit is contained in:
parent
db5716b776
commit
cb735c9439
37 changed files with 1699 additions and 1384 deletions
120
test/components/views/rooms/RoomPreviewCard-test.tsx
Normal file
120
test/components/views/rooms/RoomPreviewCard-test.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { mocked, Mocked } from "jest-mock";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { PendingEventOrdering } from "matrix-js-sdk/src/client";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { stubClient, wrapInMatrixClientContext, mkRoomMember } from "../../../test-utils";
|
||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import _RoomPreviewCard from "../../../../src/components/views/rooms/RoomPreviewCard";
|
||||
|
||||
const RoomPreviewCard = wrapInMatrixClientContext(_RoomPreviewCard);
|
||||
|
||||
describe("RoomPreviewCard", () => {
|
||||
let client: Mocked<MatrixClient>;
|
||||
let room: Room;
|
||||
let alice: RoomMember;
|
||||
let enabledFeatures: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
client = mocked(MatrixClientPeg.get());
|
||||
client.getUserId.mockReturnValue("@alice:example.org");
|
||||
DMRoomMap.makeShared();
|
||||
|
||||
room = new Room("!1:example.org", client, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
alice = mkRoomMember(room.roomId, "@alice:example.org");
|
||||
jest.spyOn(room, "getMember").mockImplementation(userId => userId === alice.userId ? alice : null);
|
||||
|
||||
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
|
||||
client.getRooms.mockReturnValue([room]);
|
||||
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
|
||||
|
||||
enabledFeatures = [];
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(settingName =>
|
||||
enabledFeatures.includes(settingName) ? true : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderPreview = async (): Promise<void> => {
|
||||
render(
|
||||
<RoomPreviewCard
|
||||
room={room}
|
||||
onJoinButtonClicked={() => { }}
|
||||
onRejectButtonClicked={() => { }}
|
||||
/>,
|
||||
);
|
||||
await act(() => Promise.resolve()); // Allow effects to settle
|
||||
};
|
||||
|
||||
it("shows a beta pill on Jitsi video room invites", async () => {
|
||||
jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo);
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
|
||||
enabledFeatures = ["feature_video_rooms"];
|
||||
|
||||
await renderPreview();
|
||||
screen.getByRole("button", { name: /beta/i });
|
||||
});
|
||||
|
||||
it("shows a beta pill on Element video room invites", async () => {
|
||||
jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
|
||||
enabledFeatures = ["feature_video_rooms", "feature_element_call_video_rooms"];
|
||||
|
||||
await renderPreview();
|
||||
screen.getByRole("button", { name: /beta/i });
|
||||
});
|
||||
|
||||
it("doesn't show a beta pill on normal invites", async () => {
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
|
||||
|
||||
await renderPreview();
|
||||
expect(screen.queryByRole("button", { name: /beta/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows instructions on Jitsi video rooms invites if video rooms are disabled", async () => {
|
||||
jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo);
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
|
||||
|
||||
await renderPreview();
|
||||
screen.getByText(/enable video rooms in labs/i);
|
||||
});
|
||||
|
||||
it("shows instructions on Element video rooms invites if video rooms are disabled", async () => {
|
||||
jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
|
||||
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
|
||||
enabledFeatures = ["feature_element_call_video_rooms"];
|
||||
|
||||
await renderPreview();
|
||||
screen.getByText(/enable video rooms in labs/i);
|
||||
});
|
||||
});
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { mocked, Mocked } from "jest-mock";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { IDevice } from "matrix-js-sdk/src/crypto/deviceinfo";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
|
@ -23,25 +23,26 @@ import { stubClient, setupAsyncStoreWithClient, mockPlatformPeg } from "./test-u
|
|||
import { MatrixClientPeg } from "../src/MatrixClientPeg";
|
||||
import WidgetStore from "../src/stores/WidgetStore";
|
||||
import WidgetUtils from "../src/utils/WidgetUtils";
|
||||
import { JitsiCall } from "../src/models/Call";
|
||||
import { JitsiCall, ElementCall } from "../src/models/Call";
|
||||
import createRoom, { canEncryptToAllUsers } from '../src/createRoom';
|
||||
|
||||
describe("createRoom", () => {
|
||||
mockPlatformPeg();
|
||||
|
||||
let client: MatrixClient;
|
||||
let client: Mocked<MatrixClient>;
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
client = MatrixClientPeg.get();
|
||||
client = mocked(MatrixClientPeg.get());
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it("sets up video rooms correctly", async () => {
|
||||
it("sets up Jitsi video rooms correctly", async () => {
|
||||
setupAsyncStoreWithClient(WidgetStore.instance, client);
|
||||
jest.spyOn(WidgetUtils, "waitForRoomWidget").mockResolvedValue();
|
||||
const createCallSpy = jest.spyOn(JitsiCall, "create");
|
||||
|
||||
const userId = client.getUserId();
|
||||
const userId = client.getUserId()!;
|
||||
const roomId = await createRoom({ roomType: RoomType.ElementVideo });
|
||||
|
||||
const [[{
|
||||
|
@ -51,25 +52,63 @@ describe("createRoom", () => {
|
|||
},
|
||||
events: {
|
||||
"im.vector.modular.widgets": widgetPower,
|
||||
[JitsiCall.MEMBER_EVENT_TYPE]: jitsiMemberPower,
|
||||
[JitsiCall.MEMBER_EVENT_TYPE]: callMemberPower,
|
||||
},
|
||||
},
|
||||
}]] = mocked(client.createRoom).mock.calls as any; // no good type
|
||||
const [[widgetRoomId, widgetStateKey]] = mocked(client.sendStateEvent).mock.calls;
|
||||
}]] = client.createRoom.mock.calls as any; // no good type
|
||||
|
||||
// We should have had enough power to be able to set up the Jitsi widget
|
||||
// We should have had enough power to be able to set up the widget
|
||||
expect(userPower).toBeGreaterThanOrEqual(widgetPower);
|
||||
// and should have actually set it up
|
||||
expect(widgetRoomId).toEqual(roomId);
|
||||
expect(widgetStateKey).toEqual("im.vector.modular.widgets");
|
||||
expect(createCallSpy).toHaveBeenCalled();
|
||||
|
||||
// All members should be able to update their connected devices
|
||||
expect(jitsiMemberPower).toEqual(0);
|
||||
// Jitsi widget should be immutable for admins
|
||||
expect(callMemberPower).toEqual(0);
|
||||
// widget should be immutable for admins
|
||||
expect(widgetPower).toBeGreaterThan(100);
|
||||
// and we should have been reset back to admin
|
||||
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
|
||||
});
|
||||
|
||||
it("sets up Element video rooms correctly", async () => {
|
||||
const userId = client.getUserId()!;
|
||||
const createCallSpy = jest.spyOn(ElementCall, "create");
|
||||
const roomId = await createRoom({ roomType: RoomType.UnstableCall });
|
||||
|
||||
const [[{
|
||||
power_level_content_override: {
|
||||
users: {
|
||||
[userId]: userPower,
|
||||
},
|
||||
events: {
|
||||
[ElementCall.CALL_EVENT_TYPE.name]: callPower,
|
||||
[ElementCall.MEMBER_EVENT_TYPE.name]: callMemberPower,
|
||||
},
|
||||
},
|
||||
}]] = client.createRoom.mock.calls as any; // no good type
|
||||
|
||||
// We should have had enough power to be able to set up the call
|
||||
expect(userPower).toBeGreaterThanOrEqual(callPower);
|
||||
// and should have actually set it up
|
||||
expect(createCallSpy).toHaveBeenCalled();
|
||||
|
||||
// All members should be able to update their connected devices
|
||||
expect(callMemberPower).toEqual(0);
|
||||
// call should be immutable for admins
|
||||
expect(callPower).toBeGreaterThan(100);
|
||||
// and we should have been reset back to admin
|
||||
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
|
||||
});
|
||||
|
||||
it("doesn't create calls in non-video-rooms", async () => {
|
||||
const createJitsiCallSpy = jest.spyOn(JitsiCall, "create");
|
||||
const createElementCallSpy = jest.spyOn(ElementCall, "create");
|
||||
|
||||
await createRoom({});
|
||||
|
||||
expect(createJitsiCallSpy).not.toHaveBeenCalled();
|
||||
expect(createElementCallSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEncryptToAllUsers", () => {
|
||||
|
@ -83,20 +122,20 @@ describe("canEncryptToAllUsers", () => {
|
|||
"@badUser:localhost": {},
|
||||
};
|
||||
|
||||
let client: MatrixClient;
|
||||
let client: Mocked<MatrixClient>;
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
client = MatrixClientPeg.get();
|
||||
client = mocked(MatrixClientPeg.get());
|
||||
});
|
||||
|
||||
it("returns true if all devices have crypto", async () => {
|
||||
mocked(client.downloadKeys).mockResolvedValue(trueUser);
|
||||
client.downloadKeys.mockResolvedValue(trueUser);
|
||||
const response = await canEncryptToAllUsers(client, ["@goodUser:localhost"]);
|
||||
expect(response).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if not all users have crypto", async () => {
|
||||
mocked(client.downloadKeys).mockResolvedValue({ ...trueUser, ...falseUser });
|
||||
client.downloadKeys.mockResolvedValue({ ...trueUser, ...falseUser });
|
||||
const response = await canEncryptToAllUsers(client, ["@goodUser:localhost", "@badUser:localhost"]);
|
||||
expect(response).toBe(false);
|
||||
});
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -39,6 +39,7 @@ describe("StopGapWidget", () => {
|
|||
creatorUserId: "@alice:example.org",
|
||||
type: "example",
|
||||
url: "https://example.org",
|
||||
roomId: "!1:example.org",
|
||||
},
|
||||
room: mkRoom(client, "!1:example.org"),
|
||||
userId: "@alice:example.org",
|
||||
|
|
|
@ -15,10 +15,10 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { mocked, MockedObject } from "jest-mock";
|
||||
import { ClientEvent, ITurnServer as IClientTurnServer, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "matrix-js-sdk/src/client";
|
||||
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
|
||||
import { Direction, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { ITurnServer, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api";
|
||||
import { Widget, MatrixWidgetType, WidgetKind, WidgetDriver, ITurnServer } from "matrix-widget-api";
|
||||
|
||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||
import { RoomViewStore } from "../../../src/stores/RoomViewStore";
|
||||
|
@ -27,22 +27,75 @@ import { stubClient } from "../../test-utils";
|
|||
|
||||
describe("StopGapWidgetDriver", () => {
|
||||
let client: MockedObject<MatrixClient>;
|
||||
let driver: WidgetDriver;
|
||||
|
||||
const mkDefaultDriver = (): WidgetDriver => new StopGapWidgetDriver(
|
||||
[],
|
||||
new Widget({
|
||||
id: "test",
|
||||
creatorUserId: "@alice:example.org",
|
||||
type: "example",
|
||||
url: "https://example.org",
|
||||
}),
|
||||
WidgetKind.Room,
|
||||
false,
|
||||
"!1:example.org",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
client = mocked(MatrixClientPeg.get());
|
||||
client.getUserId.mockReturnValue("@alice:example.org");
|
||||
});
|
||||
|
||||
driver = new StopGapWidgetDriver(
|
||||
it("auto-approves capabilities of virtual Element Call widgets", async () => {
|
||||
const driver = new StopGapWidgetDriver(
|
||||
[],
|
||||
new Widget({
|
||||
id: "test",
|
||||
id: "group_call",
|
||||
creatorUserId: "@alice:example.org",
|
||||
type: "example",
|
||||
url: "https://example.org",
|
||||
type: MatrixWidgetType.Custom,
|
||||
url: "https://call.element.io",
|
||||
}),
|
||||
WidgetKind.Room,
|
||||
true,
|
||||
"!1:example.org",
|
||||
);
|
||||
|
||||
// These are intentionally raw identifiers rather than constants, so it's obvious what's being requested
|
||||
const requestedCapabilities = new Set([
|
||||
"m.always_on_screen",
|
||||
"town.robin.msc3846.turn_servers",
|
||||
"org.matrix.msc2762.timeline:!1:example.org",
|
||||
"org.matrix.msc2762.receive.state_event:m.room.member",
|
||||
"org.matrix.msc2762.send.state_event:org.matrix.msc3401.call",
|
||||
"org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call",
|
||||
"org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#@alice:example.org",
|
||||
"org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call.member",
|
||||
"org.matrix.msc3819.send.to_device:m.call.invite",
|
||||
"org.matrix.msc3819.receive.to_device:m.call.invite",
|
||||
"org.matrix.msc3819.send.to_device:m.call.candidates",
|
||||
"org.matrix.msc3819.receive.to_device:m.call.candidates",
|
||||
"org.matrix.msc3819.send.to_device:m.call.answer",
|
||||
"org.matrix.msc3819.receive.to_device:m.call.answer",
|
||||
"org.matrix.msc3819.send.to_device:m.call.hangup",
|
||||
"org.matrix.msc3819.receive.to_device:m.call.hangup",
|
||||
"org.matrix.msc3819.send.to_device:m.call.reject",
|
||||
"org.matrix.msc3819.receive.to_device:m.call.reject",
|
||||
"org.matrix.msc3819.send.to_device:m.call.select_answer",
|
||||
"org.matrix.msc3819.receive.to_device:m.call.select_answer",
|
||||
"org.matrix.msc3819.send.to_device:m.call.negotiate",
|
||||
"org.matrix.msc3819.receive.to_device:m.call.negotiate",
|
||||
"org.matrix.msc3819.send.to_device:m.call.sdp_stream_metadata_changed",
|
||||
"org.matrix.msc3819.receive.to_device:m.call.sdp_stream_metadata_changed",
|
||||
"org.matrix.msc3819.send.to_device:org.matrix.call.sdp_stream_metadata_changed",
|
||||
"org.matrix.msc3819.receive.to_device:org.matrix.call.sdp_stream_metadata_changed",
|
||||
"org.matrix.msc3819.send.to_device:m.call.replaces",
|
||||
"org.matrix.msc3819.receive.to_device:m.call.replaces",
|
||||
]);
|
||||
|
||||
// As long as this resolves, we'll know that it didn't try to pop up a modal
|
||||
const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities);
|
||||
expect(approvedCapabilities).toEqual(requestedCapabilities);
|
||||
});
|
||||
|
||||
describe("sendToDevice", () => {
|
||||
|
@ -59,6 +112,10 @@ describe("StopGapWidgetDriver", () => {
|
|||
},
|
||||
};
|
||||
|
||||
let driver: WidgetDriver;
|
||||
|
||||
beforeEach(() => { driver = mkDefaultDriver(); });
|
||||
|
||||
it("sends unencrypted messages", async () => {
|
||||
await driver.sendToDevice("org.example.foo", false, contentMap);
|
||||
expect(client.queueToDevice.mock.calls).toMatchSnapshot();
|
||||
|
@ -80,6 +137,10 @@ describe("StopGapWidgetDriver", () => {
|
|||
});
|
||||
|
||||
describe("getTurnServers", () => {
|
||||
let driver: WidgetDriver;
|
||||
|
||||
beforeEach(() => { driver = mkDefaultDriver(); });
|
||||
|
||||
it("stops if VoIP isn't supported", async () => {
|
||||
jest.spyOn(client, "pollingTurnServers", "get").mockReturnValue(false);
|
||||
const servers = driver.getTurnServers();
|
||||
|
@ -135,6 +196,10 @@ describe("StopGapWidgetDriver", () => {
|
|||
});
|
||||
|
||||
describe("readEventRelations", () => {
|
||||
let driver: WidgetDriver;
|
||||
|
||||
beforeEach(() => { driver = mkDefaultDriver(); });
|
||||
|
||||
it('reads related events from the current room', async () => {
|
||||
jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id');
|
||||
|
||||
|
|
|
@ -23,17 +23,21 @@ import { Call } from "../../src/models/Call";
|
|||
|
||||
export class MockedCall extends Call {
|
||||
private static EVENT_TYPE = "org.example.mocked_call";
|
||||
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
|
||||
|
||||
private constructor(private readonly room: Room, private readonly id: string) {
|
||||
super({
|
||||
id,
|
||||
eventId: "$1:example.org",
|
||||
roomId: room.roomId,
|
||||
type: MatrixWidgetType.Custom,
|
||||
url: "https://example.org",
|
||||
name: "Group call",
|
||||
creatorUserId: "@alice:example.org",
|
||||
});
|
||||
private constructor(room: Room, id: string) {
|
||||
super(
|
||||
{
|
||||
id,
|
||||
eventId: "$1:example.org",
|
||||
roomId: room.roomId,
|
||||
type: MatrixWidgetType.Custom,
|
||||
url: "https://example.org",
|
||||
name: "Group call",
|
||||
creatorUserId: "@alice:example.org",
|
||||
},
|
||||
room.client,
|
||||
);
|
||||
}
|
||||
|
||||
public static get(room: Room): MockedCall | null {
|
||||
|
@ -61,12 +65,10 @@ export class MockedCall extends Call {
|
|||
}
|
||||
|
||||
// No action needed for any of the following methods since this is just a mock
|
||||
public async clean(): Promise<void> {}
|
||||
protected getDevices(): string[] { return []; }
|
||||
protected async setDevices(): Promise<void> { }
|
||||
// Public to allow spying
|
||||
public async performConnection(
|
||||
audioInput: MediaDeviceInfo | null,
|
||||
videoInput: MediaDeviceInfo | null,
|
||||
): Promise<void> {}
|
||||
public async performConnection(): Promise<void> {}
|
||||
public async performDisconnection(): Promise<void> {}
|
||||
|
||||
public destroy() {
|
||||
|
@ -77,7 +79,7 @@ export class MockedCall extends Call {
|
|||
room: this.room.roomId,
|
||||
user: "@alice:example.org",
|
||||
content: { terminated: true },
|
||||
skey: this.id,
|
||||
skey: this.widget.id,
|
||||
})]);
|
||||
|
||||
super.destroy();
|
||||
|
|
|
@ -99,7 +99,7 @@ export function createTestClient(): MatrixClient {
|
|||
},
|
||||
|
||||
getPushActionsForEvent: jest.fn(),
|
||||
getRoom: jest.fn().mockImplementation(mkStubRoom),
|
||||
getRoom: jest.fn().mockImplementation(roomId => mkStubRoom(roomId, "My room", client)),
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
getVisibleRooms: jest.fn().mockReturnValue([]),
|
||||
loginFlows: jest.fn(),
|
||||
|
@ -335,8 +335,10 @@ export function mkRoomMember(roomId: string, userId: string, membership = "join"
|
|||
name: userId,
|
||||
rawDisplayName: userId,
|
||||
roomId,
|
||||
events: {},
|
||||
getAvatarUrl: () => {},
|
||||
getMxcAvatarUrl: () => {},
|
||||
getDMInviter: () => {},
|
||||
} as unknown as RoomMember;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,673 +0,0 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
import { IMyDevice, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import {
|
||||
CALL_MEMBER_STATE_EVENT_TYPE,
|
||||
CALL_STATE_EVENT_TYPE,
|
||||
fixStuckDevices,
|
||||
getGroupCall,
|
||||
removeOurDevice,
|
||||
STUCK_DEVICE_TIMEOUT_MS,
|
||||
useConnectedMembers,
|
||||
} from "../../src/utils/GroupCallUtils";
|
||||
import { createTestClient, mkEvent } from "../test-utils";
|
||||
|
||||
[
|
||||
{
|
||||
callStateEventType: CALL_STATE_EVENT_TYPE.name,
|
||||
callMemberStateEventType: CALL_MEMBER_STATE_EVENT_TYPE.name,
|
||||
},
|
||||
{
|
||||
callStateEventType: CALL_STATE_EVENT_TYPE.altName,
|
||||
callMemberStateEventType: CALL_MEMBER_STATE_EVENT_TYPE.altName,
|
||||
},
|
||||
].forEach(({ callStateEventType, callMemberStateEventType }) => {
|
||||
describe(`GroupCallUtils (${callStateEventType}, ${callMemberStateEventType})`, () => {
|
||||
const roomId = "!room:example.com";
|
||||
let client: MatrixClient;
|
||||
let callEvent: MatrixEvent;
|
||||
const callId = "test call";
|
||||
const callId2 = "test call 2";
|
||||
const userId1 = "@user1:example.com";
|
||||
const now = 1654616071686;
|
||||
|
||||
const setUpNonCallStateEvent = () => {
|
||||
callEvent = mkEvent({
|
||||
room: roomId,
|
||||
user: userId1,
|
||||
event: true,
|
||||
type: "test",
|
||||
skey: userId1,
|
||||
content: {},
|
||||
});
|
||||
};
|
||||
|
||||
const setUpEmptyStateKeyCallEvent = () => {
|
||||
callEvent = mkEvent({
|
||||
room: roomId,
|
||||
user: userId1,
|
||||
event: true,
|
||||
type: callStateEventType,
|
||||
skey: "",
|
||||
content: {},
|
||||
});
|
||||
};
|
||||
|
||||
const setUpValidCallEvent = () => {
|
||||
callEvent = mkEvent({
|
||||
room: roomId,
|
||||
user: userId1,
|
||||
event: true,
|
||||
type: callStateEventType,
|
||||
skey: callId,
|
||||
content: {},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
client = createTestClient();
|
||||
});
|
||||
|
||||
describe("getGroupCall", () => {
|
||||
describe("for a non-existing room", () => {
|
||||
beforeEach(() => {
|
||||
mocked(client.getRoom).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("should return null", () => {
|
||||
expect(getGroupCall(client, roomId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for an existing room", () => {
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
room = new Room(roomId, client, client.getUserId());
|
||||
mocked(client.getRoom).mockImplementation((rid: string) => {
|
||||
return rid === roomId
|
||||
? room
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null if no 'call' state event exist", () => {
|
||||
expect(getGroupCall(client, roomId)).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("with call state events", () => {
|
||||
let callEvent1: MatrixEvent;
|
||||
let callEvent2: MatrixEvent;
|
||||
let callEvent3: MatrixEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
callEvent1 = mkEvent({
|
||||
room: roomId,
|
||||
user: client.getUserId(),
|
||||
event: true,
|
||||
type: callStateEventType,
|
||||
content: {},
|
||||
ts: 150,
|
||||
skey: "call1",
|
||||
});
|
||||
room.getLiveTimeline().addEvent(callEvent1, {
|
||||
toStartOfTimeline: false,
|
||||
});
|
||||
|
||||
callEvent2 = mkEvent({
|
||||
room: roomId,
|
||||
user: client.getUserId(),
|
||||
event: true,
|
||||
type: callStateEventType,
|
||||
content: {},
|
||||
ts: 100,
|
||||
skey: "call2",
|
||||
});
|
||||
room.getLiveTimeline().addEvent(callEvent2, {
|
||||
toStartOfTimeline: false,
|
||||
});
|
||||
|
||||
// terminated call - should never be returned
|
||||
callEvent3 = mkEvent({
|
||||
room: roomId,
|
||||
user: client.getUserId(),
|
||||
event: true,
|
||||
type: callStateEventType,
|
||||
content: {
|
||||
["m.terminated"]: "time's up",
|
||||
},
|
||||
ts: 500,
|
||||
skey: "call3",
|
||||
});
|
||||
room.getLiveTimeline().addEvent(callEvent3, {
|
||||
toStartOfTimeline: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the newest call state event (1)", () => {
|
||||
expect(getGroupCall(client, roomId)).toBe(callEvent1);
|
||||
});
|
||||
|
||||
it("should return the newest call state event (2)", () => {
|
||||
callEvent2.getTs = () => 200;
|
||||
expect(getGroupCall(client, roomId)).toBe(callEvent2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useConnectedMembers", () => {
|
||||
describe("for a non-call event", () => {
|
||||
beforeEach(() => {
|
||||
setUpNonCallStateEvent();
|
||||
});
|
||||
|
||||
it("should return an empty list", () => {
|
||||
expect(useConnectedMembers(client, callEvent)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("for an empty state key", () => {
|
||||
beforeEach(() => {
|
||||
setUpEmptyStateKeyCallEvent();
|
||||
});
|
||||
|
||||
it("should return an empty list", () => {
|
||||
expect(useConnectedMembers(client, callEvent)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("for a valid call state event", () => {
|
||||
beforeEach(() => {
|
||||
setUpValidCallEvent();
|
||||
});
|
||||
|
||||
describe("and a non-existing room", () => {
|
||||
beforeEach(() => {
|
||||
mocked(client.getRoom).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("should return an empty list", () => {
|
||||
expect(useConnectedMembers(client, callEvent)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("and an existing room", () => {
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
room = new Room(roomId, client, client.getUserId());
|
||||
mocked(client.getRoom).mockImplementation((rid: string) => {
|
||||
return rid === roomId
|
||||
? room
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
it("should return an empty list if no call member state events exist", () => {
|
||||
expect(useConnectedMembers(client, callEvent)).toEqual([]);
|
||||
});
|
||||
|
||||
describe("and some call member state events", () => {
|
||||
const userId2 = "@user2:example.com";
|
||||
const userId3 = "@user3:example.com";
|
||||
const userId4 = "@user4:example.com";
|
||||
let expectedEvent1: MatrixEvent;
|
||||
let expectedEvent2: MatrixEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
.setSystemTime(now);
|
||||
|
||||
expectedEvent1 = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: userId1,
|
||||
skey: userId1,
|
||||
type: callMemberStateEventType,
|
||||
content: {
|
||||
["m.expires_ts"]: now + 100,
|
||||
["m.calls"]: [
|
||||
{
|
||||
["m.call_id"]: callId2,
|
||||
},
|
||||
{
|
||||
["m.call_id"]: callId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
room.getLiveTimeline().addEvent(expectedEvent1, { toStartOfTimeline: false });
|
||||
|
||||
expectedEvent2 = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: userId2,
|
||||
skey: userId2,
|
||||
type: callMemberStateEventType,
|
||||
content: {
|
||||
["m.expires_ts"]: now + 100,
|
||||
["m.calls"]: [
|
||||
{
|
||||
["m.call_id"]: callId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
room.getLiveTimeline().addEvent(expectedEvent2, { toStartOfTimeline: false });
|
||||
|
||||
// expired event
|
||||
const event3 = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: userId3,
|
||||
skey: userId3,
|
||||
type: callMemberStateEventType,
|
||||
content: {
|
||||
["m.expires_ts"]: now - 100,
|
||||
["m.calls"]: [
|
||||
{
|
||||
["m.call_id"]: callId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
room.getLiveTimeline().addEvent(event3, { toStartOfTimeline: false });
|
||||
|
||||
// other call
|
||||
const event4 = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: userId4,
|
||||
skey: userId4,
|
||||
type: callMemberStateEventType,
|
||||
content: {
|
||||
["m.expires_ts"]: now + 100,
|
||||
["m.calls"]: [
|
||||
{
|
||||
["m.call_id"]: callId2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
room.getLiveTimeline().addEvent(event4, { toStartOfTimeline: false });
|
||||
|
||||
// empty calls
|
||||
const event5 = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: userId4,
|
||||
skey: userId4,
|
||||
type: callMemberStateEventType,
|
||||
content: {
|
||||
["m.expires_ts"]: now + 100,
|
||||
["m.calls"]: [],
|
||||
},
|
||||
});
|
||||
room.getLiveTimeline().addEvent(event5, { toStartOfTimeline: false });
|
||||
|
||||
// no calls prop
|
||||
const event6 = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: userId4,
|
||||
skey: userId4,
|
||||
type: callMemberStateEventType,
|
||||
content: {
|
||||
["m.expires_ts"]: now + 100,
|
||||
},
|
||||
});
|
||||
room.getLiveTimeline().addEvent(event6, { toStartOfTimeline: false });
|
||||
});
|
||||
|
||||
it("should return the expected call member events", () => {
|
||||
const callMemberEvents = useConnectedMembers(client, callEvent);
|
||||
expect(callMemberEvents).toHaveLength(2);
|
||||
expect(callMemberEvents).toContain(expectedEvent1);
|
||||
expect(callMemberEvents).toContain(expectedEvent2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeOurDevice", () => {
|
||||
describe("for a non-call event", () => {
|
||||
beforeEach(() => {
|
||||
setUpNonCallStateEvent();
|
||||
});
|
||||
|
||||
it("should not update the state", () => {
|
||||
removeOurDevice(client, callEvent);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for an empty state key", () => {
|
||||
beforeEach(() => {
|
||||
setUpEmptyStateKeyCallEvent();
|
||||
});
|
||||
|
||||
it("should not update the state", () => {
|
||||
removeOurDevice(client, callEvent);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for a valid call state event", () => {
|
||||
beforeEach(() => {
|
||||
setUpValidCallEvent();
|
||||
});
|
||||
|
||||
describe("and a non-existing room", () => {
|
||||
beforeEach(() => {
|
||||
mocked(client.getRoom).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("should not update the state", () => {
|
||||
removeOurDevice(client, callEvent);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("and an existing room", () => {
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
room = new Room(roomId, client, client.getUserId());
|
||||
room.getLiveTimeline().addEvent(callEvent, { toStartOfTimeline: false });
|
||||
mocked(client.getRoom).mockImplementation((rid: string) => {
|
||||
return rid === roomId
|
||||
? room
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update the state if no call member event exists", () => {
|
||||
removeOurDevice(client, callEvent);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("and a call member state event", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
.setSystemTime(now);
|
||||
|
||||
const callMemberEvent = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: client.getUserId(),
|
||||
skey: client.getUserId(),
|
||||
type: callMemberStateEventType,
|
||||
content: {
|
||||
["m.expires_ts"]: now - 100,
|
||||
["m.calls"]: [
|
||||
{
|
||||
["m.call_id"]: callId,
|
||||
["m.devices"]: [
|
||||
// device to be removed
|
||||
{ "m.device_id": client.getDeviceId() },
|
||||
{ "m.device_id": "device 2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// no device list
|
||||
["m.call_id"]: callId,
|
||||
},
|
||||
{
|
||||
// other call
|
||||
["m.call_id"]: callId2,
|
||||
["m.devices"]: [
|
||||
{ "m.device_id": client.getDeviceId() },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
room.getLiveTimeline().addEvent(callMemberEvent, { toStartOfTimeline: false });
|
||||
});
|
||||
|
||||
it("should remove the device from the call", async () => {
|
||||
await removeOurDevice(client, callEvent);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
roomId,
|
||||
CALL_MEMBER_STATE_EVENT_TYPE.name,
|
||||
{
|
||||
["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS,
|
||||
["m.calls"]: [
|
||||
{
|
||||
["m.call_id"]: callId,
|
||||
["m.devices"]: [
|
||||
{ "m.device_id": "device 2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// no device list
|
||||
["m.call_id"]: callId,
|
||||
},
|
||||
{
|
||||
// other call
|
||||
["m.call_id"]: callId2,
|
||||
["m.devices"]: [
|
||||
{ "m.device_id": client.getDeviceId() },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
client.getUserId(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fixStuckDevices", () => {
|
||||
let thisDevice: IMyDevice;
|
||||
let otherDevice: IMyDevice;
|
||||
let noLastSeenTsDevice: IMyDevice;
|
||||
let stuckDevice: IMyDevice;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
.setSystemTime(now);
|
||||
|
||||
thisDevice = { device_id: "ABCDEFGHI", last_seen_ts: now - STUCK_DEVICE_TIMEOUT_MS - 100 };
|
||||
otherDevice = { device_id: "ABCDEFGHJ", last_seen_ts: now };
|
||||
noLastSeenTsDevice = { device_id: "ABCDEFGHK" };
|
||||
stuckDevice = { device_id: "ABCDEFGHL", last_seen_ts: now - STUCK_DEVICE_TIMEOUT_MS - 100 };
|
||||
|
||||
mocked(client.getDeviceId).mockReturnValue(thisDevice.device_id);
|
||||
mocked(client.getDevices).mockResolvedValue({
|
||||
devices: [
|
||||
thisDevice,
|
||||
otherDevice,
|
||||
noLastSeenTsDevice,
|
||||
stuckDevice,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("for a non-call event", () => {
|
||||
beforeEach(() => {
|
||||
setUpNonCallStateEvent();
|
||||
});
|
||||
|
||||
it("should not update the state", () => {
|
||||
fixStuckDevices(client, callEvent, true);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for an empty state key", () => {
|
||||
beforeEach(() => {
|
||||
setUpEmptyStateKeyCallEvent();
|
||||
});
|
||||
|
||||
it("should not update the state", () => {
|
||||
fixStuckDevices(client, callEvent, true);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("for a valid call state event", () => {
|
||||
beforeEach(() => {
|
||||
setUpValidCallEvent();
|
||||
});
|
||||
|
||||
describe("and a non-existing room", () => {
|
||||
beforeEach(() => {
|
||||
mocked(client.getRoom).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("should not update the state", () => {
|
||||
fixStuckDevices(client, callEvent, true);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("and an existing room", () => {
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
room = new Room(roomId, client, client.getUserId());
|
||||
room.getLiveTimeline().addEvent(callEvent, { toStartOfTimeline: false });
|
||||
mocked(client.getRoom).mockImplementation((rid: string) => {
|
||||
return rid === roomId
|
||||
? room
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update the state if no call member event exists", () => {
|
||||
fixStuckDevices(client, callEvent, true);
|
||||
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("and a call member state event", () => {
|
||||
beforeEach(() => {
|
||||
const callMemberEvent = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: client.getUserId(),
|
||||
skey: client.getUserId(),
|
||||
type: callMemberStateEventType,
|
||||
content: {
|
||||
["m.expires_ts"]: now - 100,
|
||||
["m.calls"]: [
|
||||
{
|
||||
["m.call_id"]: callId,
|
||||
["m.devices"]: [
|
||||
{ "m.device_id": thisDevice.device_id },
|
||||
{ "m.device_id": otherDevice.device_id },
|
||||
{ "m.device_id": noLastSeenTsDevice.device_id },
|
||||
{ "m.device_id": stuckDevice.device_id },
|
||||
],
|
||||
},
|
||||
{
|
||||
// no device list
|
||||
["m.call_id"]: callId,
|
||||
},
|
||||
{
|
||||
// other call
|
||||
["m.call_id"]: callId2,
|
||||
["m.devices"]: [
|
||||
{ "m.device_id": stuckDevice.device_id },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
room.getLiveTimeline().addEvent(callMemberEvent, { toStartOfTimeline: false });
|
||||
});
|
||||
|
||||
it("should remove stuck devices from the call, except this device", async () => {
|
||||
await fixStuckDevices(client, callEvent, false);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
roomId,
|
||||
CALL_MEMBER_STATE_EVENT_TYPE.name,
|
||||
{
|
||||
["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS,
|
||||
["m.calls"]: [
|
||||
{
|
||||
["m.call_id"]: callId,
|
||||
["m.devices"]: [
|
||||
{ "m.device_id": thisDevice.device_id },
|
||||
{ "m.device_id": otherDevice.device_id },
|
||||
{ "m.device_id": noLastSeenTsDevice.device_id },
|
||||
],
|
||||
},
|
||||
{
|
||||
// no device list
|
||||
["m.call_id"]: callId,
|
||||
},
|
||||
{
|
||||
// other call
|
||||
["m.call_id"]: callId2,
|
||||
["m.devices"]: [
|
||||
{ "m.device_id": stuckDevice.device_id },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
client.getUserId(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove stuck devices from the call, including this device", async () => {
|
||||
await fixStuckDevices(client, callEvent, true);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||
roomId,
|
||||
CALL_MEMBER_STATE_EVENT_TYPE.name,
|
||||
{
|
||||
["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS,
|
||||
["m.calls"]: [
|
||||
{
|
||||
["m.call_id"]: callId,
|
||||
["m.devices"]: [
|
||||
{ "m.device_id": otherDevice.device_id },
|
||||
{ "m.device_id": noLastSeenTsDevice.device_id },
|
||||
],
|
||||
},
|
||||
{
|
||||
// no device list
|
||||
["m.call_id"]: callId,
|
||||
},
|
||||
{
|
||||
// other call
|
||||
["m.call_id"]: callId2,
|
||||
["m.devices"]: [
|
||||
{ "m.device_id": stuckDevice.device_id },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
client.getUserId(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue