Skip to content

Commit 71a01b7

Browse files
authored
Fix room list often showing the wrong icons for calls (#32881)
* Give rooms with calls a proper accessible description Besides improving accessibility, this makes it possible to check for the presence of a call indicator in the room list in Playwright tests. * Make room list react to calls in a room, even when not connected to them To use the results of CallStore.getRoom reactively, you need to listen for Call events, not ConnectedCalls events. * Don't assume that every call starts off as a video call If a Call object is created by way of someone starting a voice call, then of course the call's initial type needs to be 'voice'. * Make room list items react to changes in call type The type of a call may change over time; therefore room list items explicitly need to react to the changes. * Update a call's type before notifying listeners of the change If we notify listeners of a change in a call's type before actually making that change, the listeners will be working with glitched state. This would cause the room list to show the wrong call type in certain situations. * Ignore the Vitest attachments directory
1 parent 441b292 commit 71a01b7

15 files changed

Lines changed: 468 additions & 44 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ storybook-static
2727
/packages/shared-components/node_modules
2828
/packages/shared-components/dist
2929
/packages/shared-components/src/i18nKeys.d.ts
30+
/packages/shared-components/.vitest-attachments
3031

3132
# TSC incremental compilation information
3233
*.tsbuildinfo

apps/web/playwright/e2e/voip/element-call.spec.ts

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -189,25 +189,31 @@ test.describe("Element Call", () => {
189189
expect(hash.get("skipLobby")).toEqual("true");
190190
});
191191

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

209-
expect(hash.get("intent")).toEqual("join_existing");
210-
expect(hash.get("skipLobby")).toEqual(null);
214+
expect(hash.get("intent")).toEqual("join_existing");
215+
expect(hash.get("skipLobby")).toEqual(null);
216+
});
211217
});
212218

213219
[true, false].forEach((skipLobbyToggle) => {

apps/web/src/models/Call.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,16 +108,15 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
108108
protected readonly widgetUid: string;
109109
protected readonly room: Room;
110110

111-
private _callType: CallType = CallType.Video;
111+
private _callType: CallType;
112112
public get callType(): CallType {
113113
return this._callType;
114114
}
115115

116116
protected set callType(callType: CallType) {
117-
if (this._callType !== callType) {
118-
this.emit(CallEvent.CallTypeChanged, callType);
119-
}
117+
const prevCallType = this._callType;
120118
this._callType = callType;
119+
if (callType !== prevCallType) this.emit(CallEvent.CallTypeChanged, callType);
121120
}
122121

123122
/**
@@ -184,11 +183,13 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
184183
*/
185184
public readonly widget: IApp,
186185
protected readonly client: MatrixClient,
186+
initialCallType: CallType,
187187
) {
188188
super();
189189
this.widgetUid = WidgetUtils.getWidgetUid(this.widget);
190190
this.room = this.client.getRoom(this.roomId)!;
191191
WidgetMessagingStore.instance.on(WidgetMessagingStoreEvent.StopMessaging, this.onStopMessaging);
192+
this._callType = initialCallType;
192193
}
193194

194195
/**
@@ -347,7 +348,7 @@ export class JitsiCall extends Call {
347348
private participantsExpirationTimer: number | null = null;
348349

349350
private constructor(widget: IApp, client: MatrixClient) {
350-
super(widget, client);
351+
super(widget, client, CallType.Video);
351352

352353
this.room.on(RoomStateEvent.Update, this.onRoomState);
353354
this.on(CallEvent.ConnectionState, this.onConnectionState);
@@ -899,7 +900,7 @@ export class ElementCall extends Call {
899900
widget: IApp,
900901
client: MatrixClient,
901902
) {
902-
super(widget, client);
903+
super(widget, client, session.getConsensusCallIntent() === "audio" ? CallType.Voice : CallType.Video);
903904

904905
this.session.on(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged);
905906
this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, this.checkDestroy);

apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export class RoomListItemViewModel
8787
});
8888

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

@@ -102,6 +102,7 @@ export class RoomListItemViewModel
102102
public dispose(): void {
103103
super.dispose();
104104
this.currentCall?.off(CallEvent.Participants, this.onCallParticipantsChanged);
105+
this.currentCall?.off(CallEvent.CallTypeChanged, this.onCallTypeChanged);
105106
}
106107

107108
private onNotificationChanged = (): void => {
@@ -128,16 +129,25 @@ export class RoomListItemViewModel
128129
this.updateItem();
129130
};
130131

132+
/**
133+
* Handler for call type changes. Only updates the item if the call type is actually present in the snapshot.
134+
*/
135+
private onCallTypeChanged = (): void => {
136+
if (this.snapshot.current.notification.callType !== undefined) this.updateItem();
137+
};
138+
131139
/**
132140
* Listen to participant changes for the current call in this room (if any) to trigger updates when participants join/leave the call.
133141
*/
134142
private listenToCallParticipants(): void {
135143
const call = CallStore.instance.getCall(this.props.room.roomId);
136144

137-
// Remove listener from previous call (if any) and add to new call to track participant changes
145+
// Remove listeners from previous call (if any) and add to new call to track changes
138146
if (call !== this.currentCall) {
139147
this.currentCall?.off(CallEvent.Participants, this.onCallParticipantsChanged);
148+
this.currentCall?.off(CallEvent.CallTypeChanged, this.onCallTypeChanged);
140149
call?.on(CallEvent.Participants, this.onCallParticipantsChanged);
150+
call?.on(CallEvent.CallTypeChanged, this.onCallTypeChanged);
141151
}
142152
this.currentCall = call;
143153
}

apps/web/test/test-utils/call.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
RoomStateEvent,
1919
type IContent,
2020
} from "matrix-js-sdk/src/matrix";
21+
import { CallType } from "matrix-js-sdk/src/webrtc/call";
2122
import { mocked, type Mocked } from "jest-mock";
2223
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
2324

