diff --git a/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png b/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png index c210321017..fcd039ba26 100644 Binary files a/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png and b/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png differ diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index 89b15e68b2..b6c5b73e93 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -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 { diff --git a/res/themes/dark/css/_dark.pcss b/res/themes/dark/css/_dark.pcss index dbd5176653..99fd31f371 100644 --- a/res/themes/dark/css/_dark.pcss +++ b/res/themes/dark/css/_dark.pcss @@ -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; diff --git a/res/themes/legacy-light/css/_legacy-light.pcss b/res/themes/legacy-light/css/_legacy-light.pcss index 8c00393d41..c2b0bd5995 100644 --- a/res/themes/legacy-light/css/_legacy-light.pcss +++ b/res/themes/legacy-light/css/_legacy-light.pcss @@ -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; diff --git a/res/themes/light-high-contrast/css/_light-high-contrast.pcss b/res/themes/light-high-contrast/css/_light-high-contrast.pcss index ada87d7a33..483917fb0c 100644 --- a/res/themes/light-high-contrast/css/_light-high-contrast.pcss +++ b/res/themes/light-high-contrast/css/_light-high-contrast.pcss @@ -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 */ diff --git a/res/themes/light/css/_light.pcss b/res/themes/light/css/_light.pcss index fcab227283..3bd803c53e 100644 --- a/res/themes/light/css/_light.pcss +++ b/res/themes/light/css/_light.pcss @@ -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; diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 6f9b768588..b3f20a07ed 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -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; 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 ( + + + + ); +}; + export default class UserMenu extends React.Component { public static contextType = SDKContext; declare public context: React.ContextType; @@ -97,8 +143,6 @@ export default class UserMenu extends React.Component { 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 { 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 { 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. @@ -158,13 +177,6 @@ export default class UserMenu extends React.Component { }); }; - 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 { 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(); @@ -398,17 +391,7 @@ export default class UserMenu extends React.Component { - - - + {topSection} {primaryOptionList} diff --git a/src/settings/watchers/ThemeWatcher.ts b/src/settings/watchers/ThemeWatcher.ts index d56ee559c2..9876dcfe6b 100644 --- a/src/settings/watchers/ThemeWatcher.ts +++ b/src/settings/watchers/ThemeWatcher.ts @@ -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", () => { let client: MatrixClient; @@ -151,4 +152,26 @@ describe("", () => { }); }); }); + + it("should toggle theme on switcher click", async () => { + sdkContext.client = stubClient(); + const spy = jest.spyOn(SettingsStore, "setValue"); + + const UserMenu = wrapInSdkContext(UnwrappedUserMenu, sdkContext); + render(); + + 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"); + }); }); diff --git a/test/unit-tests/settings/watchers/ThemeWatcher-test.tsx b/test/unit-tests/settings/watchers/ThemeWatcher-test.tsx index 3b98e504dd..325b55693c 100644 --- a/test/unit-tests/settings/watchers/ThemeWatcher-test.tsx +++ b/test/unit-tests/settings/watchers/ThemeWatcher-test.tsx @@ -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); + }); });