Skip to content

Commit 4bee845

Browse files
Half-Shotdbkr
andauthored
Show user status in timeline (#32991)
* Use other branch * All the changes that got lost * Fix merge * Ensure emoji can only be one character long * Fixup labs feature * Remove redundant check * Update snapshot * update snapshot * add snapshot * unpin * fix pnpm lock * undo pn[m lockfile changes altogether as we shouldn't actually need any afaik * update snpahot for changed IDs * Snapshot update * Snapshot update * There is now another section * more snapshots * more snapshot * More snapshots * oh come on snapshots * actual snapshot update * Fix sonar issues * just update the thing manually * [screams internally] * Update snapshot * test for useUserStatus * Make useUserStatus actually truncate * Split out slash command to its own file & add test * Remove irrelevant comment * doc * Comment on non-obvious error message --------- Co-authored-by: David Baker <dbkr@users.noreply.github.com>
1 parent 4c3cb07 commit 4bee845

24 files changed

Lines changed: 523 additions & 22 deletions

File tree

apps/web/src/MatrixClientPeg.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,9 @@ class MatrixClientPegClass implements IMatrixClientPeg {
297297
opts.lazyLoadMembers = true;
298298
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
299299
opts.threadSupport = true;
300+
if (SettingsStore.getValue("feature_user_status")) {
301+
opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status"];
302+
}
300303

301304
if (SettingsStore.getValue("feature_sliding_sync")) {
302305
throw new UserFriendlyError("sliding_sync_legacy_no_longer_supported");

apps/web/src/components/views/messages/SenderProfile.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useCreateAutoDisposedViewModel, DisambiguatedProfileView } from "@eleme
1313

1414
import { DisambiguatedProfileViewModel } from "../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel";
1515
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
16+
import { useUserStatus } from "../../../hooks/useUserStatus";
1617

1718
interface IProps {
1819
mxEvent: MatrixEvent;
@@ -27,6 +28,7 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
2728
userId: sender,
2829
member: mxEvent.sender,
2930
});
31+
const userStatus = useUserStatus(sender);
3032

3133
const disambiguatedProfileVM = useCreateAutoDisposedViewModel(
3234
() =>
@@ -37,9 +39,13 @@ export default function SenderProfile({ mxEvent, onClick, withTooltip }: IProps)
3739
colored: true,
3840
emphasizeDisplayName: true,
3941
withTooltip,
42+
userStatus,
4043
}),
4144
);
4245

