From 64abaabaf7909c32d73974f104687d291fbb796a Mon Sep 17 00:00:00 2001 From: David Langley Date: Fri, 3 Jul 2026 12:17:29 +0100 Subject: [PATCH 1/5] Show user status in DM room list, DM room header, member list & user profile Extends MSC4426 user status display (already in the timeline) to: - DM room list items: status emoji after the room name (RoomListItemViewSnapshot + RoomListItemViewModel fetch/UserProfileUpdate subscription) - DM room header: emoji + status text after the room name - Right panel user profile: emoji + status text between name and MXID - Member list: status emoji after the member name via DisambiguatedProfile (member-tile layout switched to grid so name truncates and emoji stays) Adds a shared UserStatusMessageView (emoji + text) and getUserStatusEmoji helper, extracts a reusable fetchUserStatus util from useUserStatus, and bumps the status emoji to body-lg per design. Also syncs the jest transformIgnorePatterns matrix-js-sdk entry from develop so unit tests run. Co-Authored-By: Claude Fable 5 --- .../views/messages/_DisambiguatedProfile.pcss | 15 +- .../res/css/views/right_panel/_UserInfo.pcss | 5 + apps/web/res/css/views/rooms/_RoomHeader.pcss | 6 + .../user_info/UserInfoHeaderView.tsx | 7 +- .../MemberList/tiles/RoomMemberTileView.tsx | 6 + .../views/rooms/RoomHeader/RoomHeader.tsx | 8 +- apps/web/src/hooks/useUserStatus.ts | 30 +- apps/web/src/utils/userStatus.ts | 26 +- .../room-list/RoomListItemViewModel.ts | 51 +- .../shared-components/src/core/userStatus.ts | 8 + packages/shared-components/src/index.ts | 3 +- .../__snapshots__/RoomListView.test.tsx.snap | 1768 ++++++++++++----- .../RoomListItemView/RoomListItemContent.tsx | 18 +- .../RoomListItemView.module.css | 10 + .../RoomListItemView/RoomListItemView.tsx | 3 + .../RoomListItemView.test.tsx.snap | 143 +- .../VirtualizedRoomListView.test.tsx.snap | 130 +- .../DisambiguatedProfileView.tsx | 10 +- .../DisambiguatedProfile.test.tsx.snap | 2 +- .../status/UserStatusMessageView.module.css | 25 + .../src/status/UserStatusMessageView.tsx | 37 + 21 files changed, 1644 insertions(+), 667 deletions(-) create mode 100644 packages/shared-components/src/status/UserStatusMessageView.module.css create mode 100644 packages/shared-components/src/status/UserStatusMessageView.tsx diff --git a/apps/web/res/css/views/messages/_DisambiguatedProfile.pcss b/apps/web/res/css/views/messages/_DisambiguatedProfile.pcss index f2d63b7a1e2..72d813f2d80 100644 --- a/apps/web/res/css/views/messages/_DisambiguatedProfile.pcss +++ b/apps/web/res/css/views/messages/_DisambiguatedProfile.pcss @@ -9,19 +9,28 @@ Please see LICENSE files in the repository root for full details. /** Disambiguated profile needs to have a different layout in the member tile */ .mx_MemberTileView .mx_DisambiguatedProfile { - display: flex; - flex-direction: column; + /* Grid so that the name and the user status emoji share the first row + (with the name truncating and the emoji always visible), while the + disambiguated MXID gets a full row below. */ + display: grid; + grid-template-columns: minmax(0, max-content) auto; + align-items: center; .mx_DisambiguatedProfile_mxid { + grid-column: 1 / -1; margin-inline-start: 0; font: var(--cpd-font-body-sm-regular); text-overflow: ellipsis; overflow: hidden; } + .mx_DisambiguatedProfile_userStatus { + margin-inline-start: var(--cpd-space-0-5x); + } + span:not(.mx_DisambiguatedProfile_mxid) { /** - In a member tile, this span element is a flex child and so + In a member tile, this span element is a grid child and so we need the following for text overflow to work. **/ overflow: hidden; diff --git a/apps/web/res/css/views/right_panel/_UserInfo.pcss b/apps/web/res/css/views/right_panel/_UserInfo.pcss index 70cc4ba0641..239e60ff51a 100644 --- a/apps/web/res/css/views/right_panel/_UserInfo.pcss +++ b/apps/web/res/css/views/right_panel/_UserInfo.pcss @@ -113,6 +113,11 @@ Please see LICENSE files in the repository root for full details. height: 20px; } + .mx_UserInfo_userStatusMessage { + justify-content: center; + max-width: 100%; + } + .mx_UserInfo_timezone { height: 20px; margin: 0; diff --git a/apps/web/res/css/views/rooms/_RoomHeader.pcss b/apps/web/res/css/views/rooms/_RoomHeader.pcss index a42811fae2a..5d79498e4a1 100644 --- a/apps/web/res/css/views/rooms/_RoomHeader.pcss +++ b/apps/web/res/css/views/rooms/_RoomHeader.pcss @@ -68,6 +68,12 @@ Please see LICENSE files in the repository root for full details. padding: var(--cpd-space-1x); } +.mx_RoomHeader_userStatus { + /* With the heading's own 4px gap this makes up the 8px gap from the design */ + margin-inline-start: var(--cpd-space-1x); + min-width: 0; +} + .mx_RoomHeader .mx_FacePile { color: $secondary-content; background: $background; diff --git a/apps/web/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx b/apps/web/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx index 275175198c4..9859f37df9d 100644 --- a/apps/web/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx +++ b/apps/web/src/components/views/right_panel/user_info/UserInfoHeaderView.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { type User, type RoomMember } from "matrix-js-sdk/src/matrix"; import { Heading, Tooltip, Text } from "@vector-im/compound-web"; -import { Flex } from "@element-hq/web-shared-components"; +import { Flex, UserStatusMessageView } from "@element-hq/web-shared-components"; import { useUserfoHeaderViewModel } from "../../../viewmodels/right_panel/user_info/UserInfoHeaderViewModel"; import MemberAvatar from "../../avatars/MemberAvatar"; @@ -16,6 +16,7 @@ import { Container, type Member, type IDevice } from "../UserInfo"; import PresenceLabel from "../../rooms/PresenceLabel"; import CopyableText from "../../elements/CopyableText"; import { UserInfoHeaderVerificationView } from "./UserInfoHeaderVerificationView"; +import { useUserStatus } from "../../../../hooks/useUserStatus"; export interface UserInfoHeaderViewProps { member: Member; @@ -33,6 +34,7 @@ export const UserInfoHeaderView: React.FC = ({ const vm = useUserfoHeaderViewModel({ member, roomId }); const avatarUrl = (member as User).avatarUrl; const displayName = (member as RoomMember).rawDisplayName; + const userStatus = useUserStatus(member.userId); let presenceLabel: JSX.Element | undefined; @@ -73,6 +75,9 @@ export const UserInfoHeaderView: React.FC = ({ {displayName} + {userStatus && ( + + )} {presenceLabel} {vm.timezoneInfo && ( diff --git a/apps/web/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx b/apps/web/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx index a3813050884..aab430a3eac 100644 --- a/apps/web/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx +++ b/apps/web/src/components/views/rooms/MemberList/tiles/RoomMemberTileView.tsx @@ -18,6 +18,7 @@ import { MemberTileView } from "./common/MemberTileView"; import { InvitedIconView } from "./common/InvitedIconView"; import { type MemberWithSeparator } from "../../../../viewmodels/memberlist/MemberListViewModel"; import { DisambiguatedProfileViewModel } from "../../../../../viewmodels/room/timeline/event-tile/DisambiguatedProfileViewModel"; +import { useUserStatus } from "../../../../../hooks/useUserStatus"; interface IProps { /** @@ -47,17 +48,22 @@ export function RoomMemberTileView(props: IProps): JSX.Element { /> ); const name = vm.name; + const userStatus = useUserStatus(member.userId); const disambiguatedProfileVM = useCreateAutoDisposedViewModel( () => new DisambiguatedProfileViewModel({ fallbackName: name, member, withTooltip: true, + userStatus, }), ); useEffect(() => { disambiguatedProfileVM.setMember(name, member); }, [disambiguatedProfileVM, member, name]); + useEffect(() => { + disambiguatedProfileVM.setUserStatus(userStatus); + }, [disambiguatedProfileVM, userStatus]); const nameJSX = ; const presenceState = member.presenceState; diff --git a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx index 4f7d0b9c1e2..85ce332cf39 100644 --- a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx +++ b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx @@ -20,7 +20,7 @@ import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error- import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; import { HistoryVisibility, JoinRule, type Room } from "matrix-js-sdk/src/matrix"; import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; -import { Flex, Box } from "@element-hq/web-shared-components"; +import { Flex, Box, UserStatusMessageView } from "@element-hq/web-shared-components"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { HistoryIcon, UserProfileSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; @@ -57,6 +57,7 @@ import { ToggleableIcon } from "./toggle/ToggleableIcon.tsx"; import { CurrentRightPanelPhaseContextProvider } from "../../../../contexts/CurrentRightPanelPhaseContext.tsx"; import { LocalRoom } from "../../../../models/LocalRoom.ts"; import { useIsEncrypted } from "../../../../hooks/useIsEncrypted.ts"; +import { useUserStatus } from "../../../../hooks/useUserStatus.ts"; function RoomHeaderButtons({ room, @@ -448,6 +449,7 @@ export default function RoomHeader({ const historyVisibility = useRoomState(room, (state) => state.getHistoryVisibility()); const dmMember = useDmMember(room); const isDirectMessage = !!dmMember; + const dmUserStatus = useUserStatus(dmMember?.userId); const isRoomEncrypted = useIsEncrypted(client, room); const e2eStatus = useEncryptionStatus(client, room); const askToJoinEnabled = useFeatureEnabled("feature_ask_to_join"); @@ -496,6 +498,10 @@ export default function RoomHeader({ > {roomName} + {isDirectMessage && dmUserStatus && ( + + )} + {!isDirectMessage && joinRule === JoinRule.Public && ( (); + const [userStatus, setUserStatus] = useState(); useTypedEventEmitter(matrixClient, ClientEvent.UserProfileUpdate, (syncedUserId, syncProfile) => { if (syncedUserId !== userId) { return; } - setRawUserStatus(syncProfile["org.matrix.msc4426.status"]); + setUserStatus(validateUserStatus(syncProfile["org.matrix.msc4426.status"])); }); useEffect(() => { (async () => { @@ -42,28 +39,15 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine return; } if (!userId) { - setRawUserStatus(undefined); - return; - } - if ((await matrixClient.doesServerSupportExtendedProfiles()) === false) { - setRawUserStatus(undefined); + setUserStatus(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); - } - } + setUserStatus(await fetchUserStatus(matrixClient, userId)); })(); }, [isEnabled, userId, matrixClient]); if (!isEnabled) { return; } - return validateUserStatus(rawUserStatus); + return userStatus; } diff --git a/apps/web/src/utils/userStatus.ts b/apps/web/src/utils/userStatus.ts index 6020b6bf893..00d82b2a068 100644 --- a/apps/web/src/utils/userStatus.ts +++ b/apps/web/src/utils/userStatus.ts @@ -6,7 +6,10 @@ Please see LICENSE files in the repository root for full details. */ import { type UserStatus } from "@element-hq/web-shared-components"; -import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; + +const logger = rootLogger.getChild("userStatus"); // MSC4426 defines the maximum length of a status to be 256 bytes of UTF-8, // so we truncate anything longer than that. @@ -35,6 +38,27 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin }; } +/** + * Fetch the MSC4426 user status of the given user. Returns undefined if the server does not + * support extended profiles, the user has no (valid) status, or the status could not be fetched. + * + * @param client The Matrix client to fetch the status with. + * @param userId The ID of the user whose status is being fetched. + */ +export async function fetchUserStatus(client: MatrixClient, userId: string): Promise { + if ((await client.doesServerSupportExtendedProfiles()) === false) { + return undefined; + } + try { + return validateUserStatus(await client.getExtendedProfileProperty(userId, "org.matrix.msc4426.status")); + } catch (ex) { + if (!(ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND")) { + logger.warn(`Failed to get userStatus for ${userId}`, ex); + } + return undefined; + } +} + export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Promise { return client.setExtendedProfileProperty("org.matrix.msc4426.status", { emoji: userStatus.emoji, diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index d4bc29a8097..f3051895abd 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -11,11 +11,12 @@ import { type RoomListItemViewSnapshot, type RoomListItemViewActions, type Section, + type UserStatus, } from "@element-hq/web-shared-components"; -import { RoomEvent } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, RoomEvent } from "matrix-js-sdk/src/matrix"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; -import type { Room, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix"; +import type { Room, MatrixClient, RoomMember, ClientEventHandlerMap } from "matrix-js-sdk/src/matrix"; import type { RoomNotificationState } from "../../stores/notifications/RoomNotificationState"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { NotificationStateEvents } from "../../stores/notifications/NotificationState"; @@ -41,6 +42,7 @@ import { type Call, CallEvent } from "../../models/Call"; import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3"; import { getCustomSectionData, isDefaultSectionTag } from "../../stores/room-list-v3/section"; import { _t } from "../../languageHandler"; +import { fetchUserStatus, validateUserStatus } from "../../utils/userStatus"; /** * View section type without `isSelected` field @@ -75,6 +77,11 @@ export class RoomListItemViewModel */ private availableSections: Sections; + /** + * The user ID of the other user if this room is a DM, used to show their user status. + */ + private readonly dmUserId?: string; + public constructor(props: RoomItemProps) { // Get notification state first so we can generate a complete initial snapshot const notifState = RoomNotificationStateStore.instance.getRoomState(props.room); @@ -133,6 +140,24 @@ export class RoomListItemViewModel // Load message preview asynchronously (sync data is already complete) void this.loadAndSetMessagePreview(); + + // If this room is a DM, show the MSC4426 user status of the other user + this.dmUserId = DMRoomMap.shared().getUserIdForRoomId(props.room.roomId) ?? undefined; + if (this.dmUserId) { + props.client.on(ClientEvent.UserProfileUpdate, this.onUserProfileUpdate); + this.disposables.track(() => { + props.client.off(ClientEvent.UserProfileUpdate, this.onUserProfileUpdate); + }); + + const userStatusWatchRef = SettingsStore.watchSetting("feature_user_status", null, () => + this.loadAndSetUserStatus(), + ); + this.disposables.track(() => { + SettingsStore.unwatchSetting(userStatusWatchRef); + }); + + this.loadAndSetUserStatus(); + } } public dispose(): void { @@ -255,6 +280,28 @@ export class RoomListItemViewModel this.snapshot.merge({ messagePreview }); } + /** + * Handler for profile updates received via sync, to keep the DM user's status up to date. + */ + private onUserProfileUpdate: ClientEventHandlerMap[ClientEvent.UserProfileUpdate] = (userId, profile) => { + if (userId !== this.dmUserId || !SettingsStore.getValue("feature_user_status")) return; + this.snapshot.merge({ userStatus: validateUserStatus(profile?.["org.matrix.msc4426.status"]) }); + }; + + /** + * Fetch and set the user status of the DM user, if the feature is enabled. + */ + private loadAndSetUserStatus(): void { + void (async () => { + let userStatus: UserStatus | undefined; + if (this.dmUserId && SettingsStore.getValue("feature_user_status")) { + userStatus = await fetchUserStatus(this.props.client, this.dmUserId); + } + if (this.disposables.isDisposed) return; + this.snapshot.merge({ userStatus }); + })(); + } + /** * Generate a complete RoomListItem with all synchronous data. * Message preview is loaded separately to avoid blocking initial render. diff --git a/packages/shared-components/src/core/userStatus.ts b/packages/shared-components/src/core/userStatus.ts index 6aada947ef0..ee82524d464 100644 --- a/packages/shared-components/src/core/userStatus.ts +++ b/packages/shared-components/src/core/userStatus.ts @@ -13,3 +13,11 @@ export interface UserStatus { emoji: string; text: string; } + +/** + * Get the emoji of a user status limited to its first grapheme cluster, + * so that a malformed status containing more than one emoji doesn't break the layout. + */ +export function getUserStatusEmoji(status: UserStatus): string | undefined { + return [...new Intl.Segmenter().segment(status.emoji)][0]?.segment; +} diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 41ef3a54fb0..b997e3cbb70 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -90,7 +90,8 @@ export * from "./core/utils/FormattingUtils"; export * from "./core/utils/ToastContext.tsx"; export * from "./core/i18n/I18nApi"; export * from "./core/utils/linkify"; -export type * from "./core/userStatus.ts"; +export * from "./core/userStatus.ts"; export * from "./status/SetStatusView"; +export * from "./status/UserStatusMessageView"; // MVVM export * from "./core/viewmodel"; diff --git a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap index 23fad55e270..32e8080e330 100644 --- a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap @@ -111,11 +111,16 @@ exports[` > renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Engineering +
+ Engineering +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Design +
+ Design +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Product +
+ Product +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Marketing +
+ Marketing +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Sales +
+ Sales +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Support +
+ Support +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Announcements +
+ Announcements +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Off-topic +
+ Off-topic +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Alpha +
+ Team Alpha +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Beta +
+ Team Beta +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project X +
+ Project X +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project Y +
+ Project Y +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Water Cooler +
+ Water Cooler +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Feedback +
+ Feedback +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Ideas +
+ Ideas +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Bugs +
+ Bugs +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Features +
+ Features +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Releases +
+ Releases +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Engineering +
+ Engineering +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Design +
+ Design +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Product +
+ Product +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Marketing +
+ Marketing +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Sales +
+ Sales +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Support +
+ Support +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Announcements +
+ Announcements +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Off-topic +
+ Off-topic +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Alpha +
+ Team Alpha +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Beta +
+ Team Beta +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project X +
+ Project X +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project Y +
+ Project Y +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Water Cooler +
+ Water Cooler +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Feedback +
+ Feedback +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Ideas +
+ Ideas +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Bugs +
+ Bugs +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Features +
+ Features +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Releases +
+ Releases +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Engineering +
+ Engineering +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Design +
+ Design +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Product +
+ Product +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Marketing +
+ Marketing +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Sales +
+ Sales +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Support +
+ Support +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Announcements +
+ Announcements +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Off-topic +
+ Off-topic +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Alpha +
+ Team Alpha +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Beta +
+ Team Beta +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project X +
+ Project X +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project Y +
+ Project Y +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Water Cooler +
+ Water Cooler +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Feedback +
+ Feedback +
> renders LargeFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Ideas +
+ Ideas +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Engineering +
+ Engineering +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Design +
+ Design +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Product +
+ Product +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Marketing +
+ Marketing +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Sales +
+ Sales +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Support +
+ Support +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Announcements +
+ Announcements +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Off-topic +
+ Off-topic +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Alpha +
+ Team Alpha +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Beta +
+ Team Beta +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project X +
+ Project X +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project Y +
+ Project Y +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Water Cooler +
+ Water Cooler +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Feedback +
+ Feedback +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Ideas +
+ Ideas +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Bugs +
+ Bugs +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Features +
+ Features +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Releases +
+ Releases +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Engineering +
+ Engineering +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Design +
+ Design +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Product +
+ Product +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Marketing +
+ Marketing +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Sales +
+ Sales +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Support +
+ Support +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Announcements +
+ Announcements +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Off-topic +
+ Off-topic +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Alpha +
+ Team Alpha +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Beta +
+ Team Beta +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project X +
+ Project X +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project Y +
+ Project Y +
> renders LargeSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Water Cooler +
+ Water Cooler +
> renders SmallFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders SmallFlatList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders SmallSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders SmallSectionList story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Engineering +
+ Engineering +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Design +
+ Design +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Product +
+ Product +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Marketing +
+ Marketing +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Sales +
+ Sales +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Support +
+ Support +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Announcements +
+ Announcements +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Off-topic +
+ Off-topic +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Alpha +
+ Team Alpha +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Beta +
+ Team Beta +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project X +
+ Project X +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project Y +
+ Project Y +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Water Cooler +
+ Water Cooler +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Feedback +
+ Feedback +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Ideas +
+ Ideas +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Bugs +
+ Bugs +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Features +
+ Features +
> renders Toast story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Releases +
+ Releases +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Engineering +
+ Engineering +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Design +
+ Design +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Product +
+ Product +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Marketing +
+ Marketing +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Sales +
+ Sales +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Support +
+ Support +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Announcements +
+ Announcements +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Off-topic +
+ Off-topic +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Alpha +
+ Team Alpha +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Beta +
+ Team Beta +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project X +
+ Project X +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Project Y +
+ Project Y +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Water Cooler +
+ Water Cooler +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Feedback +
+ Feedback +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Ideas +
+ Ideas +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Bugs +
+ Bugs +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Features +
+ Features +
> renders WithActiveFilter story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Releases +
+ Releases +
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
-
- {item.name} -
+ +
+ {item.name} +
+ {item.userStatus && ( + + + {getUserStatusEmoji(item.userStatus)} + + + )} +
{item.messagePreview && ( {item.messagePreview} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css index 4359634380e..9eaa7e6ad45 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css @@ -99,6 +99,16 @@ text-overflow: ellipsis; } +.roomName { + /* Allow the room name to shrink and truncate when displayed next to the user status emoji */ + min-width: 0; +} + +.userStatusEmoji { + flex-shrink: 0; + line-height: 1; +} + .selected { .container { background-color: var(--cpd-color-bg-action-tertiary-selected); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx index fbfa2f9e285..0ca002d3e7a 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx @@ -16,6 +16,7 @@ import { RoomListItemContent } from "./RoomListItemContent"; import { type RoomNotifState } from "./RoomNotifs"; import styles from "./RoomListItemView.module.css"; import { useViewModel, type ViewModel } from "../../../../core/viewmodel"; +import { type UserStatus } from "../../../../core/userStatus"; import { _t } from "../../../../core/i18n/i18n"; /** @@ -72,6 +73,8 @@ export interface RoomListItemViewSnapshot { isBold: boolean; /** Optional message preview text */ messagePreview?: string; + /** The MSC4426 user status of the other user in a DM room, if any */ + userStatus?: UserStatus; /** Notification decoration data */ notification: NotificationDecorationData; /** Whether the more options menu should be shown */ diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap index 353ce9c7c43..8ec0f731e8c 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/__snapshots__/RoomListItemView.test.tsx.snap @@ -37,11 +37,16 @@ exports[` > renders Bold story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Team Updates +
+ Team Updates +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders Invitation story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Secret Project +
+ Secret Project +
> renders NoMessagePreview story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders Selected story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders UnsentMessage story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders WithHoverMenu story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders WithMention story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders WithNotification story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders WithVideoCall story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders WithVoiceCall story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- General +
+ General +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Random +
+ Random +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Engineering +
+ Engineering +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Design +
+ Design +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Product +
+ Product +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Marketing +
+ Marketing +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Sales +
+ Sales +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Support +
+ Support +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Announcements +
+ Announcements +
> renders Default story 1`] = ` class="RoomListItemView-module_ellipsis" >
- Off-topic +
+ Off-topic +
): JSX.Element { const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName, userStatus } = useViewModel(vm); - const userStatusEmoji = userStatus && [...new Intl.Segmenter().segment(userStatus.emoji)][0]?.segment; + const userStatusEmoji = userStatus && getUserStatusEmoji(userStatus); const displayNameClasses = classNames(colorClass, { [styles.disambiguatedProfile_displayName]: emphasizeDisplayName, @@ -128,7 +129,12 @@ export function DisambiguatedProfileView({ vm, className }: Readonly - + {/* mx_DisambiguatedProfile_userStatus is required for PCSS selectors like .mx_MemberTileView .mx_DisambiguatedProfile_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 9d4c1fc3d66..4c55b3ba27a 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 @@ -37,7 +37,7 @@ exports[`DisambiguatedProfileView > renders the full example 1`] = ` @eve:matrix.org 🏝️ diff --git a/packages/shared-components/src/status/UserStatusMessageView.module.css b/packages/shared-components/src/status/UserStatusMessageView.module.css new file mode 100644 index 00000000000..c1e23523a60 --- /dev/null +++ b/packages/shared-components/src/status/UserStatusMessageView.module.css @@ -0,0 +1,25 @@ +/* + * 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. + */ + +.userStatusMessage { + display: inline-flex; + align-items: center; + gap: var(--cpd-space-1x); + min-width: 0; +} + +.userStatusMessage_emoji { + flex-shrink: 0; + line-height: 1; +} + +.userStatusMessage_text { + color: var(--cpd-color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/packages/shared-components/src/status/UserStatusMessageView.tsx b/packages/shared-components/src/status/UserStatusMessageView.tsx new file mode 100644 index 00000000000..1beb8cba0fc --- /dev/null +++ b/packages/shared-components/src/status/UserStatusMessageView.tsx @@ -0,0 +1,37 @@ +/* + * 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, { type JSX } from "react"; +import classNames from "classnames"; +import { Text } from "@vector-im/compound-web"; + +import { getUserStatusEmoji, type UserStatus } from "../core/userStatus"; +import styles from "./UserStatusMessageView.module.css"; + +export interface UserStatusMessageViewProps extends React.HTMLAttributes { + /** + * The user status to display. + */ + status: UserStatus; +} + +/** + * Displays a user's MSC4426 status as its emoji followed by the status text, + * e.g. next to the room name in a DM room header or under the name in a user profile. + */ +export function UserStatusMessageView({ status, className, ...props }: UserStatusMessageViewProps): JSX.Element { + return ( + + + {getUserStatusEmoji(status)} + + + {status.text} + + + ); +} From ef89c56dbdca48bc10993362387897f039b7bba3 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 7 Jul 2026 12:53:31 +0100 Subject: [PATCH 2/5] Add status to dm room summary Also courtesy of claude --- .../views/right_panel/_RoomSummaryCard.pcss | 4 +++ .../right_panel/RoomSummaryCardViewModel.tsx | 12 ++++++++ .../views/right_panel/RoomSummaryCardView.tsx | 11 +++++++- .../right_panel/RoomSummaryCardView-test.tsx | 28 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/web/res/css/views/right_panel/_RoomSummaryCard.pcss b/apps/web/res/css/views/right_panel/_RoomSummaryCard.pcss index 96d1b0867e7..32dec470ac4 100644 --- a/apps/web/res/css/views/right_panel/_RoomSummaryCard.pcss +++ b/apps/web/res/css/views/right_panel/_RoomSummaryCard.pcss @@ -28,6 +28,10 @@ Please see LICENSE files in the repository root for full details. text-overflow: ellipsis; } + .mx_RoomSummaryCard_userStatus { + max-width: 100%; + } + .mx_RoomSummaryCard_topic { padding: 0 12px; color: var(--cpd-color-text-secondary); diff --git a/apps/web/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx b/apps/web/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx index d336dc42e5b..044dbe7b5f5 100644 --- a/apps/web/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx +++ b/apps/web/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx @@ -13,6 +13,7 @@ import { RoomEvent, RoomStateEvent, } from "matrix-js-sdk/src/matrix"; +import { type UserStatus } from "@element-hq/web-shared-components"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; @@ -41,9 +42,17 @@ import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; import { tagRoom } from "../../../utils/room/tagRoom"; import { inviteToRoom } from "../../../utils/room/inviteToRoom"; import { getTagsForRoom } from "../../../utils/room/getTagsForRoom"; +import { useDmMember } from "../../views/avatars/WithPresenceIndicator"; +import { useUserStatus } from "../../../hooks/useUserStatus"; export interface RoomSummaryCardState { isDirectMessage: boolean; + /** + * The MSC4426 status of the other user in a direct message, shown below the name. + * Undefined for non-DM rooms, when the user status feature is disabled, or when the + * user has no status. + */ + userStatus: UserStatus | undefined; /** * Whether the room is encrypted, used to display the correct badge and icon */ @@ -182,6 +191,8 @@ export function useRoomSummaryCardViewModel( const isFavorite = roomTags.includes(DefaultTagID.Favourite); const isDirectMessage = useIsDirectMessage(room); + const dmMember = useDmMember(room); + const userStatus = useUserStatus(dmMember?.userId); const onRoomMembersClick = (): void => { RightPanelStore.instance.pushCard({ phase: RightPanelPhases.MemberList }, true); @@ -261,6 +272,7 @@ export function useRoomSummaryCardViewModel( return { isDirectMessage, + userStatus, isRoomEncrypted, roomJoinRule, e2eStatus, diff --git a/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx b/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx index 23d858e4140..eb301918808 100644 --- a/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx +++ b/apps/web/src/components/views/right_panel/RoomSummaryCardView.tsx @@ -39,7 +39,13 @@ import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error" import ErrorSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; import { JoinRule, type Room } from "matrix-js-sdk/src/matrix"; -import { Box, Flex, HistoryVisibilityBadge, LinkedText } from "@element-hq/web-shared-components"; +import { + Box, + Flex, + HistoryVisibilityBadge, + LinkedText, + UserStatusMessageView, +} from "@element-hq/web-shared-components"; import BaseCard from "./BaseCard.tsx"; import { _t } from "../../../languageHandler.tsx"; @@ -153,6 +159,9 @@ const RoomSummaryCardView: React.FC = ({ > {name} + {vm.userStatus && ( + + )} ", () => { // Setup mock view models const vmDefaultValues: RoomSummaryCardState = { isDirectMessage: false, + userStatus: undefined, isRoomEncrypted: false, e2eStatus: undefined, isVideoRoom: false, @@ -325,4 +326,31 @@ describe("", () => { expect(screen.queryByText("Public room")).toBeInTheDocument(); }); }); + + describe("user status", () => { + it("shows the other user's status when set", () => { + mocked(useRoomSummaryCardViewModel).mockReturnValue({ + ...vmDefaultValues, + isDirectMessage: true, + userStatus: { emoji: "💬", text: "In a meeting" }, + }); + + getComponent(); + + expect(screen.getByText("In a meeting")).toBeInTheDocument(); + expect(screen.getByText("💬")).toBeInTheDocument(); + }); + + it("does not show a status when there is none", () => { + mocked(useRoomSummaryCardViewModel).mockReturnValue({ + ...vmDefaultValues, + isDirectMessage: true, + userStatus: undefined, + }); + + getComponent(); + + expect(screen.queryByText("In a meeting")).not.toBeInTheDocument(); + }); + }); }); From 74eed721732587731c4897459b150c35cecd4f2e Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 7 Jul 2026 13:31:12 +0100 Subject: [PATCH 3/5] Add user status to autocomplete suggestion Also claude-powered --- apps/web/src/autocomplete/Components.tsx | 6 ++++ apps/web/src/autocomplete/UserProvider.tsx | 7 +++- apps/web/src/autocomplete/UserStatusIcon.tsx | 29 ++++++++++++++++ .../autocomplete/Components-test.tsx | 22 +++++++++++++ packages/shared-components/src/index.ts | 1 + .../src/status/UserStatusIconView.test.tsx | 24 ++++++++++++++ .../src/status/UserStatusIconView.tsx | 33 +++++++++++++++++++ 7 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/autocomplete/UserStatusIcon.tsx create mode 100644 apps/web/test/unit-tests/autocomplete/Components-test.tsx create mode 100644 packages/shared-components/src/status/UserStatusIconView.test.tsx create mode 100644 packages/shared-components/src/status/UserStatusIconView.tsx diff --git a/apps/web/src/autocomplete/Components.tsx b/apps/web/src/autocomplete/Components.tsx index f1493d0c238..4885e235b9f 100644 --- a/apps/web/src/autocomplete/Components.tsx +++ b/apps/web/src/autocomplete/Components.tsx @@ -22,6 +22,8 @@ interface ITextualCompletionProps { "className"?: string; "aria-selected"?: boolean; "ref"?: Ref; + /** Optional node rendered immediately after the title, e.g. a user status emoji. */ + "titleAdornment"?: React.ReactNode; } export const TextualCompletion = (props: ITextualCompletionProps): JSX.Element => { @@ -30,6 +32,7 @@ export const TextualCompletion = (props: ITextualCompletionProps): JSX.Element = subtitle, description, className, + titleAdornment, "aria-selected": ariaSelectedAttribute, ref, ...restProps @@ -43,6 +46,7 @@ export const TextualCompletion = (props: ITextualCompletionProps): JSX.Element = ref={ref} > {title} + {titleAdornment} {subtitle} {description}
@@ -60,6 +64,7 @@ export const PillCompletion = (props: IPillCompletionProps): JSX.Element => { description, className, children, + titleAdornment, "aria-selected": ariaSelectedAttribute, ref, ...restProps @@ -74,6 +79,7 @@ export const PillCompletion = (props: IPillCompletionProps): JSX.Element => { > {children} {title} + {titleAdornment} {subtitle} {description}
diff --git a/apps/web/src/autocomplete/UserProvider.tsx b/apps/web/src/autocomplete/UserProvider.tsx index 39accdc8da8..2dc3523370a 100644 --- a/apps/web/src/autocomplete/UserProvider.tsx +++ b/apps/web/src/autocomplete/UserProvider.tsx @@ -25,6 +25,7 @@ import { KnownMembership } from "matrix-js-sdk/src/types"; import { MatrixClientPeg } from "../MatrixClientPeg"; import QueryMatcher from "./QueryMatcher"; import { PillCompletion } from "./Components"; +import { UserStatusIcon } from "./UserStatusIcon"; import AutocompleteProvider from "./AutocompleteProvider"; import { _t } from "../languageHandler"; import { makeUserPermalink } from "../utils/permalinks/Permalinks"; @@ -127,7 +128,11 @@ export default class UserProvider extends AutocompleteProvider { suffix: selection.beginning && range!.start === 0 ? ": " : " ", href: makeUserPermalink(user.userId), component: ( - + } + description={description ?? undefined} + > ), diff --git a/apps/web/src/autocomplete/UserStatusIcon.tsx b/apps/web/src/autocomplete/UserStatusIcon.tsx new file mode 100644 index 00000000000..45bdd118014 --- /dev/null +++ b/apps/web/src/autocomplete/UserStatusIcon.tsx @@ -0,0 +1,29 @@ +/* +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, { type JSX } from "react"; +import { UserStatusIconView } from "@element-hq/web-shared-components"; + +import { useUserStatus } from "../hooks/useUserStatus"; + +interface Props { + /** + * The ID of the user whose status should be displayed. + */ + userId: string; +} + +/** + * Fetches and displays the MSC4426 status emoji for a user, e.g. after their + * display name in the user mention autocomplete. Renders nothing if the feature + * is disabled or the user has no status. + */ +export function UserStatusIcon({ userId }: Props): JSX.Element | null { + const status = useUserStatus(userId); + if (!status) return null; + return ; +} diff --git a/apps/web/test/unit-tests/autocomplete/Components-test.tsx b/apps/web/test/unit-tests/autocomplete/Components-test.tsx new file mode 100644 index 00000000000..6709f633f62 --- /dev/null +++ b/apps/web/test/unit-tests/autocomplete/Components-test.tsx @@ -0,0 +1,22 @@ +/* +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 { render, screen } from "jest-matrix-react"; + +import { PillCompletion } from "../../../src/autocomplete/Components"; + +describe("PillCompletion", () => { + it("renders the titleAdornment immediately after the title", () => { + render(💡} description="@alice:example.org" />); + + const title = screen.getByText("Alice"); + const adornment = screen.getByText("💡"); + // The adornment must sit right after the title so the emoji appears just after the display name. + expect(title.nextElementSibling).toBe(adornment); + }); +}); diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index b997e3cbb70..79b37a13e5e 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -93,5 +93,6 @@ export * from "./core/utils/linkify"; export * from "./core/userStatus.ts"; export * from "./status/SetStatusView"; export * from "./status/UserStatusMessageView"; +export * from "./status/UserStatusIconView"; // MVVM export * from "./core/viewmodel"; diff --git a/packages/shared-components/src/status/UserStatusIconView.test.tsx b/packages/shared-components/src/status/UserStatusIconView.test.tsx new file mode 100644 index 00000000000..c903eba696d --- /dev/null +++ b/packages/shared-components/src/status/UserStatusIconView.test.tsx @@ -0,0 +1,24 @@ +/* + * 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 { describe, expect, it } from "vitest"; +import { render, screen } from "@test-utils"; + +import { UserStatusIconView } from "./UserStatusIconView"; + +describe("UserStatusIconView", () => { + it("renders the status emoji", () => { + render(); + expect(screen.getByText("💡")).toBeInTheDocument(); + }); + + it("renders only the first grapheme of a malformed multi-emoji status", () => { + render(); + expect(screen.getByText("💡")).toBeInTheDocument(); + }); +}); diff --git a/packages/shared-components/src/status/UserStatusIconView.tsx b/packages/shared-components/src/status/UserStatusIconView.tsx new file mode 100644 index 00000000000..f841f9e47f0 --- /dev/null +++ b/packages/shared-components/src/status/UserStatusIconView.tsx @@ -0,0 +1,33 @@ +/* + * 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, { type JSX } from "react"; +import { Text, Tooltip } from "@vector-im/compound-web"; + +import { getUserStatusEmoji, type UserStatus } from "../core/userStatus"; + +export interface UserStatusIconViewProps extends React.HTMLAttributes { + /** + * The user status to display. + */ + status: UserStatus; +} + +/** + * Displays a user's MSC4426 status as its emoji only, with the status text shown + * in a tooltip. Used where there is only room for the emoji, e.g. after a display + * name in the user mention autocomplete or in a member tile. + */ +export function UserStatusIconView({ status, className, ...props }: UserStatusIconViewProps): JSX.Element { + return ( + + + {getUserStatusEmoji(status)} + + + ); +} From bce6e46e6751546be49feb8105b4b1bebd8c4037 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 7 Jul 2026 14:33:18 +0100 Subject: [PATCH 4/5] PoC for displaying on-a-call status Claude powered. --- apps/web/src/MatrixClientPeg.ts | 2 +- apps/web/src/hooks/useUserStatus.ts | 18 ++++++- apps/web/src/i18n/strings/en_EN.json | 3 ++ apps/web/src/utils/userStatus.ts | 22 ++++++++ .../unit-tests/hooks/useUserStatus-test.tsx | 51 +++++++++++++++++++ .../test/unit-tests/utils/userStatus-test.ts | 45 +++++++++++++++- 6 files changed, 137 insertions(+), 4 deletions(-) diff --git a/apps/web/src/MatrixClientPeg.ts b/apps/web/src/MatrixClientPeg.ts index 03d47b0a335..38397fe322e 100644 --- a/apps/web/src/MatrixClientPeg.ts +++ b/apps/web/src/MatrixClientPeg.ts @@ -266,7 +266,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours opts.threadSupport = true; if (SettingsStore.getValue("feature_user_status")) { - opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status"]; + opts.unstableMSC4429SyncUserProfileFields = ["org.matrix.msc4426.status", "org.matrix.msc4426.call"]; } if (SettingsStore.getValue("feature_sliding_sync")) { diff --git a/apps/web/src/hooks/useUserStatus.ts b/apps/web/src/hooks/useUserStatus.ts index 131e43f9aec..e3c2184deb9 100644 --- a/apps/web/src/hooks/useUserStatus.ts +++ b/apps/web/src/hooks/useUserStatus.ts @@ -13,7 +13,7 @@ import { type UserStatus } from "@element-hq/web-shared-components"; import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import { useTypedEventEmitter } from "./useEventEmitter"; import { useFeatureEnabled } from "./useSettings"; -import { validateUserStatus } from "../utils/userStatus"; +import { resolveUserStatus } from "../utils/userStatus"; const logger = rootLogger.getChild("useUserStatus"); @@ -28,6 +28,7 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine const isEnabled = useFeatureEnabled("feature_user_status"); const matrixClient = useMatrixClientContext(); const [rawUserStatus, setRawUserStatus] = useState(); + const [rawCallStatus, setRawCallStatus] = useState(); useTypedEventEmitter(matrixClient, ClientEvent.UserProfileUpdate, (syncedUserId, syncProfile) => { if (syncedUserId !== userId) { @@ -35,6 +36,7 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine } setRawUserStatus(syncProfile["org.matrix.msc4426.status"]); + setRawCallStatus(syncProfile["org.matrix.msc4426.call"]); }); useEffect(() => { (async () => { @@ -43,10 +45,12 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine } if (!userId) { setRawUserStatus(undefined); + setRawCallStatus(undefined); return; } if ((await matrixClient.doesServerSupportExtendedProfiles()) === false) { setRawUserStatus(undefined); + setRawCallStatus(undefined); return; } try { @@ -59,11 +63,21 @@ export function useUserStatus(userId: string | undefined): UserStatus | undefine logger.warn(`Failed to get userStatus for ${userId}`, ex); } } + try { + const result = await matrixClient.getExtendedProfileProperty(userId, "org.matrix.msc4426.call"); + setRawCallStatus(result); + } catch (ex) { + if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") { + setRawCallStatus(undefined); + } else { + logger.warn(`Failed to get call status for ${userId}`, ex); + } + } })(); }, [isEnabled, userId, matrixClient]); if (!isEnabled) { return; } - return validateUserStatus(rawUserStatus); + return resolveUserStatus(rawUserStatus, rawCallStatus); } diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index da7ef74f66f..75c6a25c63e 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -3823,6 +3823,9 @@ "verify_button": "Verify User", "verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices." }, + "user_status": { + "on_a_call": "On a call" + }, "voip": { "already_in_call": "Already in call", "already_in_call_person": "You're already in a call with this person.", diff --git a/apps/web/src/utils/userStatus.ts b/apps/web/src/utils/userStatus.ts index 6020b6bf893..443359c8f3d 100644 --- a/apps/web/src/utils/userStatus.ts +++ b/apps/web/src/utils/userStatus.ts @@ -8,6 +8,8 @@ Please see LICENSE files in the repository root for full details. import { type UserStatus } from "@element-hq/web-shared-components"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { _t } from "../languageHandler"; + // MSC4426 defines the maximum length of a status to be 256 bytes of UTF-8, // so we truncate anything longer than that. const MAX_STATUS_TEXT_BYTES = 256; @@ -35,6 +37,26 @@ export function validateUserStatus(rawUserStatus: unknown): UserStatus | undefin }; } +export function isUserOnCall(rawCallStatus: unknown): boolean { + return typeof rawCallStatus === "object" && rawCallStatus !== null; +} + +/** + * Resolves the effective user status to display, given the raw `m.status` and `m.call` profile + * field values. `m.status` always takes precedence; `m.call` is only shown as a fallback ("On a + * call") when there is no valid `m.status` set. + */ +export function resolveUserStatus(rawUserStatus: unknown, rawCallStatus: unknown): UserStatus | undefined { + const userStatus = validateUserStatus(rawUserStatus); + if (userStatus) { + return userStatus; + } + if (isUserOnCall(rawCallStatus)) { + return { emoji: "🎧", text: _t("user_status|on_a_call") }; + } + return undefined; +} + export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Promise { return client.setExtendedProfileProperty("org.matrix.msc4426.status", { emoji: userStatus.emoji, diff --git a/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx b/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx index 6f24a5b3ec5..41fe350d75f 100644 --- a/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx +++ b/apps/web/test/unit-tests/hooks/useUserStatus-test.tsx @@ -153,4 +153,55 @@ describe("useUserStatus", () => { // Should still have original status expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" }); }); + + it("returns 'On a call' when m.call is set and there is no m.status", async () => { + client.getExtendedProfileProperty.mockImplementation((_uid, key) => { + if (key === "org.matrix.msc4426.call") return Promise.resolve({}); + return Promise.resolve(undefined); + }); + const { result } = render(); + await waitFor(() => expect(result.current).toEqual({ emoji: "🎧", text: "On a call" })); + }); + + it("prefers m.status over m.call when both are set", async () => { + client.getExtendedProfileProperty.mockImplementation((_uid, key) => { + if (key === "org.matrix.msc4426.status") return Promise.resolve({ emoji: "🐎", text: "on a horse" }); + if (key === "org.matrix.msc4426.call") return Promise.resolve({}); + return Promise.resolve(undefined); + }); + const { result } = render(); + await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" })); + }); + + it("reverts to m.status when a call ends", async () => { + client.getExtendedProfileProperty.mockImplementation((_uid, key) => { + if (key === "org.matrix.msc4426.status") return Promise.resolve({ emoji: "🐎", text: "on a horse" }); + if (key === "org.matrix.msc4426.call") return Promise.resolve({}); + return Promise.resolve(undefined); + }); + const { result } = render(); + await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" })); + + client.emit(ClientEvent.UserProfileUpdate, userId, { + "org.matrix.msc4426.status": { emoji: "🐎", text: "on a horse" }, + "org.matrix.msc4426.call": undefined, + }); + + await waitFor(() => expect(result.current).toEqual({ emoji: "🐎", text: "on a horse" })); + }); + + it("returns undefined when a call ends and there is no m.status", async () => { + client.getExtendedProfileProperty.mockImplementation((_uid, key) => { + if (key === "org.matrix.msc4426.call") return Promise.resolve({}); + return Promise.resolve(undefined); + }); + const { result } = render(); + await waitFor(() => expect(result.current).toEqual({ emoji: "🎧", text: "On a call" })); + + client.emit(ClientEvent.UserProfileUpdate, userId, { + "org.matrix.msc4426.call": undefined, + }); + + await waitFor(() => expect(result.current).toBeUndefined()); + }); }); diff --git a/apps/web/test/unit-tests/utils/userStatus-test.ts b/apps/web/test/unit-tests/utils/userStatus-test.ts index 5ae29dcded9..8f941ead6c0 100644 --- a/apps/web/test/unit-tests/utils/userStatus-test.ts +++ b/apps/web/test/unit-tests/utils/userStatus-test.ts @@ -7,7 +7,13 @@ Please see LICENSE files in the repository root for full details. import { type MatrixClient } from "matrix-js-sdk/src/matrix"; -import { clearUserStatus, setUserStatus, userStatusTextWithinMaxLength } from "../../../src/utils/userStatus"; +import { + clearUserStatus, + isUserOnCall, + resolveUserStatus, + setUserStatus, + userStatusTextWithinMaxLength, +} from "../../../src/utils/userStatus"; import { stubClient } from "../../test-utils"; describe("userStatus utils", () => { @@ -22,6 +28,43 @@ describe("userStatus utils", () => { }); }); + describe("isUserOnCall", () => { + it("returns true for an object value", () => { + expect(isUserOnCall({})).toBe(true); + expect(isUserOnCall({ call_joined_ts: 12345 })).toBe(true); + }); + it("returns false for undefined", () => { + expect(isUserOnCall(undefined)).toBe(false); + }); + it("returns false for null", () => { + expect(isUserOnCall(null)).toBe(false); + }); + it("returns false for a non-object value", () => { + expect(isUserOnCall("in a call")).toBe(false); + }); + }); + + describe("resolveUserStatus", () => { + it("returns undefined when neither m.status nor m.call are set", () => { + expect(resolveUserStatus(undefined, undefined)).toBeUndefined(); + }); + it("returns the m.status value when only m.status is set", () => { + expect(resolveUserStatus({ emoji: "🐎", text: "on a horse" }, undefined)).toEqual({ + emoji: "🐎", + text: "on a horse", + }); + }); + it("returns the call status when only m.call is set", () => { + expect(resolveUserStatus(undefined, {})).toEqual({ emoji: "🎧", text: "On a call" }); + }); + it("prefers m.status over m.call when both are set", () => { + expect(resolveUserStatus({ emoji: "🐎", text: "on a horse" }, {})).toEqual({ + emoji: "🐎", + text: "on a horse", + }); + }); + }); + describe("setUserStatus", () => { let client: MatrixClient; From 11ac8c1fdc1f5e3f0f2a51fc62f5686f8c0e9b78 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 7 Jul 2026 15:16:03 +0100 Subject: [PATCH 5/5] Add support for updating on-a-call status Also Claude. --- apps/web/src/stores/CallStore.ts | 13 +++ apps/web/src/utils/userStatus.ts | 10 ++ .../test/unit-tests/stores/CallStore-test.ts | 101 +++++++++++++++++- .../test/unit-tests/utils/userStatus-test.ts | 32 ++++++ 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/apps/web/src/stores/CallStore.ts b/apps/web/src/stores/CallStore.ts index 119a0f6d6e8..0ae2231ab2d 100644 --- a/apps/web/src/stores/CallStore.ts +++ b/apps/web/src/stores/CallStore.ts @@ -17,6 +17,7 @@ import WidgetStore from "./WidgetStore"; import SettingsStore from "../settings/SettingsStore"; import { SettingLevel } from "../settings/SettingLevel"; import { Call, CallEvent, ConnectionState } from "../models/Call"; +import { clearUserOnCall, setUserOnCall } from "../utils/userStatus"; export enum CallStoreEvent { // Signals a change in the call associated with a given room @@ -132,9 +133,21 @@ export class CallStore extends AsyncStoreWithClient { return this._connectedCalls; } private set connectedCalls(value: Set) { + const wasInCall = this._connectedCalls.size > 0; this._connectedCalls = value; + const nowInCall = value.size > 0; this.emit(CallStoreEvent.ConnectedCalls, value); + // While the user is participating in a call, advertise an `m.call` profile field (MSC4426) + // so their "on a call" status is visible to others, clearing it once they leave all calls. + // Only act on the empty<->non-empty edge so concurrent calls don't re-write the field. + if (wasInCall !== nowInCall && SettingsStore.getValue("feature_user_status") && this.matrixClient) { + const client = this.matrixClient; + void (nowInCall ? setUserOnCall(client) : clearUserOnCall(client)).catch((err) => + logger.warn("Failed to update m.call profile field", err), + ); + } + // The room IDs are persisted to settings so we can detect unclean disconnects SettingsStore.setValue( "activeCallRoomIds", diff --git a/apps/web/src/utils/userStatus.ts b/apps/web/src/utils/userStatus.ts index 443359c8f3d..c660ae992ba 100644 --- a/apps/web/src/utils/userStatus.ts +++ b/apps/web/src/utils/userStatus.ts @@ -67,3 +67,13 @@ export function setUserStatus(client: MatrixClient, userStatus: UserStatus): Pro export function clearUserStatus(client: MatrixClient): Promise { return client.setExtendedProfileProperty("org.matrix.msc4426.status", null); } + +export function setUserOnCall(client: MatrixClient): Promise { + return client.setExtendedProfileProperty("org.matrix.msc4426.call", { + call_joined_ts: Date.now(), + }); +} + +export function clearUserOnCall(client: MatrixClient): Promise { + return client.setExtendedProfileProperty("org.matrix.msc4426.call", null); +} diff --git a/apps/web/test/unit-tests/stores/CallStore-test.ts b/apps/web/test/unit-tests/stores/CallStore-test.ts index 847c4b35929..a12a8c790bd 100644 --- a/apps/web/test/unit-tests/stores/CallStore-test.ts +++ b/apps/web/test/unit-tests/stores/CallStore-test.ts @@ -6,23 +6,28 @@ */ import { type CallMembership, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc"; -import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { type MockedObject } from "jest-mock"; +import { logger } from "matrix-js-sdk/src/logger"; -import { ElementCall } from "../../../src/models/Call"; +import { ConnectionState, ElementCall } from "../../../src/models/Call"; import { CallStore } from "../../../src/stores/CallStore"; import { setUpClientRoomAndStores, cleanUpClientRoomAndStores, setupAsyncStoreWithClient, enableCalls, + useMockedCalls, + MockedCall, + flushPromises, } from "../../test-utils"; describe("CallStore", () => { let client: MockedObject; let room: Room; + let enabledSettings: Set; beforeEach(() => { - enableCalls(); + ({ enabledSettings } = enableCalls()); const res = setUpClientRoomAndStores(); client = res.client; room = res.room; @@ -66,4 +71,94 @@ describe("CallStore", () => { { type: "type-d", other_data: "baz" }, ]); }); + + describe("m.call profile field (on-a-call status)", () => { + let call: MockedCall; + let call2: MockedCall; + + beforeEach(async () => { + useMockedCalls(); + // Reset the singleton so calls from previous tests don't linger in the map + // (setupAsyncStoreWithClient only runs onReady, never onNotReady). + // @ts-ignore protected access + await CallStore.instance.onNotReady(); + await setupAsyncStoreWithClient(CallStore.instance, client); + MockedCall.create(room, "1"); + call = CallStore.instance.getCall(room.roomId) as MockedCall; + }); + + const createSecondCall = (): void => { + const room2 = new Room("!2:example.org", client, "@alice:example.org"); + client.getRoom.mockImplementation((roomId) => { + if (roomId === room.roomId) return room; + if (roomId === room2.roomId) return room2; + return null; + }); + MockedCall.create(room2, "2"); + call2 = CallStore.instance.getCall(room2.roomId) as MockedCall; + }; + + describe("when feature_user_status is enabled", () => { + beforeEach(() => { + enabledSettings.add("feature_user_status"); + }); + + it("sets m.call when the user joins their first call", () => { + call.setConnectionState(ConnectionState.Connected); + + expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", { + call_joined_ts: expect.any(Number), + }); + }); + + it("clears m.call when the user leaves their last call", () => { + call.setConnectionState(ConnectionState.Connected); + client.setExtendedProfileProperty.mockClear(); + + call.setConnectionState(ConnectionState.Disconnected); + + expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", null); + }); + + it("does not re-write m.call when joining a second concurrent call", () => { + call.setConnectionState(ConnectionState.Connected); + client.setExtendedProfileProperty.mockClear(); + + createSecondCall(); + call2.setConnectionState(ConnectionState.Connected); + + expect(client.setExtendedProfileProperty).not.toHaveBeenCalled(); + }); + + it("does not clear m.call while still connected to another call", () => { + call.setConnectionState(ConnectionState.Connected); + createSecondCall(); + call2.setConnectionState(ConnectionState.Connected); + client.setExtendedProfileProperty.mockClear(); + + call.setConnectionState(ConnectionState.Disconnected); + + expect(client.setExtendedProfileProperty).not.toHaveBeenCalled(); + }); + + it("swallows failures to update the profile field", async () => { + const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {}); + client.setExtendedProfileProperty.mockRejectedValue(new Error("Server does not support extended profiles")); + + expect(() => call.setConnectionState(ConnectionState.Connected)).not.toThrow(); + await flushPromises(); + + expect(warnSpy).toHaveBeenCalledWith("Failed to update m.call profile field", expect.any(Error)); + }); + }); + + describe("when feature_user_status is disabled", () => { + it("never touches the m.call profile field", () => { + call.setConnectionState(ConnectionState.Connected); + call.setConnectionState(ConnectionState.Disconnected); + + expect(client.setExtendedProfileProperty).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/apps/web/test/unit-tests/utils/userStatus-test.ts b/apps/web/test/unit-tests/utils/userStatus-test.ts index 8f941ead6c0..6c7f5325e0a 100644 --- a/apps/web/test/unit-tests/utils/userStatus-test.ts +++ b/apps/web/test/unit-tests/utils/userStatus-test.ts @@ -8,9 +8,11 @@ Please see LICENSE files in the repository root for full details. import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { + clearUserOnCall, clearUserStatus, isUserOnCall, resolveUserStatus, + setUserOnCall, setUserStatus, userStatusTextWithinMaxLength, } from "../../../src/utils/userStatus"; @@ -95,4 +97,34 @@ describe("userStatus utils", () => { expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null); }); }); + + describe("setUserOnCall", () => { + let client: MatrixClient; + + beforeEach(() => { + client = stubClient(); + }); + + it("sets the m.call profile field with a join timestamp", async () => { + setUserOnCall(client); + + expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", { + call_joined_ts: expect.any(Number), + }); + }); + }); + + describe("clearUserOnCall", () => { + let client: MatrixClient; + + beforeEach(() => { + client = stubClient(); + }); + + it("clears the m.call profile field", async () => { + clearUserOnCall(client); + + expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.call", null); + }); + }); });