1
0
mirror of https://github.com/matrix-org/matrix-react-sdk.git synced 2025-08-07 21:23:00 +03:00

Use native js-sdk group call support (#9625)

* Use native js-sdk group call support

Now that the js-sdk supports group calls natively, our group call implementation can be simplified a bit. Switching to the js-sdk implementation also brings the react-sdk up to date with recent MSC3401 changes, and adds support for joining calls from multiple devices. (So, the previous logic which sent to-device messages to prevent multi-device sessions is no longer necessary.)

* Fix strings

* Fix strict type errors
This commit is contained in:
Robin
2022-11-28 16:37:32 -05:00
committed by GitHub
parent 3c7781a561
commit 2c612d5aa1
20 changed files with 383 additions and 567 deletions

View File

@@ -121,7 +121,7 @@ describe("CallEvent", () => {
it("shows call details and connection controls if the call is loaded", async () => {
jest.advanceTimersByTime(90000);
call.participants = new Set([alice, bob]);
call.participants = new Map([[alice, new Set(["a"])], [bob, new Set(["b"])]]);
renderEvent();
screen.getByText("@alice:example.org started a video call");

View File

@@ -22,6 +22,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api";
import {
stubClient,
@@ -74,7 +75,9 @@ describe("RoomTile", () => {
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(room, "1");
call = CallStore.instance.getCall(room.roomId) as MockedCall;
const maybeCall = CallStore.instance.getCall(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
@@ -123,19 +126,25 @@ describe("RoomTile", () => {
});
it("tracks participants", () => {
const alice = mkRoomMember(room.roomId, "@alice:example.org");
const bob = mkRoomMember(room.roomId, "@bob:example.org");
const carol = mkRoomMember(room.roomId, "@carol:example.org");
const alice: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@alice:example.org"), new Set(["a"]),
];
const bob: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@bob:example.org"), new Set(["b1", "b2"]),
];
const carol: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@carol:example.org"), new Set(["c"]),
];
expect(screen.queryByLabelText(/participant/)).toBe(null);
act(() => { call.participants = new Set([alice]); });
act(() => { call.participants = new Map([alice]); });
expect(screen.getByLabelText("1 participant").textContent).toBe("1");
act(() => { call.participants = new Set([alice, bob, carol]); });
expect(screen.getByLabelText("3 participants").textContent).toBe("3");
act(() => { call.participants = new Map([alice, bob, carol]); });
expect(screen.getByLabelText("4 participants").textContent).toBe("4");
act(() => { call.participants = new Set(); });
act(() => { call.participants = new Map(); });
expect(screen.queryByLabelText(/participant/)).toBe(null);
});
});

View File

@@ -131,7 +131,7 @@ describe("CallLobby", () => {
for (const [userId, avatar] of zip(userIds, avatars)) {
fireEvent.focus(avatar!);
screen.getByRole("tooltip", { name: userId });
screen.getAllByRole("tooltip", { name: userId });
}
};
@@ -139,15 +139,21 @@ describe("CallLobby", () => {
expect(screen.queryByLabelText(/joined/)).toBe(null);
expectAvatars([]);
act(() => { call.participants = new Set([alice]); });
act(() => { call.participants = new Map([[alice, new Set(["a"])]]); });
screen.getByText("1 person joined");
expectAvatars([alice.userId]);
act(() => { call.participants = new Set([alice, bob, carol]); });
screen.getByText("3 people joined");
expectAvatars([alice.userId, bob.userId, carol.userId]);
act(() => {
call.participants = new Map([
[alice, new Set(["a"])],
[bob, new Set(["b1", "b2"])],
[carol, new Set(["c"])],
]);
});
screen.getByText("4 people joined");
expectAvatars([alice.userId, bob.userId, bob.userId, carol.userId]);
act(() => { call.participants = new Set(); });
act(() => { call.participants = new Map(); });
expect(screen.queryByLabelText(/joined/)).toBe(null);
expectAvatars([]);
});
@@ -166,7 +172,7 @@ describe("CallLobby", () => {
SdkConfig.put({
"element_call": { participant_limit: 2, url: "", use_exclusively: false, brand: "Element Call" },
});
call.participants = new Set([bob, carol]);
call.participants = new Map([[bob, new Set("b")], [carol, new Set("c")]]);
await renderView();
const connectSpy = jest.spyOn(call, "connect");

View File

@@ -68,7 +68,7 @@ describe("createRoom", () => {
// 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);
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, null);
});
it("sets up Element video rooms correctly", async () => {
@@ -98,7 +98,7 @@ describe("createRoom", () => {
// 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);
expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, null);
});
it("doesn't create calls in non-video-rooms", async () => {

View File

@@ -18,17 +18,17 @@ import EventEmitter from "events";
import { mocked } from "jest-mock";
import { waitFor } from "@testing-library/react";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import { ClientEvent, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { GroupCallIntent } from "matrix-js-sdk/src/webrtc/groupCall";
import type { Mocked } from "jest-mock";
import type { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api";
import { JitsiCallMemberContent, ElementCallMemberContent, Layout } from "../../src/models/Call";
import { JitsiCallMemberContent, Layout } from "../../src/models/Call";
import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../test-utils";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../src/MediaDeviceHandler";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
@@ -341,7 +341,7 @@ describe("JitsiCall", () => {
});
it("tracks participants in room state", async () => {
expect([...call.participants]).toEqual([]);
expect(call.participants).toEqual(new Map());
// A participant with multiple devices (should only show up once)
await client.sendStateEvent(
@@ -361,10 +361,13 @@ describe("JitsiCall", () => {
// Now, stub out client.sendStateEvent so we can test our local echo
client.sendStateEvent.mockReset();
await call.connect();
expect([...call.participants]).toEqual([bob, alice]);
expect(call.participants).toEqual(new Map([
[alice, new Set(["alices_device"])],
[bob, new Set(["bobweb", "bobdesktop"])],
]));
await call.disconnect();
expect([...call.participants]).toEqual([bob]);
expect(call.participants).toEqual(new Map([[bob, new Set(["bobweb", "bobdesktop"])]]));
});
it("updates room state when connecting and disconnecting", async () => {
@@ -429,10 +432,10 @@ describe("JitsiCall", () => {
await call.connect();
await call.disconnect();
expect(onParticipants.mock.calls).toEqual([
[new Set([alice]), new Set()],
[new Set([alice]), new Set([alice])],
[new Set(), new Set([alice])],
[new Set(), new Set()],
[new Map([[alice, new Set(["alices_device"])]]), new Map()],
[new Map([[alice, new Set(["alices_device"])]]), new Map([[alice, new Set(["alices_device"])]])],
[new Map(), new Map([[alice, new Set(["alices_device"])]])],
[new Map(), new Map()],
]);
call.off(CallEvent.Participants, onParticipants);
@@ -568,11 +571,11 @@ describe("ElementCall", () => {
it("ignores terminated calls", async () => {
await ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
// Terminate the call
const [event] = room.currentState.getStateEvents(ElementCall.CALL_EVENT_TYPE.name);
const content = { ...event.getContent(), "m.terminated": "Call ended" };
await client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, content, event.getStateKey()!);
await call.groupCall.terminate();
expect(Call.get(room)).toBeNull();
});
@@ -599,8 +602,8 @@ describe("ElementCall", () => {
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
it("has intent m.prompt", () => {
expect(call.groupCall.getContent()["m.intent"]).toBe("m.prompt");
it("has prompt intent", () => {
expect(call.groupCall.intent).toBe(GroupCallIntent.Prompt);
});
it("connects muted", async () => {
@@ -690,19 +693,18 @@ describe("ElementCall", () => {
});
it("tracks participants in room state", async () => {
expect([...call.participants]).toEqual([]);
expect(call.participants).toEqual(new Map());
// A participant with multiple devices (should only show up once)
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
{
"m.expires_ts": 1000 * 60 * 10,
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.call_id": call.groupCall.groupCallId,
"m.devices": [
{ device_id: "bobweb", session_id: "1", feeds: [] },
{ device_id: "bobdesktop", session_id: "1", feeds: [] },
{ device_id: "bobweb", session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10 },
{ device_id: "bobdesktop", session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10 },
],
}],
},
@@ -713,11 +715,10 @@ describe("ElementCall", () => {
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
{
"m.expires_ts": -1000 * 60,
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.call_id": call.groupCall.groupCallId,
"m.devices": [
{ device_id: "carolandroid", session_id: "1", feeds: [] },
{ device_id: "carolandroid", session_id: "1", feeds: [], expires_ts: -1000 * 60 },
],
}],
},
@@ -727,10 +728,13 @@ describe("ElementCall", () => {
// Now, stub out client.sendStateEvent so we can test our local echo
client.sendStateEvent.mockReset();
await call.connect();
expect([...call.participants]).toEqual([bob, alice]);
expect(call.participants).toEqual(new Map([
[alice, new Set(["alices_device"])],
[bob, new Set(["bobweb", "bobdesktop"])],
]));
await call.disconnect();
expect([...call.participants]).toEqual([bob]);
expect(call.participants).toEqual(new Map([[bob, new Set(["bobweb", "bobdesktop"])]]));
});
it("tracks layout", async () => {
@@ -783,9 +787,8 @@ describe("ElementCall", () => {
await call.connect();
await call.disconnect();
expect(onParticipants.mock.calls).toEqual([
[new Set([alice]), new Set()],
[new Set(), new Set()],
[new Set(), new Set([alice])],
[new Map([[alice, new Set(["alices_device"])]]), new Map()],
[new Map(), new Map([[alice, new Set(["alices_device"])]])],
]);
call.off(CallEvent.Participants, onParticipants);
@@ -893,87 +896,17 @@ describe("ElementCall", () => {
call.off(CallEvent.Destroy, onDestroy);
});
describe("being kicked out by another device", () => {
const onDestroy = jest.fn();
beforeEach(async () => {
await call.connect();
call.on(CallEvent.Destroy, onDestroy);
jest.advanceTimersByTime(100);
jest.clearAllMocks();
});
afterEach(() => {
call.off(CallEvent.Destroy, onDestroy);
});
it("does not terminate the call if we are the last", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
getSender: () => (client.getUserId()),
} as MatrixEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeTruthy();
});
it("ignores messages from our device", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getSender: () => (client.getUserId()),
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: client.getDeviceId(), timestamp: Date.now() }),
} as MatrixEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeFalsy();
expect(onDestroy).not.toHaveBeenCalled();
});
it("ignores messages from other users", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getSender: () => (bob.userId),
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
} as MatrixEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeFalsy();
expect(onDestroy).not.toHaveBeenCalled();
});
it("ignores messages from the past", async () => {
client.emit(ClientEvent.ToDeviceEvent, {
getSender: () => (client.getUserId()),
getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
getContent: () => ({ device_id: "random_device_id", timestamp: 0 }),
} as MatrixEvent);
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(
[ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
).toBeFalsy();
expect(onDestroy).not.toHaveBeenCalled();
});
});
it("ends the call after a random delay if the last participant leaves without ending it", async () => {
// Bob connects
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
{
"m.expires_ts": 1000 * 60 * 10,
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.devices": [{ device_id: "bobweb", session_id: "1", feeds: [] }],
"m.call_id": call.groupCall.groupCallId,
"m.devices": [
{ device_id: "bobweb", session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10 },
],
}],
},
bob.userId,
@@ -987,9 +920,8 @@ describe("ElementCall", () => {
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
{
"m.expires_ts": 1000 * 60 * 10,
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.call_id": call.groupCall.groupCallId,
"m.devices": [],
}],
},
@@ -1025,20 +957,22 @@ describe("ElementCall", () => {
device_id: "alicedesktopneveronline",
};
const mkContent = (devices: IMyDevice[]): ElementCallMemberContent => ({
"m.expires_ts": 1000 * 60 * 10,
const mkContent = (devices: IMyDevice[]) => ({
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.devices": devices.map(d => ({ device_id: d.device_id, session_id: "1", feeds: [] })),
"m.call_id": call.groupCall.groupCallId,
"m.devices": devices.map(d => ({
device_id: d.device_id, session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10,
})),
}],
});
const expectDevices = (devices: IMyDevice[]) => expect(
room.currentState.getStateEvents(ElementCall.MEMBER_EVENT_TYPE.name, alice.userId).getContent(),
room.currentState.getStateEvents(ElementCall.MEMBER_EVENT_TYPE.name, alice.userId)?.getContent(),
).toEqual({
"m.expires_ts": expect.any(Number),
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.devices": devices.map(d => ({ device_id: d.device_id, session_id: "1", feeds: [] })),
"m.call_id": call.groupCall.groupCallId,
"m.devices": devices.map(d => ({
device_id: d.device_id, session_id: "1", feeds: [], expires_ts: expect.any(Number),
})),
}],
});
@@ -1055,23 +989,10 @@ describe("ElementCall", () => {
});
it("doesn't clean up valid devices", async () => {
await call.connect();
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
mkContent([aliceWeb, aliceDesktop]),
alice.userId,
);
await call.clean();
expectDevices([aliceWeb, aliceDesktop]);
});
it("cleans up our own device if we're disconnected", async () => {
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
mkContent([aliceWeb, aliceDesktop]),
mkContent([aliceDesktop]),
alice.userId,
);
@@ -1079,11 +1000,11 @@ describe("ElementCall", () => {
expectDevices([aliceDesktop]);
});
it("cleans up devices that have been offline for too long", async () => {
it("cleans up our own device if we're disconnected", async () => {
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
mkContent([aliceDesktop, aliceDesktopOffline]),
mkContent([aliceWeb, aliceDesktop]),
alice.userId,
);
@@ -1132,8 +1053,8 @@ describe("ElementCall", () => {
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
it("has intent m.room", () => {
expect(call.groupCall.getContent()["m.intent"]).toBe("m.room");
it("has room intent", () => {
expect(call.groupCall.intent).toBe(GroupCallIntent.Room);
});
it("doesn't end the call when the last participant leaves", async () => {

View File

@@ -16,11 +16,13 @@ limitations under the License.
import { MatrixWidgetType } from "matrix-widget-api";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { mkEvent } from "./test-utils";
import { Call, ConnectionState, ElementCall, JitsiCall } from "../../src/models/Call";
import { CallStore } from "../../src/stores/CallStore";
export class MockedCall extends Call {
public static readonly EVENT_TYPE = "org.example.mocked_call";
@@ -49,7 +51,6 @@ export class MockedCall extends Call {
}
public static create(room: Room, id: string) {
// Update room state to let CallStore know that a call might now exist
room.addLiveEvents([mkEvent({
event: true,
type: this.EVENT_TYPE,
@@ -59,16 +60,17 @@ export class MockedCall extends Call {
skey: id,
ts: Date.now(),
})]);
// @ts-ignore deliberately calling a private method
// Let CallStore know that a call might now exist
CallStore.instance.updateRoom(room);
}
public get groupCall(): MatrixEvent {
return this.event;
}
public readonly groupCall = { creationTs: this.event.getTs() } as unknown as GroupCall;
public get participants(): Set<RoomMember> {
public get participants(): Map<RoomMember, Set<string>> {
return super.participants;
}
public set participants(value: Set<RoomMember>) {
public set participants(value: Map<RoomMember, Set<string>>) {
super.participants = value;
}
@@ -77,8 +79,7 @@ export class MockedCall extends Call {
}
// No action needed for any of the following methods since this is just a mock
protected getDevices(): string[] { return []; }
protected async setDevices(): Promise<void> { }
public async clean(): Promise<void> {}
// Public to allow spying
public async performConnection(): Promise<void> {}
public async performDisconnection(): Promise<void> {}

View File

@@ -39,6 +39,7 @@ import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg';
import { makeType } from "../../src/utils/TypeUtils";
import { ValidatedServerConfig } from "../../src/utils/ValidatedServerConfig";
@@ -190,6 +191,7 @@ export function createTestClient(): MatrixClient {
setVideoInput: jest.fn(),
setAudioInput: jest.fn(),
setAudioSettings: jest.fn(),
stopAllStreams: jest.fn(),
} as unknown as MediaHandler),
uploadContent: jest.fn(),
getEventMapper: () => (opts) => new MatrixEvent(opts),
@@ -197,6 +199,7 @@ export function createTestClient(): MatrixClient {
doesServerSupportLogoutDevices: jest.fn().mockReturnValue(true),
requestPasswordEmailToken: jest.fn().mockRejectedValue({}),
setPassword: jest.fn().mockRejectedValue({}),
groupCallEventHandler: { groupCalls: new Map<string, GroupCall>() },
} as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client);
@@ -453,7 +456,7 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl
getMyMembership: jest.fn().mockReturnValue("join"),
maySendMessage: jest.fn().mockReturnValue(true),
currentState: {
getStateEvents: jest.fn(),
getStateEvents: jest.fn((_type, key) => key === undefined ? [] : null),
getMember: jest.fn(),
mayClientSendStateEvent: jest.fn().mockReturnValue(true),
maySendStateEvent: jest.fn().mockReturnValue(true),

View File

@@ -99,12 +99,15 @@ describe("IncomingCallEvent", () => {
const renderToast = () => { render(<IncomingCallToast callEvent={call.event} />); };
it("correctly shows all the information", () => {
call.participants = new Set([alice, bob]);
call.participants = new Map([
[alice, new Set("a")],
[bob, new Set(["b1", "b2"])],
]);
renderToast();
screen.getByText("Video call started");
screen.getByText("Video");
screen.getByLabelText("2 participants");
screen.getByLabelText("3 participants");
screen.getByRole("button", { name: "Join" });
screen.getByRole("button", { name: "Close" });