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 RoomScrollStateStore } from "../stores/RoomScrollStateStore";
|
||||||
import { type ConsoleLogger, type IndexedDBLogStore } from "../rageshake/rageshake";
|
import { type ConsoleLogger, type IndexedDBLogStore } from "../rageshake/rageshake";
|
||||||
import type ActiveWidgetStore from "../stores/ActiveWidgetStore";
|
import type ActiveWidgetStore from "../stores/ActiveWidgetStore";
|
||||||
|
import type { CallStatusListener } from "../CallStatusListener";
|
||||||
import { type IConfigOptions } from "../IConfigOptions";
|
import { type IConfigOptions } from "../IConfigOptions";
|
||||||
import { type MatrixDispatcher } from "../dispatcher/dispatcher";
|
import { type MatrixDispatcher } from "../dispatcher/dispatcher";
|
||||||
import { type DeepReadonly } from "./common";
|
import { type DeepReadonly } from "./common";
|
||||||
@@ -84,6 +85,7 @@ declare global {
|
|||||||
mxContentMessages: ContentMessages;
|
mxContentMessages: ContentMessages;
|
||||||
mxToastStore: ToastStore;
|
mxToastStore: ToastStore;
|
||||||
mxDeviceListener: DeviceListener;
|
mxDeviceListener: DeviceListener;
|
||||||
|
mxCallStatusListener: CallStatusListener;
|
||||||
getRoomListStoreV3: () => RoomListStoreV3Class;
|
getRoomListStoreV3: () => RoomListStoreV3Class;
|
||||||
mxPlatformPeg: PlatformPeg;
|
mxPlatformPeg: PlatformPeg;
|
||||||
mxIntegrationManagers: typeof IntegrationManagers;
|
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(),
|
isInitialSyncComplete: vi.fn(),
|
||||||
setGuest: vi.fn(),
|
setGuest: vi.fn(),
|
||||||
setNotifTimelineSet: vi.fn(),
|
setNotifTimelineSet: vi.fn(),
|
||||||
|
getRooms: vi.fn().mockReturnValue([]),
|
||||||
|
matrixRTC: { on: vi.fn(), off: vi.fn() },
|
||||||
});
|
});
|
||||||
// stub this
|
// stub this
|
||||||
vi.spyOn(MatrixClientPeg, "set").mockImplementation(() => {});
|
vi.spyOn(MatrixClientPeg, "set").mockImplementation(() => {});
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ import { checkBrowserSupport } from "./SupportedBrowser";
|
|||||||
import { type URLParams } from "./vector/url_utils.ts";
|
import { type URLParams } from "./vector/url_utils.ts";
|
||||||
import { type OnLoggedInPayload } from "./dispatcher/payloads/OnLoggedInPayload.ts";
|
import { type OnLoggedInPayload } from "./dispatcher/payloads/OnLoggedInPayload.ts";
|
||||||
import { filterBoolean } from "./utils/arrays.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 HOMESERVER_URL_KEY = "mx_hs_url";
|
||||||
const ID_SERVER_URL_KEY = "mx_is_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
|
// This needs to be started after crypto is set up
|
||||||
DeviceListener.sharedInstance().start(client);
|
DeviceListener.sharedInstance().start(client);
|
||||||
|
|
||||||
|
CallStatusListener.sharedInstance().start(CallStore.instance, client);
|
||||||
|
|
||||||
// Similarly, don't start sending presence updates until we've started
|
// Similarly, don't start sending presence updates until we've started
|
||||||
// the client
|
// the client
|
||||||
if (!SettingsStore.getValue("lowBandwidth")) {
|
if (!SettingsStore.getValue("lowBandwidth")) {
|
||||||
@@ -1184,6 +1189,7 @@ export function stopMatrixClient(unsetClient = true): void {
|
|||||||
IntegrationManagers.sharedInstance().stopWatching();
|
IntegrationManagers.sharedInstance().stopWatching();
|
||||||
Mjolnir.sharedInstance().stop();
|
Mjolnir.sharedInstance().stop();
|
||||||
DeviceListener.sharedInstance().stop();
|
DeviceListener.sharedInstance().stop();
|
||||||
|
CallStatusListener.sharedInstance().stop();
|
||||||
DMRoomMap.shared()?.stop();
|
DMRoomMap.shared()?.stop();
|
||||||
EventIndexPeg.stop();
|
EventIndexPeg.stop();
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export enum CallStoreEvent {
|
|||||||
// Signals a change in the call associated with a given room
|
// Signals a change in the call associated with a given room
|
||||||
Call = "call",
|
Call = "call",
|
||||||
// Signals a change in the active calls
|
// 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",
|
ConnectedCalls = "connected_calls",
|
||||||
// Signals a change in the configured RTC transports.
|
// Signals a change in the configured RTC transports.
|
||||||
TransportsUpdated = "transports_updated",
|
TransportsUpdated = "transports_updated",
|
||||||
@@ -136,8 +139,9 @@ export class CallStore extends AsyncStoreWithClient<EmptyObject> {
|
|||||||
return this._connectedCalls;
|
return this._connectedCalls;
|
||||||
}
|
}
|
||||||
private set connectedCalls(value: Set<Call>) {
|
private set connectedCalls(value: Set<Call>) {
|
||||||
|
const prevValue = this._connectedCalls;
|
||||||
this._connectedCalls = value;
|
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
|
// The room IDs are persisted to settings so we can detect unclean disconnects
|
||||||
SettingsStore.setValue(
|
SettingsStore.setValue(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { stubClient } from "test-utils";
|
|||||||
import {
|
import {
|
||||||
clearUserStatus,
|
clearUserStatus,
|
||||||
fetchUserStatus,
|
fetchUserStatus,
|
||||||
|
setUserOnCall,
|
||||||
setUserStatus,
|
setUserStatus,
|
||||||
userStatusFromProfile,
|
userStatusFromProfile,
|
||||||
userStatusTextWithinMaxLength,
|
userStatusTextWithinMaxLength,
|
||||||
@@ -155,4 +156,30 @@ describe("userStatus utils", () => {
|
|||||||
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null);
|
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> {
|
export function clearUserStatus(client: MatrixClient): Promise<void> {
|
||||||
return client.setExtendedProfileProperty("org.matrix.msc4426.status", null);
|
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.
|
* Enables the feature flags required for call tests.
|
||||||
*/
|
*/
|
||||||
export function enableCalls(): { enabledSettings: Set<string> } {
|
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 => {
|
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName): any => {
|
||||||
if (settingName.startsWith("feature_")) return enabledSettings.has(settingName);
|
if (settingName.startsWith("feature_")) return enabledSettings.has(settingName);
|
||||||
if (settingName === "activeCallRoomIds") return [];
|
if (settingName === "activeCallRoomIds") return [];
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
describe("CallStore", () => {
|
describe("CallStore", () => {
|
||||||
let client: MockedObject<MatrixClient>;
|
let client: MockedObject<MatrixClient>;
|
||||||
let room: Room;
|
let room: Room;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
enableCalls();
|
enableCalls();
|
||||||
const res = setUpClientRoomAndStores();
|
const res = setUpClientRoomAndStores();
|
||||||
|
|||||||
Reference in New Issue
Block a user