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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 112 additions and 83 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -86,14 +86,8 @@ Please see LICENSE files in the repository root for full details.
}
.mx_UserMenu_contextMenu_themeButton {
min-width: 32px;
max-width: 32px;
width: 32px;
height: 32px;
flex-shrink: 0;
margin-left: 8px;
border-radius: 32px;
background-color: $theme-button-bg-color;
cursor: pointer;
/* to make alignment easier, create flexbox for the image */
display: flex;
@ -102,6 +96,13 @@ Please see LICENSE files in the repository root for full details.
/* For enhanced visibility under contrast control */
outline: 1px solid transparent;
/* Compound overrides to match transitional designs */
padding: var(--cpd-space-2x);
svg {
width: 16px;
height: 16px;
}
}
&.mx_UserMenu_contextMenu_guestPrompts {

View File

@ -250,7 +250,6 @@ $visual-bell-bg-color: #800;
$event-timestamp-color: var(--cpd-color-text-secondary);
$composer-shadow-color: rgba(0, 0, 0, 0.28);
$breadcrumb-placeholder-bg-color: #272c35;
$theme-button-bg-color: #e3e8f0;
$resend-button-divider-color: var(--cpd-color-gray-700);
$inlinecode-border-color: $quinary-content;
$inlinecode-background-color: $system;

View File

@ -138,8 +138,6 @@ $room-icon-unread-color: var(--cpd-color-icon-tertiary);
/* ******************** */
$theme-button-bg-color: #e3e8f0;
$roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #fff;

View File

@ -21,7 +21,6 @@ $input-darker-bg-color: $quinary-content;
$input-darker-fg-color: $secondary-content;
$resend-button-divider-color: $input-darker-bg-color;
$icon-button-color: var(--cpd-color-icon-tertiary);
$theme-button-bg-color: $quinary-content;
/* not using a compound color here for now as we want to have the same color in
light and dark theme. Until we have a non-symetrical token for it, let's keep
it hardcoded to the following value */

View File

@ -321,7 +321,6 @@ $visual-bell-bg-color: #faa;
$event-timestamp-color: var(--cpd-color-text-secondary);
$composer-shadow-color: rgba(0, 0, 0, 0.04);
$breadcrumb-placeholder-bg-color: #e8eef5;
$theme-button-bg-color: $quinary-content;
$resend-button-divider-color: $input-darker-bg-color;
$inlinecode-border-color: $quinary-content;
$inlinecode-background-color: $system;

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}

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) {

View File

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, waitFor } from "jest-matrix-react";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import { DEVICE_CODE_SCOPE, type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
import { mocked } from "jest-mock";
@ -22,6 +22,7 @@ import Modal from "../../../../src/Modal";
import { mockOpenIdConfiguration } from "../../../test-utils/oidc";
import { Action } from "../../../../src/dispatcher/actions";
import { UserTab } from "../../../../src/components/views/dialogs/UserTab";
import SettingsStore from "../../../../src/settings/SettingsStore.ts";
describe("<UserMenu>", () => {
let client: MatrixClient;
@ -151,4 +152,26 @@ describe("<UserMenu>", () => {
});
});
});
it("should toggle theme on switcher click", async () => {
sdkContext.client = stubClient();
const spy = jest.spyOn(SettingsStore, "setValue");
const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext);
render(<UserMenu isPanelCollapsed={true} />);
screen.getByRole("button", { name: /User menu/i }).click();
const themeSwitchButton = await screen.findByRole("button", { name: "Switch to dark mode" });
expect(themeSwitchButton).toBeInTheDocument();
expect(spy).not.toHaveBeenCalled();
fireEvent.click(themeSwitchButton);
expect(spy).toHaveBeenCalledWith("use_system_theme", null, "device", false);
expect(spy).toHaveBeenCalledWith("theme", null, "device", "dark");
fireEvent.click(themeSwitchButton);
expect(spy).toHaveBeenCalledWith("use_system_theme", null, "device", false);
expect(spy).toHaveBeenCalledWith("theme", null, "device", "light");
});
});

View File

@ -189,4 +189,20 @@ describe("ThemeWatcher", function () {
const themeWatcher = new ThemeWatcher();
expect(themeWatcher.getEffectiveTheme()).toBe("dark");
});
it("should identify custom dark themes as dark", () => {
SettingsStore.getValueAt = makeGetValueAt({ use_system_theme: false, theme: "custom-darkula" });
SettingsStore.getValue = makeGetValue({
custom_themes: [
{
name: "darkula",
is_dark: true,
},
],
});
const themeWatcher = new ThemeWatcher();
expect(themeWatcher.getEffectiveTheme()).toBe("custom-darkula");
expect(themeWatcher.isUserOnDarkTheme()).toBe(true);
});
});