Refactor and redesign user menu (#32812)

* Initial quick settings menu

* Total refactor

* Quick design fixes.

* Refactor to use a view model.

* Remove unused strings

* Apply label

* Refactor naming

* Fixup most tests

* Remove specific theming for old user menu

* prettier

* Lots of cleanup

* Allow overriding the menu classes

* update snap

* Oops translations

* tidy

* Cleanup guest flows.

* Copyrights

* Remove unused classname

* Match guest view to designs

* Add guest screenshots

* Update guests

* snapshot

* Cleanup

* fix import

* Update tests

* More sceenshot fixes

* update collapsed

* move statements to prevent flake

* update snap

* Kick it along

* Click the room list

* Fiddle with the room video list.

* More screenshot adjustments

* fix imports

* fix another import

* Update snaps

* update snaps

* Fix snap flakes

* Refactor to move actions to view component, and callbacks to Actions

* Cleanup

* Cleanup

* Cleanup

* invert auth

* More bits

* fix

* Change md buttons to sm

* Try to assemble the snapshot component of the house of cards

* Consistent newlines between tests

* Update snapshot

Not sure why this was like this, this seems consistet for a logged in user

* Update snapshot

again these seem sensible for a guest

* Remove test

I don't really understand why the thing it asserts matters, so I'm removing
it for now.

* Update snapshot

* screenshot

* Don't show profile picture for guests

I'm not really sure what it meant for this interface to have a
property with a default value, so I've removed it and added the
property to the view model.

* Show avatar in story

* update snapshots for showAvatar

* Update screenshots

& hopefully make hover consistent in one

* Use outline home icon

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
This commit is contained in:
Will Hunt
2026-05-06 08:34:36 +00:00
committed by GitHub
co-authored by David Baker
parent bbd2d81a08
commit d4f419d1b5
58 changed files with 1231 additions and 874 deletions
@@ -1,441 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
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, createRef, type ReactNode, useMemo } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import {
ChatSolidIcon,
HomeSolidIcon,
LockSolidIcon,
QrCodeIcon,
SettingsSolidIcon,
LeaveIcon,
NotificationsSolidIcon,
ThemeIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { IconButton } from "@vector-im/compound-web";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { type ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { _t } from "../../languageHandler";
import { ChevronFace, ContextMenuButton, type MenuProps } from "./ContextMenu";
import { UserTab } from "../views/dialogs/UserTab";
import { type OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import FeedbackDialog from "../views/dialogs/FeedbackDialog";
import Modal from "../../Modal";
import LogoutDialog, { shouldShowLogoutDialog } from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
import { findHighContrastTheme, isHighContrastTheme } from "../../theme";
import { useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import { getHomePageUrl } from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import BaseAvatar from "../views/avatars/BaseAvatar";
import { SettingLevel } from "../../settings/SettingLevel";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import { UIFeature } from "../../settings/UIFeature";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import UserIdentifierCustomisations from "../../customisations/UserIdentifier";
import PosthogTrackers from "../../PosthogTrackers";
import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
import { SDKContext } from "../../contexts/SDKContext";
import { shouldShowFeedback } from "../../utils/Feedback";
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher.ts";
import { useTypedEventEmitterState } from "../../hooks/useEventEmitter.ts";
interface IProps {
isPanelCollapsed: boolean;
children?: ReactNode;
}
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
interface IState {
contextMenuPosition: PartialDOMRect | null;
selectedSpace?: Room | null;
}
const toRightOf = (rect: PartialDOMRect): MenuProps => {
return {
left: rect.width + rect.left + 8,
top: rect.top,
chevronFace: ChevronFace.None,
};
};
const below = (rect: PartialDOMRect): MenuProps => {
return {
left: rect.left,
top: rect.top + rect.height,
chevronFace: ChevronFace.None,
};
};
const ThemeSwitchButton = (): JSX.Element => {
const [onFocus, isActive, ref] = useRovingTabIndex();
const themeWatcher = useMemo(() => new ThemeWatcher(), []);
const [isHighContrast, isDark] = useTypedEventEmitterState(
themeWatcher,
ThemeWatcherEvent.Change,
(theme: string) => [isHighContrastTheme(theme), themeWatcher.isUserOnDarkTheme()],
);
const onSwitchThemeClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
PosthogTrackers.trackInteraction("WebUserMenuThemeToggleButton", ev);
// Disable system theme matching if the user hits this button
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
let newTheme = isDark ? "light" : "dark";
if (isHighContrast) {
const hcTheme = findHighContrastTheme(newTheme);
if (hcTheme) {
newTheme = hcTheme;
}
}
SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab
themeWatcher.recheck(newTheme);
};
return (
<IconButton
ref={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
className="mx_UserMenu_contextMenu_themeButton"
onClick={onSwitchThemeClick}
tooltip={isDark ? _t("user_menu|switch_theme_light") : _t("user_menu|switch_theme_dark")}
size="32px"
kind="secondary"
>
<ThemeIcon />
</IconButton>
);
};
export default class UserMenu extends React.Component<IProps, IState> {
public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>;
private dispatcherRef?: string;
private themeWatcherRef?: string;
private readonly dndWatcherRef?: string;
private buttonRef = createRef<HTMLButtonElement>();
public constructor(props: IProps) {
super(props);
this.state = {
contextMenuPosition: null,
selectedSpace: SpaceStore.instance.activeSpaceRoom,
};
}
private get hasHomePage(): boolean {
return !!getHomePageUrl(SdkConfig.get(), this.context.client!);
}
public componentDidMount(): void {
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
public componentWillUnmount(): void {
SettingsStore.unwatchSetting(this.themeWatcherRef);
SettingsStore.unwatchSetting(this.dndWatcherRef);
defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
}
private onProfileUpdate = async (): Promise<void> => {
// the store triggered an update, so force a layout update. We don't
// have any state to store here for that to magically happen.
this.forceUpdate();
};
private onSelectedSpaceUpdate = async (): Promise<void> => {
this.setState({
selectedSpace: SpaceStore.instance.activeSpaceRoom,
});
};
private onAction = (payload: ActionPayload): void => {
switch (payload.action) {
case Action.ToggleUserMenu:
if (this.state.contextMenuPosition) {
this.setState({ contextMenuPosition: null });
} else {
if (this.buttonRef.current) this.buttonRef.current.click();
}
break;
}
};
private onOpenMenuClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.setState({ contextMenuPosition: ev.currentTarget.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
left: ev.clientX,
top: ev.clientY,
width: 20,
height: 0,
},
});
};
private onCloseMenu = (): void => {
this.setState({ contextMenuPosition: null });
};
private onSettingsOpen = (ev: ButtonEvent, tabId?: string, props?: Record<string, any>): void => {
ev.preventDefault();
ev.stopPropagation();
const payload: OpenToTabPayload = { action: Action.ViewUserSettings, initialTabId: tabId, props };
defaultDispatcher.dispatch(payload);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onProvideFeedback = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
Modal.createDialog(FeedbackDialog);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onSignOutClick = async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
ev.stopPropagation();
if (await shouldShowLogoutDialog(MatrixClientPeg.safeGet())) {
Modal.createDialog(LogoutDialog);
} else {
defaultDispatcher.dispatch({ action: "logout" });
}
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onSignInClick = (): void => {
defaultDispatcher.dispatch({ action: "start_login" });
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onRegisterClick = (): void => {
defaultDispatcher.dispatch({ action: "start_registration" });
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onHomeClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch<ViewHomePagePayload>({ action: Action.ViewHomePage });
this.setState({ contextMenuPosition: null }); // also close the menu
};
private renderContextMenu = (): React.ReactNode => {
if (!this.state.contextMenuPosition) return null;
let topSection: JSX.Element | undefined;
if (MatrixClientPeg.safeGet().isGuest()) {
topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
{_t(
"auth|sign_in_prompt",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onSignInClick}>
{sub}
</AccessibleButton>
),
},
)}
{SettingsStore.getValue(UIFeature.Registration)
? _t(
"auth|create_account_prompt",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={this.onRegisterClick}>
{sub}
</AccessibleButton>
),
},
)
: null}
</div>
);
}
let homeButton: JSX.Element | undefined;
if (this.hasHomePage) {
homeButton = (
<IconizedContextMenuOption
icon={<HomeSolidIcon />}
label={_t("common|home")}
onClick={this.onHomeClick}
/>
);
}
let feedbackButton: JSX.Element | undefined;
if (shouldShowFeedback()) {
feedbackButton = (
<IconizedContextMenuOption
icon={<ChatSolidIcon />}
label={_t("common|feedback")}
onClick={this.onProvideFeedback}
/>
);
}
const linkNewDeviceButton = (
<IconizedContextMenuOption
icon={<QrCodeIcon />}
label={_t("user_menu|link_new_device")}
onClick={(e) => this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })}
/>
);
let primaryOptionList = (
<IconizedContextMenuOptionList>
{homeButton}
{linkNewDeviceButton}
<IconizedContextMenuOption
icon={<NotificationsSolidIcon />}
label={_t("notifications|enable_prompt_toast_title")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Notifications)}
/>
<IconizedContextMenuOption
icon={<LockSolidIcon />}
label={_t("room_settings|security|title")}
onClick={(e) => this.onSettingsOpen(e, UserTab.Security)}
/>
<IconizedContextMenuOption
icon={<SettingsSolidIcon />}
label={_t("user_menu|settings")}
onClick={(e) => this.onSettingsOpen(e)}
/>
{feedbackButton}
<IconizedContextMenuOption
className="mx_IconizedContextMenu_option_red"
icon={<LeaveIcon />}
label={_t("action|sign_out")}
onClick={this.onSignOutClick}
/>
</IconizedContextMenuOptionList>
);
if (MatrixClientPeg.safeGet().isGuest()) {
primaryOptionList = (
<IconizedContextMenuOptionList>
{homeButton}
<IconizedContextMenuOption
icon={<SettingsSolidIcon />}
label={_t("common|settings")}
onClick={(e) => this.onSettingsOpen(e)}
/>
{feedbackButton}
</IconizedContextMenuOptionList>
);
}
const position = this.props.isPanelCollapsed
? toRightOf(this.state.contextMenuPosition)
: below(this.state.contextMenuPosition);
const userIdentifierString = UserIdentifierCustomisations.getDisplayUserIdentifier(
MatrixClientPeg.safeGet().getSafeUserId(),
{
withDisplayName: true,
},
);
return (
<IconizedContextMenu {...position} onFinished={this.onCloseMenu} className="mx_UserMenu_contextMenu">
<div className="mx_UserMenu_contextMenu_header">
<div className="mx_UserMenu_contextMenu_name">
<span className="mx_UserMenu_contextMenu_displayName">
{OwnProfileStore.instance.displayName}
</span>
<span className="mx_UserMenu_contextMenu_userId" title={userIdentifierString || ""}>
{userIdentifierString}
</span>
</div>
<ThemeSwitchButton />
</div>
{topSection}
{primaryOptionList}
</IconizedContextMenu>
);
};
public render(): React.ReactNode {
const avatarSize = 32; // should match border-radius of the avatar
const userId = MatrixClientPeg.safeGet().getSafeUserId();
const displayName = OwnProfileStore.instance.displayName || userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize);
let name: JSX.Element | undefined;
if (!this.props.isPanelCollapsed) {
name = <div className="mx_UserMenu_name">{displayName}</div>;
}
return (
<div className="mx_UserMenu">
<ContextMenuButton
className="mx_UserMenu_contextMenuButton"
onClick={this.onOpenMenuClick}
ref={this.buttonRef}
label={_t("a11y|user_menu")}
isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
>
<div className="mx_UserMenu_userAvatar">
<BaseAvatar
idName={userId}
name={displayName}
url={avatarUrl}
size={avatarSize + "px"}
className="mx_UserMenu_userAvatar_BaseAvatar"
/>
</div>
{name}
{this.renderContextMenu()}
</ContextMenuButton>
{this.props.children}
</div>
);
}
}
@@ -28,13 +28,7 @@ const UserAvatar: React.FC = () => {
return (
<div className={`mx_ShareType_option-icon ${LocationShareType.Own}`}>
<BaseAvatar
idName={userId}
name={displayName}
url={avatarUrl}
size={avatarSize}
className="mx_UserMenu_userAvatar_BaseAvatar"
/>
<BaseAvatar idName={userId} name={displayName} url={avatarUrl} size={avatarSize} />
</div>
);
};
@@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
@@ -18,6 +19,7 @@ import React, {
useLayoutEffect,
useRef,
useState,
useContext,
} from "react";
import { DragDropContext, Draggable, Droppable, type DroppableProvidedProps } from "react-beautiful-dnd";
import classNames from "classnames";
@@ -31,6 +33,7 @@ import {
PlusIcon,
ChevronRightIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useCreateAutoDisposedViewModel, UserMenu } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import { useContextMenu } from "../../structures/ContextMenu";
@@ -62,7 +65,6 @@ import { SettingLevel } from "../../../settings/SettingLevel";
import UIStore from "../../../stores/UIStore";
import QuickSettingsButton from "./QuickSettingsButton";
import { useSettingValue } from "../../../hooks/useSettings";
import UserMenu from "../../structures/UserMenu";
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
import { useDispatcher } from "../../../hooks/useDispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher";
@@ -79,6 +81,9 @@ import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNav
import { KeyboardShortcut } from "../settings/KeyboardShortcut";
import { ModuleApi } from "../../../modules/Api.ts";
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";
const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
@@ -384,6 +389,7 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(
);
const SpacePanel: React.FC = () => {
const client = useMatrixClientContext();
const [dragging, setDragging] = useState(false);
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
const ref = useRef<HTMLDivElement>(null);
@@ -391,6 +397,7 @@ const SpacePanel: React.FC = () => {
if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current);
return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
}, []);
const sdkContext = useContext(SDKContext);
useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
if (payload.action === Action.ToggleSpacePanel) {
@@ -400,6 +407,26 @@ const SpacePanel: React.FC = () => {
const newRoomListEnabled = useSettingValue("feature_new_room_list");
const userMenuVm = useCreateAutoDisposedViewModel(
() =>
new UserMenuViewModel(
defaultDispatcher,
client,
isPanelCollapsed,
sdkContext.oidcClientStore.accountManagementEndpoint,
),
);
useDispatcher(defaultDispatcher, (payload) => {
if (payload.action === Action.ToggleUserMenu) {
userMenuVm.setOpen(!userMenuVm.getSnapshot().open);
}
});
useEffect(() => {
userMenuVm.setExpanded(!isPanelCollapsed);
}, [userMenuVm, isPanelCollapsed]);
return (
<RovingTabIndexProvider handleHomeEnd handleUpDown={!dragging}>
{({ onKeyDownHandler, onDragEndHandler }) => (
@@ -438,23 +465,22 @@ const SpacePanel: React.FC = () => {
ref={ref}
aria-label={_t("common|spaces")}
>
<UserMenu isPanelCollapsed={isPanelCollapsed}>
<AccessibleButton
className={classNames("mx_SpacePanel_toggleCollapse", {
expanded: !isPanelCollapsed,
})}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
caption={
<KeyboardShortcut
value={{ ctrlOrCmdKey: true, shiftKey: true, key: "d" }}
className="mx_SpacePanel_Tooltip_KeyboardShortcut"
/>
}
>
<ChevronRightIcon />
</AccessibleButton>
</UserMenu>
<UserMenu vm={userMenuVm} />
<AccessibleButton
className={classNames("mx_SpacePanel_toggleCollapse", {
expanded: !isPanelCollapsed,
})}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
caption={
<KeyboardShortcut
value={{ ctrlOrCmdKey: true, shiftKey: true, key: "d" }}
className="mx_SpacePanel_Tooltip_KeyboardShortcut"
/>
}
>
<ChevronRightIcon />
</AccessibleButton>
<Droppable droppableId="top-level-spaces">
{(provided, snapshot) => (
<InnerSpacePanel
+2
View File
@@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
@@ -194,5 +195,6 @@ export class SdkContextClass {
public onLoggedOut(): void {
this._UserProfilesStore = undefined;
this._OidcClientStore = undefined;
}
}
+1 -9
View File
@@ -15,8 +15,7 @@
"room_name": "Room %(name)s",
"room_status_bar": "Room status bar",
"seek_bar_label": "Audio seek bar",
"unread_messages": "Unread messages.",
"user_menu": "User menu"
"unread_messages": "Unread messages."
},
"a11y_jump_first_unread_room": "Jump to first unread room.",
"action": {
@@ -341,7 +340,6 @@
"sign_in_instead_prompt": "Already have an account? <a>Sign in here</a>",
"sign_in_or_register": "Sign In or Create Account",
"sign_in_or_register_description": "Use your account or create a new one to continue.",
"sign_in_prompt": "Got an account? <a>Sign in</a>",
"sign_in_with_sso": "Sign in with single sign-on",
"signing_in": "Signing In…",
"soft_logout": {
@@ -3895,12 +3893,6 @@
"verify_button": "Verify User",
"verify_explainer": "For extra security, verify this user by checking a one-time code on both of your devices."
},
"user_menu": {
"link_new_device": "Link new device",
"settings": "All settings",
"switch_theme_dark": "Switch to dark mode",
"switch_theme_light": "Switch to light mode"
},
"voip": {
"already_in_call": "Already in call",
"already_in_call_person": "You're already in a call with this person.",
@@ -0,0 +1,129 @@
/*
* 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 { BaseViewModel, type UserMenuSnapshot, type UserMenuViewActions } from "@element-hq/web-shared-components";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
import Modal from "../../Modal";
import { Action } from "../../dispatcher/actions";
import { UserTab } from "../../components/views/dialogs/UserTab";
import FeedbackDialog from "../../components/views/dialogs/FeedbackDialog";
import { shouldShowFeedback } from "../../utils/Feedback";
import { getHomePageUrl } from "../../utils/pages";
import SdkConfig from "../../SdkConfig";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
// Matches maximum size of an avatar in the UserMenu
const AVATAR_PX = 88;
export class UserMenuViewModel extends BaseViewModel<UserMenuSnapshot, undefined> implements UserMenuViewActions {
private static computeSnapshot(
client: MatrixClient,
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;
return {
open: false,
userId,
displayName,
avatarUrl,
expanded: !isPanelCollapsed,
manageAccountHref: accountManagementEndpoint,
showAvatar: isAuthenticated,
actions: {
createAccount: !isAuthenticated,
signIn: !isAuthenticated,
openHomePage: hasHomePage,
linkNewDevice: isAuthenticated,
openSecurity: isAuthenticated,
openFeedback: shouldShowFeedback(),
openSettings: true,
},
};
}
public constructor(
private readonly dispatcher: MatrixDispatcher,
client: MatrixClient,
isPanelCollapsed: boolean,
accountManagementEndpoint?: string,
) {
super(undefined, UserMenuViewModel.computeSnapshot(client, isPanelCollapsed, accountManagementEndpoint));
OwnProfileStore.instance.on(UPDATE_EVENT, this.recalculateProfile);
}
public dispose(): void {
OwnProfileStore.instance.off(UPDATE_EVENT, this.recalculateProfile);
super.dispose();
}
public readonly recalculateProfile = (): void => {
const displayName = OwnProfileStore.instance.displayName || this.snapshot.current.userId;
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_PX) ?? undefined;
this.snapshot.merge({ displayName, avatarUrl });
};
public readonly setOpen = (isOpen: boolean): void => {
this.snapshot.merge({ open: isOpen });
};
public readonly setExpanded = (expanded: boolean): void => {
this.snapshot.merge({ expanded });
};
public readonly createAccount = (): void => {
this.setOpen(false);
this.dispatcher.dispatch({ action: "start_registration" });
};
public readonly signIn = (): void => {
this.setOpen(false);
this.dispatcher.dispatch({ action: "start_login" });
};
public readonly openHomePage = (): void => {
this.setOpen(false);
this.dispatcher.dispatch({ action: Action.ViewHomePage });
};
public readonly openFeedback = (): void => {
this.setOpen(false);
Modal.createDialog(FeedbackDialog);
};
public readonly linkNewDevice = (): void => {
this.setOpen(false);
this.dispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.SessionManager,
props: { showMsc4108QrCode: true },
});
};
public readonly openSecurity = (): void => {
this.setOpen(false);
this.dispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Security,
});
};
public readonly openSettings = (): void => {
this.setOpen(false);
this.dispatcher.dispatch({
action: Action.ViewUserSettings,
});
};
}