diff --git a/apps/web/src/MatrixClientPeg.ts b/apps/web/src/MatrixClientPeg.ts index d96ae629d3..5f8125be67 100644 --- a/apps/web/src/MatrixClientPeg.ts +++ b/apps/web/src/MatrixClientPeg.ts @@ -297,7 +297,9 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours opts.threadSupport = true; - opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status"]; + 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/hooks/useUserStatus.ts b/apps/web/src/hooks/useUserStatus.ts index 04b1372ad2..cb6cb4e18d 100644 --- a/apps/web/src/hooks/useUserStatus.ts +++ b/apps/web/src/hooks/useUserStatus.ts @@ -11,6 +11,7 @@ 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"); @@ -19,7 +20,15 @@ export interface UserStatus { 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; +} + export function useUserStatus(userId: string | undefined): UserStatus | undefined { + const isEnabled = useFeatureEnabled("feature_user_status"); const matrixClient = useMatrixClientContext(); const [rawUserStatus, setRawUserStatus] = useState(); @@ -33,6 +42,9 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine }); useEffect(() => { (async () => { + if (!isEnabled) { + return; + } if (!userId) { setRawUserStatus(undefined); return; @@ -52,23 +64,26 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine } } })(); - }, [userId, matrixClient]); + }, [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 undefined; + return; } if ("emoji" in rawUserStatus === false || typeof rawUserStatus.emoji !== "string" || !rawUserStatus.emoji) { logger.warn(`"emoji" property was not a valid string for ${userId}`); - return undefined; + return; } if ("text" in rawUserStatus === false || typeof rawUserStatus.text !== "string" || !rawUserStatus.text) { - logger.warn(`"status" property was not a valid string for ${userId}`); - return undefined; + logger.warn(`"text" property was not a valid string for ${userId}`); + return; } return { emoji: rawUserStatus.emoji, - text: rawUserStatus.text, + text: userStatusTextWithinMaxLength(rawUserStatus.text) ? rawUserStatus.text : `${rawUserStatus.text}…`, }; } diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index d56399dda4..f61acce97e 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -1530,6 +1530,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", @@ -3135,6 +3140,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 02a692a209..b6a5107501 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 @@ -226,6 +227,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; @@ -795,6 +797,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..77b8a12346 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 async onChange(): Promise { + 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..a20a716021 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 { userStatusTextWithinMaxLength } from "../hooks/useUserStatus"; export { CommandCategories, Command }; @@ -819,6 +821,39 @@ export const Commands = [ }, renderingTypes: [TimelineRenderingType.Room], }), + 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) { + return reject(new UserFriendlyError("slash_command|status|too_long_emoji")); + } + if (text && !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], + }), // 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/docs/labs.md b/docs/labs.md index aba48767e0..e6e63e913e 100644 --- a/docs/labs.md +++ b/docs/labs.md @@ -139,5 +139,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.