Set auto on-a-call status (#34306)

* Support for reading m.call status

(well, the prefixed version)

* Abstract the details away in userStatusFromProfile

make the validate functions non-exported

* Write on on-=a-call status

* Add tests

* Move user call status logic to its own listener

* Move tests

* use vi rather than jest

* add more mocks

* call async
This commit is contained in:
David Baker
2026-07-21 14:58:00 +00:00
committed by GitHub
parent a509a32a5c
commit 8effa81ad8
10 changed files with 191 additions and 2 deletions
+2
View File
@@ -29,6 +29,7 @@ import { type SetupEncryptionStore } from "../stores/SetupEncryptionStore";
import { type RoomScrollStateStore } from "../stores/RoomScrollStateStore";
import { type ConsoleLogger, type IndexedDBLogStore } from "../rageshake/rageshake";
import type ActiveWidgetStore from "../stores/ActiveWidgetStore";
import type { CallStatusListener } from "../CallStatusListener";
import { type IConfigOptions } from "../IConfigOptions";
import { type MatrixDispatcher } from "../dispatcher/dispatcher";
import { type DeepReadonly } from "./common";
@@ -84,6 +85,7 @@ declare global {
mxContentMessages: ContentMessages;
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxCallStatusListener: CallStatusListener;
getRoomListStoreV3: () => RoomListStoreV3Class;
mxPlatformPeg: PlatformPeg;
mxIntegrationManagers: typeof IntegrationManagers;
+76
View File
@@ -0,0 +1,76 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// @vitest-environment happy-dom
import { EventEmitter } from "events";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { CallStatusListener } from "./CallStatusListener";
import { type Call } from "./models/Call";
import SettingsStore from "./settings/SettingsStore";
import { CallStoreEvent, type CallStore } from "./stores/CallStore";
import * as userStatus from "./utils/userStatus";
describe("CallStatusListener", () => {
let listener: CallStatusListener;
let callStore: CallStore & EventEmitter;
let matrixClient: MatrixClient;
let setUserOnCallSpy: ReturnType<typeof vi.spyOn>;
const emitConnectedCalls = (newValue: Set<Call>, oldValue: Set<Call>): void => {
callStore.emit(CallStoreEvent.ConnectedCalls, newValue, oldValue);
};
beforeEach(() => {
listener = new CallStatusListener();
callStore = new EventEmitter() as unknown as CallStore & EventEmitter;
matrixClient = {} as MatrixClient;
setUserOnCallSpy = vi.spyOn(userStatus, "setUserOnCall").mockResolvedValue(undefined);
vi.spyOn(SettingsStore, "getValue").mockReturnValue(true);
});
afterEach(() => {
listener.stop();
vi.restoreAllMocks();
});
it("sets the user on-a-call status when the user joins a call", () => {
listener.start(callStore, matrixClient);
emitConnectedCalls(new Set([{} as Call]), new Set());
expect(setUserOnCallSpy).toHaveBeenCalledExactlyOnceWith(matrixClient, true);
});
it("clears the user on-a-call status when the user leaves their last call", () => {
listener.start(callStore, matrixClient);
emitConnectedCalls(new Set(), new Set([{} as Call]));
expect(setUserOnCallSpy).toHaveBeenCalledExactlyOnceWith(matrixClient, false);
});
it("does nothing when the number of connected calls changes but stays non-zero", () => {
listener.start(callStore, matrixClient);
emitConnectedCalls(new Set([{} as Call, {} as Call]), new Set([{} as Call]));
expect(setUserOnCallSpy).not.toHaveBeenCalled();
});
it("does nothing when the feature flag is disabled", () => {
vi.spyOn(SettingsStore, "getValue").mockReturnValue(false);
listener.start(callStore, matrixClient);
emitConnectedCalls(new Set([{} as Call]), new Set());
expect(setUserOnCallSpy).not.toHaveBeenCalled();
});
});
+53
View File
@@ -0,0 +1,53 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Call } from "./models/Call";
import SettingsStore from "./settings/SettingsStore";
import { type CallStore, CallStoreEvent } from "./stores/CallStore";
import { setUserOnCall } from "./utils/userStatus";
import { logger } from "matrix-js-sdk/src/logger";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
/**
* Listener that updates the user's on-a-call (m.call) user profile status according to
* whether the user is on a call. Nothing should be calling any methods on it apart
* from start / stop.
*/
export class CallStatusListener {
private callStore: CallStore | undefined;
private matrixClient: MatrixClient | undefined;
public static sharedInstance(): CallStatusListener {
if (!window.mxCallStatusListener) window.mxCallStatusListener = new CallStatusListener();
return window.mxCallStatusListener;
}
public start(callStore: CallStore, matrixClient: MatrixClient): void {
this.callStore = callStore;
this.matrixClient = matrixClient;
this.callStore.on(CallStoreEvent.ConnectedCalls, this.onConnectedCallsChanged);
}
public stop(): void {
this.callStore?.off(CallStoreEvent.ConnectedCalls, this.onConnectedCallsChanged);
this.callStore = undefined;
this.matrixClient = undefined;
}
private onConnectedCallsChanged = (newValue: Set<Call>, oldValue: Set<Call>): void => {
const wasInCall = oldValue.size > 0;
const nowInCall = newValue.size > 0;
if (wasInCall !== nowInCall && SettingsStore.getValue("feature_user_status") && this.matrixClient) {
setUserOnCall(this.matrixClient, nowInCall).catch((err) =>
logger.warn("Failed to update m.call profile field", err),
);
}
};
}
+2
View File
@@ -71,6 +71,8 @@ describe("Lifecycle", () => {
isInitialSyncComplete: vi.fn(),
setGuest: vi.fn(),
setNotifTimelineSet: vi.fn(),
getRooms: vi.fn().mockReturnValue([]),
matrixRTC: { on: vi.fn(), off: vi.fn() },
});
// stub this
vi.spyOn(MatrixClientPeg, "set").mockImplementation(() => {});
+6
View File
@@ -67,6 +67,8 @@ import { checkBrowserSupport } from "./SupportedBrowser";
import { type URLParams } from "./vector/url_utils.ts";
import { type OnLoggedInPayload } from "./dispatcher/payloads/OnLoggedInPayload.ts";
import { filterBoolean } from "./utils/arrays.ts";
import { CallStatusListener } from "./CallStatusListener.ts";
import { CallStore } from "./stores/CallStore.ts";
const HOMESERVER_URL_KEY = "mx_hs_url";
const ID_SERVER_URL_KEY = "mx_is_url";
@@ -1072,6 +1074,9 @@ async function startMatrixClient(
// This needs to be started after crypto is set up
DeviceListener.sharedInstance().start(client);
CallStatusListener.sharedInstance().start(CallStore.instance, client);
// Similarly, don't start sending presence updates until we've started
// the client
if (!SettingsStore.getValue("lowBandwidth")) {
@@ -1184,6 +1189,7 @@ export function stopMatrixClient(unsetClient = true): void {
IntegrationManagers.sharedInstance().stopWatching();
Mjolnir.sharedInstance().stop();
DeviceListener.sharedInstance().stop();
CallStatusListener.sharedInstance().stop();
DMRoomMap.shared()?.stop();
EventIndexPeg.stop();
const cli = MatrixClientPeg.get();
+5 -1
View File
@@ -23,6 +23,9 @@ export enum CallStoreEvent {
// Signals a change in the call associated with a given room
Call = "call",
// Signals a change in the active calls
// Parameters:
// - Set<Call> The set of calls the user is currently connected to
// - Set<Call> The set of calls the user was connected to before this event
ConnectedCalls = "connected_calls",
// Signals a change in the configured RTC transports.
TransportsUpdated = "transports_updated",
@@ -136,8 +139,9 @@ export class CallStore extends AsyncStoreWithClient<EmptyObject> {
return this._connectedCalls;
}
private set connectedCalls(value: Set<Call>) {
const prevValue = this._connectedCalls;
this._connectedCalls = value;
this.emit(CallStoreEvent.ConnectedCalls, value);
this.emit(CallStoreEvent.ConnectedCalls, value, prevValue);
// The room IDs are persisted to settings so we can detect unclean disconnects
SettingsStore.setValue(
+27
View File
@@ -14,6 +14,7 @@ import { stubClient } from "test-utils";
import {
clearUserStatus,
fetchUserStatus,
setUserOnCall,
setUserStatus,
userStatusFromProfile,
userStatusTextWithinMaxLength,
@@ -155,4 +156,30 @@ describe("userStatus utils", () => {
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null);
});
});
describe("setUserOnCall", () => {
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
});
it("sets the call status with the current time if onCall is true", async () => {
vi.useFakeTimers().setSystemTime(12345);
await setUserOnCall(client, true);
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", {
call_joined_ts: 12345,
});
vi.useRealTimers();
});
it("clears the call status if onCall is false", async () => {
await setUserOnCall(client, false);
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", null);
});
});
});
+18
View File
@@ -146,3 +146,21 @@ export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Pro
export function clearUserStatus(client: MatrixClient): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.status", null);
}
/**
* Sets or clears the user's m.call status to represent that they are currently on a call or not.
* If onCall is true, the status will be set to show that they joined the call at the time when this
* function is called.
* @param client The matrix client to use
* @param onCall Whether the user is currently on a call.
*/
export function setUserOnCall(client: MatrixClient, onCall: boolean): Promise<void> {
return client.setExtendedProfileProperty(
"org.matrix.msc4426.call",
onCall
? {
call_joined_ts: Date.now(),
}
: null,
);
}
+1 -1
View File
@@ -135,7 +135,7 @@ export function useMockedCalls() {
* Enables the feature flags required for call tests.
*/
export function enableCalls(): { enabledSettings: Set<string> } {
const enabledSettings = new Set(["feature_video_rooms", "feature_element_call_video_rooms"]);
const enabledSettings = new Set(["feature_video_rooms", "feature_element_call_video_rooms", "feature_user_status"]);
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName): any => {
if (settingName.startsWith("feature_")) return enabledSettings.has(settingName);
if (settingName === "activeCallRoomIds") return [];
@@ -22,6 +22,7 @@ import {
describe("CallStore", () => {
let client: MockedObject<MatrixClient>;
let room: Room;
beforeEach(() => {
enableCalls();
const res = setUpClientRoomAndStores();