Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
34eb9ec
Use other branch
Half-Shot Mar 31, 2026
b077f6b
All the changes that got lost
Half-Shot Mar 31, 2026
5894faf
Fix merge
Half-Shot Mar 31, 2026
c3986a9
Ensure emoji can only be one character long
Half-Shot Apr 2, 2026
eb356d9
Fixup labs feature
Half-Shot Apr 2, 2026
61cf98b
Merge remote-tracking branch 'origin/develop' into hs/enable-profile-…
dbkr Apr 15, 2026
ec69498
Remove redundant check
dbkr Apr 15, 2026
06bea86
Merge remote-tracking branch 'origin/develop' into hs/enable-profile-…
dbkr Apr 16, 2026
c4d5aba
Update snapshot
dbkr Apr 16, 2026
5bb6458
update snapshot
dbkr Apr 16, 2026
215e75d
add snapshot
dbkr Apr 16, 2026
6ec26c2
Merge branch 'develop' into hs/enable-profile-updates
dbkr Apr 16, 2026
f726c9d
unpin
dbkr Apr 16, 2026
9331e99
Merge branch 'develop' into hs/enable-profile-updates
dbkr Apr 16, 2026
6feaedc
fix pnpm lock
dbkr Apr 16, 2026
9860448
undo pn[m lockfile changes altogether
dbkr Apr 16, 2026
cc87a0a
update snpahot for changed IDs
dbkr Apr 17, 2026
3b3b042
Snapshot update
dbkr Apr 17, 2026
cb15bae
Snapshot update
dbkr Apr 17, 2026
ac1f409
There is now another section
dbkr Apr 17, 2026
729e3a0
more snapshots
dbkr Apr 17, 2026
c42f0c2
more snapshot
dbkr Apr 17, 2026
ebcc96c
More snapshots
dbkr Apr 17, 2026
b049bd1
oh come on snapshots
dbkr Apr 17, 2026
39fb0ec
Merge branch 'develop' into hs/enable-profile-updates
dbkr Apr 17, 2026
3c44dfd
actual snapshot update
dbkr Apr 17, 2026
d63ed51
Fix sonar issues
dbkr Apr 17, 2026
7611001
Merge remote-tracking branch 'origin/develop' into hs/enable-profile-…
dbkr Apr 17, 2026
1dbd663
just update the thing manually
dbkr Apr 17, 2026
b7b8430
[screams internally]
dbkr Apr 17, 2026
40759cd
Update snapshot
dbkr Apr 20, 2026
a7f89c3
test for useUserStatus
dbkr Apr 20, 2026
d5580be
Make useUserStatus actually truncate
dbkr Apr 20, 2026
cc6eb25
Merge branch 'develop' into hs/enable-profile-updates
dbkr Apr 24, 2026
a22f836
Split out slash command to its own file
dbkr Apr 27, 2026
32053d6
Merge remote-tracking branch 'origin/develop' into hs/enable-profile-…
dbkr Apr 28, 2026
f9d2fe8
Remove irrelevant comment
dbkr Apr 28, 2026
2754b28
doc
dbkr Apr 28, 2026
13965c7
Comment on non-obvious error message
dbkr Apr 28, 2026
3c3b55b
Merge branch 'develop' into hs/enable-profile-updates
dbkr Apr 28, 2026
a08d20d
Merge branch 'develop' into hs/enable-profile-updates
dbkr Apr 28, 2026
823f71f
Merge branch 'develop' into hs/enable-profile-updates
dbkr Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/src/MatrixClientPeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/views/messages/SenderProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +28,7 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
userId: sender,
member: mxEvent.sender,
});
const userStatus = useUserStatus(sender);

const disambiguatedProfileVM = useCreateAutoDisposedViewModel(
() =>
Expand All @@ -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]);
Expand Down
98 changes: 98 additions & 0 deletions apps/web/src/hooks/useUserStatus.ts
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
dbkr marked this conversation as resolved.
const isEnabled = useFeatureEnabled("feature_user_status");
const matrixClient = useMatrixClientContext();
const [rawUserStatus, setRawUserStatus] = useState<unknown>();

