diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index d7ce4e6e7f..54a3ce965b 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -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>): 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; diff --git a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx index 873f786d1b..9850f244e0 100644 --- a/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx @@ -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()]]), + 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()); + 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()]]), + off: jest.fn(), + on: jest.fn(), + }; + const secondCall = { + callType: CallType.Video, + participants: new Map([[matrixClient.getUserId()! as unknown as RoomMember, new Set()]]), + 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", () => {