diff --git a/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png b/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png index 8cd0cbf863d..6038500c477 100644 Binary files a/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png and b/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png b/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png index 14fd316f792..b3496cc2667 100644 Binary files a/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png and b/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png b/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png index 3d1d02f663b..64be83b5e2c 100644 Binary files a/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png and b/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png differ 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/res/css/views/settings/_AvatarSetting.pcss b/apps/web/res/css/views/settings/_AvatarSetting.pcss index b928522820e..fc87bf422aa 100644 --- a/apps/web/res/css/views/settings/_AvatarSetting.pcss +++ b/apps/web/res/css/views/settings/_AvatarSetting.pcss @@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details. */ .mx_AvatarSetting_avatar { - width: 90px; - min-width: 90px; /* so it doesn't get crushed by the flexbox in languages with longer words */ - height: 90px; + width: 120px; + min-width: 120px; /* so it doesn't get crushed by the flexbox in languages with longer words */ + height: 120px; margin-top: 8px; position: relative; @@ -31,9 +31,9 @@ Please see LICENSE files in the repository root for full details. & > img { display: block; - height: 90px; + height: 120px; width: inherit; - border-radius: 90px; + border-radius: 120px; cursor: pointer; object-fit: cover; } diff --git a/apps/web/res/css/views/settings/_UserProfileSettings.pcss b/apps/web/res/css/views/settings/_UserProfileSettings.pcss index 845aa52c1c8..67750b943f7 100644 --- a/apps/web/res/css/views/settings/_UserProfileSettings.pcss +++ b/apps/web/res/css/views/settings/_UserProfileSettings.pcss @@ -19,10 +19,14 @@ Please see LICENSE files in the repository root for full details. gap: 16px; .mx_UserProfileSettings_profile_displayName { - flex-grow: 1; + flex-grow: 0; width: 100%; gap: 0; } + .mx_UserProfileSettings_profile_nameAndStatus { + width: 100%; + gap: 16px; + } } .mx_UserProfileSettings_profile_controls { 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 dbdb0f619a0..96088877acc 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 && ( = ({ diff --git a/apps/web/src/components/views/settings/UserProfileSettings.tsx b/apps/web/src/components/views/settings/UserProfileSettings.tsx index 29506d8ccbf..5d1567f3bc7 100644 --- a/apps/web/src/components/views/settings/UserProfileSettings.tsx +++ b/apps/web/src/components/views/settings/UserProfileSettings.tsx @@ -11,7 +11,12 @@ import { logger } from "matrix-js-sdk/src/logger"; import { EditInPlace, Alert, ErrorMessage } from "@vector-im/compound-web"; import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out"; import SignOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/sign-out"; -import { Flex, useToastContext } from "@element-hq/web-shared-components"; +import { + Flex, + SetStatusView, + useCreateAutoDisposedViewModel, + useToastContext, +} from "@element-hq/web-shared-components"; import { _t } from "../../../languageHandler"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; @@ -27,6 +32,8 @@ import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog"; import Modal from "../../../Modal"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { SettingsSection } from "./shared/SettingsSection.tsx"; +import { SetStatusViewModel } from "../../../viewmodels/status/SetStatusViewModel.ts"; +import SettingsStore from "../../../settings/SettingsStore.ts"; const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => ( <> @@ -131,6 +138,9 @@ const UserProfileSettings: React.FC = ({ })(); }, [client]); + const userStatusEnabled = SettingsStore.getValue("feature_user_status"); + const setStatusVM = useCreateAutoDisposedViewModel(() => new SetStatusViewModel({ client })); + const onAvatarRemove = useCallback(async () => { const removeToast = toastRack.displayToast( {_t("settings|general|avatar_remove_progress")}, @@ -212,21 +222,27 @@ const UserProfileSettings: React.FC = ({ placeholderId={client.getUserId() ?? ""} disabled={!canSetAvatar} /> - - {displayNameError && {_t("settings|general|display_name_error")}} - + + + + {displayNameError && ( + {_t("settings|general|display_name_error")} + )} + + {userStatusEnabled && } + {avatarError && ( diff --git a/apps/web/src/components/views/spaces/SpacePanel.tsx b/apps/web/src/components/views/spaces/SpacePanel.tsx index 20754a429be..bb9d2fe4581 100644 --- a/apps/web/src/components/views/spaces/SpacePanel.tsx +++ b/apps/web/src/components/views/spaces/SpacePanel.tsx @@ -462,7 +462,7 @@ const SpacePanel: React.FC = () => { ref={ref} aria-label={_t("common|spaces")} > - + (); + 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/menus/UserMenuViewModel.ts b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts index ee021d3f467..a8c7b0c925c 100644 --- a/apps/web/src/viewmodels/menus/UserMenuViewModel.ts +++ b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts @@ -20,11 +20,14 @@ import { getHomePageUrl } from "../../utils/pages"; import SdkConfig from "../../SdkConfig"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import { clearUserStatus } from "../../utils/userStatus"; +import { type SetStatusViewModel, UserMenuSetStatusViewModel } from "../status/SetStatusViewModel"; +import SettingsStore from "../../settings/SettingsStore"; // Matches maximum size of an avatar in the UserMenu const AVATAR_PX = 88; export class UserMenuViewModel extends BaseViewModel implements UserMenuViewActions { + public readonly setStatusVm: SetStatusViewModel; private static computeSnapshot( client: MatrixClient, isPanelCollapsed: boolean, @@ -45,6 +48,7 @@ export class UserMenuViewModel extends BaseViewModel { + 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 +279,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/apps/web/src/viewmodels/status/SetStatusViewModel.ts b/apps/web/src/viewmodels/status/SetStatusViewModel.ts new file mode 100644 index 00000000000..d455bc372c3 --- /dev/null +++ b/apps/web/src/viewmodels/status/SetStatusViewModel.ts @@ -0,0 +1,78 @@ +/* +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 { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { + BaseViewModel, + type SetStatusViewSnapshot, + type SetStatusViewActions, + type UserStatus, +} from "@element-hq/web-shared-components"; + +import { clearUserStatus, setUserStatus } from "../../utils/userStatus"; +import { OwnProfileStore } from "../../stores/OwnProfileStore"; +import dis from "../../dispatcher/dispatcher"; +import { UserTab } from "../../components/views/dialogs/UserTab"; +import { Action } from "../../dispatcher/actions"; + +export interface SetStatusViewModelProps { + /** + * The Matrix client instance. + */ + client: MatrixClient; +} + +export class SetStatusViewModel + extends BaseViewModel + implements SetStatusViewActions +{ + public constructor(props: SetStatusViewModelProps) { + super(props, { + userStatus: OwnProfileStore.instance.userStatus, + }); + } + + public setStatus = (userStatus: UserStatus): void => { + const oldStatus = this.snapshot.current.userStatus; + + this.snapshot.merge({ userStatus }); + setUserStatus(this.props.client, userStatus).catch((err) => { + this.snapshot.merge({ userStatus: oldStatus }); + logger.warn("Failed to set user status", err); + }); + }; + + public clearStatus = (): void => { + const oldStatus = this.snapshot.current.userStatus; + + this.snapshot.merge({ userStatus: undefined }); + clearUserStatus(this.props.client).catch((err) => { + this.snapshot.merge({ userStatus: oldStatus }); + logger.warn("Failed to clear user status", err); + }); + }; +} + +/** + * A version of the view model that overrides the click handler to open settings instead. + */ +export class UserMenuSetStatusViewModel extends SetStatusViewModel { + public constructor(props: SetStatusViewModelProps) { + super(props); + } + + public onSetStatusClick = (): void => { + dis.dispatch({ + action: Action.ToggleUserMenu, + }); + dis.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Account, + }); + }; +} diff --git a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap index 136057a7aee..64057828854 100644 --- a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap @@ -7,19 +7,19 @@ exports[` should show all activated MetaSpaces in the correct orde class="mx_SpacePanel collapsed" >
+
+
    +
  • + 💬 In a meeting + +
  • +
  • + 💡 Focus Time + +
  • +
  • + 🚙 On the road + +
  • +
  • + ☕️ Be right back + +
  • +
  • + 🌴 Away + +
  • +
+
+
renders an open menu 1`] = ` > Sally Sanderson with a longer name +
+ +
+
    +
  • + 💬 In a meeting + +
  • +
  • + 💡 Focus Time + +
  • +
  • + 🚙 On the road + +
  • +
  • + ☕️ Be right back + +
  • +
  • + 🌴 Away + +
  • +
+
+
> 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/SetStatusView.module.css b/packages/shared-components/src/status/SetStatusView.module.css new file mode 100644 index 00000000000..63aeb4faed7 --- /dev/null +++ b/packages/shared-components/src/status/SetStatusView.module.css @@ -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. +*/ + +.setStatusTrigger { + background: none; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline solid; + display: flex; + align-items: center; + width: fit-content; + /* Make the button a little taller so it matches the height of the status pill */ + min-height: 2.1em; + + svg { + width: 20px; + height: 20px; + margin-right: 1.5px; + } +} + +.menuItem { + place-items: start; + gap: var(--cpd-space-1x); + color: var(--cpd-color-text-secondary); + font-weight: var(--cpd-font-weight-medium); + font-size: var(--cpd-font-size-body-md); +} diff --git a/packages/shared-components/src/status/SetStatusView.stories.tsx b/packages/shared-components/src/status/SetStatusView.stories.tsx new file mode 100644 index 00000000000..f063da4d393 --- /dev/null +++ b/packages/shared-components/src/status/SetStatusView.stories.tsx @@ -0,0 +1,74 @@ +/* + * 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 { type Meta, type StoryObj } from "@storybook/react-vite"; +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import { SetStatusView, type SetStatusViewActions, type SetStatusViewSnapshot } from "./SetStatusView"; +import { useMockedViewModel } from "../core/viewmodel"; +import { withViewDocs } from "../../.storybook/withViewDocs"; + +const SetStatusViewWrapperImpl = ({ + setStatus, + clearStatus, + ...snapshot +}: SetStatusViewSnapshot & SetStatusViewActions): JSX.Element => { + const vm = useMockedViewModel(snapshot, { + setStatus, + clearStatus, + }); + return ; +}; + +const SetStatusViewWrapper = withViewDocs(SetStatusViewWrapperImpl, SetStatusView); + +const meta = { + title: "Status/SetStatusView", + component: SetStatusViewWrapper, + tags: ["autodocs"], + args: { + setStatus: fn(), + clearStatus: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const NoStatus: Story = {}; + +export const WithStatus: Story = { + args: { + userStatus: { emoji: "🦩", text: "Flamboyant" }, + }, +}; + +// Rules needed for any story where the menu is open. +const MENU_OPEN_A11Y_RULES = [ + { + // Menu contains a header which is invalid + id: "aria-required-children", + enabled: false, + }, + { + // Menu pops open by default + id: "aria-hidden-focus", + enabled: false, + }, +]; + +export const Open: Story = { + parameters: { + a11y: { + config: { + rules: MENU_OPEN_A11Y_RULES, + }, + }, + }, + tags: ["!dev", "!autodocs"], +}; diff --git a/packages/shared-components/src/status/SetStatusView.tsx b/packages/shared-components/src/status/SetStatusView.tsx new file mode 100644 index 00000000000..92844a19c9b --- /dev/null +++ b/packages/shared-components/src/status/SetStatusView.tsx @@ -0,0 +1,105 @@ +/* +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 { Dropdown, type DropdownTriggerProps, Text } from "@vector-im/compound-web"; +import { ReactionIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t, _td, type UserStatus } from ".."; +import { useViewModel, type ViewModel } from "../core/viewmodel"; +import { StatusPillView } from "./StatusPillView"; +import styles from "./SetStatusView.module.css"; + +const PRESET_STATUSES = [ + { emoji: "💬", textKey: _td("status|set_status|in_a_meeting") }, + { emoji: "💡", textKey: _td("status|set_status|focus_time") }, + { emoji: "🚙", textKey: _td("status|set_status|on_the_road") }, + { emoji: "☕️", textKey: _td("status|set_status|be_right_back") }, + { emoji: "🌴", textKey: _td("status|set_status|away") }, +]; + +export interface SetStatusViewSnapshot { + /** + * The current user status, or undefined if no status is set. + */ + userStatus?: UserStatus; +} + +export interface SetStatusViewActions { + /** + * Called when the user clicks to start setting a status. + * + * If falsy, the default dropdown will open for the user to choose a status. + */ + onSetStatusClick?: () => void; + + /** + * Called when the user selects a preset status from the dropdown. + */ + setStatus: (status: UserStatus) => void; + + /** + * Called when the user clears their current status. + */ + clearStatus: () => void; +} + +export type SetStatusViewModel = ViewModel; + +export type SetStatusViewProps = { + vm: SetStatusViewModel; +}; + +export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element { + const { userStatus } = useViewModel(vm); + + if (userStatus) { + return ; + } + + const renderTrigger = (props: DropdownTriggerProps): JSX.Element => { + const trigger = ( + + ); + + return trigger; + }; + + const onValueChange = (value: string): void => { + const status = PRESET_STATUSES.find((s) => s.textKey === value); + + if (!status) { + return; + } + + vm.setStatus({ + emoji: status.emoji, + text: _t(status.textKey), + }); + }; + + return vm.onSetStatusClick ? ( + renderTrigger({ onClick: vm.onSetStatusClick }) + ) : ( + [s.textKey, `${s.emoji} ${_t(s.textKey)}`])} + label={null} + placeholder={null} + trigger={renderTrigger} + onValueChange={onValueChange} + /> + ); +} diff --git a/packages/shared-components/src/status/StatusPillView.module.css b/packages/shared-components/src/status/StatusPillView.module.css new file mode 100644 index 00000000000..0d347c06f5a --- /dev/null +++ b/packages/shared-components/src/status/StatusPillView.module.css @@ -0,0 +1,35 @@ +/* + * 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. + */ + +.statusPill { + border-radius: 30px; + background: var(--cpd-color-bg-subtle-secondary); + display: inline-flex; + /* Disables filling to available width if we're in a flex container */ + align-self: flex-start; + align-items: center; + padding-inline-start: var(--cpd-space-2x); + gap: 6px; + white-space: nowrap; + overflow: hidden; + max-width: 100%; + + .menuStatusEmoji { + flex-shrink: 0; + } + + .menuStatusText { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + } + + span { + font: var(--cpd-font-body-md-medium); + color: var(--cpd-color-text-secondary); + } +} diff --git a/packages/shared-components/src/status/StatusPillView.tsx b/packages/shared-components/src/status/StatusPillView.tsx new file mode 100644 index 00000000000..c76bc15f4d5 --- /dev/null +++ b/packages/shared-components/src/status/StatusPillView.tsx @@ -0,0 +1,40 @@ +/* + * 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 { IconButton, Text } from "@vector-im/compound-web"; +import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t, type UserStatus } from ".."; +import styles from "./StatusPillView.module.css"; + +export const StatusPillView = React.forwardRef< + HTMLDivElement, + { + status: UserStatus; + clearStatus: () => void; + } & React.HTMLAttributes +>(function StatusPillView({ status, clearStatus, ...props }, ref): JSX.Element { + return ( +
+ + {status.emoji} + + + {status.text} + + + + +
+ ); +}); 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} + + + ); +}