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:
@@ -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")) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user