46+
useEffect(() => {
47+
disambiguatedProfileVM.setUserStatus(userStatus);
48+
}, [disambiguatedProfileVM, userStatus]);
4349
useEffect(() => {
4450
disambiguatedProfileVM.setMember(sender ?? "", member);
4551
}, [disambiguatedProfileVM, member, sender]);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { useEffect, useState } from "react";
9+
import { ClientEvent, MatrixError } from "matrix-js-sdk/src/matrix";
10+
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
11+
12+
import { useMatrixClientContext } from "../contexts/MatrixClientContext";
13+
import { useTypedEventEmitter } from "./useEventEmitter";
14+
import { useFeatureEnabled } from "./useSettings";
15+
16+
const logger = rootLogger.getChild("useUserStatus");
17+
18+
export interface UserStatus {
19+
emoji: string;
20+
text: string;
21+
}
22+
23+
const MAX_STATUS_TEXT_BYTES = 256;
24+
25+
export function userStatusTextWithinMaxLength(text: string): boolean {
26+
const textEncoder = new TextEncoder();
27+
return textEncoder.encode(text).length <= MAX_STATUS_TEXT_BYTES;
28+
}
29+
30+
/**
31+
* Hook to get the MSC4426 user status for a given user ID. Returns undefined if the feature is disabled,
32+
* the user does not have a status, or if there was an error fetching the status.
33+
*
34+
* @param userId The ID of the user whose status is being fetched.
35+
* @returns The user's status, or undefined if not available.
36+
*/
37+
export function useUserStatus(userId: string | undefined): UserStatus | undefined {
38+
const isEnabled = useFeatureEnabled("feature_user_status");
39+
const matrixClient = useMatrixClientContext();
40+
const [rawUserStatus, setRawUserStatus] = useState<unknown>();
41+
42+
useTypedEventEmitter(matrixClient, ClientEvent.UserProfileUpdate, (syncedUserId, syncProfile) => {
43+
if (syncedUserId !== userId) {
44+
return;
45+
}
46+
if (syncProfile["org.matrix.msc4426.status"]) {
47+
setRawUserStatus(syncProfile["org.matrix.msc4426.status"]);
48+
}
49+
});
50+
useEffect(() => {
51+
(async () => {
52+
if (!isEnabled) {
53+
return;
54+
}
55+
if (!userId) {
56+
setRawUserStatus(undefined);
57+
return;
58+
}
59+
if ((await matrixClient.doesServerSupportExtendedProfiles()) === false) {
60+
setRawUserStatus(undefined);
61+
return;
62+
}
63+
try {
64+
const result = await matrixClient.getExtendedProfileProperty(userId, "org.matrix.msc4426.status");
65+
setRawUserStatus(result);
66+
} catch (ex) {
67+
if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") {
68+
setRawUserStatus(undefined);
69+
} else {
70+
logger.warn(`Failed to get userStatus for ${userId}`, ex);
71+
}
72+
}
73+
})();
74+
}, [isEnabled, userId, matrixClient]);
75+
if (!isEnabled) {
76+
return;
77+
}
78+
79+
if (typeof rawUserStatus !== "object" || rawUserStatus === null) {
80+
logger.warn(`value of "org.matrix.msc4426.status" was not an object for ${userId}`);
81+
return;
82+
}
83+
if ("emoji" in rawUserStatus === false || typeof rawUserStatus.emoji !== "string" || !rawUserStatus.emoji) {
84+
logger.warn(`"emoji" property was not a valid string for ${userId}`);
85+
return;
86+
}
87+
if ("text" in rawUserStatus === false || typeof rawUserStatus.text !== "string" || !rawUserStatus.text) {
88+
logger.warn(`"text" property was not a valid string for ${userId}`);
89+
return;
90+
}
91+
92+
return {
93+
emoji: rawUserStatus.emoji,
94+
text: userStatusTextWithinMaxLength(rawUserStatus.text)
95+
? rawUserStatus.text
96+
: `${rawUserStatus.text.slice(0, MAX_STATUS_TEXT_BYTES)}…`,
97+
};
98+
}

apps/web/src/i18n/strings/en_EN.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,11 @@
15411541
"experimental_section": "Early previews",
15421542
"extended_profiles_msc_support": "Requires your server to support MSC4133",
15431543
"feature_disable_call_per_sender_encryption": "Disable per-sender encryption for Element Call",
1544+
"feature_user_status": {
1545+
"description": "Enables being able to see and set a current status.",
1546+
"display_name": "User status",
1547+
"required_msc_support": "Requires MSC4429 (Profile Updates for Legacy Sync)"
1548+
},
15441549
"feature_wysiwyg_composer_description": "Use rich text instead of Markdown in the message composer.",
15451550
"group_calls": "New group call experience",
15461551
"group_developer": "Developer",
@@ -3148,6 +3153,14 @@
31483153
"server_error_detail": "Server unavailable, overloaded, or something else went wrong.",
31493154
"shrug": "Prepends ¯\\_(ツ)_/¯ to a plain-text message",
31503155
"spoiler": "Sends the given message as a spoiler",
3156+
"status": {
3157+
"description": "Set your current status",
3158+
"no_args": "No arguments provided. You should supply an emoij and an optional text component.",
3159+
"no_emoji": "You did not provide an emoji",
3160+
"no_text": "You did not provide any status text",
3161+
"too_long_emoji": "The first argument must be an emoji",
3162+
"too_long_text": "The text you provided was too long."
3163+
},
31513164
"tableflip": "Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message",
31523165
"topic": "Gets or sets the room topic",
31533166
"topic_none": "This room has no topic.",

