From bce6e46e6751546be49feb8105b4b1bebd8c4037 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 7 Jul 2026 14:33:18 +0100 Subject: [PATCH 1/2] PoC for displaying on-a-call status Claude powered. --- apps/web/src/MatrixClientPeg.ts | 2 +- apps/web/src/hooks/useUserStatus.ts | 18 ++++++- apps/web/src/i18n/strings/en_EN.json | 3 ++ apps/web/src/utils/userStatus.ts | 22 ++++++++ .../unit-tests/hooks/useUserStatus-test.tsx | 51 +++++++++++++++++++ .../test/unit-tests/utils/userStatus-test.ts | 45 +++++++++++++++- 6 files changed, 137 insertions(+), 4 deletions(-) diff --git a/apps/web/src/MatrixClientPeg.ts b/apps/web/src/MatrixClientPeg.ts index 03d47b0a335..38397fe322e 100644 --- a/apps/web/src/MatrixClientPeg.ts +++ b/apps/web/src/MatrixClientPeg.ts @@ -266,7 +266,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours opts.threadSupport = true; if (SettingsStore.getValue("feature_user_status")) { - opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status"]; + opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status", "org.matrix.msc4426.call"]; } if (SettingsStore.getValue("feature_sliding_sync")) { diff --git a/apps/web/src/hooks/useUserStatus.ts b/apps/web/src/hooks/useUserStatus.ts index 131e43f9aec..e3c2184deb9 100644 --- a/apps/web/src/hooks/useUserStatus.ts +++ b/apps/web/src/hooks/useUserStatus.ts @@ -13,7 +13,7 @@ import { type UserStatus } from "@element-hq/web-shared-components"; import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import { useTypedEventEmitter } from "./useEventEmitter"; import { useFeatureEnabled } from "./useSettings"; -import { validateUserStatus } from "../utils/userStatus"; +import { resolveUserStatus } from "../utils/userStatus"; const logger = rootLogger.getChild("useUserStatus"); @@ -28,6 +28,7 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine const isEnabled = useFeatureEnabled("feature_user_status"); const matrixClient = useMatrixClientContext(); const [rawUserStatus, setRawUserStatus] = useState(); + const [rawCallStatus, setRawCallStatus] = useState(); useTypedEventEmitter(matrixClient, ClientEvent.UserProfileUpdate, (syncedUserId, syncProfile) => { if (syncedUserId !== userId) { @@ -35,6 +36,7 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine } setRawUserStatus(syncProfile["org.matrix.msc4426.status"]); + setRawCallStatus(syncProfile["org.matrix.msc4426.call"]); }); useEffect(() => { (async () => { @@ -43,10 +45,12 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine } if (!userId) { setRawUserStatus(undefined); + setRawCallStatus(undefined); return; } if ((await matrixClient.doesServerSupportExtendedProfiles()) === false) { setRawUserStatus(undefined); + setRawCallStatus(undefined); return; } try { @@ -59,11 +63,21 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine logger.warn(`Failed to get userStatus for ${userId}`, ex); } } + try { + const result = await matrixClient.getExtendedProfileProperty(userId, "org.matrix.msc4426.call"); + setRawCallStatus(result); + } catch (ex) { + if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") { + setRawCallStatus(undefined); + } else { + logger.warn(`Failed to get call status for ${userId}`, ex); + } + } })(); }, [isEnabled, userId, matrixClient]); if (!isEnabled) { return; } - return validateUserStatus(rawUserStatus); + return resolveUserStatus(rawUserStatus, rawCallStatus); } diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index da7ef74f66f..75c6a25c63e 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -3823,6 +3823,9 @@ "verify_button": "Verify User", "verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices." }, + "user_status": { + "on_a_call": "On a call" + }, "voip": { "already_in_call": "Already in call", "already_in_call_person": "You're already in a call with this person.", diff --git a/apps/web/src/utils/userStatus.ts b/apps/web/src/utils/userStatus.ts index 6020b6bf893..443359c8f3d 100644 --- a/apps/web/src/utils/userStatus.ts +++ b/apps/web/src/utils/userStatus.ts @@ -8,6 +8,8 @@ Please see LICENSE files in the repository root for full details. import { type UserStatus } from "@element-hq/web-shared-components"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { _t } from "../languageHandler"; + // MSC4426 defines the maximum length of a status to be 256 bytes of UTF-8, // so we truncate anything longer than that. const MAX_STATUS_TEXT_BYTES = 256; @@ -35,6 +37,26 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin }; } +export function isUserOnCall(rawCallStatus: unknown): boolean { + return typeof rawCallStatus === "object" && rawCallStatus !== null; +} + +/** + * Resolves the effective user status to display, given the raw `m.status` and `m.call` profile + * field values. `m.status` always takes precedence; `m.call` is only shown as a fallback ("On a + * call") when there is no valid `m.status` set. + */ +export function resolveUserStatus(rawUserStatus: unknown, rawCallStatus: unknown): UserStatus | undefined { + const userStatus = validateUserStatus(rawUserStatus); + if (userStatus) { + return userStatus; + } + if (isUserOnCall(rawCallStatus)) { + return { emoji: "🎧", text: _t("user_status|on_a_call") }; + } + return undefined; +} + export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Promise { return client.setExtendedProfileProperty("org.matrix.msc4426.status", { emoji: userStatus.emoji, diff --git a/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx b/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx index 6f24a5b3ec5..41fe350d75f 100644 --- a/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx +++ b/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx @@ -153,4 +153,55 @@ describe("useUserStatus", () => { // Should still have original status expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }); }); + + it("returns 'On a call' when m.call is set and there is no m.status", async () => { + client.getExtendedProfileProperty.mockImplementation((_uid, key) => { + if (key === "org.matrix.msc4426.call") return Promise.resolve({}); + return Promise.resolve(undefined); + }); + const { result } = render(); + await waitFor(() => expect(result.current).toEqual({ emoji: "🎧", text: "On a call" })); + }); + + it("prefers m.status over m.call when both are set", async () => { + client.getExtendedProfileProperty.mockImplementation((_uid, key) => { + if (key === "org.matrix.msc4426.status") return Promise.resolve({ emoji: "🐎", text: "on a horse" }); + if (key === "org.matrix.msc4426.call") return Promise.resolve({}); + return Promise.resolve(undefined); + }); + const { result } = render(); + await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" })); + }); + + it("reverts to m.status when a call ends", async () => { + client.getExtendedProfileProperty.mockImplementation((_uid, key) => { + if (key === "org.matrix.msc4426.status") return Promise.resolve({ emoji: "🐎", text: "on a horse" }); + if (key === "org.matrix.msc4426.call") return Promise.resolve({}); + return Promise.resolve(undefined); + }); + const { result } = render(); + await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" })); + + client.emit(ClientEvent.UserProfileUpdate, userId, { + "org.matrix.msc4426.status": { emoji: "🐎", text: "on a horse" }, + "org.matrix.msc4426.call": undefined, + }); + + await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" })); + }); + + it("returns undefined when a call ends and there is no m.status", async () => { + client.getExtendedProfileProperty.mockImplementation((_uid, key) => { + if (key === "org.matrix.msc4426.call") return Promise.resolve({}); + return Promise.resolve(undefined); + }); + const { result } = render(); + await waitFor(() => expect(result.current).toEqual({ emoji: "🎧", text: "On a call" })); + + client.emit(ClientEvent.UserProfileUpdate, userId, { + "org.matrix.msc4426.call": undefined, + }); + + await waitFor(() => expect(result.current).toBeUndefined()); + }); }); diff --git a/apps/web/test/unit-tests/utils/userStatus-test.ts b/apps/web/test/unit-tests/utils/userStatus-test.ts index 5ae29dcded9..8f941ead6c0 100644 --- a/apps/web/test/unit-tests/utils/userStatus-test.ts +++ b/apps/web/test/unit-tests/utils/userStatus-test.ts @@ -7,7 +7,13 @@ Please see LICENSE files in the repository root for full details. import { type MatrixClient } from "matrix-js-sdk/src/matrix"; -import { clearUserStatus, setUserStatus, userStatusTextWithinMaxLength } from "../../../src/utils/userStatus"; +import { + clearUserStatus, + isUserOnCall, + resolveUserStatus, + setUserStatus, + userStatusTextWithinMaxLength, +} from "../../../src/utils/userStatus"; import { stubClient } from "../../test-utils"; describe("userStatus utils", () => { @@ -22,6 +28,43 @@ describe("userStatus utils", () => { }); }); + describe("isUserOnCall", () => { + it("returns true for an object value", () => { + expect(isUserOnCall({})).toBe(true); + expect(isUserOnCall({ call_joined_ts: 12345 })).toBe(true); + }); + it("returns false for undefined", () => { + expect(isUserOnCall(undefined)).toBe(false); + }); + it("returns false for null", () => { + expect(isUserOnCall(null)).toBe(false); + }); + it("returns false for a non-object value", () => { + expect(isUserOnCall("in a call")).toBe(false); + }); + }); + + describe("resolveUserStatus", () => { + it("returns undefined when neither m.status nor m.call are set", () => { + expect(resolveUserStatus(undefined, undefined)).toBeUndefined(); + }); + it("returns the m.status value when only m.status is set", () => { + expect(resolveUserStatus({ emoji: "🐎", text: "on a horse" }, undefined)).toEqual({ + emoji: "🐎", + text: "on a horse", + }); + }); + it("returns the call status when only m.call is set", () => { + expect(resolveUserStatus(undefined, {})).toEqual({ emoji: "🎧", text: "On a call" }); + }); + it("prefers m.status over m.call when both are set", () => { + expect(resolveUserStatus({ emoji: "🐎", text: "on a horse" }, {})).toEqual({ + emoji: "🐎", + text: "on a horse", + }); + }); + }); + describe("setUserStatus", () => { let client: MatrixClient; From 11ac8c1fdc1f5e3f0f2a51fc62f5686f8c0e9b78 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 7 Jul 2026 15:16:03 +0100 Subject: [PATCH 2/2] Add support for updating on-a-call status Also Claude. --- apps/web/src/stores/CallStore.ts | 13 +++ apps/web/src/utils/userStatus.ts | 10 ++ .../test/unit-tests/stores/CallStore-test.ts | 101 +++++++++++++++++- .../test/unit-tests/utils/userStatus-test.ts | 32 ++++++ 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/apps/web/src/stores/CallStore.ts b/apps/web/src/stores/CallStore.ts index 119a0f6d6e8..0ae2231ab2d 100644 --- a/apps/web/src/stores/CallStore.ts +++ b/apps/web/src/stores/CallStore.ts @@ -17,6 +17,7 @@ import WidgetStore from "./WidgetStore"; import SettingsStore from "../settings/SettingsStore"; import { SettingLevel } from "../settings/SettingLevel"; import { Call, CallEvent, ConnectionState } from "../models/Call"; +import { clearUserOnCall, setUserOnCall } from "../utils/userStatus"; export enum CallStoreEvent { // Signals a change in the call associated with a given room @@ -132,9 +133,21 @@ export class CallStore extends AsyncStoreWithClient { return this._connectedCalls; } private set connectedCalls(value: Set) { + const wasInCall = this._connectedCalls.size > 0; this._connectedCalls = value; + const nowInCall = value.size > 0; this.emit(CallStoreEvent.ConnectedCalls, value); + // While the user is participating in a call, advertise an `m.call` profile field (MSC4426) + // so their "on a call" status is visible to others, clearing it once they leave all calls. + // Only act on the empty<->non-empty edge so concurrent calls don't re-write the field. + if (wasInCall !== nowInCall && SettingsStore.getValue("feature_user_status") && this.matrixClient) { + const client = this.matrixClient; + void (nowInCall ? setUserOnCall(client) : clearUserOnCall(client)).catch((err) => + logger.warn("Failed to update m.call profile field", err), + ); + } + // The room IDs are persisted to settings so we can detect unclean disconnects SettingsStore.setValue( "activeCallRoomIds", diff --git a/apps/web/src/utils/userStatus.ts b/apps/web/src/utils/userStatus.ts index 443359c8f3d..c660ae992ba 100644 --- a/apps/web/src/utils/userStatus.ts +++ b/apps/web/src/utils/userStatus.ts @@ -67,3 +67,13 @@ export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Pro export function clearUserStatus(client: MatrixClient): Promise { return client.setExtendedProfileProperty("org.matrix.msc4426.status", null); } + +export function setUserOnCall(client: MatrixClient): Promise { + return client.setExtendedProfileProperty("org.matrix.msc4426.call", { + call_joined_ts: Date.now(), + }); +} + +export function clearUserOnCall(client: MatrixClient): Promise { + return client.setExtendedProfileProperty("org.matrix.msc4426.call", null); +} diff --git a/apps/web/test/unit-tests/stores/CallStore-test.ts b/apps/web/test/unit-tests/stores/CallStore-test.ts index 847c4b35929..a12a8c790bd 100644 --- a/apps/web/test/unit-tests/stores/CallStore-test.ts +++ b/apps/web/test/unit-tests/stores/CallStore-test.ts @@ -6,23 +6,28 @@ */ import { type CallMembership, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc"; -import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { type MockedObject } from "jest-mock"; +import { logger } from "matrix-js-sdk/src/logger"; -import { ElementCall } from "../../../src/models/Call"; +import { ConnectionState, ElementCall } from "../../../src/models/Call"; import { CallStore } from "../../../src/stores/CallStore"; import { setUpClientRoomAndStores, cleanUpClientRoomAndStores, setupAsyncStoreWithClient, enableCalls, + useMockedCalls, + MockedCall, + flushPromises, } from "../../test-utils"; describe("CallStore", () => { let client: MockedObject; let room: Room; + let enabledSettings: Set; beforeEach(() => { - enableCalls(); + ({ enabledSettings } = enableCalls()); const res = setUpClientRoomAndStores(); client = res.client; room = res.room; @@ -66,4 +71,94 @@ describe("CallStore", () => { { type: "type-d", other_data: "baz" }, ]); }); + + describe("m.call profile field (on-a-call status)", () => { + let call: MockedCall; + let call2: MockedCall; + + beforeEach(async () => { + useMockedCalls(); + // Reset the singleton so calls from previous tests don't linger in the map + // (setupAsyncStoreWithClient only runs onReady, never onNotReady). + // @ts-ignore protected access + await CallStore.instance.onNotReady(); + await setupAsyncStoreWithClient(CallStore.instance, client); + MockedCall.create(room, "1"); + call = CallStore.instance.getCall(room.roomId) as MockedCall; + }); + + const createSecondCall = (): void => { + const room2 = new Room("!2:example.org", client, "@alice:example.org"); + client.getRoom.mockImplementation((roomId) => { + if (roomId === room.roomId) return room; + if (roomId === room2.roomId) return room2; + return null; + }); + MockedCall.create(room2, "2"); + call2 = CallStore.instance.getCall(room2.roomId) as MockedCall; + }; + + describe("when feature_user_status is enabled", () => { + beforeEach(() => { + enabledSettings.add("feature_user_status"); + }); + + it("sets m.call when the user joins their first call", () => { + call.setConnectionState(ConnectionState.Connected); + + expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", { + call_joined_ts: expect.any(Number), + }); + }); + + it("clears m.call when the user leaves their last call", () => { + call.setConnectionState(ConnectionState.Connected); + client.setExtendedProfileProperty.mockClear(); + + call.setConnectionState(ConnectionState.Disconnected); + + expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", null); + }); + + it("does not re-write m.call when joining a second concurrent call", () => { + call.setConnectionState(ConnectionState.Connected); + client.setExtendedProfileProperty.mockClear(); + + createSecondCall(); + call2.setConnectionState(ConnectionState.Connected); + + expect(client.setExtendedProfileProperty).not.toHaveBeenCalled(); + }); + + it("does not clear m.call while still connected to another call", () => { + call.setConnectionState(ConnectionState.Connected); + createSecondCall(); + call2.setConnectionState(ConnectionState.Connected); + client.setExtendedProfileProperty.mockClear(); + + call.setConnectionState(ConnectionState.Disconnected); + + expect(client.setExtendedProfileProperty).not.toHaveBeenCalled(); + }); + + it("swallows failures to update the profile field", async () => { + const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {}); + client.setExtendedProfileProperty.mockRejectedValue(new Error("Server does not support extended profiles")); + + expect(() => call.setConnectionState(ConnectionState.Connected)).not.toThrow(); + await flushPromises(); + + expect(warnSpy).toHaveBeenCalledWith("Failed to update m.call profile field", expect.any(Error)); + }); + }); + + describe("when feature_user_status is disabled", () => { + it("never touches the m.call profile field", () => { + call.setConnectionState(ConnectionState.Connected); + call.setConnectionState(ConnectionState.Disconnected); + + expect(client.setExtendedProfileProperty).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/apps/web/test/unit-tests/utils/userStatus-test.ts b/apps/web/test/unit-tests/utils/userStatus-test.ts index 8f941ead6c0..6c7f5325e0a 100644 --- a/apps/web/test/unit-tests/utils/userStatus-test.ts +++ b/apps/web/test/unit-tests/utils/userStatus-test.ts @@ -8,9 +8,11 @@ Please see LICENSE files in the repository root for full details. import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { + clearUserOnCall, clearUserStatus, isUserOnCall, resolveUserStatus, + setUserOnCall, setUserStatus, userStatusTextWithinMaxLength, } from "../../../src/utils/userStatus"; @@ -95,4 +97,34 @@ describe("userStatus utils", () => { expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null); }); }); + + describe("setUserOnCall", () => { + let client: MatrixClient; + + beforeEach(() => { + client = stubClient(); + }); + + it("sets the m.call profile field with a join timestamp", async () => { + setUserOnCall(client); + + expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", { + call_joined_ts: expect.any(Number), + }); + }); + }); + + describe("clearUserOnCall", () => { + let client: MatrixClient; + + beforeEach(() => { + client = stubClient(); + }); + + it("clears the m.call profile field", async () => { + clearUserOnCall(client); + + expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", null); + }); + }); });