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 fcc6af53b3..f016ac3067 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/full-example-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/full-example-auto.png differ 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 0000000000..ad7c25d317 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/DisambiguatedProfile/DisambiguatedProfile.stories.tsx/with-user-status-auto.png differ 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
    + + 🏝️ +
    `;