apps/web/src/settings/Settings.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/*
2+
Copyright 2026 Element Creations Ltd.
23
Copyright 2024, 2025 New Vector Ltd.
34
Copyright 2018-2024 The Matrix.org Foundation C.I.C.
45
Copyright 2017 Travis Ralston
@@ -228,6 +229,7 @@ export interface Settings {
228229
"feature_ask_to_join": IFeature;
229230
"feature_notifications": IFeature;
230231
"feature_msc4362_encrypted_state_events": IFeature;
232+
"feature_user_status": IFeature;
231233
// These are in the feature namespace but aren't actually features
232234
"feature_hidebold": IBaseSetting<boolean>;
233235

@@ -789,6 +791,30 @@ export const SETTINGS: Settings = {
789791
shouldWarn: true,
790792
default: false,
791793
},
794+
"feature_user_status": {
795+
isFeature: true,
796+
labsGroup: LabGroup.Profile,
797+
displayName: _td("labs|feature_user_status|display_name"),
798+
description: _td("labs|feature_user_status|description"),
799+
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
800+
supportedLevelsAreOrdered: true,
801+
controller: new ServerSupportUnstableFeatureController(
802+
"feature_user_status",
803+
defaultWatchManager,
804+
[["org.matrix.msc4429"], ["org.matrix.msc4429.stable"]],
805+
undefined,
806+
_td("labs|feature_user_status|required_msc_support"),
807+
false,
808+
// We have to assume it's available during early startup because of a race:
809+
// The feature is used to enable extra sync filters during MatrixClient setup
810+
// and we can't check for serverside support until the client has finished setting up.
811+
// Once the client has setup, (so by the time the user actually opens the labs menu) we can
812+
// enforce proper checks.
813+
true,
814+
true,
815+
),
816+
default: false,
817+
},
792818
"useCompactLayout": {
793819
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
794820
displayName: _td("settings|preferences|compact_modern"),

apps/web/src/settings/controllers/ServerSupportUnstableFeatureController.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/*
2+
Copyright 2026 Element Creations Ltd.
23
Copyright 2024 New Vector Ltd.
34
Copyright 2023 The Matrix.org Foundation C.I.C.
45
@@ -12,6 +13,7 @@ import { type WatchManager } from "../WatchManager";
1213
import SettingsStore from "../SettingsStore";
1314
import { type SettingKey } from "../Settings.tsx";
1415
import { _t } from "../../languageHandler.tsx";
16+
import PlatformPeg from "../../PlatformPeg.ts";
1517

1618
/**
1719
* Disables a given setting if the server unstable feature it requires is not supported
@@ -28,6 +30,9 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
2830
* @param unstableFeatureGroups - If any one of the feature groups is satisfied,
2931
* then the setting is considered enabled. A feature group is satisfied if all of
3032
* the features in the group are supported (all features in a group are required).
33+
* @param defaultEnabled - If we haven't been able to check for support yet, should
34+
* this feature be enabled or disabled (default).
35+
* @param forceReload - Should the client force reload.
3136
*/
3237
public constructor(
3338
private readonly settingName: SettingKey,
@@ -36,12 +41,23 @@ export default class ServerSupportUnstableFeatureController extends MatrixClient
3641
private readonly stableVersion?: string,
3742
private readonly disabledMessage?: TranslationKey,
3843
private readonly forcedValue: any = false,
44+
private readonly defaultEnabled = false,
45+
private readonly forceReload = false,
3946
) {
4047
super();
4148
}
4249

50+
public onChange(): void {
51+
if (this.forceReload) {
52+
PlatformPeg.get()?.reload();
53+
}
54+
}
55+
4356
public get disabled(): boolean {
44-
return !this.enabled;
57+
if (this.enabled !== undefined) {
58+
return !this.enabled;
59+
}
60+
return !this.defaultEnabled;
4561
}
4662

