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;
|
opts.threadSupport = true;
|
||||||
if (SettingsStore.getValue("feature_user_status")) {
|
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")) {
|
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 { useMatrixClientContext } from "../contexts/MatrixClientContext";
|
||||||
import { useTypedEventEmitter } from "./useEventEmitter";
|
import { useTypedEventEmitter } from "./useEventEmitter";
|
||||||
import { useFeatureEnabled } from "./useSettings";
|
import { useFeatureEnabled } from "./useSettings";
|
||||||
import { fetchUserStatus, validateUserStatus } from "../utils/userStatus";
|
import { fetchUserStatus, userStatusFromProfile } from "../utils/userStatus";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("useUserStatus");
|
const logger = rootLogger.getChild("useUserStatus");
|
||||||
|
|
||||||
@@ -34,7 +34,9 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUserStatus(validateUserStatus(syncProfile["org.matrix.msc4426.status"]));
|
setUserStatus(
|
||||||
|
userStatusFromProfile(syncProfile["org.matrix.msc4426.status"], syncProfile["org.matrix.msc4426.call"]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -3828,6 +3828,9 @@
|
|||||||
"verify_button": "Verify User",
|
"verify_button": "Verify User",
|
||||||
"verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices."
|
"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": {
|
"voip": {
|
||||||
"already_in_call": "Already in call",
|
"already_in_call": "Already in call",
|
||||||
"already_in_call_person": "You're already in a call with this person.",
|
"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 { _t } from "../languageHandler";
|
||||||
import { mediaFromMxc } from "../customisations/Media";
|
import { mediaFromMxc } from "../customisations/Media";
|
||||||
import SettingsStore from "../settings/SettingsStore";
|
import SettingsStore from "../settings/SettingsStore";
|
||||||
import { validateUserStatus } from "../utils/userStatus";
|
import { userStatusFromProfile } from "../utils/userStatus";
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -206,7 +206,8 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
|
|||||||
this.matrixClient.getSafeUserId(),
|
this.matrixClient.getSafeUserId(),
|
||||||
"org.matrix.msc4426.status",
|
"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> => {
|
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.
|
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 { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
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,
|
// MSC4426 defines the maximum length of a status to be 256 bytes of UTF-8,
|
||||||
// so we truncate anything longer than that.
|
// so we truncate anything longer than that.
|
||||||
const MAX_STATUS_TEXT_BYTES = 256;
|
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.
|
// Static Intl.Segmenter for grabbing the first grapheme of a user status emoji.
|
||||||
// We make one and keep it for performance.
|
// We make one and keep it for performance.
|
||||||
const intlSegmenter = new Intl.Segmenter();
|
const intlSegmenter = new Intl.Segmenter();
|
||||||
@@ -32,7 +41,7 @@ export function userStatusTextWithinMaxLength(text: string): boolean {
|
|||||||
* @param rawUserStatus The raw user status object to validate.
|
* @param rawUserStatus The raw user status object to validate.
|
||||||
* @returns A UserStatus object if valid, otherwise undefined.
|
* @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) {
|
if (typeof rawUserStatus !== "object" || rawUserStatus === null) {
|
||||||
return undefined;
|
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
|
* Takes the raw result from getExtendedProfileProperty for m.call, validates it and
|
||||||
* support extended profiles, the user has no (valid) status, or the status could not be fetched.
|
* 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 client The Matrix client to fetch the status with.
|
||||||
* @param userId The ID of the user whose status is being fetched.
|
* @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) {
|
if ((await client.doesServerSupportExtendedProfiles()) === false) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rawUserStatus: unknown;
|
||||||
|
let rawCallStatus: unknown;
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (ex) {
|
||||||
if (!(ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND")) {
|
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 RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||||
import { getCustomSectionData, isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
import { getCustomSectionData, isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
||||||
import { _t } from "../../languageHandler";
|
import { _t } from "../../languageHandler";
|
||||||
import { fetchUserStatus, validateUserStatus } from "../../utils/userStatus";
|
import { fetchUserStatus, userStatusFromProfile } from "../../utils/userStatus";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View section type without `isSelected` field
|
* View section type without `isSelected` field
|
||||||
@@ -292,7 +292,12 @@ export class RoomListItemViewModel
|
|||||||
*/
|
*/
|
||||||
private onUserProfileUpdate: ClientEventHandlerMap[ClientEvent.UserProfileUpdate] = (userId, profile) => {
|
private onUserProfileUpdate: ClientEventHandlerMap[ClientEvent.UserProfileUpdate] = (userId, profile) => {
|
||||||
if (userId !== this.dmUserId || !SettingsStore.getValue("feature_user_status")) return;
|
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 { type MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { BaseViewModel, type UserStatusIconViewSnapshot } from "@element-hq/web-shared-components";
|
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 SettingsStore from "../../settings/SettingsStore";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
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 => {
|
private onUserProfileUpdate = (syncedUserId: string, syncProfile: Record<string, unknown> | null): void => {
|
||||||
if (syncedUserId !== this.props.userId) return;
|
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,
|
clearUserStatus,
|
||||||
fetchUserStatus,
|
fetchUserStatus,
|
||||||
setUserStatus,
|
setUserStatus,
|
||||||
|
userStatusFromProfile,
|
||||||
userStatusTextWithinMaxLength,
|
userStatusTextWithinMaxLength,
|
||||||
} from "../../../src/utils/userStatus";
|
} from "../../../src/utils/userStatus";
|
||||||
import { stubClient } from "../../test-utils";
|
import { stubClient } from "../../test-utils";
|
||||||
|
|
||||||
describe("userStatus 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", () => {
|
describe("userStatusTextWithinMaxLength", () => {
|
||||||
it("returns true for text within the max length", () => {
|
it("returns true for text within the max length", () => {
|
||||||
const text = "a".repeat(256);
|
const text = "a".repeat(256);
|
||||||
|
|||||||
Reference in New Issue
Block a user