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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -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;
}
@@ -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 {
@@ -181,7 +181,7 @@ const AvatarSetting: React.FC<IProps> = ({
<BaseAvatar
idName={placeholderId}
name={placeholderName}
size="90px"
size="120px"
url={avatarURL}
altText={avatarAccessibleName}
/>
@@ -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,11 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
})();
}, [client]);
const userStatusEnabled = SettingsStore.getValue("feature_user_status");
const setStatusVM = useCreateAutoDisposedViewModel(
() => new SetStatusViewModel({ client, ownProfileStore: OwnProfileStore.instance }),
);
const onAvatarRemove = useCallback(async () => {
const removeToast = toastRack.displayToast(
<SpinnerToast>{_t("settings|general|avatar_remove_progress")}</SpinnerToast>,
@@ -212,21 +224,27 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({
placeholderId={client.getUserId() ?? ""}
disabled={!canSetAvatar}
/>
<EditInPlace
className="mx_UserProfileSettings_profile_displayName"
label={_t("settings|general|display_name")}
value={displayName}
saveButtonLabel={_t("common|save")}
cancelButtonLabel={_t("common|cancel")}
savedLabel={_t("common|saved")}
savingLabel={_t("common|updating")}
onChange={onDisplayNameChanged}
onCancel={onDisplayNameCancel}
onSave={onDisplayNameSave}
disabled={!canSetDisplayName}
>
{displayNameError && <ErrorMessage>{_t("settings|general|display_name_error")}</ErrorMessage>}
</EditInPlace>
<Flex direction="column" className="mx_UserProfileSettings_profile_nameAndStatus">
<EditInPlace
className="mx_UserProfileSettings_profile_displayName"
label={_t("settings|general|display_name")}
value={displayName}
saveButtonLabel={_t("common|save")}
cancelButtonLabel={_t("common|cancel")}
savedLabel={_t("common|saved")}
savingLabel={_t("common|updating")}
onChange={onDisplayNameChanged}
onCancel={onDisplayNameCancel}
onSave={onDisplayNameSave}
disabled={!canSetDisplayName}
>
{displayNameError && (
<ErrorMessage>{_t("settings|general|display_name_error")}</ErrorMessage>
)}
</EditInPlace>
{userStatusEnabled && <SetStatusView vm={setStatusVM} />}
</Flex>
</div>
{avatarError && (
<Alert title={_t("settings|general|avatar_upload_error_title")} type="critical">
@@ -84,6 +84,7 @@ import { useModuleSpacePanelItems } from "../../../modules/ExtrasApi.ts";
import { UserMenuViewModel } from "../../../viewmodels/menus/UserMenuViewModel.ts";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
import { SDKContext } from "../../../contexts/SDKContext.ts";
import { OwnProfileStore } from "../../../stores/OwnProfileStore.ts";
const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
@@ -408,6 +409,7 @@ const SpacePanel: React.FC = () => {
const userMenuVm = useCreateAutoDisposedViewModel(
() =>
new UserMenuViewModel(
{ ownProfileStore: OwnProfileStore.instance },
defaultDispatcher,
client,
isPanelCollapsed,
@@ -8,7 +8,7 @@
import { BaseViewModel, type UserMenuSnapshot, type UserMenuViewActions } from "@element-hq/web-shared-components";
import { logger } from "matrix-js-sdk/src/logger";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { type OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
import Modal from "../../Modal";
@@ -20,21 +20,37 @@ 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<UserMenuSnapshot, undefined> implements UserMenuViewActions {
interface UserMenuViewModelProps {
ownProfileStore: OwnProfileStore;
}
export class UserMenuViewModel
extends BaseViewModel<UserMenuSnapshot, UserMenuViewModelProps>
implements UserMenuViewActions
{
public readonly setStatusVm: SetStatusViewModel;
private static computeSnapshot(
client: MatrixClient,
ownProfileStore: OwnProfileStore,
isPanelCollapsed: boolean,
accountManagementEndpoint?: string,
): UserMenuSnapshot {
const hasHomePage = !!getHomePageUrl(SdkConfig.get(), client);
const isAuthenticated = !client.isGuest();
const userId = client.getSafeUserId();
const displayName = OwnProfileStore.instance.displayName || userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
const displayName = ownProfileStore.displayName || userId;
const avatarUrl = ownProfileStore.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
const setStatusViewModel = new UserMenuSetStatusViewModel({
client,
ownProfileStore,
});
return {
open: false,
@@ -44,7 +60,9 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
expanded: !isPanelCollapsed,
manageAccountHref: accountManagementEndpoint,
showAvatar: isAuthenticated,
userStatus: OwnProfileStore.instance.userStatus,
userStatus: ownProfileStore.userStatus,
showUserStatus: SettingsStore.getValue("feature_user_status") && isAuthenticated,
setStatusViewModel,
actions: {
createAccount: !isAuthenticated,
signIn: !isAuthenticated,
@@ -58,24 +76,35 @@ export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined
}
public constructor(
props: UserMenuViewModelProps,
private readonly dispatcher: MatrixDispatcher,
private readonly client: MatrixClient,
isPanelCollapsed: boolean,
accountManagementEndpoint?: string,
) {
super(undefined, UserMenuViewModel.computeSnapshot(client, isPanelCollapsed, accountManagementEndpoint));
OwnProfileStore.instance.on(UPDATE_EVENT, this.recalculateProfile);
super(
props,
UserMenuViewModel.computeSnapshot(
client,
props.ownProfileStore,
isPanelCollapsed,
accountManagementEndpoint,
),
);
this.setStatusVm = new UserMenuSetStatusViewModel({ client, ownProfileStore: props.ownProfileStore });
props.ownProfileStore.on(UPDATE_EVENT, this.recalculateProfile);
}
public dispose(): void {
OwnProfileStore.instance.off(UPDATE_EVENT, this.recalculateProfile);
this.props.ownProfileStore.off(UPDATE_EVENT, this.recalculateProfile);
this.setStatusVm.dispose();
super.dispose();
}
public readonly recalculateProfile = (): void => {
const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
const userStatus = OwnProfileStore.instance.userStatus;
const displayName = this.props.ownProfileStore.displayName || this.snapshot.current.userId;
const avatarUrl = this.props.ownProfileStore.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
const userStatus = this.props.ownProfileStore.userStatus;
this.snapshot.merge({ displayName, avatarUrl, userStatus });
};
@@ -0,0 +1,212 @@
// @vitest-environment happy-dom
/*
* 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 { waitFor } from "test-utils-rtl";
import { vi, describe, it, expect, beforeEach, afterEach, type MockInstance, type MockedObject } from "vitest";
import { SetStatusViewModel, UserMenuSetStatusViewModel } from "./SetStatusViewModel";
import {
getMockClientWithEventEmitter,
MockEventEmitter,
mockClientMethodsServer,
mockClientMethodsUser,
} from "../../../test/test-utils";
import type { UserStatus as MatrixUserStatus } from "@element-hq/web-shared-components";
import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { UserTab } from "../../components/views/dialogs/UserTab";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
const STATUS: MatrixUserStatus = { emoji: "🧪", text: "Testing" };
describe("SetStatusViewModel", () => {
let client: MockedObject<MatrixClient>;
let mockOwnProfileStoreInstance: MockEventEmitter<OwnProfileStore> & OwnProfileStore;
beforeEach(() => {
mockOwnProfileStoreInstance = new MockEventEmitter<OwnProfileStore>({
userStatus: undefined,
}) as unknown as MockEventEmitter<OwnProfileStore> & OwnProfileStore;
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
...mockClientMethodsServer(),
setExtendedProfileProperty: vi.fn().mockResolvedValue(undefined),
});
vi.mocked(mockOwnProfileStoreInstance).userStatus = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("initialises snapshot from OwnProfileStore userStatus", () => {
vi.mocked(mockOwnProfileStoreInstance).userStatus = STATUS;
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
expect(vm.getSnapshot().userStatus).toEqual(STATUS);
});
it("initialises snapshot with undefined when no status is set", () => {
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
expect(vm.getSnapshot().userStatus).toBeUndefined();
});
it("updates the snapshot when OwnProfileStore emits an update", () => {
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
expect(vm.getSnapshot().userStatus).toBeUndefined();
vi.mocked(mockOwnProfileStoreInstance).userStatus = STATUS;
mockOwnProfileStoreInstance.emit(UPDATE_EVENT);
expect(vm.getSnapshot().userStatus).toEqual(STATUS);
});
it("stops listening to OwnProfileStore once disposed", () => {
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.dispose();
vi.mocked(mockOwnProfileStoreInstance).userStatus = STATUS;
mockOwnProfileStoreInstance.emit(UPDATE_EVENT);
expect(vm.getSnapshot().userStatus).toBeUndefined();
});
describe("setStatus", () => {
it("optimistically updates the snapshot", () => {
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.setStatus(STATUS);
expect(vm.getSnapshot().userStatus).toEqual(STATUS);
});
it("calls setExtendedProfileProperty with the new status", async () => {
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.setStatus(STATUS);
await waitFor(() =>
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", {
emoji: STATUS.emoji,
text: STATUS.text,
}),
);
});
it("notifies subscribers of the update", () => {
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
const subscriber = vi.fn();
vm.subscribe(subscriber);
vm.setStatus(STATUS);
expect(subscriber).toHaveBeenCalledTimes(1);
});
it("rolls back the snapshot on failure", async () => {
vi.mocked(mockOwnProfileStoreInstance).userStatus = STATUS;
client.setExtendedProfileProperty.mockRejectedValue(new Error("network error"));
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
const newStatus = { emoji: "🦎", text: "Gecko" };
vm.setStatus(newStatus);
expect(vm.getSnapshot().userStatus).toEqual(newStatus);
await waitFor(() => expect(vm.getSnapshot().userStatus).toEqual(STATUS));
});
});
describe("clearStatus", () => {
it("optimistically clears the snapshot", () => {
vi.mocked(mockOwnProfileStoreInstance).userStatus = STATUS;
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.clearStatus();
expect(vm.getSnapshot().userStatus).toBeUndefined();
});
it("calls setExtendedProfileProperty with null", async () => {
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.clearStatus();
await waitFor(() =>
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null),
);
});
it("notifies subscribers of the update", () => {
vi.mocked(mockOwnProfileStoreInstance).userStatus = STATUS;
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
const subscriber = vi.fn();
vm.subscribe(subscriber);
vm.clearStatus();
expect(subscriber).toHaveBeenCalledTimes(1);
});
it("rolls back the snapshot on failure", async () => {
vi.mocked(mockOwnProfileStoreInstance).userStatus = STATUS;
client.setExtendedProfileProperty.mockRejectedValue(new Error("network error"));
const vm = new SetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.clearStatus();
expect(vm.getSnapshot().userStatus).toBeUndefined();
await waitFor(() => expect(vm.getSnapshot().userStatus).toEqual(STATUS));
});
});
});
describe("UserMenuSetStatusViewModel", () => {
let client: MockedObject<MatrixClient>;
let dispatchSpy: MockInstance;
let mockOwnProfileStoreInstance: MockEventEmitter<OwnProfileStore> & OwnProfileStore;
beforeEach(() => {
mockOwnProfileStoreInstance = new MockEventEmitter<OwnProfileStore>({
userStatus: undefined,
}) as unknown as MockEventEmitter<OwnProfileStore> & OwnProfileStore;
vi.spyOn(OwnProfileStore, "instance", "get").mockReturnValue(mockOwnProfileStoreInstance);
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
...mockClientMethodsServer(),
setExtendedProfileProperty: vi.fn().mockResolvedValue(undefined),
});
vi.mocked(mockOwnProfileStoreInstance).userStatus = undefined;
dispatchSpy = vi.spyOn(dis, "dispatch").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("dispatches ToggleUserMenu and ViewUserSettings on onSetStatusClick", async () => {
const vm = new UserMenuSetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.onSetStatusClick();
await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.ToggleUserMenu });
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewUserSettings,
initialTabId: UserTab.Account,
});
});
});
it("inherits setStatus from SetStatusViewModel", async () => {
const vm = new UserMenuSetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.setStatus(STATUS);
await waitFor(() =>
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", {
emoji: STATUS.emoji,
text: STATUS.text,
}),
);
});
it("inherits clearStatus from SetStatusViewModel", async () => {
const vm = new UserMenuSetStatusViewModel({ client, ownProfileStore: mockOwnProfileStoreInstance });
vm.clearStatus();
await waitFor(() =>
expect(client.setExtendedProfileProperty).toHaveBeenCalledWith("org.matrix.msc4426.status", null),
);
});
});
@@ -0,0 +1,86 @@
/*
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 { UPDATE_EVENT } from "../../stores/AsyncStore";
import dis from "../../dispatcher/dispatcher";
import { UserTab } from "../../components/views/dialogs/UserTab";
import { Action } from "../../dispatcher/actions";
import { type OwnProfileStore } from "../../stores/OwnProfileStore";
export interface SetStatusViewModelProps {
/**
* The Matrix client instance.
*/
client: MatrixClient;
ownProfileStore: OwnProfileStore;
}
export class SetStatusViewModel
extends BaseViewModel<SetStatusViewSnapshot, SetStatusViewModelProps>
implements SetStatusViewActions
{
public constructor(props: SetStatusViewModelProps) {
super(props, {
userStatus: props.ownProfileStore.userStatus,
});
this.disposables.trackListener(props.ownProfileStore, UPDATE_EVENT, this.onProfileStoreUpdate);
}
private onProfileStoreUpdate = (): void => {
this.snapshot.merge({ userStatus: this.props.ownProfileStore.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,
});
};
}
@@ -7,19 +7,19 @@ exports[`<SpacePanel /> should show all activated MetaSpaces in the correct orde
class="mx_SpacePanel collapsed"
>
<div
class="_wrapper_owhbm_8 mx_UserMenu"
class="_wrapper_1n8uy_8 mx_UserMenu"
>
<button
aria-expanded="false"
aria-haspopup="menu"
aria-label="User menu"
class="_triggerButton_owhbm_115"
class="_triggerButton_1n8uy_95"
data-state="closed"
id="radix-react-use-id-1"
type="button"
>
<div
class="_avatarWrapper_owhbm_65"
class="_avatarWrapper_1n8uy_72"
>
<span
aria-label="@test:test"
@@ -17,11 +17,13 @@ import { Action } from "../../../src/dispatcher/actions";
import { UserTab } from "../../../src/components/views/dialogs/UserTab";
import Modal from "../../../src/Modal";
import FeedbackDialog from "../../../src/components/views/dialogs/FeedbackDialog";
import { type OwnProfileStore } from "../../../src/stores/OwnProfileStore";
import { TestSDKContext } from "../../unit-tests/TestSDKContext.ts";
describe("UserMenuViewModel", () => {
let dispatcher: MatrixDispatcher;
let client: MockedObject<MatrixClient>;
let mockOwnProfileStore: OwnProfileStore;
let sdkContext: TestSDKContext;
beforeEach(() => {
@@ -36,6 +38,13 @@ describe("UserMenuViewModel", () => {
// @ts-ignore UserMenuViewModel uses SDKContext in the constructor
SDKContextClass.instance = sdkContext;
sdkContext._client = client;
mockOwnProfileStore = {
displayName: "Sally Sanderson",
userStatus: undefined,
getHttpAvatarUrl: jest.fn().mockReturnValue("https://foo.dummy/avatar.png"),
on: jest.fn(),
} as unknown as OwnProfileStore;
});
afterEach(() => {
jest.resetAllMocks();
@@ -44,40 +53,52 @@ describe("UserMenuViewModel", () => {
});
it("should generate a menu options for a logged in client", () => {
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true);
expect(vm.getSnapshot()).toMatchSnapshot();
expect(vm.getSnapshot().userId).toEqual("@alice:domain");
expect(vm.getSnapshot().displayName).toEqual("Sally Sanderson");
expect(vm.getSnapshot().avatarUrl).toEqual("https://foo.dummy/avatar.png");
expect(vm.getSnapshot().showAvatar).toEqual(true);
expect(vm.getSnapshot().expanded).toEqual(false);
});
it("should show a link for account management", async () => {
const vm = new UserMenuViewModel(dispatcher, client, true, "https://example.org/");
const vm = new UserMenuViewModel(
{ ownProfileStore: mockOwnProfileStore },
dispatcher,
client,
true,
"https://example.org/",
);
vm.setOpen(true);
expect(vm.getSnapshot().manageAccountHref).toEqual("https://example.org/");
});
it("should generate a menu options for a guest", () => {
client.isGuest.mockReturnValue(true);
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true);
expect(vm.getSnapshot()).toMatchSnapshot();
expect(vm.getSnapshot().displayName).toEqual("Sally Sanderson");
expect(vm.getSnapshot().showAvatar).toEqual(false);
expect(vm.getSnapshot().showUserStatus).toEqual(false);
});
it("should generate a menu options that include feedback", () => {
SdkConfig.put({ bug_report_endpoint_url: "https://example.org" });
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true);
expect(vm.getSnapshot().actions.openFeedback).toEqual(true);
});
it("should generate a menu options that includes a home page", () => {
SdkConfig.put({ embedded_pages: { home_url: "https://example.org" } });
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true);
expect(vm.getSnapshot().actions.openHomePage).toEqual(true);
});
it("can toggle menu", () => {
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true);
expect(vm.getSnapshot().open).toEqual(true);
vm.setOpen(false);
@@ -85,7 +106,7 @@ describe("UserMenuViewModel", () => {
});
it("can toggle expanded state", () => {
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setExpanded(true);
expect(vm.getSnapshot().expanded).toEqual(true);
vm.setExpanded(false);
@@ -94,7 +115,7 @@ describe("UserMenuViewModel", () => {
it("can open the home menu", async () => {
SdkConfig.put({ embedded_pages: { home_url: "https://example.org" } });
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
const dispatcherSpy = jest.fn();
dispatcher.register(dispatcherSpy);
vm.setOpen(true);
@@ -107,7 +128,7 @@ describe("UserMenuViewModel", () => {
});
it("can open the 'link new device' settings menu", async () => {
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
const dispatcherSpy = jest.fn();
dispatcher.register(dispatcherSpy);
vm.setOpen(true);
@@ -122,7 +143,7 @@ describe("UserMenuViewModel", () => {
});
it("can open the 'security' settings menu", async () => {
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
const dispatcherSpy = jest.fn();
dispatcher.register(dispatcherSpy);
vm.setOpen(true);
@@ -138,7 +159,7 @@ describe("UserMenuViewModel", () => {
it("can open the 'feedback' settings menu", async () => {
jest.spyOn(Modal, "createDialog");
SdkConfig.put({ bug_report_endpoint_url: "https://example.org" });
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
const dispatcherSpy = jest.fn();
dispatcher.register(dispatcherSpy);
vm.setOpen(true);
@@ -147,7 +168,7 @@ describe("UserMenuViewModel", () => {
});
it("can open the settings menu", async () => {
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
const dispatcherSpy = jest.fn();
dispatcher.register(dispatcherSpy);
vm.setOpen(true);
@@ -160,7 +181,7 @@ describe("UserMenuViewModel", () => {
});
it("can clear a user status", async () => {
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true);
vm.clearStatus();
await waitFor(() =>
@@ -172,7 +193,7 @@ describe("UserMenuViewModel", () => {
client.isGuest.mockReturnValue(true);
const dispatcherSpy = jest.fn();
dispatcher.register(dispatcherSpy);
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true);
vm.createAccount();
await waitFor(() =>
@@ -186,7 +207,7 @@ describe("UserMenuViewModel", () => {
client.isGuest.mockReturnValue(true);
const dispatcherSpy = jest.fn();
dispatcher.register(dispatcherSpy);
const vm = new UserMenuViewModel(dispatcher, client, true);
const vm = new UserMenuViewModel({ ownProfileStore: mockOwnProfileStore }, dispatcher, client, true);
vm.setOpen(true);
vm.signIn();
await waitFor(() =>
@@ -1,45 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`UserMenuViewModel should generate a menu options for a guest 1`] = `
{
"actions": {
"createAccount": true,
"linkNewDevice": false,
"openFeedback": false,
"openHomePage": false,
"openSecurity": false,
"openSettings": true,
"signIn": true,
},
"avatarUrl": undefined,
"displayName": "@alice:domain",
"expanded": false,
"manageAccountHref": undefined,
"open": true,
"showAvatar": false,
"userId": "@alice:domain",
"userStatus": undefined,
}
`;
exports[`UserMenuViewModel should generate a menu options for a logged in client 1`] = `
{
"actions": {
"createAccount": false,
"linkNewDevice": true,
"openFeedback": false,
"openHomePage": false,
"openSecurity": true,
"openSettings": true,
"signIn": false,
},
"avatarUrl": undefined,
"displayName": "@alice:domain",
"expanded": false,
"manageAccountHref": undefined,
"open": true,
"showAvatar": true,
"userId": "@alice:domain",
"userStatus": undefined,
}
`;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -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>
);
});