4763
public set disabled(newDisabledValue: boolean) {

apps/web/src/slash-commands/SlashCommands.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/*
2+
Copyright 2026 Element Creations Ltd.
23
Copyright 2024 New Vector Ltd.
34
Copyright 2020 The Matrix.org Foundation C.I.C.
45
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
@@ -62,6 +63,7 @@ import { goto, join } from "./join";
6263
import { manuallyVerifyDevice } from "../components/views/dialogs/ManualDeviceKeyVerificationDialog";
6364
import upgraderoom from "./upgraderoom/upgraderoom";
6465
import { emoticon } from "./emoticon";
66+
import { statusCommand } from "./status";
6567

6668
export { CommandCategories, Command };
6769

@@ -819,6 +821,7 @@ export const Commands = [
819821
},
820822
renderingTypes: [TimelineRenderingType.Room],
821823
}),
824+
statusCommand,
822825

823826
// Command definitions for autocompletion ONLY:
824827
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { _td } from "@element-hq/web-shared-components";
9+
10+
import { Command, CommandCategories, splitAtFirstSpace } from "./SlashCommands";
11+
import SettingsStore from "../settings/SettingsStore";
12+
import { reject, success } from "./utils";
13+
import { UserFriendlyError } from "../languageHandler";
14+
import { userStatusTextWithinMaxLength } from "../hooks/useUserStatus";
15+
import { TimelineRenderingType } from "../contexts/RoomContext";
16+
17+
export const statusCommand = new Command({
18+
command: "status",
19+
args: "<emoji> <text>",
20+
description: _td("slash_command|status|description"),
21+
isEnabled: () => SettingsStore.getValue("feature_user_status"),
22+
runFn: function (cli, _roomId, _threadId, args) {
23+
if (!args) {
24+
return reject(new UserFriendlyError("slash_command|status|no_args"));
25+
}
26+
const [emojiText, text] = splitAtFirstSpace(args);
27+
if (!emojiText) {
28+
return reject(new UserFriendlyError("slash_command|status|no_emoji"));
29+
}
30+
if (!text) {
31+
return reject(new UserFriendlyError("slash_command|status|no_text"));
32+
}
33+
const [emoji, additionalSegment] = [...new Intl.Segmenter().segment(emojiText)];
34+
if (additionalSegment) {
35+
// This is "too long" in that it's more than one grapheme, so the error we give is
36+
// that it's "not an emoji".
37+
return reject(new UserFriendlyError("slash_command|status|too_long_emoji"));
38+
}
39+
if (!userStatusTextWithinMaxLength(text)) {
40+
return reject(new UserFriendlyError("slash_command|status|too_long_text"));
41+
}
42+
return success(
43+
cli.setExtendedProfileProperty("org.matrix.msc4426.status", {
44+
emoji: emoji.segment,
45+
text,
46+
}),
47+
);
48+
},
49+
category: CommandCategories.actions,
50+
renderingTypes: [TimelineRenderingType.Room],
51+
});

apps/web/src/viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { type MouseEvent } from "react";
1515
import { _t } from "../../../../languageHandler";
1616
import { getUserNameColorClass } from "../../../../utils/FormattingUtils";
1717
import UserIdentifier from "../../../../customisations/UserIdentifier";
18+
import type { UserStatus } from "../../../../hooks/useUserStatus";
1819

1920
/**
2021
* Information about a member for disambiguation purposes.
@@ -46,6 +47,10 @@ export interface DisambiguatedProfileViewModelProps {
4647
* The member information for disambiguation.
4748
*/
4849
member?: MemberInfo | null;
50+
/**
51+
* The user's present status.
52+
*/
53+
userStatus?: UserStatus;
4954
/**
5055
* The fallback name to use if the member's display name is not available.
5156
*/
@@ -62,6 +67,7 @@ export interface DisambiguatedProfileViewModelProps {
6267
* Whether to show a tooltip with additional information.
6368
*/
6469
withTooltip?: boolean;
70+
6571
/**
6672
* Optional click handler for the profile.
6773
*/
@@ -79,7 +85,7 @@ export class DisambiguatedProfileViewModel
7985
private static readonly computeSnapshot = (
8086
props: DisambiguatedProfileViewModelProps,
8187
): DisambiguatedProfileViewSnapshot => {
82-
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip } = props;
88+
const { member, fallbackName, colored, emphasizeDisplayName, withTooltip, userStatus } = props;
8389

8490
// Compute display name
8591
const displayName = member?.rawDisplayName || fallbackName;
@@ -122,11 +128,15 @@ export class DisambiguatedProfileViewModel
122128
displayIdentifier,
123129
title,
124130
emphasizeDisplayName,
131+
userStatus,
125132
};
126133
};
127134

128135
public constructor(props: DisambiguatedProfileViewModelProps) {
129136
super(props, DisambiguatedProfileViewModel.computeSnapshot(props));
137+
this.snapshot.merge({
138+
userStatus: props.userStatus,
139+
});
130140
}
131141

132142
public setMember(fallbackName: string, member?: MemberInfo | null): void {
@@ -136,6 +146,13 @@ export class DisambiguatedProfileViewModel
136146
this.snapshot.set(DisambiguatedProfileViewModel.computeSnapshot(this.props));
137147
}
138148

149+
public setUserStatus(userStatus?: UserStatus): void {
150+
this.props.userStatus = userStatus;
151+
this.snapshot.merge({
152+
userStatus,
153+
});
154+
}
155+
139156
public onClick = (evt: MouseEvent<HTMLDivElement>): void => {
140157
this.props.onClick?.(evt);
141158
};

0 commit comments

Comments
 (0)