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:
Robin 2022-09-16 11:12:27 -04:00 committed by GitHub
parent db5716b776
commit cb735c9439
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1699 additions and 1384 deletions

View 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);
});
});

View file

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

View file

@ -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",

View file

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

View file

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

View file

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

View file

@ -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(),
);
});
});
});
});
});
});
});