Show & clear your own on-a-call status (#34613)

* Show & clear your own on-a-call status

The user menu / settings now reflects your own on-a-call status
and pressing the 'clear' button will clear both m.status and m.call,
whichever are set.

* Update function name

* Fix tests

And make the clear status function simpler by just throwing if either fails.

* Fix more tests
This commit is contained in:
David Baker
2026-08-10 14:13:42 +00:00
committed by GitHub
parent 0b264a482e
commit fef1f0e0dd
8 changed files with 54 additions and 17 deletions
+3 -7
View File
@@ -25,7 +25,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg";
import { _t } from "../languageHandler"; import { _t } from "../languageHandler";
import { mediaFromMxc } from "../customisations/Media"; import { mediaFromMxc } from "../customisations/Media";
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import { userStatusFromProfile } from "../utils/userStatus"; import { fetchUserStatus } from "../utils/userStatus";
interface IState { interface IState {
displayName?: string; displayName?: string;
@@ -202,12 +202,8 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
if (!this.matrixClient) return; if (!this.matrixClient) return;
if (!SettingsStore.getValue("feature_user_status")) return; if (!SettingsStore.getValue("feature_user_status")) return;
const rawUserStatus = await this.matrixClient.getExtendedProfileProperty( const userStatus = await fetchUserStatus(this.matrixClient, this.matrixClient.getSafeUserId());
this.matrixClient.getSafeUserId(), await this.updateState({ userStatus });
"org.matrix.msc4426.status",
);
// We don't show our own "on a call" status so we pass undefined for the call status.
await this.updateState({ userStatus: userStatusFromProfile(rawUserStatus, undefined) });
}; };
private onStateEvents = async (ev: MatrixEvent): Promise<void> => { private onStateEvents = async (ev: MatrixEvent): Promise<void> => {
+13 -3
View File
@@ -12,7 +12,7 @@ import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { stubClient } from "test-utils"; import { stubClient } from "test-utils";
import { import {
clearUserStatus, clearAllUserStatus,
fetchUserStatus, fetchUserStatus,
setUserOnCall, setUserOnCall,
setUserStatus, setUserStatus,
@@ -143,7 +143,7 @@ describe("userStatus utils", () => {
}); });
}); });
describe("clearUserStatus", () => { describe("clearAllUserStatus", () => {
let client: MatrixClient; let client: MatrixClient;
beforeEach(() => { beforeEach(() => {
@@ -151,10 +151,20 @@ describe("userStatus utils", () => {
}); });
it("clears the user status", async () => { it("clears the user status", async () => {
clearUserStatus(client); vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({ emoji: "🍩", text: "Arbitrary Status" });
await clearAllUserStatus(client);
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null); expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null);
}); });
it("clears the call status", async () => {
vi.mocked(client.getExtendedProfileProperty).mockResolvedValue({ call_joined_ts: 12345 });
await clearAllUserStatus(client);
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", null);
});
}); });
describe("setUserOnCall", () => { describe("setUserOnCall", () => {
+13 -3
View File
@@ -139,12 +139,22 @@ export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Pro
} }
/** /**
* Clears the MSC4426 user status for the given user. * Clears all MSC4426 user status for the given user, including their m.status and m.call status,
* if anything is set in those fields.
* *
* @param client The Matrix client to use. * @param client The Matrix client to use.
* @throws If either request fails, in which case the other may also not have been cleared.
*/ */
export function clearUserStatus(client: MatrixClient): Promise<void> { export async function clearAllUserStatus(client: MatrixClient): Promise<void> {
return client.setExtendedProfileProperty("org.matrix.msc4426.status", null); const rawUserStatus = await client.getExtendedProfileProperty(client.getSafeUserId(), "org.matrix.msc4426.status");
if (rawUserStatus) {
await client.setExtendedProfileProperty("org.matrix.msc4426.status", null);
}
const rawCallStatus = await client.getExtendedProfileProperty(client.getSafeUserId(), "org.matrix.msc4426.call");
if (rawCallStatus) {
await setUserOnCall(client, false);
}
} }
/** /**
@@ -19,7 +19,7 @@ import { shouldShowFeedback } from "../../utils/Feedback";
import { getHomePageUrl } from "../../utils/pages"; import { getHomePageUrl } from "../../utils/pages";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { clearUserStatus } from "../../utils/userStatus"; import { clearAllUserStatus } from "../../utils/userStatus";
import { type SetStatusViewModel, UserMenuSetStatusViewModel } from "../status/SetStatusViewModel"; import { type SetStatusViewModel, UserMenuSetStatusViewModel } from "../status/SetStatusViewModel";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
@@ -153,7 +153,7 @@ export class UserMenuViewModel
public readonly clearStatus = (): void => { public readonly clearStatus = (): void => {
this.setOpen(false); this.setOpen(false);
clearUserStatus(this.client).catch((err) => { clearAllUserStatus(this.client).catch((err) => {
logger.warn("Failed to clear user status", err); logger.warn("Failed to clear user status", err);
}); });
}; };
@@ -39,6 +39,7 @@ describe("SetStatusViewModel", () => {
client = getMockClientWithEventEmitter({ client = getMockClientWithEventEmitter({
...mockClientMethodsUser(), ...mockClientMethodsUser(),
...mockClientMethodsServer(), ...mockClientMethodsServer(),
getExtendedProfileProperty: vi.fn().mockResolvedValue(undefined),
setExtendedProfileProperty: vi.fn().mockResolvedValue(undefined), setExtendedProfileProperty: vi.fn().mockResolvedValue(undefined),
}); });
vi.mocked(mockOwnProfileStoreInstance).userStatus = undefined; vi.mocked(mockOwnProfileStoreInstance).userStatus = undefined;
@@ -127,6 +128,7 @@ describe("SetStatusViewModel", () => {
}); });
it("calls setExtendedProfileProperty with null", async () => { it("calls setExtendedProfileProperty with null", async () => {
client.getExtendedProfileProperty.mockResolvedValue(STATUS);
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance }); const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.clearStatus(); vm.clearStatus();
await waitFor(() => await waitFor(() =>
@@ -145,6 +147,7 @@ describe("SetStatusViewModel", () => {
it("rolls back the snapshot on failure", async () => { it("rolls back the snapshot on failure", async () => {
vi.mocked(mockOwnProfileStoreInstance).userStatus = STATUS; vi.mocked(mockOwnProfileStoreInstance).userStatus = STATUS;
client.getExtendedProfileProperty.mockResolvedValue(STATUS);
client.setExtendedProfileProperty.mockRejectedValue(new Error("network error")); client.setExtendedProfileProperty.mockRejectedValue(new Error("network error"));
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance }); const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.clearStatus(); vm.clearStatus();
@@ -169,6 +172,7 @@ describe("UserMenuSetStatusViewModel", () => {
client = getMockClientWithEventEmitter({ client = getMockClientWithEventEmitter({
...mockClientMethodsUser(), ...mockClientMethodsUser(),
...mockClientMethodsServer(), ...mockClientMethodsServer(),
getExtendedProfileProperty: vi.fn().mockResolvedValue(undefined),
setExtendedProfileProperty: vi.fn().mockResolvedValue(undefined), setExtendedProfileProperty: vi.fn().mockResolvedValue(undefined),
}); });
vi.mocked(mockOwnProfileStoreInstance).userStatus = undefined; vi.mocked(mockOwnProfileStoreInstance).userStatus = undefined;
@@ -203,6 +207,7 @@ describe("UserMenuSetStatusViewModel", () => {
}); });
it("inherits clearStatus from SetStatusViewModel", async () => { it("inherits clearStatus from SetStatusViewModel", async () => {
client.getExtendedProfileProperty.mockResolvedValue(STATUS);
const vm = new UserMenuSetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance }); const vm = new UserMenuSetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.clearStatus(); vm.clearStatus();
await waitFor(() => await waitFor(() =>
@@ -14,7 +14,7 @@ import {
type UserStatus, type UserStatus,
} from "@element-hq/web-shared-components"; } from "@element-hq/web-shared-components";
import { clearUserStatus, setUserStatus } from "../../utils/userStatus"; import { clearAllUserStatus, setUserStatus } from "../../utils/userStatus";
import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { UserTab } from "../../components/views/dialogs/UserTab"; import { UserTab } from "../../components/views/dialogs/UserTab";
@@ -59,7 +59,7 @@ export class SetStatusViewModel
const oldStatus = this.snapshot.current.userStatus; const oldStatus = this.snapshot.current.userStatus;
this.snapshot.merge({ userStatus: undefined }); this.snapshot.merge({ userStatus: undefined });
clearUserStatus(this.props.client).catch((err) => { clearAllUserStatus(this.props.client).catch((err) => {
this.snapshot.merge({ userStatus: oldStatus }); this.snapshot.merge({ userStatus: oldStatus });
logger.warn("Failed to clear user status", err); logger.warn("Failed to clear user status", err);
}); });
@@ -110,6 +110,20 @@ describe("OwnProfileStore", () => {
expect(ownProfileStore.userStatus).toEqual({ emoji: "🏝️", text: "On a tropical holiday" }); expect(ownProfileStore.userStatus).toEqual({ emoji: "🏝️", text: "On a tropical holiday" });
}); });
it("should reflect our own on-a-call status if no other status is set", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
client.getExtendedProfileProperty.mockImplementation(async (_userId, key) =>
key === "org.matrix.msc4426.call" ? { call_joined_ts: 12345 } : undefined,
);
await ownProfileStore.start();
expect(client.getExtendedProfileProperty).toHaveBeenCalledWith(
client.getSafeUserId(),
"org.matrix.msc4426.call",
);
expect(ownProfileStore.userStatus).toEqual({ emoji: "📞", text: "On a call" });
});
it("should update the user status when a UserProfileUpdate event is received for the current user", async () => { it("should update the user status when a UserProfileUpdate event is received for the current user", async () => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🏝️", text: "On a tropical holiday" }); client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🏝️", text: "On a tropical holiday" });
@@ -32,6 +32,7 @@ describe("UserMenuViewModel", () => {
...mockClientMethodsUser(), ...mockClientMethodsUser(),
...mockClientMethodsServer(), ...mockClientMethodsServer(),
getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)), getAuthMetadata: jest.fn().mockRejectedValue(new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404)),
getExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined), setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
}); });
sdkContext = new TestSDKContext(); sdkContext = new TestSDKContext();
@@ -178,6 +179,7 @@ describe("UserMenuViewModel", () => {
}); });
it("can clear a user status", async () => { it("can clear a user status", async () => {
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🧪", text: "Testing" });
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true); const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true); vm.setOpen(true);
vm.clearStatus(); vm.clearStatus();