Update UserMenu theme toggle to use IconButton (#32591)

* Update UserMenu theme toggle to use IconButton

This lets it use the correct compound colour based on theme

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Delint

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update screenshot

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-20 14:08:22 +00:00
committed by GitHub
parent c2ed88fbf0
commit fd4695f3d5
10 changed files with 112 additions and 83 deletions
+52 -69
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, createRef, type ReactNode } from "react";
import React, { type JSX, createRef, type ReactNode, useMemo } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import {
ChatSolidIcon,
@@ -18,6 +18,7 @@ import {
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";
@@ -31,8 +32,8 @@ 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 { 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";
@@ -52,6 +53,8 @@ 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;
@@ -62,8 +65,6 @@ type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
interface IState {
contextMenuPosition: PartialDOMRect | null;
isDarkTheme: boolean;
isHighContrast: boolean;
selectedSpace?: Room | null;
}
@@ -83,6 +84,51 @@ const below = (rect: PartialDOMRect): MenuProps => {
};
};
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>;
@@ -97,8 +143,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.state = {
contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(),
isHighContrast: this.isUserOnHighContrastTheme(),
selectedSpace: SpaceStore.instance.activeSpaceRoom,
};
}
@@ -111,7 +155,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
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 {
@@ -122,30 +165,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
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<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.
@@ -158,13 +177,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
});
};
private onThemeChanged = (): void => {
this.setState({
isDarkTheme: this.isUserOnDarkTheme(),
isHighContrast: this.isUserOnHighContrastTheme(),
});
};
private onAction = (payload: ActionPayload): void => {
switch (payload.action) {
case Action.ToggleUserMenu:
@@ -200,25 +212,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
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<string, any>): void => {
ev.preventDefault();
ev.stopPropagation();
@@ -398,17 +391,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
</span>
</div>
<RovingAccessibleButton
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={
this.state.isDarkTheme
? _t("user_menu|switch_theme_light")
: _t("user_menu|switch_theme_dark")
}
>
<ThemeIcon width="16px" height="16px" />
</RovingAccessibleButton>
<ThemeSwitchButton />
</div>
{topSection}
{primaryOptionList}
+12 -1
View File
@@ -13,7 +13,7 @@ import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import SettingsStore from "../SettingsStore";
import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { findHighContrastTheme } from "../../theme";
import { findHighContrastTheme, getCustomTheme } from "../../theme";
import { type ActionPayload } from "../../dispatcher/payloads";
import { SettingLevel } from "../SettingLevel";
@@ -125,6 +125,17 @@ export default class ThemeWatcher extends TypedEventEmitter<ThemeWatcherEvent, T
return SettingsStore.getValue("theme");
}
/**
* Returns true if user is on a dark theme, if false implies user is on a light theme
*/
public isUserOnDarkTheme(): boolean {
const theme = this.currentTheme;
if (theme.startsWith("custom-")) {
return !!getCustomTheme(theme.substring("custom-".length)).is_dark;
}
return theme === "dark" || theme === "dark-hc";
}
private themeBasedOnSystem(): string | undefined {
let newTheme: string | undefined;
if (this.preferDark.matches) {