diff --git a/.gitignore b/.gitignore index 6cf32a0680..34a8640e70 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,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 diff --git a/apps/web/playwright/e2e/voip/element-call.spec.ts b/apps/web/playwright/e2e/voip/element-call.spec.ts index da956dfe15..813d9a0601 100644 --- a/apps/web/playwright/e2e/voip/element-call.spec.ts +++ b/apps/web/playwright/e2e/voip/element-call.spec.ts @@ -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) => { diff --git a/apps/web/src/models/Call.ts b/apps/web/src/models/Call.ts index 535c04f2f2..4f7feec84d 100644 --- a/apps/web/src/models/Call.ts +++ b/apps/web/src/models/Call.ts @@ -108,16 +108,15 @@ export abstract class Call extends TypedEventEmitter { @@ -128,16 +129,25 @@ export class RoomListItemViewModel this.updateItem(); }; + /** + * Handler for call type changes. Only updates the item if the call type is actually present in the snapshot. + */ + private onCallTypeChanged = (): void => { + 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; } diff --git a/apps/web/test/test-utils/call.ts b/apps/web/test/test-utils/call.ts index 90ef5d1ea2..bbfe7eaee8 100644 --- a/apps/web/test/test-utils/call.ts +++ b/apps/web/test/test-utils/call.ts @@ -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"; @@ -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; } diff --git a/apps/web/test/test-utils/test-utils.ts b/apps/web/test/test-utils/test-utils.ts index dce7257b83..4118960072 100644 --- a/apps/web/test/test-utils/test-utils.ts +++ b/apps/web/test/test-utils/test-utils.ts @@ -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 { diff --git a/apps/web/test/unit-tests/models/Call-test.ts b/apps/web/test/unit-tests/models/Call-test.ts index 39f44ed4eb..ba3b264b54 100644 --- a/apps/web/test/unit-tests/models/Call-test.ts +++ b/apps/web/test/unit-tests/models/Call-test.ts @@ -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"; @@ -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, + ]; + 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, + ]; + 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(); diff --git a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx index de023d4dda..e3b349e085 100644 --- a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx @@ -5,6 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ +import EventEmitter from "events"; import { type MatrixClient, type MatrixEvent, @@ -26,7 +27,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", () => ({ @@ -436,6 +437,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", () => { diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-video-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-video-call-auto.png new file mode 100644 index 0000000000..e96aa05730 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-video-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-voice-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-voice-call-auto.png new file mode 100644 index 0000000000..7412a2419b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room-list/RoomListItemView/RoomListItemView.stories.tsx/with-voice-call-auto.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 80a43241c8..aa7c02d0f1 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -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", diff --git a/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.stories.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.stories.tsx index 7279231d20..1682c3231e 100644 --- a/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.stories.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.stories.tsx @@ -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", diff --git a/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.test.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.test.tsx index 691ea07648..646e114de3 100644 --- a/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.test.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.test.tsx @@ -19,6 +19,8 @@ const { Bold, WithNotification, WithMention, + WithVoiceCall, + WithVideoCall, Invitation, UnsentMessage, NoMessagePreview, @@ -52,6 +54,16 @@ describe("", () => { expect(container).toMatchSnapshot(); }); + it("renders WithVoiceCall story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders WithVideoCall story", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it("renders Invitation story", () => { const { container } = render(); expect(container).toMatchSnapshot(); diff --git a/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.tsx b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.tsx index 2ba83f11a9..04e7238407 100644 --- a/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.tsx +++ b/packages/shared-components/src/room-list/RoomListItemView/RoomListItemView.tsx @@ -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 }); } diff --git a/packages/shared-components/src/room-list/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap index 441589bd07..084ce3ec52 100644 --- a/packages/shared-components/src/room-list/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap @@ -319,11 +319,11 @@ exports[` > renders Invitation story 1`] = ` aria-expanded="false" aria-haspopup="menu" aria-label="More Options" - aria-labelledby="_r_2i_" + aria-labelledby="_r_3i_" class="_icon-button_1215g_8" data-kind="primary" data-state="closed" - id="radix-_r_2g_" + id="radix-_r_3g_" role="button" style="--cpd-icon-button-size: 24px; padding: 2px;" tabindex="0" @@ -351,11 +351,11 @@ exports[` > renders Invitation story 1`] = ` aria-expanded="false" aria-haspopup="menu" aria-label="Notification options" - aria-labelledby="_r_2p_" + aria-labelledby="_r_3p_" class="_icon-button_1215g_8" data-kind="primary" data-state="closed" - id="radix-_r_2n_" + id="radix-_r_3n_" role="button" style="--cpd-icon-button-size: 24px; padding: 2px;" tabindex="0" @@ -461,11 +461,11 @@ exports[` > renders NoMessagePreview story 1`] = ` aria-expanded="false" aria-haspopup="menu" aria-label="More Options" - aria-labelledby="_r_3i_" + aria-labelledby="_r_4i_" class="_icon-button_1215g_8" data-kind="primary" data-state="closed" - id="radix-_r_3g_" + id="radix-_r_4g_" role="button" style="--cpd-icon-button-size: 24px; padding: 2px;" tabindex="0" @@ -493,11 +493,11 @@ exports[` > renders NoMessagePreview story 1`] = ` aria-expanded="false" aria-haspopup="menu" aria-label="Notification options" - aria-labelledby="_r_3p_" + aria-labelledby="_r_4p_" class="_icon-button_1215g_8" data-kind="primary" data-state="closed" - id="radix-_r_3n_" + id="radix-_r_4n_" role="button" style="--cpd-icon-button-size: 24px; padding: 2px;" tabindex="0" @@ -721,11 +721,11 @@ exports[` > renders UnsentMessage story 1`] = ` aria-expanded="false" aria-haspopup="menu" aria-label="More Options" - aria-labelledby="_r_32_" + aria-labelledby="_r_42_" class="_icon-button_1215g_8" data-kind="primary" data-state="closed" - id="radix-_r_30_" + id="radix-_r_40_" role="button" style="--cpd-icon-button-size: 24px; padding: 2px;" tabindex="0" @@ -753,11 +753,11 @@ exports[` > renders UnsentMessage story 1`] = ` aria-expanded="false" aria-haspopup="menu" aria-label="Notification options" - aria-labelledby="_r_39_" + aria-labelledby="_r_49_" class="_icon-button_1215g_8" data-kind="primary" data-state="closed" - id="radix-_r_37_" + id="radix-_r_47_" role="button" style="--cpd-icon-button-size: 24px; padding: 2px;" tabindex="0" @@ -869,11 +869,11 @@ exports[` > renders WithHoverMenu story 1`] = ` aria-expanded="false" aria-haspopup="menu" aria-label="More Options" - aria-labelledby="_r_42_" + aria-labelledby="_r_52_" class="_icon-button_1215g_8" data-kind="primary" data-state="closed" - id="radix-_r_40_" + id="radix-_r_50_" role="button" style="--cpd-icon-button-size: 24px; padding: 2px;" tabindex="0" @@ -901,11 +901,11 @@ exports[` > renders WithHoverMenu story 1`] = ` aria-expanded="false" aria-haspopup="menu" aria-label="Notification options" - aria-labelledby="_r_49_" + aria-labelledby="_r_59_" class="_icon-button_1215g_8" data-kind="primary" data-state="closed" - id="radix-_r_47_" + id="radix-_r_57_" role="button" style="--cpd-icon-button-size: 24px; padding: 2px;" tabindex="0" @@ -1234,3 +1234,299 @@ exports[` > renders WithNotification story 1`] = ` `; + +exports[` > renders WithVideoCall story 1`] = ` +
+
+ + +
+ +
+ + + + +`; + +exports[` > renders WithVoiceCall story 1`] = ` +
+
+ + +
+ +
+ + + + +`;