useTypedEventEmitter(matrixClient, ClientEvent.UserProfileUpdate, (syncedUserId, syncProfile) => {
if (syncedUserId !== userId) {
return;
}
if (syncProfile["org.matrix.msc4426.status"]) {
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
}
});
useEffect(() => {
(async () => {
if (!isEnabled) {
return;
}
if (!userId) {
setRawUserStatus(undefined);
return;
}
if ((await matrixClient.doesServerSupportExtendedProfiles()) === false) {
setRawUserStatus(undefined);
return;
}
try {
const result = await matrixClient.getExtendedProfileProperty(userId, "org.matrix.msc4426.status");
setRawUserStatus(result);
} catch (ex) {
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
setRawUserStatus(undefined);
} else {
logger.warn(`Failed to get userStatus for ${userId}`, ex);
}
}
})();
}, [isEnabled, userId, matrixClient]);
if (!isEnabled) {
return;
}

if (typeof rawUserStatus !== "object" || rawUserStatus === null) {
logger.warn(`value of "org.matrix.msc4426.status" was not an object for ${userId}`);
return;
}
if ("emoji" in rawUserStatus === false || typeof rawUserStatus.emoji !== "string" || !rawUserStatus.emoji) {
logger.warn(`"emoji" property was not a valid string for ${userId}`);
return;
}
if ("text" in rawUserStatus === false || typeof rawUserStatus.text !== "string" || !rawUserStatus.text) {
logger.warn(`"text" property was not a valid string for ${userId}`);
return;
}

return {
emoji: rawUserStatus.emoji,
text: userStatusTextWithinMaxLength(rawUserStatus.text)
? rawUserStatus.text
: `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}…`,
};
}
13 changes: 13 additions & 0 deletions apps/web/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i18n key meaning seems different than the translation

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it's checking that the emoji is a single grapheme, so the error is that more than one grapheme is "not an emoji" in that it could be multiple emoji, or letters etc. Commented.

"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.",
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/settings/Settings.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -228,6 +229,7 @@ export interface Settings {
"feature_ask_to_join": IFeature;
"feature_notifications": IFeature;
"feature_msc4362_encrypted_state_events": IFeature;
"feature_user_status": IFeature;
// These are in the feature namespace but aren't actually features
"feature_hidebold": IBaseSetting<boolean>;

Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.

Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/slash-commands/SlashCommands.tsx
Original file line number Diff line number Diff line change
@@ -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>
Expand Down Expand Up @@ -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 };

Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/slash-commands/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { _td } from "@element-hq/web-shared-components";

import { Command, CommandCategories, splitAtFirstSpace } from "./SlashCommands";
import SettingsStore from "../settings/SettingsStore";
import { reject, success } from "./utils";
import { UserFriendlyError } from "../languageHandler";
import { userStatusTextWithinMaxLength } from "../hooks/useUserStatus";
import { TimelineRenderingType } from "../contexts/RoomContext";

export const statusCommand = new Command({
command: "status",
args: "<emoji> <text>",
description: _td("slash_command|status|description"),
isEnabled: () => SettingsStore.getValue("feature_user_status"),
runFn: function (cli, _roomId, _threadId, args) {
if (!args) {
return reject(new UserFriendlyError("slash_command|status|no_args"));
}
const [emojiText, text] = splitAtFirstSpace(args);
if (!emojiText) {
return reject(new UserFriendlyError("slash_command|status|no_emoji"));
}
if (!text) {
return reject(new UserFriendlyError("slash_command|status|no_text"));
}
const [emoji, additionalSegment] = [...new Intl.Segmenter().segment(emojiText)];
if (additionalSegment) {
// This is "too long" in that it's more than one grapheme, so the error we give is
// that it's "not an emoji".
return reject(new UserFriendlyError("slash_command|status|too_long_emoji"));
}
if (!userStatusTextWithinMaxLength(text)) {
return reject(new UserFriendlyError("slash_command|status|too_long_text"));
}
return success(
cli.setExtendedProfileProperty("org.matrix.msc4426.status", {
emoji: emoji.segment,
text,
}),
);
},
category: CommandCategories.actions,
renderingTypes: [TimelineRenderingType.Room],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -62,6 +67,7 @@ export interface DisambiguatedProfileViewModelProps {
* Whether to show a tooltip with additional information.
*/
withTooltip?: boolean;

/**
* Optional click handler for the profile.
*/
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -136,6 +146,13 @@ export class DisambiguatedProfileViewModel
this.snapshot.set(DisambiguatedProfileViewModel.computeSnapshot(this.props));
}

public setUserStatus(userStatus?: UserStatus): void {
this.props.userStatus = userStatus;
this.snapshot.merge({
userStatus,
});
}

public onClick = (evt: MouseEvent<HTMLDivElement>): void => {
this.props.onClick?.(evt);
};
Expand Down
Loading
Loading