Support for reading m.call status (#34295)

* Support for reading m.call status

(well, the prefixed version)

* Abstract the details away in userStatusFromProfile

make the validate functions non-exported

* Add test

* format the oxen

* Remove set function

should be in the other PR
This commit is contained in:
David Baker
2026-07-17 10:07:38 +00:00
committed by GitHub
parent 79ac7fbc9e
commit adbe90ceb7
8 changed files with 124 additions and 16 deletions
+1 -1
View File
@@ -270,7 +270,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
}
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")) {
+4 -2
View File
@@ -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 { fetchUserStatus, validateUserStatus } from "../utils/userStatus";
import { fetchUserStatus, userStatusFromProfile } from "../utils/userStatus";
const logger = rootLogger.getChild("useUserStatus");
@@ -34,7 +34,9 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine
return;
}
setUserStatus(validateUserStatus(syncProfile["org.matrix.msc4426.status"]));
setUserStatus(
userStatusFromProfile(syncProfile["org.matrix.msc4426.status"], syncProfile["org.matrix.msc4426.call"]),
);
});
useEffect(() => {
(async () => {
+3
View File
@@ -3828,6 +3828,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.",
+3 -2
View File
@@ -25,7 +25,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg";
import { _t } from "../languageHandler";
import { mediaFromMxc } from "../customisations/Media";
import SettingsStore from "../settings/SettingsStore";
import { validateUserStatus } from "../utils/userStatus";
import { userStatusFromProfile } from "../utils/userStatus";
interface IState {
displayName?: string;
@@ -206,7 +206,8 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
this.matrixClient.getSafeUserId(),
"org.matrix.msc4426.status",
);
await this.updateState({ userStatus: validateUserStatus(rawUserStatus) });
// 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> => {
+61 -7
View File
@@ -5,14 +5,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type UserStatus } from "@element-hq/web-shared-components";
import { _td, type UserStatus } from "@element-hq/web-shared-components";
import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
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;
// We don't use the actual UserStatus type here as we want to translate the string at runtime,
// so we can make the types reflect the fact it's not ready for human consumption.
const ON_A_CALL_STATUS = {
emoji: "📞",
textKey: _td("user_status|on_a_call"),
};
// Static Intl.Segmenter for grabbing the first grapheme of a user status emoji.
// We make one and keep it for performance.
const intlSegmenter = new Intl.Segmenter();
@@ -32,7 +41,7 @@ export function userStatusTextWithinMaxLength(text: string): boolean {
* @param rawUserStatus The raw user status object to validate.
* @returns A UserStatus object if valid, otherwise undefined.
*/
export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefined {
function validateUserStatus(rawUserStatus: unknown): UserStatus | undefined {
if (typeof rawUserStatus !== "object" || rawUserStatus === null) {
return undefined;
}
@@ -51,8 +60,38 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin
}
/**
* Fetch the MSC4426 user status of the given user. Returns undefined if the server does not
* support extended profiles, the user has no (valid) status, or the status could not be fetched.
* Takes the raw result from getExtendedProfileProperty for m.call, validates it and
* returns true a UserStatus object reflect it, undefined if there is no status or it
* does not say that the user is on a call.
* Designed to be the same API as validateUserStatus for simplicty.
* @param rawCallStatus
*/
function validateMCallStatus(rawCallStatus: unknown): UserStatus | undefined {
if (!rawCallStatus || typeof rawCallStatus !== "object") return undefined;
if (!("call_joined_ts" in rawCallStatus) || typeof rawCallStatus.call_joined_ts !== "number") return undefined;
if (rawCallStatus.call_joined_ts > 0) return { emoji: ON_A_CALL_STATUS.emoji, text: _t(ON_A_CALL_STATUS.textKey) };
return undefined;
}
/**
* Takes both MSC4426 user status fields (m.status and m.call) and returns a UserStatus
* object that reflects the information they represent.
*/
export function userStatusFromProfile(userStatus: unknown, callStatus: unknown): UserStatus | undefined {
const validatedUserStatus = validateUserStatus(userStatus);
if (validatedUserStatus) return validatedUserStatus;
const validatedCallStatus = validateMCallStatus(callStatus);
if (validatedCallStatus) return validatedCallStatus;
return undefined;
}
/**
* Fetch the MSC4426 user status of the given user, taking into account m.call if present.
* Returns undefined if the server does not support extended profiles, the user has no
* (valid) status, or the status could not be fetched.
*
* @param client The Matrix client to fetch the status with.
* @param userId The ID of the user whose status is being fetched.
@@ -61,14 +100,29 @@ export async function fetchUserStatus(client: MatrixClient, userId: string): Pro
if ((await client.doesServerSupportExtendedProfiles()) === false) {
return undefined;
}
let rawUserStatus: unknown;
let rawCallStatus: unknown;
try {
return validateUserStatus(await client.getExtendedProfileProperty(userId, "org.matrix.msc4426.status"));
// nb. one of these may be redundant since one takes precedence over the other, but the two
// are fetched in the same call by the js-sdk anyway so it will only be one API call and this
// is simpler and duplicates less logic.
rawUserStatus = await client.getExtendedProfileProperty(userId, "org.matrix.msc4426.status");
} catch (ex) {
if (!(ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND")) {
logger.warn(`Failed to get userStatus for ${userId}`, ex);
logger.warn(`Failed to get user status for ${userId}`, ex);
}
return undefined;
}
try {
rawCallStatus = await client.getExtendedProfileProperty(userId, "org.matrix.msc4426.call");
} catch (ex) {
if (!(ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND")) {
logger.warn(`Failed to get call status for ${userId}`, ex);
}
}
return userStatusFromProfile(rawUserStatus, rawCallStatus);
}
/**
@@ -43,7 +43,7 @@ import { type Call, CallEvent } from "../../models/Call";
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
import { getCustomSectionData, isDefaultSectionTag } from "../../stores/room-list-v3/section";
import { _t } from "../../languageHandler";
import { fetchUserStatus, validateUserStatus } from "../../utils/userStatus";
import { fetchUserStatus, userStatusFromProfile } from "../../utils/userStatus";
/**
* View section type without `isSelected` field
@@ -292,7 +292,12 @@ export class RoomListItemViewModel
*/
private onUserProfileUpdate: ClientEventHandlerMap[ClientEvent.UserProfileUpdate] = (userId, profile) => {
if (userId !== this.dmUserId || !SettingsStore.getValue("feature_user_status")) return;
this.snapshot.merge({ userStatus: validateUserStatus(profile?.["org.matrix.msc4426.status"]) });
this.snapshot.merge({
userStatus: userStatusFromProfile(
profile?.["org.matrix.msc4426.status"],
profile?.["org.matrix.msc4426.call"],
),
});
};
/**
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { type MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix";
import { BaseViewModel, type UserStatusIconViewSnapshot } from "@element-hq/web-shared-components";
import { fetchUserStatus, validateUserStatus } from "../../utils/userStatus";
import { fetchUserStatus, userStatusFromProfile } from "../../utils/userStatus";
import SettingsStore from "../../settings/SettingsStore";
import { logger } from "matrix-js-sdk/src/logger";
@@ -49,6 +49,11 @@ export class UserStatusIconViewModel extends BaseViewModel<UserStatusIconViewSna
private onUserProfileUpdate = (syncedUserId: string, syncProfile: Record<string, unknown> | null): void => {
if (syncedUserId !== this.props.userId) return;
this.snapshot.merge({ status: validateUserStatus(syncProfile?.["org.matrix.msc4426.status"]) });
this.snapshot.merge({
status: userStatusFromProfile(
syncProfile?.["org.matrix.msc4426.status"],
syncProfile?.["org.matrix.msc4426.call"],
),
});
};
}
@@ -12,11 +12,49 @@ import {
clearUserStatus,
fetchUserStatus,
setUserStatus,
userStatusFromProfile,
userStatusTextWithinMaxLength,
} from "../../../src/utils/userStatus";
import { stubClient } from "../../test-utils";
describe("userStatus utils", () => {
describe("userStatusFromProfile", () => {
it("returns the user status if it is valid", () => {
expect(userStatusFromProfile({ emoji: "🐳", text: "Feeling a little blue" }, undefined)).toEqual({
emoji: "🐳",
text: "Feeling a little blue",
});
});
it("returns undefined if the user status is invalid and there is no call status", () => {
expect(userStatusFromProfile({ text: "Feeling a little blue" }, undefined)).toBeUndefined();
});
it("returns the call status if the user status is invalid but the call status is valid", () => {
expect(userStatusFromProfile({ text: "Feeling a little blue" }, { call_joined_ts: 12345 })).toEqual({
emoji: "📞",
text: "On a call",
});
});
it("prefers the user status over the call status if both are valid", () => {
expect(
userStatusFromProfile({ emoji: "🐳", text: "Feeling a little blue" }, { call_joined_ts: 12345 }),
).toEqual({
emoji: "🐳",
text: "Feeling a little blue",
});
});
it("returns undefined if the call status has a non-positive call_joined_ts", () => {
expect(userStatusFromProfile(undefined, { call_joined_ts: 0 })).toBeUndefined();
});
it("returns undefined if neither status is valid", () => {
expect(userStatusFromProfile(undefined, undefined)).toBeUndefined();
});
});
describe("userStatusTextWithinMaxLength", () => {
it("returns true for text within the max length", () => {
const text = "a".repeat(256);