Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ storybook-static
/packages/shared-components/node_modules
/packages/shared-components/dist
/packages/shared-components/src/i18nKeys.d.ts
/packages/shared-components/.vitest-attachments

# TSC incremental compilation information
*.tsbuildinfo
Expand Down
42 changes: 24 additions & 18 deletions apps/web/playwright/e2e/voip/element-call.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,25 +189,31 @@ test.describe("Element Call", () => {
expect(hash.get("skipLobby")).toEqual("true");
});

test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
// Fake a start of a call
await sendRTCState(bot, room.roomId);
const button = page.getByTestId("join-call-button");
await expect(button).toBeInViewport({ timeout: 5000 });
// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room);
["voice", "video"].forEach((callType) => {
test(`should be able to join a ${callType} call in progress`, async ({ page, user, bot, room, app }) => {
await app.viewRoomById(room.roomId);
// Allow bob to create a call
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
// Fake a start of a call
await sendRTCState(bot, room.roomId, undefined, callType === "voice" ? "audio" : "video");
const button = page.getByTestId("join-call-button");
await expect(button).toBeInViewport({ timeout: 5000 });
// Room list should show that a call is ongoing
await expect(
page.getByRole("option", { name: `Open room TestRoom with a ${callType} call.` }),
).toBeVisible();
// And test joining
await button.click();
const frameUrlStr = await page.locator("iframe").getAttribute("src");
await expect(frameUrlStr).toBeDefined();
const url = new URL(frameUrlStr);
const hash = new URLSearchParams(url.hash.slice(1));
assertCommonCallParameters(url.searchParams, hash, user, room);

expect(hash.get("intent")).toEqual("join_existing");
expect(hash.get("skipLobby")).toEqual(null);
expect(hash.get("intent")).toEqual("join_existing");
expect(hash.get("skipLobby")).toEqual(null);
});
});

[true, false].forEach((skipLobbyToggle) => {
Expand Down
13 changes: 7 additions & 6 deletions apps/web/src/models/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,15 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
protected readonly widgetUid: string;
protected readonly room: Room;

private _callType: CallType = CallType.Video;
private _callType: CallType;
public get callType(): CallType {
return this._callType;
}

protected set callType(callType: CallType) {
if (this._callType !== callType) {
this.emit(CallEvent.CallTypeChanged, callType);
}
const prevCallType = this._callType;
this._callType = callType;
if (callType !== prevCallType) this.emit(CallEvent.CallTypeChanged, callType);
}

/**
Expand Down Expand Up @@ -184,11 +183,13 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
*/
public readonly widget: IApp,
protected readonly client: MatrixClient,
initialCallType: CallType,
) {
super();
this.widgetUid = WidgetUtils.getWidgetUid(this.widget);
this.room = this.client.getRoom(this.roomId)!;
WidgetMessagingStore.instance.on(WidgetMessagingStoreEvent.StopMessaging, this.onStopMessaging);
this._callType = initialCallType;
}

/**
Expand Down Expand Up @@ -347,7 +348,7 @@ export class JitsiCall extends Call {
private participantsExpirationTimer: number | null = null;

private constructor(widget: IApp, client: MatrixClient) {
super(widget, client);
super(widget, client, CallType.Video);

this.room.on(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState);
Expand Down Expand Up @@ -899,7 +900,7 @@ export class ElementCall extends Call {
widget: IApp,
client: MatrixClient,
) {
super(widget, client);
super(widget, client, session.getConsensusCallIntent() === "audio" ? CallType.Voice : CallType.Video);

this.session.on(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged);
this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, this.checkDestroy);
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
});

// Subscribe to call state changes
this.disposables.trackListener(CallStore.instance, CallStoreEvent.ConnectedCalls, this.onCallStateChanged);
this.disposables.trackListener(CallStore.instance, CallStoreEvent.Call, this.onCallStateChanged);
// If there is an active call for this room, listen to participant changes
this.listenToCallParticipants();

Expand All @@ -98,6 +98,7 @@
public dispose(): void {
super.dispose();
this.currentCall?.off(CallEvent.Participants, this.onCallParticipantsChanged);
this.currentCall?.off(CallEvent.CallTypeChanged, this.onCallTypeChanged);
}

private onNotificationChanged = (): void => {
Expand All @@ -124,16 +125,25 @@
this.updateItem();
};

/**
* Handler for call type changes. Only updates the item if the call type is actually present in the snapshot.
*/
private onCallTypeChanged = (): void => {

Check warning on line 131 in apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'onCallTypeChanged' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ0OF8JsNfgQaKDp03Lg&open=AZ0OF8JsNfgQaKDp03Lg&pullRequest=32881
if (this.snapshot.current.notification.callType !== undefined) this.updateItem();
};

/**
* Listen to participant changes for the current call in this room (if any) to trigger updates when participants join/leave the call.
*/
private listenToCallParticipants(): void {
const call = CallStore.instance.getCall(this.props.room.roomId);

// Remove listener from previous call (if any) and add to new call to track participant changes
// Remove listeners from previous call (if any) and add to new call to track changes
if (call !== this.currentCall) {
this.currentCall?.off(CallEvent.Participants, this.onCallParticipantsChanged);
this.currentCall?.off(CallEvent.CallTypeChanged, this.onCallTypeChanged);
call?.on(CallEvent.Participants, this.onCallParticipantsChanged);
call?.on(CallEvent.CallTypeChanged, this.onCallTypeChanged);
}
this.currentCall = call;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/test/test-utils/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
RoomStateEvent,
type IContent,
} from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { mocked, type Mocked } from "jest-mock";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";

Expand Down Expand Up @@ -52,6 +53,7 @@ export class MockedCall extends Call {
waitForIframeLoad: false,
},
room.client,
CallType.Video,
);
this.groupCall = { creationTs: this.event.getTs() } as unknown as GroupCall;
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/test/test-utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ export function createStubMatrixRTC(): MatrixRTCSessionManager {
const session = new EventEmitter() as MatrixRTCSession;
session.memberships = [];
session.getOldestMembership = () => undefined;
session.getConsensusCallIntent = () => "video";
return session;
});
return {
Expand Down
30 changes: 30 additions & 0 deletions apps/web/test/unit-tests/models/Call-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
import { CallType } from "matrix-js-sdk/src/webrtc/call";

import type { Mocked } from "jest-mock";
import type { ClientWidgetApi } from "matrix-widget-api";
Expand Down Expand Up @@ -987,6 +988,35 @@ describe("ElementCall", () => {
call.off(CallEvent.Participants, onParticipants);
});

it("emits events when call type changes", async () => {
const onCallTypeChanged = jest.fn();
call.on(CallEvent.CallTypeChanged, onCallTypeChanged);
// Should default to video when unknown
expect(call.callType).toBe(CallType.Video);

// Change call type to voice
roomSession.memberships = [
{ sender: alice.userId, deviceId: "alices_device", callIntent: "audio" } as Mocked<CallMembership>,
];
roomSession.getConsensusCallIntent.mockReturnValue("audio");
roomSession.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []);

expect(call.callType).toBe(CallType.Voice);
expect(onCallTypeChanged.mock.calls).toEqual([[CallType.Voice]]);

// Change call type back to video
roomSession.memberships = [
{ sender: alice.userId, deviceId: "alices_device", callIntent: "video" } as Mocked<CallMembership>,
];
roomSession.getConsensusCallIntent.mockReturnValue("video");
roomSession.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []);

expect(call.callType).toBe(CallType.Video);
expect(onCallTypeChanged.mock.calls).toEqual([[CallType.Voice], [CallType.Video]]);

call.off(CallEvent.CallTypeChanged, onCallTypeChanged);
});

