/* 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 Please see LICENSE files in the repository root for full details. */ import React, { createRef, ReactNode } from "react"; import { Room } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { _t } from "../../languageHandler"; import { ChevronFace, ContextMenuButton, MenuProps } from "./ContextMenu"; import { UserTab } from "../views/dialogs/UserTab"; import { 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, getCustomTheme, isHighContrastTheme } from "../../theme"; import { RovingAccessibleButton } from "../../accessibility/RovingTabIndex"; import AccessibleButton, { 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 { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { SDKContext } from "../../contexts/SDKContext"; import { shouldShowFeedback } from "../../utils/Feedback"; import DarkLightModeSvg from "../../../res/img/element-icons/roomlist/dark-light-mode.svg"; interface IProps { isPanelCollapsed: boolean; children?: ReactNode; } type PartialDOMRect = Pick; interface IState { contextMenuPosition: PartialDOMRect | null; isDarkTheme: boolean; isHighContrast: boolean; 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, }; }; export default class UserMenu extends React.Component { public static contextType = SDKContext; declare public context: React.ContextType; private dispatcherRef?: string; private themeWatcherRef?: string; private readonly dndWatcherRef?: string; private buttonRef: React.RefObject = createRef(); public constructor(props: IProps, context: React.ContextType) { super(props, context); this.state = { contextMenuPosition: null, isDarkTheme: this.isUserOnDarkTheme(), isHighContrast: this.isUserOnHighContrastTheme(), 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); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); } 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 isUserOnDarkTheme(): boolean { if (SettingsStore.getValue("use_system_theme")) { return window.matchMedia("(prefers-color-scheme: dark)").matches; } else { const theme = SettingsStore.getValue("theme"); if (theme.startsWith("custom-")) { return !!getCustomTheme(theme.substring("custom-".length)).is_dark; } return theme === "dark"; } } private isUserOnHighContrastTheme(): boolean { if (SettingsStore.getValue("use_system_theme")) { return window.matchMedia("(prefers-contrast: more)").matches; } else { const theme = SettingsStore.getValue("theme"); if (theme.startsWith("custom-")) { return false; } return isHighContrastTheme(theme); } } private onProfileUpdate = async (): Promise => { // 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 => { this.setState({ selectedSpace: SpaceStore.instance.activeSpaceRoom, }); }; private onThemeChanged = (): void => { this.setState({ isDarkTheme: this.isUserOnDarkTheme(), isHighContrast: this.isUserOnHighContrastTheme(), }); }; 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 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 = this.state.isDarkTheme ? "light" : "dark"; if (this.state.isHighContrast) { const hcTheme = findHighContrastTheme(newTheme); if (hcTheme) { newTheme = hcTheme; } } SettingsStore.setValue("theme", null, SettingLevel.DEVICE, newTheme); // set at same level as Appearance tab }; private onSettingsOpen = (ev: ButtonEvent, tabId?: string, props?: Record): 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 => { 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({ 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 = (
{_t( "auth|sign_in_prompt", {}, { a: (sub) => ( {sub} ), }, )} {SettingsStore.getValue(UIFeature.Registration) ? _t( "auth|create_account_prompt", {}, { a: (sub) => ( {sub} ), }, ) : null}
); } let homeButton: JSX.Element | undefined; if (this.hasHomePage) { homeButton = ( ); } let feedbackButton: JSX.Element | undefined; if (shouldShowFeedback()) { feedbackButton = ( ); } const linkNewDeviceButton = ( this.onSettingsOpen(e, UserTab.SessionManager, { showMsc4108QrCode: true })} /> ); let primaryOptionList = ( {homeButton} {linkNewDeviceButton} this.onSettingsOpen(e, UserTab.Notifications)} /> this.onSettingsOpen(e, UserTab.Security)} /> this.onSettingsOpen(e)} /> {feedbackButton} ); if (MatrixClientPeg.safeGet().isGuest()) { primaryOptionList = ( {homeButton} this.onSettingsOpen(e)} /> {feedbackButton} ); } const position = this.props.isPanelCollapsed ? toRightOf(this.state.contextMenuPosition) : below(this.state.contextMenuPosition); return (
{OwnProfileStore.instance.displayName} {UserIdentifierCustomisations.getDisplayUserIdentifier( MatrixClientPeg.safeGet().getSafeUserId(), { withDisplayName: true, }, )}
{topSection} {primaryOptionList}
); }; 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 =
{displayName}
; } return (
{name} {this.renderContextMenu()}
{this.props.children}
); } }