mirror of
https://github.com/vector-im/element-web.git
synced 2026-04-30 09:51:53 +02:00
Show user status in timeline (#32991)
* Use other branch * All the changes that got lost * Fix merge * Ensure emoji can only be one character long * Fixup labs feature * Remove redundant check * Update snapshot * update snapshot * add snapshot * unpin * fix pnpm lock * undo pn[m lockfile changes altogether as we shouldn't actually need any afaik * update snpahot for changed IDs * Snapshot update * Snapshot update * There is now another section * more snapshots * more snapshot * More snapshots * oh come on snapshots * actual snapshot update * Fix sonar issues * just update the thing manually * [screams internally] * Update snapshot * test for useUserStatus * Make useUserStatus actually truncate * Split out slash command to its own file & add test * Remove irrelevant comment * doc * Comment on non-obvious error message --------- Co-authored-by: David Baker <dbkr@users.noreply.github.com>
This commit is contained in:
parent
4c3cb0754b
commit
4bee845010
@ -297,6 +297,9 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
||||
opts.lazyLoadMembers = true;
|
||||
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
|
||||
opts.threadSupport = true;
|
||||
if (SettingsStore.getValue("feature_user_status")) {
|
||||
opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status"];
|
||||
}
|
||||
|
||||
if (SettingsStore.getValue("feature_sliding_sync")) {
|
||||
throw new UserFriendlyError("sliding_sync_legacy_no_longer_supported");
|
||||
|
||||
@ -13,6 +13,7 @@ import { useCreateAutoDisposedViewModel, DisambiguatedProfileView } from "@eleme
|
||||
|
||||
import { DisambiguatedProfileViewModel } from "../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
|
||||
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
|
||||
import { useUserStatus } from "../../../hooks/useUserStatus";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
@ -27,6 +28,7 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
|
||||
userId: sender,
|
||||
member: mxEvent.sender,
|
||||
});
|
||||
const userStatus = useUserStatus(sender);
|
||||
|
||||
const disambiguatedProfileVM = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
@ -37,9 +39,13 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
|
||||
colored: true,
|
||||
emphasizeDisplayName: true,
|
||||
withTooltip,
|
||||
userStatus,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
disambiguatedProfileVM.setUserStatus(userStatus);
|
||||
}, [disambiguatedProfileVM, userStatus]);
|
||||
useEffect(() => {
|
||||
disambiguatedProfileVM.setMember(sender ?? "", member);
|
||||
}, [disambiguatedProfileVM, member, sender]);
|
||||
|
||||
98
apps/web/src/hooks/useUserStatus.ts
Normal file
98
apps/web/src/hooks/useUserStatus.ts
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ClientEvent, MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
|
||||
import { useTypedEventEmitter } from "./useEventEmitter";
|
||||
import { useFeatureEnabled } from "./useSettings";
|
||||
|
||||
const logger = rootLogger.getChild("useUserStatus");
|
||||
|
||||
export interface UserStatus {
|
||||
emoji: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const MAX_STATUS_TEXT_BYTES = 256;
|
||||
|
||||
export function userStatusTextWithinMaxLength(text: string): boolean {
|
||||
const textEncoder = new TextEncoder();
|
||||
return textEncoder.encode(text).length <= MAX_STATUS_TEXT_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the MSC4426 user status for a given user ID. Returns undefined if the feature is disabled,
|
||||
* the user does not have a status, or if there was an error fetching the status.
|
||||
*
|
||||
* @param userId The ID of the user whose status is being fetched.
|
||||
* @returns The user's status, or undefined if not available.
|
||||
*/
|
||||
export function useUserStatus(userId: string | undefined): UserStatus | undefined {
|
||||
const isEnabled = useFeatureEnabled("feature_user_status");
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const [rawUserStatus, setRawUserStatus] = useState<unknown>();
|
||||
|
||||
useTypedEventEmitter(matrixClient, ClientEvent.UserProfileUpdate, (syncedUserId, syncProfile) => {
|
||||
if (syncedUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
if (syncProfile["org.matrix.msc4426.status"]) {
|
||||
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!userId) {
|
||||
setRawUserStatus(undefined);
|
||||
return;
|
||||
}
|
||||
if ((await matrixClient.doesServerSupportExtendedProfiles()) === false) {
|
||||
setRawUserStatus(undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await matrixClient.getExtendedProfileProperty(userId, "org.matrix.msc4426.status");
|
||||
setRawUserStatus(result);
|
||||
} catch (ex) {
|
||||
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
|
||||
setRawUserStatus(undefined);
|
||||
} else {
|
||||
logger.warn(`Failed to get userStatus for ${userId}`, ex);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [isEnabled, userId, matrixClient]);
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof rawUserStatus !== "object" || rawUserStatus === null) {
|
||||
logger.warn(`value of "org.matrix.msc4426.status" was not an object for ${userId}`);
|
||||
return;
|
||||
}
|
||||
if ("emoji" in rawUserStatus === false || typeof rawUserStatus.emoji !== "string" || !rawUserStatus.emoji) {
|
||||
logger.warn(`"emoji" property was not a valid string for ${userId}`);
|
||||
return;
|
||||
}
|
||||
if ("text" in rawUserStatus === false || typeof rawUserStatus.text !== "string" || !rawUserStatus.text) {
|
||||
logger.warn(`"text" property was not a valid string for ${userId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
emoji: rawUserStatus.emoji,
|
||||
text: userStatusTextWithinMaxLength(rawUserStatus.text)
|
||||
? rawUserStatus.text
|
||||
: `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}…`,
|
||||
};
|
||||
}
|
||||
@ -1541,6 +1541,11 @@
|
||||
"experimental_section": "Early previews",
|
||||
"extended_profiles_msc_support": "Requires your server to support MSC4133",
|
||||
"feature_disable_call_per_sender_encryption": "Disable per-sender encryption for Element Call",
|
||||
"feature_user_status": {
|
||||
"description": "Enables being able to see and set a current status.",
|
||||
"display_name": "User status",
|
||||
"required_msc_support": "Requires MSC4429 (Profile Updates for Legacy Sync)"
|
||||
},
|
||||
"feature_wysiwyg_composer_description": "Use rich text instead of Markdown in the message composer.",
|
||||
"group_calls": "New group call experience",
|
||||
"group_developer": "Developer",
|
||||
@ -3148,6 +3153,14 @@
|
||||
"server_error_detail": "Server unavailable, overloaded, or something else went wrong.",
|
||||
"shrug": "Prepends ¯\\_(ツ)_/¯ to a plain-text message",
|
||||
"spoiler": "Sends the given message as a spoiler",
|
||||
"status": {
|
||||
"description": "Set your current status",
|
||||
"no_args": "No arguments provided. You should supply an emoij and an optional text component.",
|
||||
"no_emoji": "You did not provide an emoji",
|
||||
"no_text": "You did not provide any status text",
|
||||
"too_long_emoji": "The first argument must be an emoji",
|
||||
"too_long_text": "The text you provided was too long."
|
||||
},
|
||||
"tableflip": "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message",
|
||||
"topic": "Gets or sets the room topic",
|
||||
"topic_none": "This room has no topic.",
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2018-2024 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2017 Travis Ralston
|
||||
@ -228,6 +229,7 @@ export interface Settings {
|
||||
"feature_ask_to_join": IFeature;
|
||||
"feature_notifications": IFeature;
|
||||
"feature_msc4362_encrypted_state_events": IFeature;
|
||||
"feature_user_status": IFeature;
|
||||
// These are in the feature namespace but aren't actually features
|
||||
"feature_hidebold": IBaseSetting<boolean>;
|
||||
|
||||
@ -789,6 +791,30 @@ export const SETTINGS: Settings = {
|
||||
shouldWarn: true,
|
||||
default: false,
|
||||
},
|
||||
"feature_user_status": {
|
||||
isFeature: true,
|
||||
labsGroup: LabGroup.Profile,
|
||||
displayName: _td("labs|feature_user_status|display_name"),
|
||||
description: _td("labs|feature_user_status|description"),
|
||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
|
||||
supportedLevelsAreOrdered: true,
|
||||
controller: new ServerSupportUnstableFeatureController(
|
||||
"feature_user_status",
|
||||
defaultWatchManager,
|
||||
[["org.matrix.msc4429"], ["org.matrix.msc4429.stable"]],
|
||||
undefined,
|
||||
_td("labs|feature_user_status|required_msc_support"),
|
||||
false,
|
||||
// We have to assume it's available during early startup because of a race:
|
||||
// The feature is used to enable extra sync filters during MatrixClient setup
|
||||
// and we can't check for serverside support until the client has finished setting up.
|
||||
// Once the client has setup, (so by the time the user actually opens the labs menu) we can
|
||||
// enforce proper checks.
|
||||
true,
|
||||
true,
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
"useCompactLayout": {
|
||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
|
||||
displayName: _td("settings|preferences|compact_modern"),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
@ -12,6 +13,7 @@ import { type WatchManager } from "../WatchManager";
|
||||
import SettingsStore from "../SettingsStore";
|
||||
import { type SettingKey } from "../Settings.tsx";
|
||||
import { _t } from "../../languageHandler.tsx";
|
||||
import PlatformPeg from "../../PlatformPeg.ts";
|
||||
|
||||
/**
|
||||
* Disables a given setting if the server unstable feature it requires is not supported
|
||||
@ -28,6 +30,9 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
|
||||
* @param unstableFeatureGroups - If any one of the feature groups is satisfied,
|
||||
* then the setting is considered enabled. A feature group is satisfied if all of
|
||||
* the features in the group are supported (all features in a group are required).
|
||||
* @param defaultEnabled - If we haven't been able to check for support yet, should
|
||||
* this feature be enabled or disabled (default).
|
||||
* @param forceReload - Should the client force reload.
|
||||
*/
|
||||
public constructor(
|
||||
private readonly settingName: SettingKey,
|
||||
@ -36,12 +41,23 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
|
||||
private readonly stableVersion?: string,
|
||||
private readonly disabledMessage?: TranslationKey,
|
||||
private readonly forcedValue: any = false,
|
||||
private readonly defaultEnabled = false,
|
||||
private readonly forceReload = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public onChange(): void {
|
||||
if (this.forceReload) {
|
||||
PlatformPeg.get()?.reload();
|
||||
}
|
||||
}
|
||||
|
||||
public get disabled(): boolean {
|
||||
return !this.enabled;
|
||||
if (this.enabled !== undefined) {
|
||||
return !this.enabled;
|
||||
}
|
||||
return !this.defaultEnabled;
|
||||
}
|
||||
|
||||
public set disabled(newDisabledValue: boolean) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
@ -62,6 +63,7 @@ import { goto, join } from "./join";
|
||||
import { manuallyVerifyDevice } from "../components/views/dialogs/ManualDeviceKeyVerificationDialog";
|
||||
import upgraderoom from "./upgraderoom/upgraderoom";
|
||||
import { emoticon } from "./emoticon";
|
||||
import { statusCommand } from "./status";
|
||||
|
||||
export { CommandCategories, Command };
|
||||
|
||||
@ -819,6 +821,7 @@ export const Commands = [
|
||||
},
|
||||
renderingTypes: [TimelineRenderingType.Room],
|
||||
}),
|
||||
statusCommand,
|
||||
|
||||
// Command definitions for autocompletion ONLY:
|
||||
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
|
||||
|
||||
51
apps/web/src/slash-commands/status.ts
Normal file
51
apps/web/src/slash-commands/status.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { _td } from "@element-hq/web-shared-components";
|
||||
|
||||
import { Command, CommandCategories, splitAtFirstSpace } from "./SlashCommands";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { reject, success } from "./utils";
|
||||
import { UserFriendlyError } from "../languageHandler";
|
||||
import { userStatusTextWithinMaxLength } from "../hooks/useUserStatus";
|
||||
import { TimelineRenderingType } from "../contexts/RoomContext";
|
||||
|
||||
export const statusCommand = new Command({
|
||||
command: "status",
|
||||
args: "<emoji> <text>",
|
||||
description: _td("slash_command|status|description"),
|
||||
isEnabled: () => SettingsStore.getValue("feature_user_status"),
|
||||
runFn: function (cli, _roomId, _threadId, args) {
|
||||
if (!args) {
|
||||
return reject(new UserFriendlyError("slash_command|status|no_args"));
|
||||
}
|
||||
const [emojiText, text] = splitAtFirstSpace(args);
|
||||
if (!emojiText) {
|
||||
return reject(new UserFriendlyError("slash_command|status|no_emoji"));
|
||||
}
|
||||
if (!text) {
|
||||
return reject(new UserFriendlyError("slash_command|status|no_text"));
|
||||
}
|
||||
const [emoji, additionalSegment] = [...new Intl.Segmenter().segment(emojiText)];
|
||||
if (additionalSegment) {
|
||||
// This is "too long" in that it's more than one grapheme, so the error we give is
|
||||
// that it's "not an emoji".
|
||||
return reject(new UserFriendlyError("slash_command|status|too_long_emoji"));
|
||||
}
|
||||
if (!userStatusTextWithinMaxLength(text)) {
|
||||
return reject(new UserFriendlyError("slash_command|status|too_long_text"));
|
||||
}
|
||||
return success(
|
||||
cli.setExtendedProfileProperty("org.matrix.msc4426.status", {
|
||||
emoji: emoji.segment,
|
||||
text,
|
||||
}),
|
||||
);
|
||||
},
|
||||
category: CommandCategories.actions,
|
||||
renderingTypes: [TimelineRenderingType.Room],
|
||||
});
|
||||
@ -15,6 +15,7 @@ import { type MouseEvent } from "react";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { getUserNameColorClass } from "../../../../utils/FormattingUtils";
|
||||
import UserIdentifier from "../../../../customisations/UserIdentifier";
|
||||
import type { UserStatus } from "../../../../hooks/useUserStatus";
|
||||
|
||||
/**
|
||||
* Information about a member for disambiguation purposes.
|
||||
@ -46,6 +47,10 @@ export interface DisambiguatedProfileViewModelProps {
|
||||
* The member information for disambiguation.
|
||||
*/
|
||||
member?: MemberInfo | null;
|
||||
/**
|
||||
* The user's present status.
|
||||
*/
|
||||
userStatus?: UserStatus;
|
||||
/**
|
||||
* The fallback name to use if the member's display name is not available.
|
||||
*/
|
||||
@ -62,6 +67,7 @@ export interface DisambiguatedProfileViewModelProps {
|
||||
* Whether to show a tooltip with additional information.
|
||||
*/
|
||||
withTooltip?: boolean;
|
||||
|
||||
/**
|
||||
* Optional click handler for the profile.
|
||||
*/
|
||||
@ -79,7 +85,7 @@ export class DisambiguatedProfileViewModel
|
||||
private static readonly computeSnapshot = (
|
||||
props: DisambiguatedProfileViewModelProps,
|
||||
): DisambiguatedProfileViewSnapshot => {
|
||||
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip } = props;
|
||||
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip, userStatus } = props;
|
||||
|
||||
// Compute display name
|
||||
const displayName = member?.rawDisplayName || fallbackName;
|
||||
@ -122,11 +128,15 @@ export class DisambiguatedProfileViewModel
|
||||
displayIdentifier,
|
||||
title,
|
||||
emphasizeDisplayName,
|
||||
userStatus,
|
||||
};
|
||||
};
|
||||
|
||||
public constructor(props: DisambiguatedProfileViewModelProps) {
|
||||
super(props, DisambiguatedProfileViewModel.computeSnapshot(props));
|
||||
this.snapshot.merge({
|
||||
userStatus: props.userStatus,
|
||||
});
|
||||
}
|
||||
|
||||
public setMember(fallbackName: string, member?: MemberInfo | null): void {
|
||||
@ -136,6 +146,13 @@ export class DisambiguatedProfileViewModel
|
||||
this.snapshot.set(DisambiguatedProfileViewModel.computeSnapshot(this.props));
|
||||
}
|
||||
|
||||
public setUserStatus(userStatus?: UserStatus): void {
|
||||
this.props.userStatus = userStatus;
|
||||
this.snapshot.merge({
|
||||
userStatus,
|
||||
});
|
||||
}
|
||||
|
||||
public onClick = (evt: MouseEvent<HTMLDivElement>): void => {
|
||||
this.props.onClick?.(evt);
|
||||
};
|
||||
|
||||
62
apps/web/test/slash-commands/status-test.ts
Normal file
62
apps/web/test/slash-commands/status-test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { stubClient } from "../test-utils";
|
||||
import { statusCommand } from "../../src/slash-commands/status";
|
||||
import { UserFriendlyError } from "../../src/languageHandler";
|
||||
|
||||
describe("/status", () => {
|
||||
const roomId = "!room:example.com";
|
||||
|
||||
let client: ReturnType<typeof stubClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
client.setExtendedProfileProperty = jest.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function run(args?: string) {
|
||||
return statusCommand.run(client, roomId, null, args);
|
||||
}
|
||||
|
||||
it("should reject if no args provided", () => {
|
||||
const result = run(undefined);
|
||||
expect(result.error).toBeInstanceOf(UserFriendlyError);
|
||||
expect((result.error as UserFriendlyError).message).toBe(
|
||||
"No arguments provided. You should supply an emoij and an optional text component.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject if no text is provided after the emoji", () => {
|
||||
const result = run("🎉");
|
||||
expect(result.error).toBeInstanceOf(UserFriendlyError);
|
||||
expect((result.error as UserFriendlyError).message).toBe("You did not provide any status text");
|
||||
});
|
||||
|
||||
it("should reject if the emoji field has more than one grapheme segment", () => {
|
||||
const result = run("ab hello");
|
||||
expect(result.error).toBeInstanceOf(UserFriendlyError);
|
||||
expect((result.error as UserFriendlyError).message).toBe("The first argument must be an emoji");
|
||||
});
|
||||
|
||||
it("should reject if the status text exceeds the maximum byte length", () => {
|
||||
const longText = "a".repeat(257);
|
||||
const result = run(`🎉 ${longText}`);
|
||||
expect(result.error).toBeInstanceOf(UserFriendlyError);
|
||||
expect((result.error as UserFriendlyError).message).toBe("The text you provided was too long.");
|
||||
});
|
||||
|
||||
it("should set the extended profile property on success", async () => {
|
||||
const result = run("🎉 Having a great day");
|
||||
expect(result.error).toBeUndefined();
|
||||
await result.promise;
|
||||
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", {
|
||||
emoji: "🎉",
|
||||
text: "Having a great day",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -31,12 +31,12 @@ exports[`ReplyChain should call setQuoteExpanded if chain is longer than 2 lines
|
||||
u
|
||||
</span>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
|
||||
@ -35,7 +35,7 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh
|
||||
class="mx_MemberTileView_name"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="@userId:matrix.org (@userId:matrix.org)"
|
||||
@ -111,7 +111,7 @@ exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon whe
|
||||
class="mx_MemberTileView_name"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="@userId:matrix.org (@userId:matrix.org)"
|
||||
@ -187,7 +187,7 @@ exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when th
|
||||
class="mx_MemberTileView_name"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="@userId:matrix.org (@userId:matrix.org)"
|
||||
|
||||
@ -71,12 +71,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
Alice
|
||||
@ -165,12 +165,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
Alice
|
||||
@ -262,12 +262,12 @@ exports[`<LayoutSwitcher /> should render 1`] = `
|
||||
class="mx_MessageTimestamp"
|
||||
/>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
Alice
|
||||
|
||||
@ -48,6 +48,6 @@ describe("<LabsUserSettingsTab />", () => {
|
||||
// non-beta labs section
|
||||
expect(screen.getByText("Early previews")).toBeInTheDocument();
|
||||
const labsSections = container.getElementsByClassName("mx_SettingsSubsection");
|
||||
expect(labsSections).toHaveLength(8);
|
||||
expect(labsSections).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
|
||||
@ -214,12 +214,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
@ -308,12 +308,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
@ -405,12 +405,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = `
|
||||
class="mx_MessageTimestamp"
|
||||
/>
|
||||
<div
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_oa3at_8"
|
||||
class="mx_DisambiguatedProfile _disambiguatedProfile_4jooo_8"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_oa3at_14 mx_DisambiguatedProfile_displayName"
|
||||
class="mx_Username_color2 _disambiguatedProfile_displayName_4jooo_14 mx_DisambiguatedProfile_displayName"
|
||||
dir="auto"
|
||||
>
|
||||
@userId:matrix.org
|
||||
|
||||
155
apps/web/test/unit-tests/hooks/useUserStatus-test.tsx
Normal file
155
apps/web/test/unit-tests/hooks/useUserStatus-test.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "jest-matrix-react";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useUserStatus, userStatusTextWithinMaxLength } from "../../../src/hooks/useUserStatus";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsUser, mockClientMethodsServer } from "../../test-utils";
|
||||
import { MatrixClientContextProvider } from "../../../src/components/structures/MatrixClientContextProvider";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
|
||||
const userId = "@alice:example.com";
|
||||
|
||||
const client = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(),
|
||||
...mockClientMethodsServer(),
|
||||
getCrypto: jest.fn().mockReturnValue(null),
|
||||
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true),
|
||||
getExtendedProfileProperty: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
function render(uid: string | undefined = userId) {
|
||||
return renderHook(() => useUserStatus(uid), {
|
||||
wrapper: ({ children }) => (
|
||||
<MatrixClientContextProvider client={client}>{children}</MatrixClientContextProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("userStatusTextWithinMaxLength", () => {
|
||||
it("returns true for short text", () => {
|
||||
expect(userStatusTextWithinMaxLength("on a horse")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for text exceeding 256 bytes", () => {
|
||||
expect(userStatusTextWithinMaxLength("a".repeat(257))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for text exactly 256 bytes", () => {
|
||||
expect(userStatusTextWithinMaxLength("a".repeat(256))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useUserStatus", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name): any => {
|
||||
if (name === "feature_user_status") return true;
|
||||
});
|
||||
client.doesServerSupportExtendedProfiles.mockResolvedValue(true);
|
||||
client.getExtendedProfileProperty.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns undefined when feature is disabled", async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
||||
const { result } = render();
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when userId is undefined", async () => {
|
||||
const { result } = render(undefined);
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when server does not support extended profiles", async () => {
|
||||
client.doesServerSupportExtendedProfiles.mockResolvedValue(false);
|
||||
const { result } = render();
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when status property is not set", async () => {
|
||||
client.getExtendedProfileProperty.mockResolvedValue(undefined);
|
||||
const { result } = render();
|
||||
await waitFor(() =>
|
||||
expect(client.getExtendedProfileProperty).toHaveBeenCalledWith(userId, "org.matrix.msc4426.status"),
|
||||
);
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when status is not an object", async () => {
|
||||
client.getExtendedProfileProperty.mockResolvedValue("not an object");
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when emoji is missing", async () => {
|
||||
client.getExtendedProfileProperty.mockResolvedValue({ text: "on a horse" });
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when text is missing", async () => {
|
||||
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎" });
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the user status when valid", async () => {
|
||||
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: "on a horse" });
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }));
|
||||
});
|
||||
|
||||
it("truncates text that exceeds 256 bytes", async () => {
|
||||
const longText = "a".repeat(257);
|
||||
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: longText });
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: `${"a".repeat(256)}…` }));
|
||||
});
|
||||
|
||||
it("returns undefined when M_NOT_FOUND error is thrown", async () => {
|
||||
const error = new Error();
|
||||
client.getExtendedProfileProperty.mockRejectedValue(error);
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(client.getExtendedProfileProperty).toHaveBeenCalled());
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
|
||||
it("updates status when UserProfileUpdate event is emitted", async () => {
|
||||
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: "on a horse" });
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }));
|
||||
|
||||
// Simulate a profile update event
|
||||
client.emit(ClientEvent.UserProfileUpdate, userId, {
|
||||
"org.matrix.msc4426.status": { emoji: "😵", text: "off a horse" },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current).toEqual({ emoji: "😵", text: "off a horse" }));
|
||||
});
|
||||
|
||||
it("ignores UserProfileUpdate events for different users", async () => {
|
||||
client.getExtendedProfileProperty.mockResolvedValue({ emoji: "🐎", text: "on a horse" });
|
||||
const { result } = render();
|
||||
await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }));
|
||||
|
||||
client.emit(ClientEvent.UserProfileUpdate, "@bob:example.com", {
|
||||
"org.matrix.msc4426.status": { emoji: "🤷", text: "unrelated status" },
|
||||
});
|
||||
|
||||
// Should still have original status
|
||||
expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" });
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@ -131,5 +131,10 @@ joining a room.
|
||||
|
||||
Replaces the legacy notification settings with a new one to manage push rules.
|
||||
|
||||
## User status (`feature_user_status`)
|
||||
|
||||
Enables setting a status message in your profile and to be able to view other's statuses.
|
||||
Requires [MSC4429](https://github.com/matrix-org/matrix-spec-proposals/pull/4429) and [MSC4426](https://github.com/matrix-org/matrix-spec-proposals/pull/4426).
|
||||
|
||||
**Warning** This feature has options which are not backwards compatible, disabling
|
||||
it may have unintended consequences.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@ -24,4 +24,8 @@
|
||||
font-size: var(--cpd-font-size-body-sm);
|
||||
margin-inline-start: 5px;
|
||||
}
|
||||
|
||||
.userStatus {
|
||||
margin-inline-start: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,6 +40,10 @@ const meta = {
|
||||
displayIdentifier: { control: "text" },
|
||||
title: { control: "text" },
|
||||
emphasizeDisplayName: { control: "boolean" },
|
||||
userStatus: {
|
||||
status: { control: "string" },
|
||||
emoji: { control: "string" },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
displayName: "Alice",
|
||||
@ -82,6 +86,17 @@ export const WithTooltip: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithUserStatus: Story = {
|
||||
args: {
|
||||
displayName: "Eve",
|
||||
title: "Eve (@eve:matrix.org)",
|
||||
userStatus: {
|
||||
emoji: "🏝️",
|
||||
text: "On holiday",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FullExample: Story = {
|
||||
args: {
|
||||
displayName: "Eve",
|
||||
@ -89,5 +104,9 @@ export const FullExample: Story = {
|
||||
colorClass: "mx_Username_color5",
|
||||
title: "Eve (@eve:matrix.org)",
|
||||
emphasizeDisplayName: true,
|
||||
userStatus: {
|
||||
emoji: "🏝️",
|
||||
text: "On holiday",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import React, { type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Text, Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
|
||||
import styles from "./DisambiguatedProfile.module.css";
|
||||
@ -38,6 +39,14 @@ export interface DisambiguatedProfileViewSnapshot {
|
||||
* Whether to emphasize the display name with additional styling.
|
||||
*/
|
||||
emphasizeDisplayName?: boolean;
|
||||
|
||||
/**
|
||||
* User status message
|
||||
*/
|
||||
userStatus?: {
|
||||
emoji: string;
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -80,7 +89,9 @@ interface DisambiguatedProfileViewProps {
|
||||
* ```
|
||||
*/
|
||||
export function DisambiguatedProfileView({ vm, className }: Readonly<DisambiguatedProfileViewProps>): JSX.Element {
|
||||
const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName } = useViewModel(vm);
|
||||
const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName, userStatus } = useViewModel(vm);
|
||||
|
||||
const userStatusEmoji = userStatus && [...new Intl.Segmenter().segment(userStatus.emoji)][0]?.segment;
|
||||
|
||||
const displayNameClasses = classNames(colorClass, {
|
||||
[styles.disambiguatedProfile_displayName]: emphasizeDisplayName,
|
||||
@ -115,6 +126,13 @@ export function DisambiguatedProfileView({ vm, className }: Readonly<Disambiguat
|
||||
{displayIdentifier}
|
||||
</span>
|
||||
)}
|
||||
{userStatus && (
|
||||
<Tooltip description={userStatus.text}>
|
||||
<Text as="span" size="md" className={styles.userStatus}>
|
||||
{userStatusEmoji}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -36,6 +36,11 @@ exports[`DisambiguatedProfileView > renders the full example 1`] = `
|
||||
>
|
||||
@eve:matrix.org
|
||||
</span>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 DisambiguatedProfile-module_userStatus"
|
||||
>
|
||||
🏝️
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user