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