Refactor element call lobby + skip lobby (#12057)

* Refactor ElementCall to use the widget lobby.
 - expose skip lobby
 - use the widget.data to build the widget url

Signed-off-by: Timo K <toger5@hotmail.de>

* Use shiftKey click to skip the lobby

Signed-off-by: Timo K <toger5@hotmail.de>

* remove Lobby component

Signed-off-by: Timo K <toger5@hotmail.de>

* update tests + remove EW lobby related tests

Signed-off-by: Timo K <toger5@hotmail.de>

* remove lobby device button tests

Signed-off-by: Timo K <toger5@hotmail.de>

* i18n

Signed-off-by: Timo K <toger5@hotmail.de>

* use voip participant label

Signed-off-by: Timo K <toger5@hotmail.de>

* update tests
Signed-off-by: Timo K <toger5@hotmail.de>

* fix rounded corners in pip

Signed-off-by: Timo K <toger5@hotmail.de>

* allow joining call in legacy room header (without banner)

Signed-off-by: Timo K <toger5@hotmail.de>

* Introduce new connection states for calls.
And use them for integrated lobby.

Signed-off-by: Timo K <toger5@hotmail.de>

* New room header call join
Fix broken top container element call.

Signed-off-by: Timo K <toger5@hotmail.de>

* i18n

Signed-off-by: Timo K <toger5@hotmail.de>

* Fix closing element call in lobby view.
(should destroy call if there the user never managed to connect (not clicked join in lobby)

Signed-off-by: Timo K <toger5@hotmail.de>

* all cases for connection state

Signed-off-by: Timo K <toger5@hotmail.de>

* add correct LiveContentSummary labels

Signed-off-by: Timo K <toger5@hotmail.de>

* Theme widget loading (no rounded corner)
destroy call when switching room while a call is loading.

Signed-off-by: Timo K <toger5@hotmail.de>

* temp

Signed-off-by: Timo K <toger5@hotmail.de>

* usei view room dispatcher instead of emitter

Signed-off-by: Timo K <toger5@hotmail.de>

* tidy up

Signed-off-by: Timo K <toger5@hotmail.de>

* returnToLobby + remove StartCallView

Signed-off-by: Timo K <toger5@hotmail.de>

* comment cleanup

Signed-off-by: Timo K <toger5@hotmail.de>

* disconnect ongoing calls before making widget sticky.

Signed-off-by: Timo K <toger5@hotmail.de>

* linter + jitsi as videoChannel

Signed-off-by: Timo K <toger5@hotmail.de>

* stickyPromise type

Signed-off-by: Timo K <toger5@hotmail.de>

* fix legacy call (jistsi, cisco, bbb) reopen
when clicking call button

Signed-off-by: Timo K <toger5@hotmail.de>

* fix tests and connect resolves

Signed-off-by: Timo K <toger5@hotmail.de>

* fix "waits for messaging when connecting" test

Signed-off-by: Timo K <toger5@hotmail.de>

* Allow to skip awaiting Call session events.
This option is used in tests to spare mocking the
events emitted when EC updates the room state

Signed-off-by: Timo K <toger5@hotmail.de>

* add sticky test

Signed-off-by: Timo K <toger5@hotmail.de>

* add test for looby tile rendering

Signed-off-by: Timo K <toger5@hotmail.de>

* fix flaky test

Signed-off-by: Timo K <toger5@hotmail.de>

* add reconnect after disconnect test (video room)

Signed-off-by: Timo K <toger5@hotmail.de>

* add shift click test to call toast

Signed-off-by: Timo K <toger5@hotmail.de>

* test for allowVoipWithNoMedia in widget url

Signed-off-by: Timo K <toger5@hotmail.de>

* fix e2e tests to search for the right element

Signed-off-by: Timo K <toger5@hotmail.de>

* destroy call after test so next test does not fail

Signed-off-by: Timo K <toger5@hotmail.de>

* new call test (connection failed)

Signed-off-by: Timo K <toger5@hotmail.de>

* reset to real timers

Signed-off-by: Timo K <toger5@hotmail.de>

* dont use skipSessionAwait for tests

Signed-off-by: Timo K <toger5@hotmail.de>

* code quality (sonar)

Signed-off-by: Timo K <toger5@hotmail.de>

* refactor call.disconnect tests (dont use skipSessionAwait)

Signed-off-by: Timo K <toger5@hotmail.de>

* miscellaneous cleanup

Signed-off-by: Timo K <toger5@hotmail.de>

* only send call notify after the call has been joined (not when just opening the lobby)

Signed-off-by: Timo K <toger5@hotmail.de>

* update call notify tests to expect notify on connect.
Not on widget creation.

Signed-off-by: Timo K <toger5@hotmail.de>

* Update playwright/e2e/room/room-header.spec.ts

Co-authored-by: Robin <robin@robin.town>

* Update src/components/views/voip/CallView.tsx

Co-authored-by: Robin <robin@robin.town>

* review
rename connect -> start
isVideoRoom not dependant on feature flags
rename allOtherCallsDisconnected -> disconnectAllOtherCalls

Signed-off-by: Timo K <toger5@hotmail.de>

* check for EC widget

Signed-off-by: Timo K <toger5@hotmail.de>

* dep array

Signed-off-by: Timo K <toger5@hotmail.de>

* rename in spyOn

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Robin <robin@robin.town>
This commit is contained in:
Timo 2024-01-29 17:06:12 +01:00 committed by GitHub
parent 3f7e21e08d
commit a370a5cfa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 693 additions and 767 deletions

View file

@ -141,7 +141,7 @@ describe("CallEvent", () => {
renderEvent();
screen.getByText("@alice:example.org started a video call");
screen.getByLabelText("2 participants");
screen.getByLabelText("2 people joined");
// Test that the join button works
const dispatcherSpy = jest.fn();
@ -155,7 +155,7 @@ describe("CallEvent", () => {
}),
);
defaultDispatcher.unregister(dispatcherRef);
await act(() => call.connect());
await act(() => call.start());
// Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" }));

View file

@ -31,6 +31,10 @@ import EventEmitter from "events";
import { setupJestCanvasMock } from "jest-canvas-mock";
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { TooltipProvider } from "@vector-im/compound-web";
// eslint-disable-next-line no-restricted-imports
import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager";
// eslint-disable-next-line no-restricted-imports
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix";
import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
@ -59,11 +63,11 @@ import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import WidgetStore from "../../../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import WidgetUtils from "../../../../src/utils/WidgetUtils";
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler";
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../src/settings/UIFeature";
import WidgetUtils from "../../../../src/utils/WidgetUtils";
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
@ -274,6 +278,7 @@ describe("LegacyRoomHeader", () => {
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
view_call: true,
}),
);
@ -407,6 +412,7 @@ describe("LegacyRoomHeader", () => {
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
view_call: true,
}),
);
@ -432,6 +438,7 @@ describe("LegacyRoomHeader", () => {
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
skipLobby: false,
view_call: true,
}),
);
@ -562,7 +569,20 @@ describe("LegacyRoomHeader", () => {
mockEnabledSettings(["feature_group_calls"]);
await withCall(async (call) => {
await call.connect();
// We set the call to skip lobby because otherwise the connection will wait until
// the user clicks the "join" button, inside the widget lobby which is hard to mock.
call.widget.data = { ...call.widget.data, skipLobby: true };
// The connect method will wait until the session actually connected. Otherwise it will timeout.
// Emitting SessionStarted will trigger the connect method to resolve.
setTimeout(
() =>
client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, {
room,
} as MatrixRTCSession),
100,
);
await call.start();
const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!;
renderHeader({ viewingCall: true, activeCall: call });

View file

@ -331,12 +331,12 @@ describe("RoomHeader", () => {
it("clicking on ongoing (unpinned) call re-pins it", () => {
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
// allow element calls
// allow calls
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
const spy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
const widget = {};
const widget = { eventId: "some_id_so_it_is_interpreted_as_non_virtual_widget" };
jest.spyOn(CallStore.instance, "getCall").mockReturnValue({ widget } as Call);
const { container } = render(<RoomHeader room={room} />, getWrapper());
@ -405,7 +405,7 @@ describe("RoomHeader", () => {
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
});
it("calls using element calls for large rooms", async () => {
it("calls using element call for large rooms", async () => {
mockRoomMembers(room, 3);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {

View file

@ -56,6 +56,7 @@ import { UIComponent } from "../../../../src/settings/UIFeature";
import { MessagePreviewStore } from "../../../../src/stores/room-list/MessagePreviewStore";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { ConnectionState } from "../../../../src/models/Call";
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
@ -235,20 +236,37 @@ describe("RoomTile", () => {
renderRoomTile();
screen.getByText("Video");
let completeWidgetLoading: () => void = () => {};
const widgetLoadingCompleted = new Promise<void>((resolve) => (completeWidgetLoading = resolve));
// Insert an await point in the connection method so we can inspect
// the intermediate connecting state
let completeConnection: () => void = () => {};
const connectionCompleted = new Promise<void>((resolve) => (completeConnection = resolve));
jest.spyOn(call, "performConnection").mockReturnValue(connectionCompleted);
let completeLobby: () => void = () => {};
const lobbyCompleted = new Promise<void>((resolve) => (completeLobby = resolve));
jest.spyOn(call, "performConnection").mockImplementation(async () => {
call.setConnectionState(ConnectionState.WidgetLoading);
await widgetLoadingCompleted;
call.setConnectionState(ConnectionState.Lobby);
await lobbyCompleted;
call.setConnectionState(ConnectionState.Connecting);
await connectionCompleted;
});
await Promise.all([
(async () => {
await screen.findByText("Loading…");
completeWidgetLoading();
await screen.findByText("Lobby");
completeLobby();
await screen.findByText("Joining…");
const joinedFound = screen.findByText("Joined");
completeConnection();
await joinedFound;
await screen.findByText("Joined");
})(),
call.connect(),
call.start(),
]);
await Promise.all([screen.findByText("Video"), call.disconnect()]);
@ -274,12 +292,12 @@ describe("RoomTile", () => {
act(() => {
call.participants = new Map([alice]);
});
expect(screen.getByLabelText("1 participant").textContent).toBe("1");
expect(screen.getByLabelText("1 person joined").textContent).toBe("1");
act(() => {
call.participants = new Map([alice, bob, carol]);
});
expect(screen.getByLabelText("4 participants").textContent).toBe("4");
expect(screen.getByLabelText("4 people joined").textContent).toBe("4");
act(() => {
call.participants = new Map();

View file

@ -38,8 +38,6 @@ import { CallView as _CallView } from "../../../../src/components/views/voip/Cal
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { CallStore } from "../../../../src/stores/CallStore";
import { Call, ConnectionState } from "../../../../src/models/Call";
import SdkConfig from "../../../../src/SdkConfig";
import MediaDeviceHandler from "../../../../src/MediaDeviceHandler";
const CallView = wrapInMatrixClientContext(_CallView);
@ -75,8 +73,10 @@ describe("CallView", () => {
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
});
const renderView = async (): Promise<void> => {
render(<CallView room={room} resizing={false} waitForCall={false} />, { wrapper: TooltipProvider });
const renderView = async (skipLobby = false): Promise<void> => {
render(<CallView room={room} resizing={false} waitForCall={false} skipLobby={skipLobby} />, {
wrapper: TooltipProvider,
});
await act(() => Promise.resolve()); // Let effects settle
};
@ -108,20 +108,6 @@ describe("CallView", () => {
expect(cleanSpy).toHaveBeenCalled();
});
it("shows lobby and keeps widget loaded when disconnected", async () => {
await renderView();
screen.getByRole("button", { name: "Join" });
screen.getAllByText(/\bwidget\b/i);
});
it("only shows widget when connected", async () => {
await renderView();
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected));
expect(screen.queryByRole("button", { name: "Join" })).toBe(null);
screen.getAllByText(/\bwidget\b/i);
});
/**
* TODO: Fix I do not understand this test
*/
@ -166,40 +152,17 @@ describe("CallView", () => {
expectAvatars([]);
});
it("connects to the call when the join button is pressed", async () => {
await renderView();
const connectSpy = jest.spyOn(call, "connect");
fireEvent.click(screen.getByRole("button", { name: "Join" }));
it("automatically connects to the call when skipLobby is true", async () => {
const connectSpy = jest.spyOn(call, "start");
await renderView(true);
await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 });
});
it("disables join button when the participant limit has been exceeded", async () => {
const bob = mkRoomMember(room.roomId, "@bob:example.org");
const carol = mkRoomMember(room.roomId, "@carol:example.org");
SdkConfig.put({
element_call: { participant_limit: 2, url: "", use_exclusively: false, brand: "Element Call" },
});
call.participants = new Map([
[bob, new Set("b")],
[carol, new Set("c")],
]);
await renderView();
const connectSpy = jest.spyOn(call, "connect");
const joinButton = screen.getByRole("button", { name: "Join" });
expect(joinButton).toHaveAttribute("aria-disabled", "true");
fireEvent.click(joinButton);
await waitFor(() => expect(connectSpy).not.toHaveBeenCalled(), { interval: 1 });
});
});
describe("without an existing call", () => {
it("creates and connects to a new call when the join button is pressed", async () => {
await renderView();
expect(Call.get(room)).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await renderView(true);
await waitFor(() => expect(CallStore.instance.getCall(room.roomId)).not.toBeNull());
const call = CallStore.instance.getCall(room.roomId)!;
@ -214,117 +177,4 @@ describe("CallView", () => {
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
});
describe("device buttons", () => {
const fakeVideoInput1: MediaDeviceInfo = {
deviceId: "v1",
groupId: "v1",
label: "Webcam",
kind: "videoinput",
toJSON: () => {},
};
const fakeVideoInput2: MediaDeviceInfo = {
deviceId: "v2",
groupId: "v2",
label: "Othercam",
kind: "videoinput",
toJSON: () => {},
};
const fakeAudioInput1: MediaDeviceInfo = {
deviceId: "v1",
groupId: "v1",
label: "Headphones",
kind: "audioinput",
toJSON: () => {},
};
const fakeAudioInput2: MediaDeviceInfo = {
deviceId: "v2",
groupId: "v2",
label: "Tailphones",
kind: "audioinput",
toJSON: () => {},
};
it("hide when no devices are available", async () => {
await renderView();
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
});
it("hide when no access to device list", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockRejectedValue("permission denied");
await renderView();
expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy();
expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy();
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
});
it("hide when unknown error with device list", async () => {
const originalGetDevices = MediaDeviceHandler.getDevices;
MediaDeviceHandler.getDevices = () => Promise.reject("unknown error");
await renderView();
expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy();
expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy();
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
MediaDeviceHandler.getDevices = originalGetDevices;
});
it("show without dropdown when only one device is available", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1]);
await renderView();
screen.getByRole("button", { name: /camera/ });
expect(screen.queryByRole("button", { name: "Video devices" })).toBe(null);
});
it("show with dropdown when multiple devices are available", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]);
await renderView();
screen.getByRole("button", { name: /microphone/ });
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
screen.getByRole("menuitem", { name: "Headphones" });
screen.getByRole("menuitem", { name: "Tailphones" });
});
it("sets video device when selected", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1, fakeVideoInput2]);
await renderView();
screen.getByRole("button", { name: /camera/ });
fireEvent.click(screen.getByRole("button", { name: "Video devices" }));
fireEvent.click(screen.getByRole("menuitem", { name: fakeVideoInput2.label }));
expect(client.getMediaHandler().setVideoInput).toHaveBeenCalledWith(fakeVideoInput2.deviceId);
});
it("sets audio device when selected", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]);
await renderView();
screen.getByRole("button", { name: /microphone/ });
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
fireEvent.click(screen.getByRole("menuitem", { name: fakeAudioInput2.label }));
expect(client.getMediaHandler().setAudioInput).toHaveBeenCalledWith(fakeAudioInput2.deviceId);
});
it("set media muted if no access to audio device", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeAudioInput1, fakeAudioInput2]);
mocked(navigator.mediaDevices.getUserMedia).mockRejectedValue("permission rejected");
await renderView();
expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy();
expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy();
});
it("set media muted if no access to video device", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1, fakeVideoInput2]);
mocked(navigator.mediaDevices.getUserMedia).mockRejectedValue("permission rejected");
await renderView();
expect(MediaDeviceHandler.startWithAudioMuted).toBeTruthy();
expect(MediaDeviceHandler.startWithVideoMuted).toBeTruthy();
});
});
});