diff --git a/apps/web/src/@types/global.d.ts b/apps/web/src/@types/global.d.ts index d994bc2013..2fe47901df 100644 --- a/apps/web/src/@types/global.d.ts +++ b/apps/web/src/@types/global.d.ts @@ -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; diff --git a/apps/web/src/CallStatusListener.test.ts b/apps/web/src/CallStatusListener.test.ts new file mode 100644 index 0000000000..9d7295a454 --- /dev/null +++ b/apps/web/src/CallStatusListener.test.ts @@ -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; + + const emitConnectedCalls = (newValue: Set, oldValue: Set): 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(); + }); +}); diff --git a/apps/web/src/CallStatusListener.ts b/apps/web/src/CallStatusListener.ts new file mode 100644 index 0000000000..ca61093bf8 --- /dev/null +++ b/apps/web/src/CallStatusListener.ts @@ -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, oldValue: Set): 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), + ); + } + }; +} diff --git a/apps/web/src/Lifecycle.test.ts b/apps/web/src/Lifecycle.test.ts index 78121c9295..c5c03592a3 100644 --- a/apps/web/src/Lifecycle.test.ts +++ b/apps/web/src/Lifecycle.test.ts @@ -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(() => {}); diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index 6b2e1e42b7..4a9e5e9bf0 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -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(); diff --git a/apps/web/src/stores/CallStore.ts b/apps/web/src/stores/CallStore.ts index 940961b461..d5ce82665e 100644 --- a/apps/web/src/stores/CallStore.ts +++ b/apps/web/src/stores/CallStore.ts @@ -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 The set of calls the user is currently connected to + // - Set 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 { return this._connectedCalls; } private set connectedCalls(value: Set) { + 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( diff --git a/apps/web/src/utils/userStatus.test.ts b/apps/web/src/utils/userStatus.test.ts index 9170b195a5..ee40ad7a39 100644 --- a/apps/web/src/utils/userStatus.test.ts +++ b/apps/web/src/utils/userStatus.test.ts @@ -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); + }); + }); }); diff --git a/apps/web/src/utils/userStatus.ts b/apps/web/src/utils/userStatus.ts index fd16a41f6a..162e403c17 100644 --- a/apps/web/src/utils/userStatus.ts +++ b/apps/web/src/utils/userStatus.ts @@ -146,3 +146,21 @@ export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Pro export function clearUserStatus(client: MatrixClient): Promise { 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 { + return client.setExtendedProfileProperty( + "org.matrix.msc4426.call", + onCall + ? { + call_joined_ts: Date.now(), + } + : null, + ); +} diff --git a/apps/web/test/test-utils/call.ts b/apps/web/test/test-utils/call.ts index 9dee9e10fd..69b3a32840 100644 --- a/apps/web/test/test-utils/call.ts +++ b/apps/web/test/test-utils/call.ts @@ -135,7 +135,7 @@ export function useMockedCalls() { * Enables the feature flags required for call tests. */ export function enableCalls(): { enabledSettings: Set } { - 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 []; diff --git a/apps/web/test/unit-tests/stores/CallStore-test.ts b/apps/web/test/unit-tests/stores/CallStore-test.ts index 2861dc7aa8..61bdc44f37 100644 --- a/apps/web/test/unit-tests/stores/CallStore-test.ts +++ b/apps/web/test/unit-tests/stores/CallStore-test.ts @@ -22,6 +22,7 @@ import { describe("CallStore", () => { let client: MockedObject; let room: Room; + beforeEach(() => { enableCalls(); const res = setUpClientRoomAndStores();