diff --git a/apps/web/playwright/snapshots/app-loading/guest-registration.spec.ts/guest-menu-linux.png b/apps/web/playwright/snapshots/app-loading/guest-registration.spec.ts/guest-menu-linux.png index 03bb5ddf0e..7226cb064f 100644 Binary files a/apps/web/playwright/snapshots/app-loading/guest-registration.spec.ts/guest-menu-linux.png and b/apps/web/playwright/snapshots/app-loading/guest-registration.spec.ts/guest-menu-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png b/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png index 8cd0cbf863..6038500c47 100644 Binary files a/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png and b/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png b/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png index 14fd316f79..b3496cc266 100644 Binary files a/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png and b/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png differ diff --git a/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png b/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png index 3d1d02f663..64be83b5e2 100644 Binary files a/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png and b/apps/web/playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/apps/web/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png b/apps/web/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png index b6b6418888..ed236f42e5 100644 Binary files a/apps/web/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png and b/apps/web/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png differ diff --git a/apps/web/res/css/views/settings/_AvatarSetting.pcss b/apps/web/res/css/views/settings/_AvatarSetting.pcss index b928522820..fc87bf422a 100644 --- a/apps/web/res/css/views/settings/_AvatarSetting.pcss +++ b/apps/web/res/css/views/settings/_AvatarSetting.pcss @@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details. */ .mx_AvatarSetting_avatar { - width: 90px; - min-width: 90px; /* so it doesn't get crushed by the flexbox in languages with longer words */ - height: 90px; + width: 120px; + min-width: 120px; /* so it doesn't get crushed by the flexbox in languages with longer words */ + height: 120px; margin-top: 8px; position: relative; @@ -31,9 +31,9 @@ Please see LICENSE files in the repository root for full details. & > img { display: block; - height: 90px; + height: 120px; width: inherit; - border-radius: 90px; + border-radius: 120px; cursor: pointer; object-fit: cover; } diff --git a/apps/web/res/css/views/settings/_UserProfileSettings.pcss b/apps/web/res/css/views/settings/_UserProfileSettings.pcss index 845aa52c1c..67750b943f 100644 --- a/apps/web/res/css/views/settings/_UserProfileSettings.pcss +++ b/apps/web/res/css/views/settings/_UserProfileSettings.pcss @@ -19,10 +19,14 @@ Please see LICENSE files in the repository root for full details. gap: 16px; .mx_UserProfileSettings_profile_displayName { - flex-grow: 1; + flex-grow: 0; width: 100%; gap: 0; } + .mx_UserProfileSettings_profile_nameAndStatus { + width: 100%; + gap: 16px; + } } .mx_UserProfileSettings_profile_controls { diff --git a/apps/web/src/components/views/settings/AvatarSetting.tsx b/apps/web/src/components/views/settings/AvatarSetting.tsx index 1c27efa75c..7407f64b56 100644 --- a/apps/web/src/components/views/settings/AvatarSetting.tsx +++ b/apps/web/src/components/views/settings/AvatarSetting.tsx @@ -181,7 +181,7 @@ const AvatarSetting: React.FC = ({ diff --git a/apps/web/src/components/views/settings/UserProfileSettings.tsx b/apps/web/src/components/views/settings/UserProfileSettings.tsx index 29506d8ccb..d2280c3a33 100644 --- a/apps/web/src/components/views/settings/UserProfileSettings.tsx +++ b/apps/web/src/components/views/settings/UserProfileSettings.tsx @@ -11,7 +11,12 @@ import { logger } from "matrix-js-sdk/src/logger"; import { EditInPlace, Alert, ErrorMessage } from "@vector-im/compound-web"; import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out"; import SignOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/sign-out"; -import { Flex, useToastContext } from "@element-hq/web-shared-components"; +import { + Flex, + SetStatusView, + useCreateAutoDisposedViewModel, + useToastContext, +} from "@element-hq/web-shared-components"; import { _t } from "../../../languageHandler"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; @@ -27,6 +32,8 @@ import LogoutDialog, { shouldShowLogoutDialog } from "../dialogs/LogoutDialog"; import Modal from "../../../Modal"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { SettingsSection } from "./shared/SettingsSection.tsx"; +import { SetStatusViewModel } from "../../../viewmodels/status/SetStatusViewModel.ts"; +import SettingsStore from "../../../settings/SettingsStore.ts"; const SpinnerToast: React.FC<{ children?: ReactNode }> = ({ children }) => ( <> @@ -131,6 +138,11 @@ const UserProfileSettings: React.FC = ({ })(); }, [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( {_t("settings|general|avatar_remove_progress")}, @@ -212,21 +224,27 @@ const UserProfileSettings: React.FC = ({ placeholderId={client.getUserId() ?? ""} disabled={!canSetAvatar} /> - - {displayNameError && {_t("settings|general|display_name_error")}} - + + + + {displayNameError && ( + {_t("settings|general|display_name_error")} + )} + + {userStatusEnabled && } + {avatarError && ( diff --git a/apps/web/src/components/views/spaces/SpacePanel.tsx b/apps/web/src/components/views/spaces/SpacePanel.tsx index 20754a429b..de45a073fe 100644 --- a/apps/web/src/components/views/spaces/SpacePanel.tsx +++ b/apps/web/src/components/views/spaces/SpacePanel.tsx @@ -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(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { @@ -408,6 +409,7 @@ const SpacePanel: React.FC = () => { const userMenuVm = useCreateAutoDisposedViewModel( () => new UserMenuViewModel( + { ownProfileStore: OwnProfileStore.instance }, defaultDispatcher, client, isPanelCollapsed, diff --git a/apps/web/src/viewmodels/menus/UserMenuViewModel.ts b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts index ee021d3f46..2c01911eb4 100644 --- a/apps/web/src/viewmodels/menus/UserMenuViewModel.ts +++ b/apps/web/src/viewmodels/menus/UserMenuViewModel.ts @@ -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 implements UserMenuViewActions { +interface UserMenuViewModelProps { + ownProfileStore: OwnProfileStore; +} + +export class UserMenuViewModel + extends BaseViewModel + 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 { - 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 }); }; diff --git a/apps/web/src/viewmodels/status/SetStatusViewModel.test.ts b/apps/web/src/viewmodels/status/SetStatusViewModel.test.ts new file mode 100644 index 0000000000..bdba5104a7 --- /dev/null +++ b/apps/web/src/viewmodels/status/SetStatusViewModel.test.ts @@ -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; + let mockOwnProfileStoreInstance: MockEventEmitter & OwnProfileStore; + + beforeEach(() => { + mockOwnProfileStoreInstance = new MockEventEmitter({ + userStatus: undefined, + }) as unknown as MockEventEmitter & 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; + let dispatchSpy: MockInstance; + let mockOwnProfileStoreInstance: MockEventEmitter & OwnProfileStore; + + beforeEach(() => { + mockOwnProfileStoreInstance = new MockEventEmitter({ + userStatus: undefined, + }) as unknown as MockEventEmitter & 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), + ); + }); +}); diff --git a/apps/web/src/viewmodels/status/SetStatusViewModel.ts b/apps/web/src/viewmodels/status/SetStatusViewModel.ts new file mode 100644 index 0000000000..0ba10c1c2a --- /dev/null +++ b/apps/web/src/viewmodels/status/SetStatusViewModel.ts @@ -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 + 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, + }); + }; +} diff --git a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap index b6d45daca0..fe2240f633 100644 --- a/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/spaces/__snapshots__/SpacePanel-test.tsx.snap @@ -7,19 +7,19 @@ exports[` should show all activated MetaSpaces in the correct orde class="mx_SpacePanel collapsed" >