From 4bee8450101b2ac7082d82aeac657a213eb12cd8 Mon Sep 17 00:00:00 2001 From: Will Hunt <2072976+Half-Shot@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:14:22 +0100 Subject: [PATCH] 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 --- apps/web/src/MatrixClientPeg.ts | 3 + .../views/messages/SenderProfile.tsx | 6 + apps/web/src/hooks/useUserStatus.ts | 98 +++++++++++ apps/web/src/i18n/strings/en_EN.json | 13 ++ apps/web/src/settings/Settings.tsx | 26 +++ .../ServerSupportUnstableFeatureController.ts | 18 +- apps/web/src/slash-commands/SlashCommands.tsx | 3 + apps/web/src/slash-commands/status.ts | 51 ++++++ .../DisambiguatedProfileViewModel.ts | 19 ++- apps/web/test/slash-commands/status-test.ts | 62 +++++++ .../__snapshots__/ReplyChain-test.tsx.snap | 4 +- .../MemberTileView-test.tsx.snap | 6 +- .../LayoutSwitcher-test.tsx.snap | 12 +- .../tabs/user/LabsUserSettingsTab-test.tsx | 2 +- .../AppearanceUserSettingsTab-test.tsx.snap | 12 +- .../unit-tests/hooks/useUserStatus-test.tsx | 155 ++++++++++++++++++ .../__snapshots__/HTMLExport-test.ts.snap | 2 +- docs/labs.md | 5 + .../full-example-auto.png | Bin 19267 -> 19920 bytes .../with-user-status-auto.png | Bin 0 -> 4732 bytes .../DisambiguatedProfile.module.css | 4 + .../DisambiguatedProfile.stories.tsx | 19 +++ .../DisambiguatedProfileView.tsx | 20 ++- .../DisambiguatedProfile.test.tsx.snap | 5 + 24 files changed, 523 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/hooks/useUserStatus.ts create mode 100644 apps/web/src/slash-commands/status.ts create mode 100644 apps/web/test/slash-commands/status-test.ts create mode 100644 apps/web/test/unit-tests/hooks/useUserStatus-test.tsx create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-user-status-auto.png diff --git a/apps/web/src/MatrixClientPeg.ts b/apps/web/src/MatrixClientPeg.ts index 8148509ef1..5f8125be67 100644 --- a/apps/web/src/MatrixClientPeg.ts +++ b/apps/web/src/MatrixClientPeg.ts @@ -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"); diff --git a/apps/web/src/components/views/messages/SenderProfile.tsx b/apps/web/src/components/views/messages/SenderProfile.tsx index f3465d24f8..70ab205cc1 100644 --- a/apps/web/src/components/views/messages/SenderProfile.tsx +++ b/apps/web/src/components/views/messages/SenderProfile.tsx @@ -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]); diff --git a/apps/web/src/hooks/useUserStatus.ts b/apps/web/src/hooks/useUserStatus.ts new file mode 100644 index 0000000000..e73679c9b4 --- /dev/null +++ b/apps/web/src/hooks/useUserStatus.ts @@ -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(); + + 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)}…`, + }; +} diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index cbd2ee9b97..c838f38bf8 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -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.", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 376b690afc..0cd889bb3c 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -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; @@ -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"), diff --git a/apps/web/src/settings/controllers/ServerSupportUnstableFeatureController.ts b/apps/web/src/settings/controllers/ServerSupportUnstableFeatureController.ts index 1b212da638..c5c3fc3911 100644 --- a/apps/web/src/settings/controllers/ServerSupportUnstableFeatureController.ts +++ b/apps/web/src/settings/controllers/ServerSupportUnstableFeatureController.ts @@ -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) { diff --git a/apps/web/src/slash-commands/SlashCommands.tsx b/apps/web/src/slash-commands/SlashCommands.tsx index f3e1d1ed85..d34949a4bc 100644 --- a/apps/web/src/slash-commands/SlashCommands.tsx +++ b/apps/web/src/slash-commands/SlashCommands.tsx @@ -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 diff --git a/apps/web/src/slash-commands/status.ts b/apps/web/src/slash-commands/status.ts new file mode 100644 index 0000000000..7a1bf36d94 --- /dev/null +++ b/apps/web/src/slash-commands/status.ts @@ -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: " ", + 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], +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts index c3b97087b3..858c8110c7 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts @@ -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): void => { this.props.onClick?.(evt); }; diff --git a/apps/web/test/slash-commands/status-test.ts b/apps/web/test/slash-commands/status-test.ts new file mode 100644 index 0000000000..2f81af17c6 --- /dev/null +++ b/apps/web/test/slash-commands/status-test.ts @@ -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; + + 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", + }); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap b/apps/web/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap index 4b072e701c..3201fd4289 100644 --- a/apps/web/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/elements/__snapshots__/ReplyChain-test.tsx.snap @@ -31,12 +31,12 @@ exports[`ReplyChain should call setQuoteExpanded if chain is longer than 2 lines u
@userId:matrix.org diff --git a/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap index 9177eaecbc..ba2c0fceb4 100644 --- a/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/rooms/memberlist/__snapshots__/MemberTileView-test.tsx.snap @@ -35,7 +35,7 @@ exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon wh class="mx_MemberTileView_name" >
should render 1`] = ` tabindex="-1" >
Alice @@ -165,12 +165,12 @@ exports[` should render 1`] = ` tabindex="-1" >
Alice @@ -262,12 +262,12 @@ exports[` should render 1`] = ` class="mx_MessageTimestamp" />
Alice diff --git a/apps/web/test/unit-tests/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx b/apps/web/test/unit-tests/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx index cb6048deb3..14dad87f00 100644 --- a/apps/web/test/unit-tests/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx +++ b/apps/web/test/unit-tests/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx @@ -48,6 +48,6 @@ describe("", () => { // non-beta labs section expect(screen.getByText("Early previews")).toBeInTheDocument(); const labsSections = container.getElementsByClassName("mx_SettingsSubsection"); - expect(labsSections).toHaveLength(8); + expect(labsSections).toHaveLength(9); }); }); diff --git a/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap b/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap index 8521fdcc3c..7c0e5a48a9 100644 --- a/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap @@ -214,12 +214,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = ` tabindex="-1" >
@userId:matrix.org @@ -308,12 +308,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = ` tabindex="-1" >
@userId:matrix.org @@ -405,12 +405,12 @@ exports[`AppearanceUserSettingsTab should render 1`] = ` class="mx_MessageTimestamp" />
@userId:matrix.org diff --git a/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx b/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx new file mode 100644 index 0000000000..5375223909 --- /dev/null +++ b/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx @@ -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 }) => ( + {children} + ), + }); +} + +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" }); + }); +}); diff --git a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index e2c37efaee..6d3fb83b66 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -57,7 +57,7 @@ exports[`HTMLExport should export 1`] = `

