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

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