UI for setting user status (#33856)

* First stage

 1. Make avatar BIGGER!
 2. Move status button out to its own component so we can reuse it

* Working version

but using context menu rather than dropdown menu which almost looks
right but not quite.

* Commit the set status view component

* Change to use dropdown with custom trigger

* Show full set status component in user menu

and styling tweaks

* In user menu, clicking takes you to settings

* Add the view model

* oxfmt

because apparently mine had decided to go away

* Fix type import

* Pass set status viewmodel in story

* Bump compound-web

for customisable dropdown

* Snapshot

* Add explicit aria label

as combobox role things don't just inherit from their inner text

* snapshot again

* Only show user status if feature flag enabled

* Make status btton view not a button

because it doesn't need to be. Also rename accordingly.

* Screenshots

* Disable in settings if feature flag off

* Snapshot

* Snapshot again

* Update screenshots

including room settings one which wasn't really supposed to change
although the avatar now takes up the space it's in - leaving it for
design to check.

* Fix size & positioning of the status button

* Update screenshots

* Screenshots

* Test for SetStatusViewModel

* Update set status view on status change

* Use a link component for the status button

which has built in hover state

* Float the status button in a 28px container

vs making it actually 28px min height

* Separate user menu profile into two sections

With 8px gap between things in the two sections and 12px gap between
sections (ie. in practice, 12px gap between the status control and username).

* Screenshot

* Screenshots

* Pass set status view model in the state

rather than as an extra prop. Fixes it in the story too.

* Pass ownprofilestore into the viewmodel

* Don't use snapshot for testing the view model

Avoids getting all the random stuff from the sub VM in the snapshot
and better to test what we care about anyway.

* don't mention the bad word

* Here too

* Don't show user status for guests

* Update guest story to remove user status
This commit is contained in:
David Baker
2026-07-06 19:28:34 +00:00
committed by GitHub
parent a26eb9f578
commit 5065683658
35 changed files with 981 additions and 161 deletions
@@ -193,6 +193,16 @@
},
"unread_messages": "Unread messages"
},
"status": {
"set_status": {
"away": "Away",
"be_right_back": "Be right back",
"focus_time": "Focus Time",
"in_a_meeting": "In a meeting",
"on_the_road": "On the road",
"set_status_prompt": "What's your status?"
}
},
"terms": {
"tac_button": "Review terms and conditions"
},
+1
View File
@@ -91,5 +91,6 @@ export * from "./core/utils/ToastContext.tsx";
export * from "./core/i18n/I18nApi";
export * from "./core/utils/linkify";
export type * from "./core/userStatus.ts";
export * from "./status/SetStatusView";
// MVVM
export * from "./core/viewmodel";
@@ -21,8 +21,8 @@
/* Override menu defaults */
padding-block: 0 !important;
section.profile {
margin: var(--cpd-space-8x) var(--cpd-space-6x);
margin-bottom: var(--cpd-space-3x);
margin-left: var(--cpd-space-6x);
margin-right: var(--cpd-space-6x);
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
@@ -48,6 +48,13 @@
margin-bottom: var(--cpd-space-2x);
}
}
.profilePrimary {
margin-top: var(--cpd-space-8x);
margin-bottom: var(--cpd-space-3x);
}
.profileSecondary {
margin-bottom: var(--cpd-space-4x);
}
section.actions {
margin: var(--cpd-space-2x) 0;
margin-bottom: var(--cpd-space-3x);
@@ -85,33 +92,6 @@
background-color: var(--cpd-color-bg-canvas-default);
}
.statusButton {
border-radius: 30px;
background: var(--cpd-color-bg-subtle-secondary);
display: flex;
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);
}
}
.triggerButton {
border: none;
background: none;
@@ -11,8 +11,14 @@ import { fn } from "storybook/test";
import { UserMenuView, type UserMenuViewSnapshot, type UserMenuViewActions } from "./UserMenu";
import avatarUrl from "../../../static/element.png";
import { useMockedViewModel } from "../../core/viewmodel";
import { MockViewModel, useMockedViewModel } from "../../core/viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import { type SetStatusViewSnapshot } from "../..";
class MockSetStatusViewModel extends MockViewModel<SetStatusViewSnapshot> {
public setStatus = fn();
public clearStatus = fn();
}
const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
const vm = useMockedViewModel<UserMenuViewSnapshot, UserMenuViewActions>(snapshot, {
@@ -26,6 +32,7 @@ const UserMenuWrapperImpl = (snapshot: UserMenuViewSnapshot): JSX.Element => {
openSettings: fn(),
clearStatus: fn(),
});
return <UserMenuView vm={vm} />;
};
@@ -60,11 +67,16 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Default: Story = {
args: {
setStatusViewModel: new MockSetStatusViewModel({}),
},
};
export const LongerName: Story = {
args: {
displayName: "Sally Sanderson with a longer name",
setStatusViewModel: new MockSetStatusViewModel({}),
},
};
@@ -90,6 +102,7 @@ export const Open: Story = {
userId: "@person-name:homeserver.com",
expanded: true,
showAvatar: true,
setStatusViewModel: new MockSetStatusViewModel({}),
},
parameters: {
a11y: {
@@ -115,6 +128,7 @@ export const OpenVeryLongName: Story = {
userId: "@person-whose-username-some-might-consider-to-be-a-little-overly-long-although-thats-their-choice-and-we-must-respect-it:homeserver.com",
expanded: true,
showAvatar: true,
setStatusViewModel: new MockSetStatusViewModel({}),
},
parameters: {
a11y: {
@@ -132,6 +146,7 @@ export const Condensed: Story = {
args: {
displayName: "Sally Sanderson with a longer name",
expanded: false,
setStatusViewModel: new MockSetStatusViewModel({}),
},
};
@@ -142,6 +157,7 @@ export const NoAvatar: Story = {
expanded: true,
open: true,
showAvatar: false,
setStatusViewModel: new MockSetStatusViewModel({}),
},
parameters: Open.parameters,
};
@@ -152,6 +168,8 @@ export const Guest: Story = {
userId: "@guest:attendees.example.org",
manageAccountHref: undefined,
showAvatar: false,
setStatusViewModel: new MockSetStatusViewModel({}),
showUserStatus: false,
actions: {
createAccount: true,
signIn: true,
@@ -190,6 +208,7 @@ export const WithStatus: Story = {
emoji: "🐹",
text: "On the wheel",
},
setStatusViewModel: new MockSetStatusViewModel({}),
},
};
@@ -200,6 +219,12 @@ export const WithStatusOpen: Story = {
emoji: "🐹",
text: "On the wheel",
},
setStatusViewModel: new MockSetStatusViewModel({
userStatus: {
emoji: "🐹",
text: "On the wheel",
},
}),
},
parameters: {
a11y: {
@@ -6,7 +6,7 @@
*/
import React, { type JSX } from "react";
import { Avatar, Button, IconButton, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
import { Avatar, Button, Link, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
import {
ChatProblemIcon,
DevicesIcon,
@@ -14,7 +14,6 @@ import {
LockIcon,
PopOutIcon,
SettingsIcon,
CloseIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
@@ -22,7 +21,7 @@ import styles from "./UserMenu.module.css";
import { useViewModel, type ViewModel } from "../../core/viewmodel";
import { useI18n } from "../../core/i18n/i18nContext";
import { type UserStatus } from "../../core/userStatus";
import { _t } from "../..";
import { SetStatusView, type SetStatusViewModel } from "../../status/SetStatusView";
export interface UserMenuViewSnapshot {
/**
@@ -57,6 +56,16 @@ export interface UserMenuViewSnapshot {
* The user status to display, or undefined for no icon / status.
*/
userStatus?: UserStatus;
/**
* Whether to show UI for user status.
* Temporary while user status is in labs.
* Default: true
*/
showUserStatus?: boolean;
/**
* ViewModel for the set status view.
*/
setStatusViewModel: SetStatusViewModel;
/**
* A set of actions that the user can perform from the menu.
*/
@@ -105,7 +114,7 @@ export declare interface UserMenuViewActions {
*/
openSettings: () => void;
/**
* Called when the user clicks the button to clear theirt status.
* Called when the user clicks the button to clear their status.
*/
clearStatus: () => void;
}
@@ -118,30 +127,20 @@ export type UserMenuViewProps = {
className?: string;
};
function StatusButton({ status, clearStatus }: { status: UserStatus; clearStatus: () => void }): JSX.Element {
return (
<div className={styles.statusButton}>
<Text as="span" className={styles.menuStatusEmoji}>
{status.emoji}
</Text>
<Text as="span" className={styles.menuStatusText}>
{status.text}
</Text>
<IconButton
onClick={clearStatus}
aria-label={_t("menus|user_menu|clear_status")}
tooltip={_t("menus|user_menu|clear_status")}
size="28px"
>
<CloseIcon />
</IconButton>
</div>
);
}
export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element {
const { userId, displayName, avatarUrl, expanded, open, manageAccountHref, actions, showAvatar, userStatus } =
useViewModel(vm);
const {
userId,
displayName,
avatarUrl,
expanded,
open,
manageAccountHref,
actions,
showAvatar,
userStatus,
setStatusViewModel,
showUserStatus = true,
} = useViewModel(vm);
const { translate: _t } = useI18n();
const trigger = (
<button className={styles.triggerButton} aria-label={_t("menus|user_menu|title")}>
@@ -170,12 +169,14 @@ export function UserMenuView({ vm, className }: UserMenuViewProps): JSX.Element
side="right"
className={styles.container}
>
<section className={styles.profile}>
<section className={classNames(styles.profile, styles.profilePrimary)}>
{showAvatar && <Avatar id={userId} name={displayName} type="round" size="64px" src={avatarUrl} />}
<Text className={styles.displayname} type="body" size="lg" weight="semibold" as="span">
{displayName}
</Text>
{userStatus && <StatusButton status={userStatus} clearStatus={vm.clearStatus} />}
{showUserStatus && <SetStatusView vm={setStatusViewModel} />}
</section>
<section className={classNames(styles.profile, styles.profileSecondary)}>
<Text data-testid="userId" size="md" as="span" type="body" className={styles.userId}>
{userId}
</Text>
@@ -286,13 +286,107 @@ exports[`UserMenu > renders a menu without an avatar 1`] = `
tabindex="-1"
>
<section
class="UserMenu-module_profile"
class="UserMenu-module_profile UserMenu-module_profilePrimary"
>
<span
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74 UserMenu-module_displayname"
>
Sally Sanderson
</span>
<div
aria-invalid="false"
class="_container_1xtut_8"
>
<div
class="SetStatusView-module_setStatusContainer"
>
<a
aria-controls="react-use-id-3"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="What's your status?"
class="_link_k9ljz_8 SetStatusView-module_setStatusTrigger"
data-kind="primary"
data-size="md"
rel="noreferrer noopener"
role="combobox"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 3 3 0 0 1-2.12.879 3 3 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60"
>
What's your status?
</span>
</a>
</div>
<div
class="_content_1xtut_62"
>
<ul
class="_content_1xtut_62"
id="react-use-id-3"
role="listbox"
>
<li
aria-selected="false"
role="option"
tabindex="0"
>
💬 In a meeting
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
💡 Focus Time
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
🚙 On the road
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
☕️ Be right back
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
🌴 Away
</li>
</ul>
</div>
</div>
</section>
<section
class="UserMenu-module_profile UserMenu-module_profileSecondary"
>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_userId"
data-testid="userId"
@@ -599,7 +693,7 @@ exports[`UserMenu > renders an open menu 1`] = `
tabindex="-1"
>
<section
class="UserMenu-module_profile"
class="UserMenu-module_profile UserMenu-module_profilePrimary"
>
<span
aria-label="@person-name:homeserver.com"
@@ -625,6 +719,100 @@ exports[`UserMenu > renders an open menu 1`] = `
>
Sally Sanderson with a longer name
</span>
<div
aria-invalid="false"
class="_container_1xtut_8"
>
<div
class="SetStatusView-module_setStatusContainer"
>
<a
aria-controls="react-use-id-3"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="What's your status?"
class="_link_k9ljz_8 SetStatusView-module_setStatusTrigger"
data-kind="primary"
data-size="md"
rel="noreferrer noopener"
role="combobox"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 3 3 0 0 1-2.12.879 3 3 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60"
>
What's your status?
</span>
</a>
</div>
<div
class="_content_1xtut_62"
>
<ul
class="_content_1xtut_62"
id="react-use-id-3"
role="listbox"
>
<li
aria-selected="false"
role="option"
tabindex="0"
>
💬 In a meeting
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
💡 Focus Time
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
🚙 On the road
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
☕️ Be right back
</li>
<li
aria-selected="false"
role="option"
tabindex="0"
>
🌴 Away
</li>
</ul>
</div>
</div>
</section>
<section
class="UserMenu-module_profile UserMenu-module_profileSecondary"
>
<span
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 UserMenu-module_userId"
data-testid="userId"
@@ -0,0 +1,32 @@
/*
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.
*/
.setStatusContainer {
/* Floats in a 28px tall box so it doesn't visually shift when it changes to the pill */
min-height: 28px;
}
.setStatusTrigger {
text-decoration: underline solid;
display: flex;
align-items: center;
width: fit-content;
svg {
width: 20px;
height: 20px;
margin-right: var(--cpd-space-1-5x);
}
}
.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);
}
@@ -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<SetStatusViewSnapshot, SetStatusViewActions>(snapshot, {
setStatus,
clearStatus,
});
return <SetStatusView vm={vm} />;
};
const SetStatusViewWrapper = withViewDocs(SetStatusViewWrapperImpl, SetStatusView);
const meta = {
title: "Status/SetStatusView",
component: SetStatusViewWrapper,
tags: ["autodocs"],
args: {
setStatus: fn(),
clearStatus: fn(),
},
} satisfies Meta<typeof SetStatusViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
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"],
};
@@ -0,0 +1,107 @@
/*
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, Link, 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<SetStatusViewSnapshot, SetStatusViewActions>;
export type SetStatusViewProps = {
vm: SetStatusViewModel;
};
export function SetStatusView({ vm }: SetStatusViewProps): JSX.Element {
const { userStatus } = useViewModel(vm);
if (userStatus) {
return <StatusPillView status={userStatus} clearStatus={vm.clearStatus} />;
}
const renderTrigger = (props: DropdownTriggerProps): JSX.Element => {
const trigger = (
<div className={styles.setStatusContainer}>
<Link
className={styles.setStatusTrigger}
aria-label={_t("status|set_status|set_status_prompt")}
{...props}
>
<ReactionIcon />
<Text as="span" type="body" size="md" weight="medium">
{_t("status|set_status|set_status_prompt")}
</Text>
</Link>
</div>
);
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 })
) : (
<Dropdown
values={PRESET_STATUSES.map((s) => [s.textKey, `${s.emoji} ${_t(s.textKey)}`])}
label={null}
placeholder={null}
trigger={renderTrigger}
onValueChange={onValueChange}
/>
);
}
@@ -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);
}
}
@@ -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<HTMLDivElement>
>(function StatusPillView({ status, clearStatus, ...props }, ref): JSX.Element {
return (
<div ref={ref} {...props} className={styles.statusPill}>
<Text as="span" className={styles.menuStatusEmoji}>
{status.emoji}
</Text>
<Text as="span" className={styles.menuStatusText}>
{status.text}
</Text>
<IconButton
onClick={clearStatus}
aria-label={_t("menus|user_menu|clear_status")}
tooltip={_t("menus|user_menu|clear_status")}
size="28px"
>
<CloseIcon />
</IconButton>
</div>
);
});