-
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • +
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • diff --git a/docs/labs.md b/docs/labs.md index 7919c4737f..e9786cddfe 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -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. diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/full-example-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/full-example-auto.png index fcc6af53b3e9520283506581b36dd5fc05cc890a..f016ac3067d498642c048d5584cd2a20cce91e0a 100644 GIT binary patch literal 19920 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 diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-user-status-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-user-status-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..ad7c25d3171893d5ec2185c8523a12ec0514c3db GIT binary patch literal 4732 zcmeHL>razs6n_8*Mnz1RLlBCaFZ{L-D-+$1rCnwK2&$;~0@0^nJ z5qRf)9{T_Q&SBr4`w0MtJ^<{;_u5+}?N7W2phd_uaz|xqWyyH9*VF{__$esuGwT9(;CX1V}%^7BF_?`gWVx7M6wHz!ha z^^yiW23>gFqQED+SPaSrp>V1)lV#YiBinm`qQjZ#Os2)MW4N3xTp}?BBkSFfXQ6J+ zFt)T*SIkC+6L;X8^(uUYV{!4IaAJd_P1Ebhcr2`pasVKPOJMw}P(dgetLc7+a$MBN zD&O#ig5(LFrcH@PDpAYJ9`8auSBBKd&d@d%gHf6cF5DSO8Jku2CMPFPUBcGgukFqn ztawKj*9RG&cT9!XI5xV?up#xksnY$iL~dqI&WqhIavnnt{OrM70&}Z?%_dMpPRvAx z@lF|2`7sgg)-E8}!uk1zuLYfoIyz>1Qq4lfnxVYqz^ngkboGsvv~-n^lzA4WBQ&>m zNR#w1n8sKu<1?jAnxO23m0bgmYNx>G&Qndoogk*^tNu6An1IeA7z<_Dh1|y zM7UsigoM_#L|wpIOzql59}-QcR4R`?C2qN~ELcT_`8A7VnUFnwjW~xktvkNF*}b-Lclpc4wb4Z>I)$94y|=$SC2! zN~!NFlRt^R7EYuZqM|LMnchb8vc~vYifY-dtfYj5gaWCa${gHjpQFl${S6uHs=zf) z<3v0&Ev@r3kM?|8J^}}PX&%q|<(gd5U!vo+ZHRmTXuIOKrL+b~qU&=Ln}fL|8TC|f zaN$xrAB$p4&MXOkw1C~4yIW_`9^wS*I))@#o$xqp?KCWAqnN~+z319q|+fSVI6#cq# zquAh!V$`%G z5C;%)><>F|?9jLs;r6Biuy;=Y0DG)GZd0=b#8x4;Ua*0K4J~bR!8Ryul)%Q3Y^1T; z{cM<(8lTv`1=y&vZFl(pEy?U+y$+zL#Ls{C)Z(~XFb+WaL34(zW3t040b!vL=i0xD Gzws}ut&wN| literal 0 HcmV?d00001 diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.module.css b/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.module.css index 5733e1c71c..39b7c5227e 100644 --- a/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.module.css +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.module.css @@ -24,4 +24,8 @@ font-size: var(--cpd-font-size-body-sm); margin-inline-start: 5px; } + + .userStatus { + margin-inline-start: 5px; + } } diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx index 3766c7b19d..02cd5913bc 100644 --- a/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx @@ -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", + }, }, }; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfileView.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfileView.tsx index fa1102b81d..f7a3a2b62a 100644 --- a/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfileView.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfileView.tsx @@ -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): 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 )} + {userStatus && ( + + + {userStatusEmoji} + + + )}
    ); } diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/__snapshots__/DisambiguatedProfile.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/__snapshots__/DisambiguatedProfile.test.tsx.snap index 3ef52cea4d..9d4c1fc3d6 100644 --- a/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/__snapshots__/DisambiguatedProfile.test.tsx.snap +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/DisambiguatedProfile/__snapshots__/DisambiguatedProfile.test.tsx.snap @@ -36,6 +36,11 @@ exports[`DisambiguatedProfileView > renders the full example 1`] = ` > @eve:matrix.org
    + + 🏝️ +
    `;