Room list: listen to call event to check number of participants (#32677)

* feat(call store): add new `CallEvent.Participants` event

The room list needs to listen to `CallEvent.Participants` to be able to
display the Call icon. This was working before
https://github.com/element-hq/element-web/pull/32663 due to an excessive
re-renders or relying on the notification events.

* chore(room list): listen to `CallEvent.Participants`

* test(room list): add test for new listener

* test(call store): add tests for `CallEvent.Particpants`

* Revert "feat(call store): add new `CallEvent.Participants` event"

This reverts commit d2a7a009a4c55325404ad38f23fa662a8103cff4.

* Revert "test(call store): add tests for `CallEvent.Particpants`"

This reverts commit 4455182fb3aea54ea10cfabb8beb7946cfdf8a6c.

* chore(room list): listen to `Call#CallEvent.Participants` insteaf of listening to `CallStore`

* test(room list): update added test

* fix(room list): clean properly listeners on previous call

* test(room list): add missing test

* fix(room list): don't use trackListeners to avoid leaking memory when listening to call event

* fix(room list): listen to participant change when vm is created

* test(room list): add test case when there is an existing call
This commit is contained in:
Florian Duros 2026-03-02 18:16:38 +01:00 committed by GitHub
parent 738fa0f9bd
commit 15530ef075
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 170 additions and 2 deletions

View File

@ -14,7 +14,7 @@ import {
import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import type { Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import type { Room, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import type { RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
@ -36,6 +36,7 @@ import dispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import PosthogTrackers from "../../PosthogTrackers";
import { type Call, CallEvent } from "../../models/Call";
interface RoomItemProps {
room: Room;
@ -52,6 +53,10 @@ export class RoomListItemViewModel
implements RoomListItemActions
{
private notifState: RoomNotificationState;
/**
* Track the current call for this room to manager listeners
*/
private currentCall: Call | null = null;
public constructor(props: RoomItemProps) {
// Get notification state first so we can generate a complete initial snapshot
@ -79,6 +84,8 @@ export class RoomListItemViewModel
// Subscribe to call state changes
this.disposables.trackListener(CallStore.instance, CallStoreEvent.ConnectedCalls, this.onCallStateChanged);
// If there is an active call for this room, listen to participant changes
this.listenToCallParticipants();
// Subscribe to room-specific events
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomChanged);
@ -88,6 +95,11 @@ export class RoomListItemViewModel
void this.loadAndSetMessagePreview();
}
public dispose(): void {
super.dispose();
this.currentCall?.off(CallEvent.Participants, this.onCallParticipantsChanged);
}
private onNotificationChanged = (): void => {
this.updateItem();
};
@ -100,9 +112,38 @@ export class RoomListItemViewModel
void this.loadAndSetMessagePreview();
};
/**
* Handler for call participant changes. Only updates the item if the call moves between having participants and not having participants, to avoid unnecessary updates.
* @param participants The current call participants
*/
private onCallParticipantsChanged = (participants: Map<RoomMember, Set<string>>): void => {
const hasCall = Boolean(this.snapshot.current.notification.callType);
// There is already an active call, we don't need to update the item
if (hasCall && participants.size > 0) return;
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
if (call !== this.currentCall) {
this.currentCall?.off(CallEvent.Participants, this.onCallParticipantsChanged);
call?.on(CallEvent.Participants, this.onCallParticipantsChanged);
}
this.currentCall = call;
}
private onCallStateChanged = (): void => {
// Only update if call state for this room actually changed
const call = CallStore.instance.getCall(this.props.room.roomId);
this.listenToCallParticipants();
const currentCallType = this.snapshot.current.notification.callType;
const newCallType =
call && call.participants.size > 0 ? (call.callType === CallType.Voice ? "voice" : "video") : undefined;

View File

@ -5,7 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type MatrixEvent, Room, RoomEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import {
type MatrixClient,
type MatrixEvent,
Room,
RoomEvent,
PendingEventOrdering,
type RoomMember,
} from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { createTestClient, flushPromises } from "../../test-utils";
@ -270,6 +277,8 @@ describe("RoomListItemViewModel", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()!, {}]]),
off: jest.fn(),
on: jest.fn(),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
@ -285,6 +294,8 @@ describe("RoomListItemViewModel", () => {
const mockCall = {
callType: CallType.Video,
participants: new Map([[matrixClient.getUserId()!, {}]]),
off: jest.fn(),
on: jest.fn(),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
@ -300,6 +311,8 @@ describe("RoomListItemViewModel", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map(),
off: jest.fn(),
on: jest.fn(),
} as unknown as Call;
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
@ -310,6 +323,120 @@ describe("RoomListItemViewModel", () => {
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
});
it("should listen to call participant changes", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map(),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
// Get the callback registered for call state changes
const mockCalls = (CallStore.instance.on as jest.Mock).mock.calls;
const callStateCallback = mockCalls[mockCalls.length - 1][1];
callStateCallback();
// Simulate participant joining
mockCall.participants.set(matrixClient.getUserId()! as unknown as RoomMember, new Set());
// Get the callback registered for participant changes
const participantsChangeCallback = mockCall.on.mock.calls[0][1];
participantsChangeCallback();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
});
it("should not update the item when there is already an active call and participants join", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set<string>()]]),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Trigger onCallStateChanged so the call is tracked and the participant listener is registered
const mockCalls = (CallStore.instance.on as jest.Mock).mock.calls;
const callStateCallback = mockCalls[mockCalls.length - 1][1];
callStateCallback();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
// Record the snapshot version before the participant event fires
const snapshotBefore = viewModel.getSnapshot();
// Simulate another participant joining while the call is already active
mockCall.participants.set("@other:server" as unknown as RoomMember, new Set<string>());
const participantsChangeCallback = mockCall.on.mock.calls[0][1];
participantsChangeCallback(mockCall.participants);
// Snapshot should not have changed
expect(viewModel.getSnapshot()).toBe(snapshotBefore);
});
it("should react to participant changes when a call already exists at instantiation time", () => {
const mockCall = {
callType: CallType.Voice,
participants: new Map([]),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
// Simulate participant joining
mockCall.participants.set(matrixClient.getUserId()! as unknown as RoomMember, new Set());
// Get the callback registered for participant changes
const participantsChangeCallback = mockCall.on.mock.calls[0][1];
participantsChangeCallback();
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
});
it("should unsubscribe from old call participants when the call changes", () => {
const firstCall = {
callType: CallType.Voice,
participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set<string>()]]),
off: jest.fn(),
on: jest.fn(),
};
const secondCall = {
callType: CallType.Video,
participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set<string>()]]),
off: jest.fn(),
on: jest.fn(),
};
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(firstCall as unknown as Call);
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
// Trigger onCallStateChanged to register the first call
const mockCalls = (CallStore.instance.on as jest.Mock).mock.calls;
const callStateCallback = mockCalls[mockCalls.length - 1][1];
callStateCallback();
const participantsCallback = firstCall.on.mock.calls[0][1];
expect(firstCall.on).toHaveBeenCalledWith("participants", participantsCallback);
// Now switch to a different call
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(secondCall as unknown as Call);
callStateCallback();
// The old call's listener must have been removed
expect(firstCall.off).toHaveBeenCalledWith("participants", participantsCallback);
// The new call must have a listener registered
expect(secondCall.on).toHaveBeenCalledWith("participants", expect.any(Function));
});
});
describe("Room name updates", () => {