@@ -52,6 +53,7 @@ export class MockedCall extends Call {
5253
waitForIframeLoad: false,
5354
},
5455
room.client,
56+
CallType.Video,
5557
);
5658
this.groupCall = { creationTs: this.event.getTs() } as unknown as GroupCall;
5759
}

apps/web/test/test-utils/test-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ export function createStubMatrixRTC(): MatrixRTCSessionManager {
381381
const session = new EventEmitter() as MatrixRTCSession;
382382
session.memberships = [];
383383
session.getOldestMembership = () => undefined;
384+
session.getConsensusCallIntent = () => "video";
384385
return session;
385386
});
386387
return {

apps/web/test/unit-tests/models/Call-test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
MatrixRTCSession,
2727
MatrixRTCSessionEvent,
2828
} from "matrix-js-sdk/src/matrixrtc";
29+
import { CallType } from "matrix-js-sdk/src/webrtc/call";
2930

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

991+
it("emits events when call type changes", async () => {
992+
const onCallTypeChanged = jest.fn();
993+
call.on(CallEvent.CallTypeChanged, onCallTypeChanged);
994+
// Should default to video when unknown
995+
expect(call.callType).toBe(CallType.Video);
996+
997+
// Change call type to voice
998+
roomSession.memberships = [
999+
{ sender: alice.userId, deviceId: "alices_device", callIntent: "audio" } as Mocked<CallMembership>,
1000+
];
1001+
roomSession.getConsensusCallIntent.mockReturnValue("audio");
1002+
roomSession.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []);
1003+
1004+
expect(call.callType).toBe(CallType.Voice);
1005+
expect(onCallTypeChanged.mock.calls).toEqual([[CallType.Voice]]);
1006+
1007+
// Change call type back to video
1008+
roomSession.memberships = [
1009+
{ sender: alice.userId, deviceId: "alices_device", callIntent: "video" } as Mocked<CallMembership>,
1010+
];
1011+
roomSession.getConsensusCallIntent.mockReturnValue("video");
1012+
roomSession.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []);
1013+
1014+
expect(call.callType).toBe(CallType.Video);
1015+
expect(onCallTypeChanged.mock.calls).toEqual([[CallType.Voice], [CallType.Video]]);
1016+
1017+
call.off(CallEvent.CallTypeChanged, onCallTypeChanged);
1018+
});
1019+
9901020
it("ends the call immediately if the session ended", async () => {
9911021
await connect(call, widgetApi);
9921022
const onDestroy = jest.fn();

apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Please see LICENSE files in the repository root for full details.
66
*/
77

8+
import EventEmitter from "events";
89
import {
910
type MatrixClient,
1011
type MatrixEvent,
@@ -26,7 +27,7 @@ import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
2627
import dispatcher from "../../../src/dispatcher/dispatcher";
2728
import { Action } from "../../../src/dispatcher/actions";
2829
import { CallStore } from "../../../src/stores/CallStore";
29-
import type { Call } from "../../../src/models/Call";
30+
import { CallEvent, type Call } from "../../../src/models/Call";
3031
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
3132

3233
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
@@ -436,6 +437,28 @@ describe("RoomListItemViewModel", () => {
436437
// The new call must have a listener registered
437438
expect(secondCall.on).toHaveBeenCalledWith("participants", expect.any(Function));
438439
});
440+
441+
it("should listen to call type changes", async () => {
442+
// Start with a voice call
443+
let callType = CallType.Voice;
444+
const mockCall = new (class extends EventEmitter {
445+
get callType() {
446+
return callType;
447+
}
448+
participants = new Map([[matrixClient.getUserId()!, {}]]);
449+
})() as unknown as Call;
450+
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
451+
452+
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
453+
await flushPromises();
454+
455+
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
456+
457+
// Now turn it into a video call
458+
callType = CallType.Video;
459+
mockCall.emit(CallEvent.CallTypeChanged, callType);
460+
expect(viewModel.getSnapshot().notification.callType).toBe("video");
461+
});
439462
});
440463

441464
describe("Room name updates", () => {
Loading
Loading

0 commit comments

Comments
 (0)