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:
Vendored
+2
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user