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
43 changes: 42 additions & 1 deletion apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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();
};
Expand All @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really what we do when using trackListener?
Will the tracker be automatically be cleaned up? Or will we end up checking if for all manually off emitters if they are still connected and only call off in the dispose step on the ones still connected?

Copy link
Member Author

@florianduros florianduros Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tracker cleans up the listeners when the view model is disposed. It's not tied to the lifecycle of the Call object

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;
Expand Down
129 changes: 128 additions & 1 deletion apps/web/test/viewmodels/room-list/RoomListItemViewModel-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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", () => {
Expand Down
Loading