it("ends the call immediately if the session ended", async () => {
await connect(call, widgetApi);
const onDestroy = jest.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Please see LICENSE files in the repository root for full details.
*/

import EventEmitter from "events";
import {
type MatrixClient,
type MatrixEvent,
Expand All @@ -27,7 +28,7 @@ import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { CallStore } from "../../../src/stores/CallStore";
import type { Call } from "../../../src/models/Call";
import { CallEvent, type Call } from "../../../src/models/Call";
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";

jest.mock("../../../src/viewmodels/room-list/utils", () => ({
Expand Down Expand Up @@ -437,6 +438,28 @@ describe("RoomListItemViewModel", () => {
// The new call must have a listener registered
expect(secondCall.on).toHaveBeenCalledWith("participants", expect.any(Function));
});

it("should listen to call type changes", async () => {
// Start with a voice call
let callType = CallType.Voice;
const mockCall = new (class extends EventEmitter {
get callType() {
return callType;
}
participants = new Map([[matrixClient.getUserId()!, {}]]);
})() as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);

viewModel = new RoomListItemViewModel({ room, client: matrixClient });
await flushPromises();

expect(viewModel.getSnapshot().notification.callType).toBe("voice");

// Now turn it into a video call
callType = CallType.Video;
mockCall.emit(CallEvent.CallTypeChanged, callType);
expect(viewModel.getSnapshot().notification.callType).toBe("video");
});
});

describe("Room name updates", () => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion packages/shared-components/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@
"one": "Open room %(roomName)s with 1 unread message.",
"other": "Open room %(roomName)s with %(count)s unread messages."
},
"unsent_message": "Open room %(roomName)s with an unsent message."
"unsent_message": "Open room %(roomName)s with an unsent message.",
"video_call": "Open room %(roomName)s with a video call.",
"voice_call": "Open room %(roomName)s with a voice call."
},
"appearance": "Appearance",
"collapse_filters": "Collapse filter list",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,42 @@ export const WithMention: Story = {
},
};

export const WithVoiceCall: Story = {
args: {
isBold: true,
notification: {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
hasUnreadCount: false,
count: 0,
muted: false,
callType: "voice",
},
},
};

export const WithVideoCall: Story = {
args: {
isBold: true,
notification: {
hasAnyNotificationOrActivity: true,
isUnsentMessage: false,
invited: false,
isMention: false,
isActivityNotification: false,
isNotification: false,
hasUnreadCount: false,
count: 0,
muted: false,
callType: "video",
},
},
};

export const Invitation: Story = {
args: {
name: "Secret Project",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const {
Bold,
WithNotification,
WithMention,
WithVoiceCall,
WithVideoCall,
Invitation,
UnsentMessage,
NoMessagePreview,
Expand Down Expand Up @@ -52,6 +54,16 @@ describe("<RoomListItemView />", () => {
expect(container).toMatchSnapshot();
});

it("renders WithVoiceCall story", () => {
const { container } = render(<WithVoiceCall />);
expect(container).toMatchSnapshot();
});

it("renders WithVideoCall story", () => {
const { container } = render(<WithVideoCall />);
expect(container).toMatchSnapshot();
});

it("renders Invitation story", () => {
const { container } = render(<Invitation />);
expect(container).toMatchSnapshot();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ function getA11yLabel(roomName: string, notification: NotificationDecorationData
return _t("room_list|a11y|mention", { roomName, count: notification.count });
} else if (notification.hasUnreadCount && notification.count) {
return _t("room_list|a11y|unread", { roomName, count: notification.count });
} else if (notification.callType === "voice") {
return _t("room_list|a11y|voice_call", { roomName });
} else if (notification.callType === "video") {
return _t("room_list|a11y|video_call", { roomName });
} else {
return _t("room_list|a11y|default", { roomName });
}
Expand Down
Loading
Loading