-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Show user status in timeline #32991
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Show user status in timeline #32991
Changes from all commits
34eb9ec
b077f6b
5894faf
c3986a9
eb356d9
61cf98b
ec69498
06bea86
c4d5aba
5bb6458
215e75d
6ec26c2
f726c9d
9331e99
6feaedc
9860448
cc87a0a
3b3b042
cb15bae
ac1f409
729e3a0
c42f0c2
ebcc96c
b049bd1
39fb0ec
3c44dfd
d63ed51
7611001
1dbd663
b7b8430
40759cd
a7f89c3
d5580be
cc6eb25
a22f836
32053d6
f9d2fe8
2754b28
13965c7
3c3b55b
a08d20d
823f71f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| 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)}…`, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i18n key meaning seems different than the translation
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.", | ||
|
|
||
| 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], | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.